2.1.0
This commit is contained in:
parent
2f14fb4a99
commit
d80617d8b4
6 changed files with 192 additions and 189 deletions
|
@ -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.
|
||||
|
||||
|
|
79
README.md
79
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: '<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'];
|
||||
|
||||
Iterable<Map> 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<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.');
|
||||
}
|
||||
);
|
||||
```
|
|
@ -1,3 +1,4 @@
|
|||
include: package:pedantic/analysis_options.yaml
|
||||
analyzer:
|
||||
strong-mode:
|
||||
implicit-casts: false
|
|
@ -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<String, String>();
|
||||
}
|
||||
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<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 {
|
||||
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};
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
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<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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
logging: ^0.11.0
|
||||
pedantic: ^1.0.0
|
Loading…
Reference in a new issue