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
* Meta update to improve Pub score.

View file

@ -10,41 +10,40 @@ 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: '<callback-url>',
key: '<client-id>',
secret: '<client-secret>',
authorizationEndpoint: '<authorization-endpoint>',
tokenEndpoint: '<access-token-endpoint>');
var opts = ExternalAuthOptions(
clientId: '<client-id>',
clientSecret: '<client-secret>',
redirectUri: Uri.parse('<callback>'));
}
```
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<User> 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<User> userService) {
return (client) async {
var response = await client.get('https://api.github.com/user');
var ghUser = JSON.decode(response.body);
var id = ghUser['id'];
var ghUser = json.decode(response.body);
var id = ghUser['id'] as int;
Iterable<Map> matchingUsers = await userService.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 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
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<User>();
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.');
}
);
```

View file

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

View file

@ -1,18 +1,24 @@
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) {
var authorizationEndpoint =
Uri.parse('http://github.com/login/oauth/authorize');
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<String, dynamic> parseParamsFromGithub(MediaType contentType, String body) {
if (contentType.type == 'application') {
if (contentType.subtype == 'x-www-form-urlencoded')
return Uri.splitQueryString(body);
@ -20,78 +26,102 @@ final AngelAuthOAuth2Options oAuth2Config = new AngelAuthOAuth2Options(
return (json.decode(body) as Map).cast<String, String>();
}
throw new FormatException(
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<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 =
(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<Map> 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<User>();
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<String, dynamic> serialize(User user) => user.toJson();
Map<String, dynamic> toJson() => {'id': id, 'github_id': githubId};
}

View file

@ -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<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()
};
}
}
/// An Angel [AuthStrategy] that signs users in via a third-party service that speaks OAuth 2.0.
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].
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<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
FutureOr<User> authenticate(RequestContext req, ResponseContext res,
[AngelAuthOptions<User> options]) async {
if (options != null) return authenticateCallback(req, res, options);
var grant = createGrant();
res.redirect(grant
.getAuthorizationUrl(Uri.parse(_options.callback),
scopes: _options.scopes)
.toString());
if (options != null) {
var result = await authenticateCallback(req, res, options);
if (result is User)
return result;
else
return null;
}
Future<User> authenticateCallback(RequestContext req, ResponseContext res,
if (_redirect == null) {
var grant = _createGrant();
_redirect = grant.getAuthorizationUrl(
this.options.redirectUri,
scopes: this.options.scopes,
);
}
res.redirect(_redirect);
return null;
}
/// The endpoint that is invoked by the third-party after successful authentication.
Future<dynamic> authenticateCallback(RequestContext req, ResponseContext res,
[AngelAuthOptions options]) async {
var grant = createGrant();
await grant.getAuthorizationUrl(Uri.parse(_options.callback),
scopes: _options.scopes);
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);
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:
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
pedantic: ^1.0.0