add example
This commit is contained in:
parent
32da3a16b5
commit
ac2c7691ea
7 changed files with 458 additions and 5 deletions
|
@ -1,6 +1,7 @@
|
|||
# 2.1.0
|
||||
* Updates
|
||||
* Support `device_code` grants.
|
||||
* Add support for [PKCE](https://tools.ietf.org/html/rfc7636).
|
||||
|
||||
# 2.0.0
|
||||
* Angel 2 support.
|
||||
|
|
71
README.md
71
README.md
|
@ -4,13 +4,19 @@
|
|||
|
||||
A class containing handlers that can be used within
|
||||
[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
|
||||
In your `pubspec.yaml`:
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
angel_framework: ^2.0.0-alpha
|
||||
angel_oauth2: ^2.0.0
|
||||
```
|
||||
|
||||
|
@ -102,4 +108,65 @@ The following are available, not including authorization code grant support (men
|
|||
* `deviceCodeGrant`
|
||||
|
||||
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
74
example/main.dart
Normal 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();
|
||||
}
|
||||
}
|
|
@ -2,4 +2,4 @@ export 'src/exception.dart';
|
|||
export 'src/pkce.dart';
|
||||
export 'src/response.dart';
|
||||
export 'src/server.dart';
|
||||
export 'src/token_type.dart';
|
||||
export 'src/token_type.dart';
|
||||
|
|
|
@ -51,9 +51,9 @@ class Pkce {
|
|||
|
||||
if (isS256) {
|
||||
foreignChallenge =
|
||||
base64Url.encode(sha256.convert(ascii.encode(codeChallenge)).bytes);
|
||||
base64Url.encode(sha256.convert(ascii.encode(codeVerifier)).bytes);
|
||||
} else {
|
||||
foreignChallenge = codeChallenge;
|
||||
foreignChallenge = codeVerifier;
|
||||
}
|
||||
|
||||
if (foreignChallenge != codeChallenge) {
|
||||
|
|
|
@ -65,6 +65,31 @@ abstract class AuthorizationServer<Client, User> {
|
|||
/// Verify that a [client] is the one identified by the [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].
|
||||
///
|
||||
/// In many applications, this will entail showing a dialog to the user in question.
|
||||
|
|
286
test/pkce_test.dart
Normal file
286
test/pkce_test.dart
Normal 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);
|
||||
}
|
Loading…
Reference in a new issue