blob: 3626d74909d03a71db93d86492c68feed007d371 [file] [log] [blame]
// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
/// This presents an API for initiating OAuth requests to github and then
/// redirecting back to the calling Dart-Pad application.
///
/// A OAuth application must be registered with GitHub and if the
/// initFromEnvironmentalVars() initializer is used then the GitHub client
/// id and client secret should be stored in the
/// PK_GITHUB_OAUTH_CLIENT_ID and PK_GITHUB_OAUTH_CLIENT_SECRET
/// enviromental variables. Likewise the authorization return url and the
/// return to dart-pad app url should be stored in
/// K_GITHUB_OAUTH_AUTH_RETURN_URL and K_GITHUB_OAUTH_RETURN_TO_APP_URL
/// environmental variables respectively.
library;
import 'dart:convert';
import 'dart:io';
import 'package:encrypt/encrypt.dart';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import 'server_cache.dart';
final Logger _logger = Logger('github_oauth_handler');
class GitHubOAuthHandler {
/// Entry point called from Dart-Pad to initiate a GitHub OAuth Token request.
/// NOTE: any change to [entryPointGitHubOAuthInitiate] must also be
/// reflected in changing value in dart-pad\lib\github.dart's
/// [GitHubUIController.entryPointGitHubOAuthInitiate] to
/// match.
static const entryPointGitHubOAuthInitiate = 'github_oauth_initiate';
/// Entry point specifed to GitHub when setting up OAuth App that GitHub will
/// redirect to after the OAuth process is completed. This entry point name
/// here must also match that used in [_returnToAppUrl] member variable (or
/// set using the K_GITHUB_OAUTH_RETURN_TO_APP_URL environmental variable).
static const entryPointGitHubReturnAuthorize = 'github_oauth_authorized';
static const minimumHiveSizeBeforeHousekeeping = 10;
static bool initialized = false;
static bool initializationEndedInErrorState = false;
static late final ServerCache _cache;
static late final String _clientId;
static late final String _clientSecret;
static late final String _authReturnUrl;
static late final String _returnToAppUrl;
static final Duration tenMinuteExpiration = Duration(minutes: 10);
/// Adds the GitHub OAuth api end point routes to the passed in Router.
static bool addRoutes(Router router) {
if (!initializationEndedInErrorState) {
// Add our routes to the router.
_logger.info('Adding GitHub OAuth routes to passed router.');
router.get('/$entryPointGitHubOAuthInitiate/<randomState|[a-zA-Z0-9]+>',
_initiateHandler);
router.get('/$entryPointGitHubReturnAuthorize', _returnAuthorizeHandler);
} else {
_logger.info('''Attempt to add GitHub OAuth routes to router FAILED
because initialization of GitHubOAuthHandler failed earlier.''');
}
return !initializationEndedInErrorState;
}
/// Set cache for tracking clients random states. We do this so that
/// we only do work for clients at the [entryPointGitHubReturnAuthorize]
/// endpoint if we can verify they entered via the
/// [entryPointGitHubOAuthInitiate] end point (and returned through the
/// GitHub OAuth process).
static void setCache(ServerCache cache) {
_cache = cache;
}
/// This routine attempts to read all required initialization parameters
/// from environmental variables.
/// This must be called before calling addRoutes() to initialize the hive and
/// static class variables.
/// Returns true if initialization was successful.
static Future<bool> initFromEnvironmentalVars() async {
if (initialized) return !initializationEndedInErrorState;
final clientId =
_stripQuotes(Platform.environment['PK_GITHUB_OAUTH_CLIENT_ID']) ??
'MissingClientIdEnvironmentalVariable';
final clientSecret =
_stripQuotes(Platform.environment['PK_GITHUB_OAUTH_CLIENT_SECRET']) ??
'MissingClientSecretEnvironmentalVariable';
var authReturnUrl =
_stripQuotes(Platform.environment['K_GITHUB_OAUTH_AUTH_RETURN_URL']) ??
'';
var returnToAppUrl = _stripQuotes(
Platform.environment['K_GITHUB_OAUTH_RETURN_TO_APP_URL']) ??
'';
var missingEnvVariables = false;
if (clientId == 'MissingClientIdEnvironmentalVariable') {
_logger.severe(
'PK_GITHUB_OAUTH_CLIENT_ID environmental variable not set! This is REQUIRED.');
missingEnvVariables = true;
}
if (clientSecret == 'MissingClientSecretEnvironmentalVariable') {
_logger.severe(
'PK_GITHUB_OAUTH_CLIENT_SECRET environmental variable not set! This is REQUIRED.');
missingEnvVariables = true;
}
if (missingEnvVariables) {
_logger.severe(
'GitHub OAuth Handler DISABLED - Ensure all required environmental variables are set and re-run.');
initializationEndedInErrorState = true;
return false;
}
_logger.info(
'''Enviroment PK_GITHUB_OAUTH_CLIENT_ID=${_replaceAllButLastFour(clientId)}');
Enviroment PK_GITHUB_OAUTH_CLIENT_SECRET=${_replaceAllButLastFour(clientSecret)}
Enviroment K_GITHUB_OAUTH_AUTH_RETURN_URL=$authReturnUrl'
Enviroment K_GITHUB_OAUTH_RETURN_TO_APP_URL=$returnToAppUrl'
''');
if (authReturnUrl.isEmpty) {
// This would be the locally running dart-services server.
authReturnUrl = 'http://localhost:8080/$entryPointGitHubReturnAuthorize';
_logger.info(
'K_GITHUB_OAUTH_AUTH_RETURN_URL environmental variable not set - defaulting to "$authReturnUrl"');
}
if (returnToAppUrl.isEmpty) {
// This would be the locally running dart-pad server.
returnToAppUrl = 'http://localhost:8000/index.html';
_logger.info(
'K_GITHUB_OAUTH_RETURN_TO_APP_URL environmental variable not set - defaulting to "$returnToAppUrl"');
}
return init(clientId, clientSecret, authReturnUrl, returnToAppUrl);
}
/// This must be called before calling addRoutes() to initialize the hive and
/// static class variables.
/// All required parameters are passed directly to this init() routine.
/// Returns true if initialization was successful.
static Future<bool> init(String clientId, String clientSecret,
String authReturnUrl, String returnToAppUrl) async {
_clientId = clientId;
_clientSecret = clientSecret;
_authReturnUrl = authReturnUrl;
_returnToAppUrl = returnToAppUrl;
var missingParameters = false;
if (_clientId.isEmpty) {
_logger.severe('GitHubOAuthHandler no client id passed to init().');
missingParameters = true;
}
if (_clientSecret.isEmpty) {
_logger.severe('GitHubOAuthHandler no client secret passed to init().');
missingParameters = true;
}
if (_authReturnUrl.isEmpty) {
_logger.severe(
'GitHubOAuthHandler no authorization return url passed to init().');
missingParameters = true;
}
if (_returnToAppUrl.isEmpty) {
_logger
.severe('GitHubOAuthHandler no return ti app url passed to init().');
missingParameters = true;
}
if (missingParameters) {
_logger.severe(
'GitHub OAuth Handler DISABLED - Ensure all required parameters not passed to init().');
initializationEndedInErrorState = true;
return false;
}
initialized = true;
return !missingParameters;
}
/// The calling app initiates a request for GitHub OAuth authorization by
/// sending get request to `/$entryPointGitHubOAuthInitiate/XXXXXXXXX` where
/// `XXXXXX` is a random alpha numeric token of at least 40 characters in
/// length.
/// When the entire process is complete the browser will be redirected to
/// the calling app at the URL defined by [_returnToAppUrl].
/// The calling app will need to use the originally sent random token
/// to decrypt the returned GitHub authorization token.
static Future<Response> _initiateHandler(
Request request, String randomState) async {
// See if we have anything stored for this random state.
var timestampStr = await _cache.get(randomState);
var newRequest = false;
if (randomState.isEmpty || randomState.length < 40) {
return Response.ok('Random token must be >=40 characters in length');
}
if (timestampStr == null) {
timestampStr = DateTime.now().millisecondsSinceEpoch.toString();
newRequest = true;
}
// Store this state/timestamp pair within the cache so
// we can later verify state on a return from GitHub.
await _cache.set(randomState, timestampStr,
expiration: tenMinuteExpiration);
/*
Incoming Random String from DartPad.
Request Users GitHub Identity.
GET https://github.com/login/oauth/authorize
client_id=XXXXXXXXXXX
redirect_uri=[_authReturnUrl]
scope=gist
state=RANDOMSTR
*/
if (newRequest) {
var url = 'https://github.com/login/oauth/authorize?';
url +=
'client_id=$_clientId&redirect_uri=$_authReturnUrl&scope=gist&state=$randomState';
_logger.fine('Redirecting to GITHUB authorize');
return Response(302, headers: {'location': url});
}
// Return to app with 'authfailed' to indicate error.
var backToAppUrl = _returnToAppUrl;
backToAppUrl += '?gh=authfailed';
return Response(302, headers: {'location': backToAppUrl});
}
/// This entry point is called by the GitHub OAuth process and is the
/// client return authorization handler defined on GitHub when creating
/// the GitHub OAuth Client Id and GitHub OAuth Secret when defining this
/// OAuth app on GitHub.
static Future<Response> _returnAuthorizeHandler(Request request) async {
/*
GitHub REdirects BACK to us here at [_authReturnUrl] with params set:
code=XXXXXXXX
and
state=RANDOMSTR we them sent earlier.
*/
_logger.fine('Entered _returnAuthorizeHandler');
var backToAppUrl = _returnToAppUrl;
var validCallback = false;
var tokenAquired = false;
try {
final code = request.requestedUri.queryParameters['code'] ?? '';
final state = request.requestedUri.queryParameters['state'] ?? '';
// See if we have anything stored for this state value.
final timestampStr = await _cache.get(state);
if (timestampStr == null) {
// ERROR!! We did not have a record of this initial request - ignore.
} else {
validCallback = true;
final client = http.Client();
/*
Now we exchange this code=XXXX for an access token.
POST https://github.com/login/oauth/access_token
client_id=XXXXXXX
client_secret=MYCLIENTSECRET
code=FROMINCOMING_PARAM code
redirect_uri=[_returnToAppUrl]
PUT "Accept: application/json" in ACCEPT HEADER on POST
and get back JSON
Accept: application/json
{
"access_token":"gho_XXXXXXXXX",
"scope":"gist",
"token_type":"bearer"
}
*/
final githubExchangeCodeUri =
'https://github.com/login/oauth/access_token';
final map = <String, dynamic>{
'client_id': _clientId,
'client_secret': _clientSecret,
'code': code,
'redirect_uri': _authReturnUrl,
};
final bodydata = json.encode(map);
await client
.post(Uri.parse(githubExchangeCodeUri),
headers: {
'Accept': 'application/vnd.github.v3+json',
'Content-Type': 'application/json',
},
body: bodydata)
.then((http.Response postResponse) {
late String accessToken, scope;
if (postResponse.statusCode >= 200 &&
postResponse.statusCode <= 299) {
final retObj =
jsonDecode(postResponse.body) as Map<String, dynamic>;
accessToken = retObj['access_token'] as String;
scope = retObj['scope'] as String;
tokenAquired = true;
// We can delete this record because we are done.
_cache.remove(state);
// Encrypt the auth token using the original random state.
final encrBase64AuthToken =
_encryptAndBase64EncodeAuthToken(accessToken, state);
// Build URL to redirect back to the app.
backToAppUrl += '?gh=$encrBase64AuthToken&scope=$scope';
_logger.fine('success - redirecting back to app');
} else if (postResponse.statusCode == 404) {
throw Exception('contentNotFound');
} else if (postResponse.statusCode == 403) {
throw Exception('rateLimitExceeded');
} else if (postResponse.statusCode != 200) {
throw Exception('unknown');
}
});
}
if (!validCallback || !tokenAquired) {
// Return to app with 'noauth' set to indicate failed authorization.
backToAppUrl += '?gh=noauth&state=$state';
}
return Response(302, headers: {'location': backToAppUrl});
} catch (e) {
// Fall through and redirect back to app with 'authfailed'.
}
// Return to app with 'authfailed' to indicate error.
backToAppUrl += '?gh=authfailed';
return Response(302, headers: {'location': backToAppUrl});
}
/// Take the GitHub auth token [ghAuthToken] and the original random
/// state string [randomStateWeWereSent] the client sent in the original
/// `/$entryPointGitHubOAuthInitiate/XXXXX` request and encrypt the token using
/// the random state string. This protects the GH token on the return
/// and also allows the client to verify that we origin of the token.
/// This is probably overkill, we could just XOR encrypt (or something
/// similarily simple), but erroring on the side of more secure
/// probably can't hurt.
/// The symetric decrypting routine is used client side in Dart-Pad t
/// decrypt the received token.
static String _encryptAndBase64EncodeAuthToken(
String ghAuthToken, String randomStateWeWereSent) {
if (randomStateWeWereSent.isEmpty) {
return 'ERROR-no stored initial state';
}
try {
final iv = IV.fromUtf8(randomStateWeWereSent.substring(0, 8));
final key = Key.fromUtf8(randomStateWeWereSent.substring(8, 40));
final sasla = Salsa20(key);
final encrypter = Encrypter(sasla);
final encryptedToken = encrypter.encrypt(ghAuthToken, iv: iv);
return Uri.encodeComponent(encryptedToken.base64);
} catch (e) {
_logger.severe('CAUGHT EXCEPTION during encryption ${e.toString()}');
}
return 'ENCRYPTION_ERROR';
}
// Used to mask all but last 4 characters.
static final selectAllButLast4 = RegExp(r'\w(?!\w{0,3}$)');
/// Masks everthing off of string but last 4 characters. Use
/// to mask secrets when logging.
static String _replaceAllButLastFour(String hide) {
return hide.replaceAll(selectAllButLast4, 'X');
}
// RegExp to select single and double start/ending quotes.
static final selectDoubleQuotes = RegExp(r'^"|"$');
static final selectSingleQuotes = RegExp(r"^'|'$");
// Ensures quotes are strip from string.
static String? _stripQuotes(String? str) {
if (str == null) return null;
return str
.replaceAll(selectDoubleQuotes, '')
.replaceAll(selectSingleQuotes, '');
}
}