This commit is contained in:
Tobe O 2019-01-05 19:43:06 -05:00
parent 2f14fb4a99
commit d80617d8b4
6 changed files with 192 additions and 189 deletions

View file

@ -1,3 +1,8 @@
# 2.1.0
* Angel 2 + Dart 2 update
* Support for handling errors + rejections.
* Use `ExternalAuthOptions`.
# 2.0.0+1 # 2.0.0+1
* Meta update to improve Pub score. * Meta update to improve Pub score.

View file

@ -10,42 +10,41 @@ First, create an options object:
```dart ```dart
configureServer(Angel app) async { configureServer(Angel app) async {
// Load from a Map, i.e. app config: // 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: // Create in-place:
var opts = const AngelAuthOAuth2Options( var opts = ExternalAuthOptions(
callback: '<callback-url>', clientId: '<client-id>',
key: '<client-id>', clientSecret: '<client-secret>',
secret: '<client-secret>', redirectUri: Uri.parse('<callback>'));
authorizationEndpoint: '<authorization-endpoint>',
tokenEndpoint: '<access-token-endpoint>');
} }
``` ```
After getting authenticated against the remote server, we need to be able to identify 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 users within our own application.
with local users.
```dart ```dart
typedef FutureOr<User> OAuth2Verifier(oauth2.Client, RequestContext, ResponseContext);
/// You might use a pure function to create a verifier that queries a /// You might use a pure function to create a verifier that queries a
/// given service. /// given service.
OAuth2Verifier oauth2verifier(Service userService) { OAuth2Verifier oauth2verifier(Service<User> userService) {
return (oauth2.Client client) async { return (client) async {
var response = await client.get('https://api.github.com/user'); var response = await client.get('https://api.github.com/user');
var ghUser = JSON.decode(response.body); var ghUser = json.decode(response.body);
var id = ghUser['id']; var id = ghUser['id'] as int;
Iterable<Map> matchingUsers = await userService.index({ var matchingUsers = await mappedUserService.index({
'query': {'githubId': id} 'query': {'github_id': id}
}); });
if (matchingUsers.isNotEmpty) { if (matchingUsers.isNotEmpty) {
// Return the corresponding user, if it exists // Return the corresponding user, if it exists.
return User.parse(matchingUsers.firstWhere((u) => u['githubId'] == id)); return matchingUsers.first;
} else { } else {
// Otherwise,create a user // Otherwise,create a user
return await userService.create({'githubId': id}).then(User.parse); return await mappedUserService.create(User(githubId: id));
} }
}; };
} }
``` ```
@ -56,9 +55,18 @@ Consider using the name of the remote authentication provider (ex. `facebook`).
```dart ```dart
configureServer(Angel app) { configureServer(Angel app) {
// ... auth.strategies['github'] = OAuth2Strategy(
var oauthStrategy = options,
new OAuth2Strategy('github', OAUTH2_CONFIG, oauth2Verifier(app.service('users'))); 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 ```dart
configureServer(Angel app) async { configureServer(Angel app) async {
// ... // ...
var auth = new AngelAuth(); var auth = AngelAuth<User>();
auth.strategies['github'] = oauth2Strategy; auth.strategies['github'] = oauth2Strategy;
// Redirect // Redirect
@ -83,7 +91,7 @@ configureServer(Angel app) async {
// Callback // Callback
app.get('/auth/github/callback', auth.authenticate( app.get('/auth/github/callback', auth.authenticate(
'github', 'github',
new AngelAuthOptions(callback: confirmPopupAuthentication()) AngelAuthOptions(callback: confirmPopupAuthentication())
)); ));
// Connect the plug-in!!! // Connect the plug-in!!!
@ -94,14 +102,11 @@ configureServer(Angel app) async {
## Custom Scope Delimiter ## Custom Scope Delimiter
This package should work out-of-the-box for most OAuth2 providers, such as Github or Dropbox. 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 (`' '`), 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 ```dart
configureServer(Angel app) async { configureServer(Angel app) async {
var opts = const AngelOAuth2Options( OAuth2Strategy(..., delimiter: ' ');
// ...
delimiter: ','
);
} }
``` ```
@ -113,7 +118,7 @@ You can add a `getParameters` callback to parse the contents of any arbitrary
response: response:
```dart ```dart
var opts = const AngelOAuth2Options( OAuth2Strategy(
// ... // ...
getParameters: (contentType, body) { getParameters: (contentType, body) {
if (contentType.type == 'application') { if (contentType.type == 'application') {
@ -122,7 +127,7 @@ var opts = const AngelOAuth2Options(
else if (contentType.subtype == 'json') return JSON.decode(body); 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.');
} }
); );
``` ```

View file

@ -1,3 +1,4 @@
include: package:pedantic/analysis_options.yaml
analyzer: analyzer:
strong-mode: strong-mode:
implicit-casts: false implicit-casts: false

View file

@ -1,97 +1,127 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'package:angel_auth/angel_auth.dart'; import 'package:angel_auth/angel_auth.dart';
import 'package:angel_framework/angel_framework.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:angel_auth_oauth2/angel_auth_oauth2.dart';
import 'package:http_parser/http_parser.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:oauth2/oauth2.dart' as oauth2;
final AngelAuthOAuth2Options oAuth2Config = new AngelAuthOAuth2Options( var authorizationEndpoint =
callback: 'http://localhost:3000/auth/github/callback', Uri.parse('http://github.com/login/oauth/authorize');
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<String, String>();
}
throw new FormatException( var tokenEndpoint = Uri.parse('https://github.com/login/oauth/access_token');
'Invalid content-type $contentType; expected application/x-www-form-urlencoded or application/json.');
}); 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<String, dynamic> 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<String, String>();
}
throw FormatException(
'Invalid content-type $contentType; expected application/x-www-form-urlencoded or application/json.');
}
main() async { main() async {
var app = new Angel(); // Create the server instance.
app.use('/users', new MapService()); 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 = var auth =
new AngelAuth<User>(jwtKey: 'oauth2 example secret', allowCookie: false); AngelAuth<User>(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 = /// Create an instance of the strategy class.
(id) => app.service('users').read(id).then((u) => User.parse(u as Map)); auth.strategies['github'] = OAuth2Strategy(
options,
authorizationEndpoint,
tokenEndpoint,
auth.serializer = (User user) async => user.id; // This function is called when the user ACCEPTS the request to sign in with Github.
(client, req, res) async {
auth.strategies['github'] = new OAuth2Strategy(
oAuth2Config,
(oauth2.Client client) async {
var response = await client.get('https://api.github.com/user'); var response = await client.get('https://api.github.com/user');
var ghUser = json.decode(response.body); var ghUser = json.decode(response.body);
var id = ghUser['id']; var id = ghUser['id'] as int;
Iterable<Map> matchingUsers = await app.service('users').index({ var matchingUsers = await mappedUserService.index({
'query': {'githubId': id} 'query': {'github_id': id}
}); });
if (matchingUsers.isNotEmpty) { if (matchingUsers.isNotEmpty) {
// Return the corresponding user, if it exists // Return the corresponding user, if it exists.
return User.parse(matchingUsers.firstWhere((u) => u['githubId'] == id)); return matchingUsers.first;
} else { } else {
// Otherwise,create a user // Otherwise,create a user
return await app return await mappedUserService.create(User(githubId: id));
.service('users')
.create({'githubId': id}).then((u) => User.parse(u as Map));
} }
}, },
// 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', auth.authenticate('github'));
app.get( app.get(
'/auth/github/callback', '/auth/github/callback',
auth.authenticate('github', 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. // In real-life, you might include a pop-up callback script.
// //
// Use `confirmPopupAuthentication`, which is bundled with // Use `confirmPopupAuthentication`, which is bundled with
// `package:angel_auth`. // `package:angel_auth`.
var user = req.container.make<User>();
res.write('Your user info: ${user.toJson()}\n\n');
res.write('Your JWT: $jwt'); res.write('Your JWT: $jwt');
await res.close();
}))); })));
await app.configure(auth.configureServer); // Start listening.
await http.startServer('127.0.0.1', 3000);
app.logger = new Logger('angel')..onRecord.listen(print); print('Listening on ${http.uri}');
print('View user listing: ${http.uri}/users');
var http = new AngelHttp(app); print('Sign in via Github: ${http.uri}/auth/github');
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');
} }
class User extends Model { class User extends Model {
@override @override
String id; String id;
int githubId; int githubId;
User({this.id, this.githubId}); User({this.id, this.githubId});
static User parse(Map map) => 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<String, dynamic> serialize(User user) => user.toJson();
Map<String, dynamic> toJson() => {'id': id, 'github_id': githubId}; Map<String, dynamic> toJson() => {'id': id, 'github_id': githubId};
} }

