From ac2c7691ea01ff465d1b4e5d2ac9f857480753e7 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Sat, 15 Dec 2018 03:39:04 -0500 Subject: [PATCH] add example --- CHANGELOG.md | 1 + README.md | 71 ++++++++++- example/main.dart | 74 +++++++++++ lib/angel_oauth2.dart | 2 +- lib/src/pkce.dart | 4 +- lib/src/server.dart | 25 ++++ test/pkce_test.dart | 286 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 458 insertions(+), 5 deletions(-) create mode 100644 example/main.dart create mode 100644 test/pkce_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 3856cae5..c52cb561 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 1bc66f12..c255aa0b 100644 --- a/README.md +++ b/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. \ No newline at end of file +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 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(); + + // 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 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('...'); +} +``` \ No newline at end of file diff --git a/example/main.dart b/example/main.dart new file mode 100644 index 00000000..98e63af6 --- /dev/null +++ b/example/main.dart @@ -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 { + @override + FutureOr findClient(String clientId) { + // TODO: Add your code to find the app associated with a client ID. + throw new UnimplementedError(); + } + + @override + FutureOr 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 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 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(); + } +} diff --git a/lib/angel_oauth2.dart b/lib/angel_oauth2.dart index 9c3494f2..75d1e2c6 100644 --- a/lib/angel_oauth2.dart +++ b/lib/angel_oauth2.dart @@ -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'; \ No newline at end of file +export 'src/token_type.dart'; diff --git a/lib/src/pkce.dart b/lib/src/pkce.dart index 35bae15a..67b06cfd 100644 --- a/lib/src/pkce.dart +++ b/lib/src/pkce.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) { diff --git a/lib/src/server.dart b/lib/src/server.dart index c8aa690e..f69dd852 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -65,6 +65,31 @@ abstract class AuthorizationServer { /// Verify that a [client] is the one identified by the [clientSecret]. FutureOr verifyClient(Client client, String clientSecret); + /// Retrieves the PKCE `code_verifier` parameter from a [RequestContext], or throws. + Future 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. diff --git a/test/pkce_test.dart b/test/pkce_test.dart new file mode 100644 index 00000000..c7506d2d --- /dev/null +++ b/test/pkce_test.dart @@ -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 { + final Uuid _uuid = new Uuid(); + + @override + FutureOr findClient(String clientId) { + return pseudoApplication; + } + + @override + Future verifyClient( + PseudoApplication client, String clientSecret) async { + return client.secret == clientSecret; + } + + @override + Future requestAuthorizationCode( + PseudoApplication client, + String redirectUri, + Iterable scopes, + String state, + RequestContext req, + ResponseContext res) async { + req.container.make(); + return {'code': 'ok'}; + } + + @override + Future 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 with MapMixin { + var inner = {}; + + @override + String operator [](Object key) => inner[key]; + + @override + void operator []=(String key, String value) => inner[key] = value; + + @override + void clear() => inner.clear(); + + @override + Iterable get keys => inner.keys; + + @override + String remove(Object key) => inner.remove(key); +}