From d80617d8b41f309cf2d1626960bd6753298efcb7 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Sat, 5 Jan 2019 19:43:06 -0500 Subject: [PATCH] 2.1.0 --- CHANGELOG.md | 5 ++ README.md | 79 +++++++++--------- analysis_options.yaml | 1 + example/main.dart | 126 +++++++++++++++++----------- lib/angel_auth_oauth2.dart | 166 ++++++++++++++----------------------- pubspec.yaml | 4 +- 6 files changed, 192 insertions(+), 189 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe600b94..ae11c7b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# 2.1.0 +* Angel 2 + Dart 2 update +* Support for handling errors + rejections. +* Use `ExternalAuthOptions`. + # 2.0.0+1 * Meta update to improve Pub score. diff --git a/README.md b/README.md index 27579958..cd6cddc0 100644 --- a/README.md +++ b/README.md @@ -10,42 +10,41 @@ First, create an options object: ```dart configureServer(Angel app) async { // Load from a Map, i.e. app config: - var opts = new AngelOAuth2Options.fromJson(map); + var opts = ExternalAuthOptions.fromMap(app.configuration['auth0'] as Map); // Create in-place: - var opts = const AngelAuthOAuth2Options( - callback: '', - key: '', - secret: '', - authorizationEndpoint: '', - tokenEndpoint: ''); + var opts = ExternalAuthOptions( + clientId: '', + clientSecret: '', + redirectUri: Uri.parse('')); } ``` After getting authenticated against the remote server, we need to be able to identify -users within our own application. Use an `OAuth2Verifier` to associate remote users -with local users. +users within our own application. ```dart +typedef FutureOr OAuth2Verifier(oauth2.Client, RequestContext, ResponseContext); + /// You might use a pure function to create a verifier that queries a /// given service. -OAuth2Verifier oauth2verifier(Service userService) { - return (oauth2.Client client) async { +OAuth2Verifier oauth2verifier(Service userService) { + return (client) async { var response = await client.get('https://api.github.com/user'); - var ghUser = JSON.decode(response.body); - var id = ghUser['id']; - - Iterable matchingUsers = await userService.index({ - 'query': {'githubId': id} - }); - - if (matchingUsers.isNotEmpty) { - // Return the corresponding user, if it exists - return User.parse(matchingUsers.firstWhere((u) => u['githubId'] == id)); - } else { - // Otherwise,create a user - return await userService.create({'githubId': id}).then(User.parse); - } + var ghUser = json.decode(response.body); + var id = ghUser['id'] as int; + + var matchingUsers = await mappedUserService.index({ + 'query': {'github_id': id} + }); + + if (matchingUsers.isNotEmpty) { + // Return the corresponding user, if it exists. + return matchingUsers.first; + } else { + // Otherwise,create a user + return await mappedUserService.create(User(githubId: id)); + } }; } ``` @@ -56,9 +55,18 @@ Consider using the name of the remote authentication provider (ex. `facebook`). ```dart configureServer(Angel app) { - // ... - var oauthStrategy = - new OAuth2Strategy('github', OAUTH2_CONFIG, oauth2Verifier(app.service('users'))); + auth.strategies['github'] = OAuth2Strategy( + options, + authorizationEndpoint, + tokenEndpoint, + yourVerifier, + + // This function is called when an error occurs, or the user REJECTS the request. + (e, req, res) async { + res.write('Ooops: $e'); + await res.close(); + }, + ); } ``` @@ -74,7 +82,7 @@ a popup window. In this case, use `confirmPopupAuthentication`, which is bundled ```dart configureServer(Angel app) async { // ... - var auth = new AngelAuth(); + var auth = AngelAuth(); auth.strategies['github'] = oauth2Strategy; // Redirect @@ -83,7 +91,7 @@ configureServer(Angel app) async { // Callback app.get('/auth/github/callback', auth.authenticate( 'github', - new AngelAuthOptions(callback: confirmPopupAuthentication()) + AngelAuthOptions(callback: confirmPopupAuthentication()) )); // Connect the plug-in!!! @@ -94,14 +102,11 @@ configureServer(Angel app) async { ## Custom Scope Delimiter This package should work out-of-the-box for most OAuth2 providers, such as Github or Dropbox. However, if your OAuth2 scopes are separated by a delimiter other than the default (`' '`), -you can add it in the `AngelOAuth2Options` constructor: +you can add it in the `OAuth2Strategy` constructor: ```dart configureServer(Angel app) async { - var opts = const AngelOAuth2Options( - // ... - delimiter: ',' - ); + OAuth2Strategy(..., delimiter: ' '); } ``` @@ -113,7 +118,7 @@ You can add a `getParameters` callback to parse the contents of any arbitrary response: ```dart -var opts = const AngelOAuth2Options( +OAuth2Strategy( // ... getParameters: (contentType, body) { if (contentType.type == 'application') { @@ -122,7 +127,7 @@ var opts = const AngelOAuth2Options( else if (contentType.subtype == 'json') return JSON.decode(body); } - throw new FormatException('Invalid content-type $contentType; expected application/x-www-form-urlencoded or application/json.'); + throw FormatException('Invalid content-type $contentType; expected application/x-www-form-urlencoded or application/json.'); } ); ``` \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml index eae1e42a..c230cee7 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,3 +1,4 @@ +include: package:pedantic/analysis_options.yaml analyzer: strong-mode: implicit-casts: false \ No newline at end of file diff --git a/example/main.dart b/example/main.dart index 95c98930..123cd0e4 100644 --- a/example/main.dart +++ b/example/main.dart @@ -1,97 +1,127 @@ import 'dart:convert'; -import 'dart:io'; import 'package:angel_auth/angel_auth.dart'; import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; import 'package:angel_auth_oauth2/angel_auth_oauth2.dart'; +import 'package:http_parser/http_parser.dart'; import 'package:logging/logging.dart'; -import 'package:oauth2/oauth2.dart' as oauth2; -final AngelAuthOAuth2Options oAuth2Config = new AngelAuthOAuth2Options( - callback: 'http://localhost:3000/auth/github/callback', - key: '6caeaf5d4c04936ec34f', - secret: '178360518cf9de4802e2346a4b6ebec525dc4427', - authorizationEndpoint: 'http://github.com/login/oauth/authorize', - tokenEndpoint: 'https://github.com/login/oauth/access_token', - getParameters: (contentType, body) { - if (contentType.type == 'application') { - if (contentType.subtype == 'x-www-form-urlencoded') - return Uri.splitQueryString(body); - else if (contentType.subtype == 'json') - return (json.decode(body) as Map).cast(); - } +var authorizationEndpoint = + Uri.parse('http://github.com/login/oauth/authorize'); - throw new FormatException( - 'Invalid content-type $contentType; expected application/x-www-form-urlencoded or application/json.'); - }); +var tokenEndpoint = Uri.parse('https://github.com/login/oauth/access_token'); + +var options = ExternalAuthOptions( + clientId: '6caeaf5d4c04936ec34f', + clientSecret: '178360518cf9de4802e2346a4b6ebec525dc4427', + redirectUri: Uri.parse('http://localhost:3000/auth/github/callback'), +); + +/// Github doesn't properly follow the OAuth2 spec, so here's logic to parse their response. +Map parseParamsFromGithub(MediaType contentType, String body) { + if (contentType.type == 'application') { + if (contentType.subtype == 'x-www-form-urlencoded') + return Uri.splitQueryString(body); + else if (contentType.subtype == 'json') + return (json.decode(body) as Map).cast(); + } + + throw FormatException( + 'Invalid content-type $contentType; expected application/x-www-form-urlencoded or application/json.'); +} main() async { - var app = new Angel(); - app.use('/users', new MapService()); + // Create the server instance. + var app = Angel(); + var http = AngelHttp(app); + app.logger = Logger('angel') + ..onRecord.listen((rec) { + print(rec); + if (rec.error != null) print(rec.error); + if (rec.stackTrace != null) print(rec.stackTrace); + }); + // Create a service that stores user data. + var userService = app.use('/users', MapService()).inner; + var mappedUserService = userService.map(User.parse, User.serialize); + + // Set up the authenticator plugin. var auth = - new AngelAuth(jwtKey: 'oauth2 example secret', allowCookie: false); + AngelAuth(jwtKey: 'oauth2 example secret', allowCookie: false); + auth.serializer = (user) async => user.id; + auth.deserializer = (id) => mappedUserService.read(id.toString()); + app.fallback(auth.decodeJwt); - auth.deserializer = - (id) => app.service('users').read(id).then((u) => User.parse(u as Map)); + /// Create an instance of the strategy class. + auth.strategies['github'] = OAuth2Strategy( + options, + authorizationEndpoint, + tokenEndpoint, - auth.serializer = (User user) async => user.id; - - auth.strategies['github'] = new OAuth2Strategy( - oAuth2Config, - (oauth2.Client client) async { + // This function is called when the user ACCEPTS the request to sign in with Github. + (client, req, res) async { var response = await client.get('https://api.github.com/user'); var ghUser = json.decode(response.body); - var id = ghUser['id']; + var id = ghUser['id'] as int; - Iterable matchingUsers = await app.service('users').index({ - 'query': {'githubId': id} + var matchingUsers = await mappedUserService.index({ + 'query': {'github_id': id} }); if (matchingUsers.isNotEmpty) { - // Return the corresponding user, if it exists - return User.parse(matchingUsers.firstWhere((u) => u['githubId'] == id)); + // Return the corresponding user, if it exists. + return matchingUsers.first; } else { // Otherwise,create a user - return await app - .service('users') - .create({'githubId': id}).then((u) => User.parse(u as Map)); + return await mappedUserService.create(User(githubId: id)); } }, + + // This function is called when an error occurs, or the user REJECTS the request. + (e, req, res) async { + res.write('Ooops: $e'); + await res.close(); + }, + + // We have to pass this parser function when working with Github. + getParameters: parseParamsFromGithub, ); + // Mount some routes app.get('/auth/github', auth.authenticate('github')); app.get( '/auth/github/callback', auth.authenticate('github', - new AngelAuthOptions(callback: (req, res, jwt) async { + AngelAuthOptions(callback: (req, res, jwt) async { // In real-life, you might include a pop-up callback script. // // Use `confirmPopupAuthentication`, which is bundled with // `package:angel_auth`. + var user = req.container.make(); + res.write('Your user info: ${user.toJson()}\n\n'); res.write('Your JWT: $jwt'); + await res.close(); }))); - await app.configure(auth.configureServer); - - app.logger = new Logger('angel')..onRecord.listen(print); - - var http = new AngelHttp(app); - var server = await http.startServer(InternetAddress.loopbackIPv4, 3000); - var url = 'http://${server.address.address}:${server.port}'; - print('Listening on $url'); - print('View user listing: $url/users'); - print('Sign in via Github: $url/auth/github'); + // Start listening. + await http.startServer('127.0.0.1', 3000); + print('Listening on ${http.uri}'); + print('View user listing: ${http.uri}/users'); + print('Sign in via Github: ${http.uri}/auth/github'); } class User extends Model { @override String id; + int githubId; User({this.id, this.githubId}); static User parse(Map map) => - new User(id: map['id'] as String, githubId: map['github_id'] as int); + User(id: map['id'] as String, githubId: map['github_id'] as int); + + static Map serialize(User user) => user.toJson(); Map toJson() => {'id': id, 'github_id': githubId}; } diff --git a/lib/angel_auth_oauth2.dart b/lib/angel_auth_oauth2.dart index b24d5895..2583d54e 100644 --- a/lib/angel_auth_oauth2.dart +++ b/lib/angel_auth_oauth2.dart @@ -3,123 +3,85 @@ library angel_auth_oauth2; import 'dart:async'; import 'package:angel_auth/angel_auth.dart'; import 'package:angel_framework/angel_framework.dart'; -import 'package:angel_validate/angel_validate.dart'; import 'package:http_parser/http_parser.dart'; import 'package:oauth2/oauth2.dart' as oauth2; -final Validator OAUTH2_OPTIONS_SCHEMA = new Validator({ - 'key*': isString, - 'secret*': isString, - 'authorizationEndpoint*': anyOf(isString, const TypeMatcher()), - 'tokenEndpoint*': anyOf(isString, const TypeMatcher()), - 'callback*': isString, - 'scopes': const TypeMatcher>() -}, defaultValues: { - 'scopes': [] -}, customErrorMessages: { - 'scopes': "'scopes' must be an Iterable of strings. You provided: {{value}}" -}); - -/// Holds credentials and also specifies the means of authenticating users against a remote server. -class AngelAuthOAuth2Options { - /// Your application's client key or client ID, registered with the remote server. - final String key; - - /// Your application's client secret, registered with the remote server. - final String secret; - - /// The remote endpoint that prompts external users for authentication credentials. - final String authorizationEndpoint; - - /// The remote endpoint that exchanges auth codes for access tokens. - final String tokenEndpoint; - - /// The callback URL that the OAuth2 server should redirect authenticated users to. - final String callback; - - /// Used to split application scopes. Defaults to `' '`. - final String delimiter; - final Iterable scopes; - - final Map Function(MediaType, String) getParameters; - - const AngelAuthOAuth2Options( - {this.key, - this.secret, - this.authorizationEndpoint, - this.tokenEndpoint, - this.callback, - this.delimiter: ' ', - this.scopes: const [], - this.getParameters}); - - factory AngelAuthOAuth2Options.fromJson(Map json) => - new AngelAuthOAuth2Options( - key: json['key'] as String, - secret: json['secret'] as String, - authorizationEndpoint: json['authorizationEndpoint'] as String, - tokenEndpoint: json['tokenEndpoint'] as String, - callback: json['callback'] as String, - scopes: (json['scopes'] as Iterable)?.cast()?.toList() ?? - []); - - Map toJson() { - return { - 'key': key, - 'secret': secret, - 'authorizationEndpoint': authorizationEndpoint, - 'tokenEndpoint': tokenEndpoint, - 'callback': callback, - 'scopes': scopes.toList() - }; - } -} - +/// An Angel [AuthStrategy] that signs users in via a third-party service that speaks OAuth 2.0. class OAuth2Strategy implements AuthStrategy { - final FutureOr Function(oauth2.Client) verifier; + /// A callback that uses the third-party service to authenticate a [User]. + /// + /// As always, return `null` if authentication fails. + final FutureOr Function(oauth2.Client, RequestContext, ResponseContext) + verifier; - AngelAuthOAuth2Options _options; + /// A callback that is triggered when an OAuth2 error occurs (i.e. the user declines to login); + final FutureOr Function( + oauth2.AuthorizationException, RequestContext, ResponseContext) onError; - /// [options] can be either a `Map` or an instance of [AngelAuthOAuth2Options]. - OAuth2Strategy(options, this.verifier) { - if (options is AngelAuthOAuth2Options) - _options = options; - else if (options is Map) - _options = new AngelAuthOAuth2Options.fromJson( - OAUTH2_OPTIONS_SCHEMA.enforce(options)); - else - throw new ArgumentError('Invalid OAuth2 options: $options'); - } + /// The options defining how to connect to the third-party. + final ExternalAuthOptions options; - oauth2.AuthorizationCodeGrant createGrant() => - new oauth2.AuthorizationCodeGrant( - _options.key, - Uri.parse(_options.authorizationEndpoint), - Uri.parse(_options.tokenEndpoint), - secret: _options.secret, - delimiter: _options.delimiter ?? ' ', - getParameters: _options.getParameters); + /// The URL to query to receive an authentication code. + final Uri authorizationEndpoint; + + /// The URL to query to exchange an authentication code for a token. + final Uri tokenEndpoint; + + /// An optional callback used to parse the response from a server who does not follow the OAuth 2.0 spec. + final Map Function(MediaType, String) getParameters; + + /// An optional delimiter used to send requests to server who does not follow the OAuth 2.0 spec. + final String delimiter; + + Uri _redirect; + + OAuth2Strategy(this.options, this.authorizationEndpoint, this.tokenEndpoint, + this.verifier, this.onError, + {this.getParameters, this.delimiter = ' '}); + + oauth2.AuthorizationCodeGrant _createGrant() => + new oauth2.AuthorizationCodeGrant(options.clientId, authorizationEndpoint, + tokenEndpoint, + secret: options.clientSecret, + delimiter: delimiter, + getParameters: getParameters); @override FutureOr authenticate(RequestContext req, ResponseContext res, [AngelAuthOptions options]) async { - if (options != null) return authenticateCallback(req, res, options); + if (options != null) { + var result = await authenticateCallback(req, res, options); + if (result is User) + return result; + else + return null; + } - var grant = createGrant(); - res.redirect(grant - .getAuthorizationUrl(Uri.parse(_options.callback), - scopes: _options.scopes) - .toString()); + if (_redirect == null) { + var grant = _createGrant(); + _redirect = grant.getAuthorizationUrl( + this.options.redirectUri, + scopes: this.options.scopes, + ); + } + + res.redirect(_redirect); return null; } - Future authenticateCallback(RequestContext req, ResponseContext res, + /// The endpoint that is invoked by the third-party after successful authentication. + Future authenticateCallback(RequestContext req, ResponseContext res, [AngelAuthOptions options]) async { - var grant = createGrant(); - await grant.getAuthorizationUrl(Uri.parse(_options.callback), - scopes: _options.scopes); - var client = - await grant.handleAuthorizationResponse(req.uri.queryParameters); - return await verifier(client); + var grant = _createGrant(); + grant.getAuthorizationUrl(this.options.redirectUri, + scopes: this.options.scopes); + + try { + var client = + await grant.handleAuthorizationResponse(req.uri.queryParameters); + return await verifier(client, req, res); + } on oauth2.AuthorizationException catch (e) { + return await onError(e, req, res); + } } } diff --git a/pubspec.yaml b/pubspec.yaml index 5f28e246..2b1e6a3a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,8 +8,8 @@ homepage: https://github.com/angel-dart/auth_oauth2.git dependencies: angel_auth: ^2.0.0 angel_framework: ^2.0.0-alpha - angel_validate: ^2.0.0-alpha http_parser: ^3.0.0 oauth2: ^1.0.0 dev_dependencies: - logging: ^0.11.0 \ No newline at end of file + logging: ^0.11.0 + pedantic: ^1.0.0 \ No newline at end of file