add example

This commit is contained in:
Tobe O 2018-12-15 03:39:04 -05:00
parent 32da3a16b5
commit ac2c7691ea
7 changed files with 458 additions and 5 deletions

View file

@ -1,6 +1,7 @@
# 2.1.0 # 2.1.0
* Updates * Updates
* Support `device_code` grants. * Support `device_code` grants.
* Add support for [PKCE](https://tools.ietf.org/html/rfc7636).
# 2.0.0 # 2.0.0
* Angel 2 support. * Angel 2 support.

View file

@ -4,13 +4,19 @@
A class containing handlers that can be used within A class containing handlers that can be used within
[Angel](https://angel-dart.github.io/) to build a spec-compliant [Angel](https://angel-dart.github.io/) to build a spec-compliant
OAuth 2.0 server. OAuth 2.0 server, including PKCE support.
* [Installation](#installation)
* [Usage](#usage)
* [Other Grants](#other-grants)
* [PKCE](#pkce)
# Installation # Installation
In your `pubspec.yaml`: In your `pubspec.yaml`:
```yaml ```yaml
dependencies: dependencies:
angel_framework: ^2.0.0-alpha
angel_oauth2: ^2.0.0 angel_oauth2: ^2.0.0
``` ```
@ -103,3 +109,64 @@ The following are available, not including authorization code grant support (men
Read the [OAuth2 specification](https://tools.ietf.org/html/rfc6749) Read the [OAuth2 specification](https://tools.ietf.org/html/rfc6749)
for in-depth information on each grant type. for in-depth information on each grant type.
## PKCE
In some cases, you will be using OAuth2 on a mobile device, or on some other
public client, where the client cannot have a client
secret.
In such a case, you may consider using
[PKCE](https://tools.ietf.org/html/rfc7636).
Both the `authorizationEndpoint` and `tokenEndpoint`
inject a `Pkce` factory into the request, so it
can be used as follows:
```dart
@override
Future requestAuthorizationCode(
PseudoApplication client,
String redirectUri,
Iterable<String> scopes,
String state,
RequestContext req,
ResponseContext res) async {
// Automatically throws an error if the request doesn't contain the
// necessary information.
var pkce = req.container.make<Pkce>();
// At this point, store `pkce.codeChallenge` and `pkce.codeChallengeMethod`,
// so that when it's time to exchange the auth code for a token, we can
// create a new [Pkce] object, and verify the client.
return await getAuthCodeSomehow(client, pkce.codeChallenge, pkce.codeChallengeMethod);
}
@override
Future<AuthorizationTokenResponse> exchangeAuthorizationCodeForToken(
String authCode,
String redirectUri,
RequestContext req,
ResponseContext res) async {
// When exchanging the authorization code for a token, we'll need
// a `code_verifier` from the client, so that we can ensure
// that the correct client is trying to use the auth code.
//
// If none is present, an OAuth2 exception is thrown.
var codeVerifier = await getPkceCodeVerifier(req);
// Next, we'll need to retrieve the code challenge and code challenge method
// from earlier.
var codeChallenge = await getTheChallenge();
var codeChallengeMethod = await getTheChallengeMethod();
// Make a new [Pkce] object.
var pkce = new Pkce(codeChallengeMethod, codeChallenge);
// Call `validate`. If the client is invalid, it throws an OAuth2 exception.
pkce.validate(codeVerifier);
// If we reach here, we know that the `code_verifier` was valid,
// so we can return our authorization token as per usual.
return new AuthorizationTokenResponse('...');
}
```

74
example/main.dart Normal file
View file

@ -0,0 +1,74 @@
// ignore_for_file: todo
import 'dart:async';
import 'package:angel_framework/angel_framework.dart';
import 'package:angel_oauth2/angel_oauth2.dart';
main() async {
var app = new Angel();
var oauth2 = new _ExampleAuthorizationServer();
var _rgxBearer = new RegExp(r'^[Bb]earer ([^\n\s]+)$');
app.group('/auth', (router) {
router
..get('/authorize', oauth2.authorizationEndpoint)
..post('/token', oauth2.tokenEndpoint);
});
// Assume that all other requests must be authenticated...
app.fallback((req, res) {
var authToken =
req.headers.value('authorization')?.replaceAll(_rgxBearer, '')?.trim();
if (authToken == null) {
throw AngelHttpException.forbidden();
} else {
// TODO: The user has a token, now verify it.
// It is up to you how to store and retrieve auth tokens within your application.
// The purpose of `package:angel_oauth2` is to provide the transport
// across which you distribute these tokens in the first place.
}
});
}
class ThirdPartyApp {}
class User {}
/// A [ThirdPartyApp] can act on behalf of a [User].
class _ExampleAuthorizationServer
extends AuthorizationServer<ThirdPartyApp, User> {
@override
FutureOr<ThirdPartyApp> findClient(String clientId) {
// TODO: Add your code to find the app associated with a client ID.
throw new UnimplementedError();
}
@override
FutureOr<bool> verifyClient(ThirdPartyApp client, String clientSecret) {
// TODO: Add your code to verify a client secret, if given one.
throw new UnimplementedError();
}
@override
FutureOr requestAuthorizationCode(
ThirdPartyApp client,
String redirectUri,
Iterable<String> scopes,
String state,
RequestContext req,
ResponseContext res) {
// TODO: In many cases, here you will render a view displaying to the user which scopes are being requested.
throw new UnimplementedError();
}
@override
FutureOr<AuthorizationTokenResponse> exchangeAuthorizationCodeForToken(
String authCode,
String redirectUri,
RequestContext req,
ResponseContext res) {
// TODO: Here, you'll convert the auth code into a full-fledged token.
// You might have the auth code stored in a database somewhere.
throw new UnimplementedError();
}
}

View file

@ -51,9 +51,9 @@ class Pkce {
if (isS256) { if (isS256) {
foreignChallenge = foreignChallenge =
base64Url.encode(sha256.convert(ascii.encode(codeChallenge)).bytes); base64Url.encode(sha256.convert(ascii.encode(codeVerifier)).bytes);
} else { } else {
foreignChallenge = codeChallenge; foreignChallenge = codeVerifier;
} }
if (foreignChallenge != codeChallenge) { if (foreignChallenge != codeChallenge) {

View file

@ -65,6 +65,31 @@ abstract class AuthorizationServer<Client, User> {
/// Verify that a [client] is the one identified by the [clientSecret]. /// Verify that a [client] is the one identified by the [clientSecret].
FutureOr<bool> verifyClient(Client client, String clientSecret); FutureOr<bool> verifyClient(Client client, String clientSecret);
/// Retrieves the PKCE `code_verifier` parameter from a [RequestContext], or throws.
Future<String> getPkceCodeVerifier(RequestContext req,
{bool body: true, String state, Uri uri}) async {
var data = body
? await req.parseBody().then((_) => req.bodyAsMap)
: req.queryParameters;
var codeVerifier = data['code_verifier'];
if (codeVerifier == null) {
throw new AuthorizationException(new ErrorResponse(
ErrorResponse.invalidRequest,
"Missing `code_verifier` parameter.",
state,
uri: uri));
} else if (codeVerifier is! String) {
throw new AuthorizationException(new ErrorResponse(
ErrorResponse.invalidRequest,
"The `code_verifier` parameter must be a string.",
state,
uri: uri));
} else {
return codeVerifier as String;
}
}
/// Prompt the currently logged-in user to grant or deny access to the [client]. /// Prompt the currently logged-in user to grant or deny access to the [client].
/// ///
/// In many applications, this will entail showing a dialog to the user in question. /// In many applications, this will entail showing a dialog to the user in question.

286
test/pkce_test.dart Normal file
View file

@ -0,0 +1,286 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'package:angel_framework/angel_framework.dart';
import 'package:angel_framework/http.dart';
import 'package:angel_oauth2/angel_oauth2.dart';
import 'package:angel_test/angel_test.dart';
import 'package:logging/logging.dart';
import 'package:oauth2/oauth2.dart' as oauth2;
import 'package:test/test.dart';
import 'package:uuid/uuid.dart';
import 'common.dart';
main() {
Angel app;
Uri authorizationEndpoint, tokenEndpoint, redirectUri;
TestClient testClient;
setUp(() async {
app = new Angel();
app.container.registerSingleton(new AuthCodes());
var server = new _Server();
app.group('/oauth2', (router) {
router
..get('/authorize', server.authorizationEndpoint)
..post('/token', server.tokenEndpoint);
});
app.logger = new Logger('angel')
..onRecord.listen((rec) {
print(rec);
if (rec.error != null) print(rec.error);
if (rec.stackTrace != null) print(rec.stackTrace);
});
var http = new AngelHttp(app);
var s = await http.startServer();
var url = 'http://${s.address.address}:${s.port}';
authorizationEndpoint = Uri.parse('$url/oauth2/authorize');
tokenEndpoint = Uri.parse('$url/oauth2/token');
redirectUri = Uri.parse('http://foo.bar/baz');
testClient = await connectTo(app);
});
tearDown(() async {
await testClient.close();
});
group('get auth code', () {
test('with challenge + implied plain', () async {
var url = authorizationEndpoint.replace(queryParameters: {
'response_type': 'code',
'client_id': 'freddie mercury',
'redirect_uri': 'https://freddie.mercu.ry',
'code_challenge': 'foo',
});
var response = await testClient
.get(url.toString(), headers: {'accept': 'application/json'});
print(response.body);
expect(
response,
allOf(
hasStatus(200),
isJson({"code": "ok"}),
));
});
test('with challenge + plain', () async {
var url = authorizationEndpoint.replace(queryParameters: {
'response_type': 'code',
'client_id': 'freddie mercury',
'redirect_uri': 'https://freddie.mercu.ry',
'code_challenge': 'foo',
'code_challenge_method': 'plain',
});
var response = await testClient
.get(url.toString(), headers: {'accept': 'application/json'});
print(response.body);
expect(
response,
allOf(
hasStatus(200),
isJson({"code": "ok"}),
));
});
test('with challenge + s256', () async {
var url = authorizationEndpoint.replace(queryParameters: {
'response_type': 'code',
'client_id': 'freddie mercury',
'redirect_uri': 'https://freddie.mercu.ry',
'code_challenge': 'foo',
'code_challenge_method': 's256',
});
var response = await testClient
.get(url.toString(), headers: {'accept': 'application/json'});
print(response.body);
expect(
response,
allOf(
hasStatus(200),
isJson({"code": "ok"}),
));
});
test('with challenge + wrong method', () async {
var url = authorizationEndpoint.replace(queryParameters: {
'response_type': 'code',
'client_id': 'freddie mercury',
'redirect_uri': 'https://freddie.mercu.ry',
'code_challenge': 'foo',
'code_challenge_method': 'bar',
});
var response = await testClient
.get(url.toString(), headers: {'accept': 'application/json'});
print(response.body);
expect(
response,
allOf(
hasStatus(400),
isJson({
"error": "invalid_request",
"error_description":
"The `code_challenge_method` parameter must be either 'plain' or 's256'."
}),
));
});
test('with no challenge', () async {
var url = authorizationEndpoint.replace(queryParameters: {
'response_type': 'code',
'client_id': 'freddie mercury',
'redirect_uri': 'https://freddie.mercu.ry'
});
var response = await testClient
.get(url.toString(), headers: {'accept': 'application/json'});
print(response.body);
expect(
response,
allOf(
hasStatus(400),
isJson({
"error": "invalid_request",
"error_description": "Missing `code_challenge` parameter."
}),
));
});
});
group('get token', () {
test('with correct verifier', () async {
var url = tokenEndpoint.replace(
userInfo: '${pseudoApplication.id}:${pseudoApplication.secret}');
var response = await testClient.post(url.toString(), headers: {
'accept': 'application/json',
'authorization': 'Basic ' + base64Url.encode(ascii.encode(url.userInfo))
}, body: {
'grant_type': 'authorization_code',
'client_id': 'freddie mercury',
'redirect_uri': 'https://freddie.mercu.ry',
'code': 'ok',
'code_verifier': 'hello',
});
print(response.body);
expect(
response,
allOf(
hasStatus(200),
isJson({"token_type": "bearer", "access_token": "yes"}),
));
});
test('with incorrect verifier', () async {
var url = tokenEndpoint.replace(
userInfo: '${pseudoApplication.id}:${pseudoApplication.secret}');
var response = await testClient.post(url.toString(), headers: {
'accept': 'application/json',
'authorization': 'Basic ' + base64Url.encode(ascii.encode(url.userInfo))
}, body: {
'grant_type': 'authorization_code',
'client_id': 'freddie mercury',
'redirect_uri': 'https://freddie.mercu.ry',
'code': 'ok',
'code_verifier': 'foo',
});
print(response.body);
expect(
response,
allOf(
hasStatus(400),
isJson({
"error": "invalid_grant",
"error_description":
"The given `code_verifier` parameter is invalid."
}),
));
});
test('with missing verifier', () async {
var url = tokenEndpoint.replace(
userInfo: '${pseudoApplication.id}:${pseudoApplication.secret}');
var response = await testClient.post(url.toString(), headers: {
'accept': 'application/json',
'authorization': 'Basic ' + base64Url.encode(ascii.encode(url.userInfo))
}, body: {
'grant_type': 'authorization_code',
'client_id': 'freddie mercury',
'redirect_uri': 'https://freddie.mercu.ry',
'code': 'ok'
});
print(response.body);
expect(
response,
allOf(
hasStatus(400),
isJson({
"error": "invalid_request",
"error_description": "Missing `code_verifier` parameter."
}),
));
});
});
}
class _Server extends AuthorizationServer<PseudoApplication, Map> {
final Uuid _uuid = new Uuid();
@override
FutureOr<PseudoApplication> findClient(String clientId) {
return pseudoApplication;
}
@override
Future<bool> verifyClient(
PseudoApplication client, String clientSecret) async {
return client.secret == clientSecret;
}
@override
Future requestAuthorizationCode(
PseudoApplication client,
String redirectUri,
Iterable<String> scopes,
String state,
RequestContext req,
ResponseContext res) async {
req.container.make<Pkce>();
return {'code': 'ok'};
}
@override
Future<AuthorizationTokenResponse> exchangeAuthorizationCodeForToken(
String authCode,
String redirectUri,
RequestContext req,
ResponseContext res) async {
var codeVerifier = await getPkceCodeVerifier(req);
var pkce = new Pkce('plain', 'hello');
pkce.validate(codeVerifier);
return new AuthorizationTokenResponse('yes');
}
}
class AuthCodes extends MapBase<String, String> with MapMixin<String, String> {
var inner = <String, String>{};
@override
String operator [](Object key) => inner[key];
@override
void operator []=(String key, String value) => inner[key] = value;
@override
void clear() => inner.clear();
@override
Iterable<String> get keys => inner.keys;
@override
String remove(Object key) => inner.remove(key);
}