View file

@ -3,123 +3,85 @@ library angel_auth_oauth2;
import 'dart:async'; import 'dart:async';
import 'package:angel_auth/angel_auth.dart'; import 'package:angel_auth/angel_auth.dart';
import 'package:angel_framework/angel_framework.dart'; import 'package:angel_framework/angel_framework.dart';
import 'package:angel_validate/angel_validate.dart';
import 'package:http_parser/http_parser.dart'; import 'package:http_parser/http_parser.dart';
import 'package:oauth2/oauth2.dart' as oauth2; import 'package:oauth2/oauth2.dart' as oauth2;
final Validator OAUTH2_OPTIONS_SCHEMA = new Validator({ /// An Angel [AuthStrategy] that signs users in via a third-party service that speaks OAuth 2.0.
'key*': isString,
'secret*': isString,
'authorizationEndpoint*': anyOf(isString, const TypeMatcher<Uri>()),
'tokenEndpoint*': anyOf(isString, const TypeMatcher<Uri>()),
'callback*': isString,
'scopes': const TypeMatcher<Iterable<String>>()
}, defaultValues: {
'scopes': <String>[]
}, 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<String> scopes;
final Map<String, String> 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<String>()?.toList() ??
<String>[]);
Map<String, dynamic> toJson() {
return {
'key': key,
'secret': secret,
'authorizationEndpoint': authorizationEndpoint,
'tokenEndpoint': tokenEndpoint,
'callback': callback,
'scopes': scopes.toList()
};
}
}
class OAuth2Strategy<User> implements AuthStrategy<User> { class OAuth2Strategy<User> implements AuthStrategy<User> {
final FutureOr<User> 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<User> 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<dynamic> Function(
oauth2.AuthorizationException, RequestContext, ResponseContext) onError;
/// [options] can be either a `Map` or an instance of [AngelAuthOAuth2Options]. /// The options defining how to connect to the third-party.
OAuth2Strategy(options, this.verifier) { final ExternalAuthOptions options;
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');
}
oauth2.AuthorizationCodeGrant createGrant() => /// The URL to query to receive an authentication code.
new oauth2.AuthorizationCodeGrant( final Uri authorizationEndpoint;
_options.key,
Uri.parse(_options.authorizationEndpoint), /// The URL to query to exchange an authentication code for a token.
Uri.parse(_options.tokenEndpoint), final Uri tokenEndpoint;
secret: _options.secret,
delimiter: _options.delimiter ?? ' ', /// An optional callback used to parse the response from a server who does not follow the OAuth 2.0 spec.
getParameters: _options.getParameters); final Map<String, dynamic> 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 @override
FutureOr<User> authenticate(RequestContext req, ResponseContext res, FutureOr<User> authenticate(RequestContext req, ResponseContext res,
[AngelAuthOptions<User> options]) async { [AngelAuthOptions<User> 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(); if (_redirect == null) {
res.redirect(grant var grant = _createGrant();
.getAuthorizationUrl(Uri.parse(_options.callback), _redirect = grant.getAuthorizationUrl(
scopes: _options.scopes) this.options.redirectUri,
.toString()); scopes: this.options.scopes,
);
}
res.redirect(_redirect);
return null; return null;
} }
Future<User> authenticateCallback(RequestContext req, ResponseContext res, /// The endpoint that is invoked by the third-party after successful authentication.
Future<dynamic> authenticateCallback(RequestContext req, ResponseContext res,
[AngelAuthOptions options]) async { [AngelAuthOptions options]) async {
var grant = createGrant(); var grant = _createGrant();
await grant.getAuthorizationUrl(Uri.parse(_options.callback), grant.getAuthorizationUrl(this.options.redirectUri,
scopes: _options.scopes); scopes: this.options.scopes);
var client =
await grant.handleAuthorizationResponse(req.uri.queryParameters); try {
return await verifier(client); 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);
}
} }
} }

View file

@ -8,8 +8,8 @@ homepage: https://github.com/angel-dart/auth_oauth2.git
dependencies: dependencies:
angel_auth: ^2.0.0 angel_auth: ^2.0.0
angel_framework: ^2.0.0-alpha angel_framework: ^2.0.0-alpha
angel_validate: ^2.0.0-alpha
http_parser: ^3.0.0 http_parser: ^3.0.0
oauth2: ^1.0.0 oauth2: ^1.0.0
dev_dependencies: dev_dependencies:
logging: ^0.11.0 logging: ^0.11.0
pedantic: ^1.0.0