From f8c59c98dcfe5225487316bc1e4ff4286c0ed396 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Mon, 23 Jan 2017 20:53:20 -0500 Subject: [PATCH 01/39] Initial commit --- .gitignore | 27 +++++++++++++++++++++++++++ LICENSE | 21 +++++++++++++++++++++ README.md | 2 ++ 3 files changed, 50 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..7c280441 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# See https://www.dartlang.org/tools/private-files.html + +# Files and directories created by pub +.buildlog +.packages +.project +.pub/ +build/ +**/packages/ + +# Files created by dart2js +# (Most Dart developers will use pub build to compile Dart, use/modify these +# rules if you intend to use dart2js directly +# Convention is to use extension '.dart.js' for Dart compiled to Javascript to +# differentiate from explicit Javascript files) +*.dart.js +*.part.js +*.js.deps +*.js.map +*.info.json + +# Directory created by dartdoc +doc/api/ + +# Don't commit pubspec lock file +# (Library packages only! Remove pattern if developing an application package) +pubspec.lock diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..3de28325 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Tobe O + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..5f53d1a6 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# oauth2_server +Server-side Dart implementation of the OAuth2 protocol. From 9a8f5d11114bbaa63de148d1e481aa8e8c2f7195 Mon Sep 17 00:00:00 2001 From: thosakwe Date: Mon, 23 Jan 2017 22:28:34 -0500 Subject: [PATCH 02/39] A LOT of work necessary... --- .DS_Store | Bin 0 -> 6148 bytes .gitignore | 1 + lib/.DS_Store | Bin 0 -> 6148 bytes lib/angel_auth_oauth2_server.dart | 0 lib/src.old/authorization_request.dart | 8 ++ lib/src.old/authorization_server.dart | 5 + lib/src.old/client.dart | 9 ++ lib/src.old/grant_type.dart | 16 ++++ lib/src.old/grants/authorization_code.dart | 13 +++ lib/src.old/resource_owner.dart | 6 ++ lib/src.old/resource_server.dart | 5 + lib/strategy.dart | 104 +++++++++++++++++++++ pubspec.yaml | 13 +++ 13 files changed, 180 insertions(+) create mode 100644 .DS_Store create mode 100644 lib/.DS_Store create mode 100644 lib/angel_auth_oauth2_server.dart create mode 100644 lib/src.old/authorization_request.dart create mode 100644 lib/src.old/authorization_server.dart create mode 100644 lib/src.old/client.dart create mode 100644 lib/src.old/grant_type.dart create mode 100644 lib/src.old/grants/authorization_code.dart create mode 100644 lib/src.old/resource_owner.dart create mode 100644 lib/src.old/resource_server.dart create mode 100644 lib/strategy.dart create mode 100644 pubspec.yaml diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..10f4d43063cf2afdea345ed0e8725c067b6d3ef7 GIT binary patch literal 6148 zcmeH~O=`nH427SXECStlndNM9fZkvT$q90S5(*^{5-7CmIeMRdHg&o#raXc4Mj8v- z-@;=7u>I%T3orrL&|R_fFf(Jm!W9>szfK>w>;3l5idTWBh?%i6VYXk}5)lvq5fA|p z5P<~|$Wt7f=LJ2J9z_I1U>OAb`_SmFy>z6;r-LCz0P33MFs@^kpf)d1d+A7Jg=RH9 zShZS=AzqJmYOCvd=}66XSPdUmcQ&75XqN4;#)M`)L_q{ZU`Ak-`Q+#Sk^bBKKWkAc z0wVCw2-x~?I_&vUb+$gdp4VTi>gz$L#^nq@egc^IQM{#xaliS3+Dk_&D>VHG1O^2W H_)`MkvH%f) literal 0 HcmV?d00001 diff --git a/.gitignore b/.gitignore index 7c280441..427e911b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ .packages .project .pub/ +.scripts-bin/ build/ **/packages/ diff --git a/lib/.DS_Store b/lib/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..89c0dbebb7f8a415a00c10c9e3246dbce154d313 GIT binary patch literal 6148 zcmeHKJxc>Y6r43F0yZfvx3xO-7aZa2#3op)Q9(h7^FDX? z25vt9q|Nwp1*`$A*n~;SjL1D3I`d><(ipqju^$e!E^+U%V+XB$fgT4ue-4xN_jskZ zM}q-->}WOEQX7?9tqxDr2m1HaMrv=k#sh0q`BnP{A9!bVGnwbkdGeW$oupV_Fc1s` z1Hr&aF~BohvN|`6J{SlFf`MNKv_E7vVe427>(;@pJ^_eJx~ovvTS7J2v2`qlj8Kx5 z60J1(6C+tU{n_(c$6{#ZNOfk8<1_y}UQ(T%{#?_MTEpmrfnZ?Dz{1S6&i@YFz^oyd;?KrCu{%! literal 0 HcmV?d00001 diff --git a/lib/angel_auth_oauth2_server.dart b/lib/angel_auth_oauth2_server.dart new file mode 100644 index 00000000..e69de29b diff --git a/lib/src.old/authorization_request.dart b/lib/src.old/authorization_request.dart new file mode 100644 index 00000000..7af02888 --- /dev/null +++ b/lib/src.old/authorization_request.dart @@ -0,0 +1,8 @@ +import 'grant_type.dart'; + +/// An authorization grant is a credential representing the resource +/// owner's authorization (to access its protected resources) used by the +/// client to obtain an access token. +abstract class AuthorizationRequest { + GrantType get type; +} \ No newline at end of file diff --git a/lib/src.old/authorization_server.dart b/lib/src.old/authorization_server.dart new file mode 100644 index 00000000..6abb9b9d --- /dev/null +++ b/lib/src.old/authorization_server.dart @@ -0,0 +1,5 @@ +/// The server issuing access tokens to the client after successfully +/// authenticating the resource owner and obtaining authorization. +abstract class AuthorizationServer { + +} \ No newline at end of file diff --git a/lib/src.old/client.dart b/lib/src.old/client.dart new file mode 100644 index 00000000..368d622e --- /dev/null +++ b/lib/src.old/client.dart @@ -0,0 +1,9 @@ +import 'dart:async'; +import 'authorization_request.dart'; + +/// An application making protected resource requests on behalf of the +/// resource owner and with its authorization. The term "client" does +/// not imply any particular implementation characteristics (e.g., +/// whether the application executes on a server, a desktop, or other +/// devices). +abstract class Client extends Stream {} diff --git a/lib/src.old/grant_type.dart b/lib/src.old/grant_type.dart new file mode 100644 index 00000000..2bed6195 --- /dev/null +++ b/lib/src.old/grant_type.dart @@ -0,0 +1,16 @@ +/// The four grant types defined in the OAuth2 specification. +enum GrantType { + AUTHORIZATION_CODE, + IMPLICIT, + RESOURCE_OWNER_PASSWORD_CREDENTIALS, + CLIENT_CREDENTIALS, + // TODO: OTHER +} + +/// `String` representations of the four main grant types. +const Map GRANT_TYPES = const { + GrantType.AUTHORIZATION_CODE: 'authorization_code', + GrantType.IMPLICIT: 'implicit' + // TODO: RESOURCE_OWNER_PASSWORD_CREDENTIALS + // TODO: CLIENT_CREDENTIALS +}; diff --git a/lib/src.old/grants/authorization_code.dart b/lib/src.old/grants/authorization_code.dart new file mode 100644 index 00000000..843070cf --- /dev/null +++ b/lib/src.old/grants/authorization_code.dart @@ -0,0 +1,13 @@ +import '../authorization_request.dart'; +import '../grant_type.dart'; + +/// The authorization code is obtained by using an authorization server +/// as an intermediary between the client and resource owner. Instead of +/// requesting authorization directly from the resource owner, the client +/// directs the resource owner to an authorization server (via its +/// user-agent as defined in [RFC2616]), which in turn directs the +/// resource owner back to the client with the authorization code. +class AuthorizationCodeGrantRequest implements AuthorizationRequest { + @override + GrantType get type => GrantType.AUTHORIZATION_CODE; +} diff --git a/lib/src.old/resource_owner.dart b/lib/src.old/resource_owner.dart new file mode 100644 index 00000000..af1b25bd --- /dev/null +++ b/lib/src.old/resource_owner.dart @@ -0,0 +1,6 @@ +/// An entity capable of granting access to a protected resource. +/// When the resource owner is a person, it is referred to as an +/// end-user. +class ResourceOwner { + +} \ No newline at end of file diff --git a/lib/src.old/resource_server.dart b/lib/src.old/resource_server.dart new file mode 100644 index 00000000..c4c7cfee --- /dev/null +++ b/lib/src.old/resource_server.dart @@ -0,0 +1,5 @@ +/// The server hosting the protected resources, capable of accepting +/// and responding to protected resource requests using access tokens. +abstract class ResourceServer { + +} \ No newline at end of file diff --git a/lib/strategy.dart b/lib/strategy.dart new file mode 100644 index 00000000..f1eedeee --- /dev/null +++ b/lib/strategy.dart @@ -0,0 +1,104 @@ +import 'dart:async'; +import 'package:angel_auth/angel_auth.dart'; +import 'package:angel_framework/angel_framework.dart'; + +abstract class Oauth2ServerStrategy extends AuthStrategy { + @override + final String name = 'oauth2-server'; + + @override + Future canLogout(RequestContext req, ResponseContext res) async => true; + + /// Convey to the user that one or more fields are missing. + /// + /// [field] can be a single value, or an `Iterable`. + AngelHttpException missingField(field) { + Iterable fields = + field is Iterable ? field.map((x) => x.toString()) : [field.toString()]; + + if (field == null) + throw new ArgumentError.notNull('field'); + else if (fields.isEmpty) + throw new ArgumentError( + 'Cannot provide an empty list of missing fields.'); + + return new AngelHttpException.badRequest( + message: + "Missing one or more of the following fields: " + fields.join(',')); + } + + /// Returns a map containing the given values of all [required] keys, + /// or throws a [missingField] error if any are missing. + Map ensureAll(RequestContext req, Iterable required) { + if (required.any((str) => + !req.body.containsKey(str) || + (req.body[str] is! String && req.body[str].isEmpty))) + throw missingField(required); + + return required + .fold({}, (map, key) => map..[key] = req.query[key]); + } + + @override + Future authenticate(RequestContext req, ResponseContext res, + [AngelAuthOptions options]) { + if (req.body['response_type'] is! String) { + return new Future.error(missingField('response_type')); + } else { + String responseType = req.body['response_type']; + + switch (responseType) { + case 'code': + return authorizationCodeGrant(req, res); + case 'authorization_code': + return accessTokenRequest(req, res); + default: + throw new AngelHttpException.badRequest( + message: "Unsupported grant type '$responseType'."); + } + } + } + + /// Generates an authorization code for a client who is requesting access. + Future createAuthorizationCode( + String clientId, String redirectUri, String scope, String state); + + /// Generates a redirect URL for a client who is requesting access via + /// an authorization code grant request. + /// + /// Do not include a query component, or trailing slashes. + Future createRedirectUrl( + String clientId, String redirectUri, String scope, String state); + + /// Performs an authorization code grant. + Future authorizationCodeGrant(RequestContext req, ResponseContext res) async { + var data = ensureAll(req, ['client_id']); + String clientId = data['client_id']; + String redirectUri = req.body['redirect_uri'], + scope = req.body['scope'], + state = req.body['state']; + var redirect = await createRedirectUrl(clientId, redirectUri?.toString(), + scope?.toString(), state?.toString()); + + var code = await createAuthorizationCode(clientId, redirectUri?.toString(), + scope?.toString(), state?.toString()); + + List query = ['code=' + Uri.encodeQueryComponent(code)]; + + if (state?.isNotEmpty == true) + query.add('state=' + Uri.encodeQueryComponent(state)); + + res.redirect(redirect + '?' + query.join('&')); + + // TODO: Support error responses: https://tools.ietf.org/html/rfc6749#section-4 + } + + /// Awards an access token to a successfully authenticated user. + Future accessTokenRequest( + RequestContext req, ResponseContext res) async { + var data = ensureAll(req, ['code', 'redirect_uri', 'client_id']); + String code = data['code'], + redirectUri = data['redirect_uri'], + clientId = data['client_id']; + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 00000000..f69e4adf --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,13 @@ +author: "Tobe O " +description: "angel_auth strategy for in-house OAuth2 login." +homepage: "https://github.com/thosakwe/oauth2_server.git" +name: "angel_auth_oauth2_server" +version: "0.0.0" +dependencies: + angel_auth: "^1.0.0-dev" + angel_framework: "^1.0.0-dev" +dev_dependencies: + angel_test: "^1.0.0-dev" + http: "^0.11.3+9" + oauth2: "^1.0.2" + test: "^0.12.18+1" From e0b512411c32c8d92f8fb621a8a215c8c9c2d68b Mon Sep 17 00:00:00 2001 From: Tobe O Date: Mon, 23 Jan 2017 22:32:09 -0500 Subject: [PATCH 03/39] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5f53d1a6..d9b53f83 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ -# oauth2_server -Server-side Dart implementation of the OAuth2 protocol. +# auth_oauth2_server +angel_auth strategy for in-house OAuth2. From c0c1a1e57211b8058302da86066b40cf559972cc Mon Sep 17 00:00:00 2001 From: Tobe O Date: Mon, 23 Jan 2017 22:32:24 -0500 Subject: [PATCH 04/39] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d9b53f83..27295930 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ # auth_oauth2_server -angel_auth strategy for in-house OAuth2. +angel_auth strategy for in-house OAuth2 login. From 8fe36310e94a55fedb02bef2dc5917a8465eb627 Mon Sep 17 00:00:00 2001 From: thosakwe Date: Sat, 11 Mar 2017 01:15:40 -0500 Subject: [PATCH 05/39] IDK where to even go... --- analysis_options.yaml | 2 + lib/.DS_Store | Bin 6148 -> 0 bytes lib/angel_auth_oauth2_server.dart | 0 lib/angel_oauth2_server.dart | 1 + lib/src.old/authorization_request.dart | 8 -- lib/src.old/authorization_server.dart | 5 - lib/src.old/client.dart | 9 -- lib/src.old/grant_type.dart | 16 ---- lib/src.old/grants/authorization_code.dart | 13 --- lib/src.old/resource_owner.dart | 6 -- lib/src.old/resource_server.dart | 5 - lib/src/grant.dart | 3 + lib/src/server.dart | 12 +++ lib/strategy.dart | 104 --------------------- pubspec.yaml | 3 +- 15 files changed, 19 insertions(+), 168 deletions(-) create mode 100644 analysis_options.yaml delete mode 100644 lib/.DS_Store delete mode 100644 lib/angel_auth_oauth2_server.dart create mode 100644 lib/angel_oauth2_server.dart delete mode 100644 lib/src.old/authorization_request.dart delete mode 100644 lib/src.old/authorization_server.dart delete mode 100644 lib/src.old/client.dart delete mode 100644 lib/src.old/grant_type.dart delete mode 100644 lib/src.old/grants/authorization_code.dart delete mode 100644 lib/src.old/resource_owner.dart delete mode 100644 lib/src.old/resource_server.dart create mode 100644 lib/src/grant.dart create mode 100644 lib/src/server.dart delete mode 100644 lib/strategy.dart diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 00000000..518eb901 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,2 @@ +analyzer: + strong-mode: true \ No newline at end of file diff --git a/lib/.DS_Store b/lib/.DS_Store deleted file mode 100644 index 89c0dbebb7f8a415a00c10c9e3246dbce154d313..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKJxc>Y6r43F0yZfvx3xO-7aZa2#3op)Q9(h7^FDX? z25vt9q|Nwp1*`$A*n~;SjL1D3I`d><(ipqju^$e!E^+U%V+XB$fgT4ue-4xN_jskZ zM}q-->}WOEQX7?9tqxDr2m1HaMrv=k#sh0q`BnP{A9!bVGnwbkdGeW$oupV_Fc1s` z1Hr&aF~BohvN|`6J{SlFf`MNKv_E7vVe427>(;@pJ^_eJx~ovvTS7J2v2`qlj8Kx5 z60J1(6C+tU{n_(c$6{#ZNOfk8<1_y}UQ(T%{#?_MTEpmrfnZ?Dz{1S6&i@YFz^oyd;?KrCu{%! diff --git a/lib/angel_auth_oauth2_server.dart b/lib/angel_auth_oauth2_server.dart deleted file mode 100644 index e69de29b..00000000 diff --git a/lib/angel_oauth2_server.dart b/lib/angel_oauth2_server.dart new file mode 100644 index 00000000..23bc99ba --- /dev/null +++ b/lib/angel_oauth2_server.dart @@ -0,0 +1 @@ +export 'src/server.dart'; \ No newline at end of file diff --git a/lib/src.old/authorization_request.dart b/lib/src.old/authorization_request.dart deleted file mode 100644 index 7af02888..00000000 --- a/lib/src.old/authorization_request.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'grant_type.dart'; - -/// An authorization grant is a credential representing the resource -/// owner's authorization (to access its protected resources) used by the -/// client to obtain an access token. -abstract class AuthorizationRequest { - GrantType get type; -} \ No newline at end of file diff --git a/lib/src.old/authorization_server.dart b/lib/src.old/authorization_server.dart deleted file mode 100644 index 6abb9b9d..00000000 --- a/lib/src.old/authorization_server.dart +++ /dev/null @@ -1,5 +0,0 @@ -/// The server issuing access tokens to the client after successfully -/// authenticating the resource owner and obtaining authorization. -abstract class AuthorizationServer { - -} \ No newline at end of file diff --git a/lib/src.old/client.dart b/lib/src.old/client.dart deleted file mode 100644 index 368d622e..00000000 --- a/lib/src.old/client.dart +++ /dev/null @@ -1,9 +0,0 @@ -import 'dart:async'; -import 'authorization_request.dart'; - -/// An application making protected resource requests on behalf of the -/// resource owner and with its authorization. The term "client" does -/// not imply any particular implementation characteristics (e.g., -/// whether the application executes on a server, a desktop, or other -/// devices). -abstract class Client extends Stream {} diff --git a/lib/src.old/grant_type.dart b/lib/src.old/grant_type.dart deleted file mode 100644 index 2bed6195..00000000 --- a/lib/src.old/grant_type.dart +++ /dev/null @@ -1,16 +0,0 @@ -/// The four grant types defined in the OAuth2 specification. -enum GrantType { - AUTHORIZATION_CODE, - IMPLICIT, - RESOURCE_OWNER_PASSWORD_CREDENTIALS, - CLIENT_CREDENTIALS, - // TODO: OTHER -} - -/// `String` representations of the four main grant types. -const Map GRANT_TYPES = const { - GrantType.AUTHORIZATION_CODE: 'authorization_code', - GrantType.IMPLICIT: 'implicit' - // TODO: RESOURCE_OWNER_PASSWORD_CREDENTIALS - // TODO: CLIENT_CREDENTIALS -}; diff --git a/lib/src.old/grants/authorization_code.dart b/lib/src.old/grants/authorization_code.dart deleted file mode 100644 index 843070cf..00000000 --- a/lib/src.old/grants/authorization_code.dart +++ /dev/null @@ -1,13 +0,0 @@ -import '../authorization_request.dart'; -import '../grant_type.dart'; - -/// The authorization code is obtained by using an authorization server -/// as an intermediary between the client and resource owner. Instead of -/// requesting authorization directly from the resource owner, the client -/// directs the resource owner to an authorization server (via its -/// user-agent as defined in [RFC2616]), which in turn directs the -/// resource owner back to the client with the authorization code. -class AuthorizationCodeGrantRequest implements AuthorizationRequest { - @override - GrantType get type => GrantType.AUTHORIZATION_CODE; -} diff --git a/lib/src.old/resource_owner.dart b/lib/src.old/resource_owner.dart deleted file mode 100644 index af1b25bd..00000000 --- a/lib/src.old/resource_owner.dart +++ /dev/null @@ -1,6 +0,0 @@ -/// An entity capable of granting access to a protected resource. -/// When the resource owner is a person, it is referred to as an -/// end-user. -class ResourceOwner { - -} \ No newline at end of file diff --git a/lib/src.old/resource_server.dart b/lib/src.old/resource_server.dart deleted file mode 100644 index c4c7cfee..00000000 --- a/lib/src.old/resource_server.dart +++ /dev/null @@ -1,5 +0,0 @@ -/// The server hosting the protected resources, capable of accepting -/// and responding to protected resource requests using access tokens. -abstract class ResourceServer { - -} \ No newline at end of file diff --git a/lib/src/grant.dart b/lib/src/grant.dart new file mode 100644 index 00000000..5cdcaecc --- /dev/null +++ b/lib/src/grant.dart @@ -0,0 +1,3 @@ +abstract class Grant { + +} \ No newline at end of file diff --git a/lib/src/server.dart b/lib/src/server.dart new file mode 100644 index 00000000..29bd8c7c --- /dev/null +++ b/lib/src/server.dart @@ -0,0 +1,12 @@ +import 'package:angel_auth/angel_auth.dart'; +import 'package:angel_framework/angel_framework.dart'; + +abstract class OAuth2Server { + final AngelAuth auth; + + RequestMiddleware verifyAuthToken() { + return (req, res) async { + + }; + } +} \ No newline at end of file diff --git a/lib/strategy.dart b/lib/strategy.dart deleted file mode 100644 index f1eedeee..00000000 --- a/lib/strategy.dart +++ /dev/null @@ -1,104 +0,0 @@ -import 'dart:async'; -import 'package:angel_auth/angel_auth.dart'; -import 'package:angel_framework/angel_framework.dart'; - -abstract class Oauth2ServerStrategy extends AuthStrategy { - @override - final String name = 'oauth2-server'; - - @override - Future canLogout(RequestContext req, ResponseContext res) async => true; - - /// Convey to the user that one or more fields are missing. - /// - /// [field] can be a single value, or an `Iterable`. - AngelHttpException missingField(field) { - Iterable fields = - field is Iterable ? field.map((x) => x.toString()) : [field.toString()]; - - if (field == null) - throw new ArgumentError.notNull('field'); - else if (fields.isEmpty) - throw new ArgumentError( - 'Cannot provide an empty list of missing fields.'); - - return new AngelHttpException.badRequest( - message: - "Missing one or more of the following fields: " + fields.join(',')); - } - - /// Returns a map containing the given values of all [required] keys, - /// or throws a [missingField] error if any are missing. - Map ensureAll(RequestContext req, Iterable required) { - if (required.any((str) => - !req.body.containsKey(str) || - (req.body[str] is! String && req.body[str].isEmpty))) - throw missingField(required); - - return required - .fold({}, (map, key) => map..[key] = req.query[key]); - } - - @override - Future authenticate(RequestContext req, ResponseContext res, - [AngelAuthOptions options]) { - if (req.body['response_type'] is! String) { - return new Future.error(missingField('response_type')); - } else { - String responseType = req.body['response_type']; - - switch (responseType) { - case 'code': - return authorizationCodeGrant(req, res); - case 'authorization_code': - return accessTokenRequest(req, res); - default: - throw new AngelHttpException.badRequest( - message: "Unsupported grant type '$responseType'."); - } - } - } - - /// Generates an authorization code for a client who is requesting access. - Future createAuthorizationCode( - String clientId, String redirectUri, String scope, String state); - - /// Generates a redirect URL for a client who is requesting access via - /// an authorization code grant request. - /// - /// Do not include a query component, or trailing slashes. - Future createRedirectUrl( - String clientId, String redirectUri, String scope, String state); - - /// Performs an authorization code grant. - Future authorizationCodeGrant(RequestContext req, ResponseContext res) async { - var data = ensureAll(req, ['client_id']); - String clientId = data['client_id']; - String redirectUri = req.body['redirect_uri'], - scope = req.body['scope'], - state = req.body['state']; - var redirect = await createRedirectUrl(clientId, redirectUri?.toString(), - scope?.toString(), state?.toString()); - - var code = await createAuthorizationCode(clientId, redirectUri?.toString(), - scope?.toString(), state?.toString()); - - List query = ['code=' + Uri.encodeQueryComponent(code)]; - - if (state?.isNotEmpty == true) - query.add('state=' + Uri.encodeQueryComponent(state)); - - res.redirect(redirect + '?' + query.join('&')); - - // TODO: Support error responses: https://tools.ietf.org/html/rfc6749#section-4 - } - - /// Awards an access token to a successfully authenticated user. - Future accessTokenRequest( - RequestContext req, ResponseContext res) async { - var data = ensureAll(req, ['code', 'redirect_uri', 'client_id']); - String code = data['code'], - redirectUri = data['redirect_uri'], - clientId = data['client_id']; - } -} diff --git a/pubspec.yaml b/pubspec.yaml index f69e4adf..f0b368b1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,8 +4,7 @@ homepage: "https://github.com/thosakwe/oauth2_server.git" name: "angel_auth_oauth2_server" version: "0.0.0" dependencies: - angel_auth: "^1.0.0-dev" - angel_framework: "^1.0.0-dev" + angel_auth: "^1.0.0" dev_dependencies: angel_test: "^1.0.0-dev" http: "^0.11.3+9" From 4a14b795f476e009f693422a4ffdf20c53b2121e Mon Sep 17 00:00:00 2001 From: Tobe O Date: Thu, 28 Sep 2017 22:16:44 -0400 Subject: [PATCH 06/39] Need to work out tokens --- .gitignore | 74 +++++++++++---- .idea/auth_oauth2_server.iml | 14 +++ .idea/modules.xml | 8 ++ .idea/vcs.xml | 6 ++ README.md | 92 ++++++++++++++++++- lib/angel_oauth2.dart | 3 + lib/angel_oauth2_server.dart | 1 - lib/src/auth_code.dart | 2 + lib/src/exception.dart | 36 ++++++++ lib/src/grant.dart | 3 - lib/src/response.dart | 12 +++ lib/src/server.dart | 123 +++++++++++++++++++++++-- lib/src/strategy.dart | 3 + lib/src/token_type.dart | 3 + pubspec.yaml | 21 +++-- test/auth_code_test.dart | 171 +++++++++++++++++++++++++++++++++++ test/common.dart | 8 ++ 17 files changed, 537 insertions(+), 43 deletions(-) create mode 100644 .idea/auth_oauth2_server.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 lib/angel_oauth2.dart delete mode 100644 lib/angel_oauth2_server.dart create mode 100644 lib/src/auth_code.dart create mode 100644 lib/src/exception.dart delete mode 100644 lib/src/grant.dart create mode 100644 lib/src/response.dart create mode 100644 lib/src/strategy.dart create mode 100644 lib/src/token_type.dart create mode 100644 test/auth_code_test.dart create mode 100644 test/common.dart diff --git a/.gitignore b/.gitignore index 427e911b..b4d6e266 100644 --- a/.gitignore +++ b/.gitignore @@ -1,28 +1,64 @@ +# Created by .ignore support plugin (hsz.mobi) +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/dictionaries + +# Sensitive or high-churn files: +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.xml +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml + +# Gradle: +.idea/**/gradle.xml +.idea/**/libraries + +# CMake +cmake-build-debug/ + +# Mongo Explorer plugin: +.idea/**/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties +### Dart template # See https://www.dartlang.org/tools/private-files.html # Files and directories created by pub -.buildlog .packages -.project .pub/ -.scripts-bin/ build/ -**/packages/ - -# Files created by dart2js -# (Most Dart developers will use pub build to compile Dart, use/modify these -# rules if you intend to use dart2js directly -# Convention is to use extension '.dart.js' for Dart compiled to Javascript to -# differentiate from explicit Javascript files) -*.dart.js -*.part.js -*.js.deps -*.js.map -*.info.json +# If you're building an application, you may want to check-in your pubspec.lock +pubspec.lock # Directory created by dartdoc +# If you don't generate documentation locally you can remove this line. doc/api/ - -# Don't commit pubspec lock file -# (Library packages only! Remove pattern if developing an application package) -pubspec.lock diff --git a/.idea/auth_oauth2_server.iml b/.idea/auth_oauth2_server.iml new file mode 100644 index 00000000..0fd729f3 --- /dev/null +++ b/.idea/auth_oauth2_server.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 00000000..2dde0608 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 00000000..35eb1ddf --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 27295930..a5b7fd1c 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,90 @@ -# auth_oauth2_server -angel_auth strategy for in-house OAuth2 login. +# auth_oauth2 +A class containing handlers that can be used within +[Angel](https://angel-dart.github.io/) to build a spec-compliant +OAuth 2.0 server. + +# Installation +In your `pubspec.yaml`: + +```yaml +dependencies: + angel_oauth2: ^1.0.0 +``` + +# Usage +Your server needs to have definitions of at least two types: +* One model that represents a third-party application (client) trying to access a user's profile. +* One that represents a user logged into the application. + +Define a server class as such: + +```dart +import 'package:angel_oauth2/angel_oauth2.dart' as oauth2; + +class MyServer extends oauth2.Server {} +``` + +Then, implement the `findClient` and `verifyClient` to ensure that the +server class can not only identify a client application via a `client_id`, +but that it can also verify its identity via a `client_secret`. + +```dart +class _Server extends Server { + final Uuid _uuid = new Uuid(); + + @override + FutureOr findClient(String clientId) { + return clientId == pseudoApplication.id ? pseudoApplication : null; + } + + @override + Future verifyClient( + PseudoApplication client, String clientSecret) async { + return client.secret == clientSecret; + } +} +``` + +Next, write some logic to be executed whenever a user visits the +authorization endpoint. In most cases, you will want to show a dialog: + +```dart +@override +Future authorize( + PseudoApplication client, + String redirectUri, + Iterable scopes, + String state, + RequestContext req, + ResponseContext res) async { + res.render('dialog'); +} +``` + +Now, write logic that exchanges an authorization code for an access token, +and optionally, a refresh token. + +```dart +@override +Future exchangeAuthCodeForAccessToken( + String authCode, + String redirectUri, + RequestContext req, + ResponseContext res) async { + return new AuthorizationCodeResponse('foo', refreshToken: 'bar'); +} +``` + +Now, set up some routes to point the server. + +```dart +void pseudoCode() { + app.group('/oauth2', (router) { + router + ..get('/authorize', server.authorizationEndpoint) + ..post('/token', server.tokenEndpoint); + }); +} +``` + +Naturally, \ No newline at end of file diff --git a/lib/angel_oauth2.dart b/lib/angel_oauth2.dart new file mode 100644 index 00000000..17ecd775 --- /dev/null +++ b/lib/angel_oauth2.dart @@ -0,0 +1,3 @@ +export 'src/response.dart'; +export 'src/server.dart'; +export 'src/token_type.dart'; \ No newline at end of file diff --git a/lib/angel_oauth2_server.dart b/lib/angel_oauth2_server.dart deleted file mode 100644 index 23bc99ba..00000000 --- a/lib/angel_oauth2_server.dart +++ /dev/null @@ -1 +0,0 @@ -export 'src/server.dart'; \ No newline at end of file diff --git a/lib/src/auth_code.dart b/lib/src/auth_code.dart new file mode 100644 index 00000000..0f9a5b3e --- /dev/null +++ b/lib/src/auth_code.dart @@ -0,0 +1,2 @@ +library auth_oauth2_server.src.auth_code; + diff --git a/lib/src/exception.dart b/lib/src/exception.dart new file mode 100644 index 00000000..0d0c0c5f --- /dev/null +++ b/lib/src/exception.dart @@ -0,0 +1,36 @@ +import 'package:angel_http_exception/angel_http_exception.dart'; + +class AuthorizationException extends AngelHttpException { + final ErrorResponse errorResponse; + + AuthorizationException(this.errorResponse, + {StackTrace stackTrace, int statusCode}) + : super(errorResponse, + stackTrace: stackTrace, message: '', statusCode: statusCode ?? 401); +} + +class ErrorResponse { + final String code, description; + + // Taken from https://www.docusign.com/p/RESTAPIGuide/Content/OAuth2/OAuth2%20Response%20Codes.htm + // TODO: Use original error messages + static const ErrorResponse invalidRequest = const ErrorResponse( + 'invalid_request', + 'The request was malformed, or contains unsupported parameters.'), + invalidClient = const ErrorResponse( + 'invalid_client', 'The client authentication failed.'), + invalidGrant = const ErrorResponse( + 'invalid_grant', 'The provided authorization is invalid.'), + unauthorizedClient = const ErrorResponse('unauthorized_client', + 'The client application is not allowed to use this grant_type.'), + unauthorizedGrantType = const ErrorResponse('unsupported_grant_type', + 'A grant_type other than “password” was used in the request.'), + invalidScope = const ErrorResponse( + 'invalid_scope', 'One or more of the scopes you provided was invalid.'), + unsupportedTokenType = const ErrorResponse('unsupported_token_type', + 'The client tried to revoke an access token on a server not supporting this feature.'), + invalidToken = const ErrorResponse( + 'invalid_token', 'The presented token is invalid.'); + + const ErrorResponse(this.code, this.description); +} diff --git a/lib/src/grant.dart b/lib/src/grant.dart deleted file mode 100644 index 5cdcaecc..00000000 --- a/lib/src/grant.dart +++ /dev/null @@ -1,3 +0,0 @@ -abstract class Grant { - -} \ No newline at end of file diff --git a/lib/src/response.dart b/lib/src/response.dart new file mode 100644 index 00000000..c77407b6 --- /dev/null +++ b/lib/src/response.dart @@ -0,0 +1,12 @@ +class AuthorizationCodeResponse { + final String accessToken; + final String refreshToken; + + const AuthorizationCodeResponse(this.accessToken, {this.refreshToken}); + + Map toJson() { + var map = {'access_token': accessToken}; + if (refreshToken?.isNotEmpty == true) map['refresh_token'] = refreshToken; + return map; + } +} \ No newline at end of file diff --git a/lib/src/server.dart b/lib/src/server.dart index 29bd8c7c..103633da 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -1,12 +1,119 @@ -import 'package:angel_auth/angel_auth.dart'; +import 'dart:async'; import 'package:angel_framework/angel_framework.dart'; +import 'exception.dart'; +import 'response.dart'; +import 'token_type.dart'; -abstract class OAuth2Server { - final AngelAuth auth; +String _getParam(RequestContext req, String name, {bool body: false}) { + var map = body == true ? req.body : req.query; + var value = map.containsKey(name) ? map[name]?.toString() : null; - RequestMiddleware verifyAuthToken() { - return (req, res) async { - - }; + if (value?.isNotEmpty != true) + throw new AngelHttpException.badRequest( + message: "Missing required parameter '$name'."); + + return value; +} + +Iterable _getScopes(RequestContext req, {bool body: false}) { + var map = body == true ? req.body : req.query; + return map['scope']?.toString()?.split(' ') ?? []; +} + +abstract class Server { + const Server(); + + /// Finds the [Client] application associated with the given [clientId]. + FutureOr findClient(String clientId); + + Future verifyClient(Client client, String clientSecret); + + Future authCodeGrant(Client client, String redirectUri, User user, + Iterable scopes, String state); + + authorize(Client client, String redirectUri, Iterable scopes, + String state, RequestContext req, ResponseContext res); + + Future exchangeAuthCodeForAccessToken( + String authCode, + String redirectUri, + RequestContext req, + ResponseContext res); + + Future authorizationEndpoint(RequestContext req, ResponseContext res) async { + var responseType = _getParam(req, 'response_type'); + + if (responseType != 'code') + throw new AngelHttpException.badRequest( + message: "Invalid response_type, expected 'code'."); + + // Ensure client ID + var clientId = _getParam(req, 'client_id'); + + // Find client + var client = await findClient(clientId); + + if (client == null) + throw new AuthorizationException(ErrorResponse.invalidClient); + + // Grab redirect URI + var redirectUri = _getParam(req, 'redirect_uri'); + + // Grab scopes + var scopes = _getScopes(req); + + var state = req.query['state']?.toString() ?? ''; + + return await authorize(client, redirectUri, scopes, state, req, res); } -} \ No newline at end of file + + Future tokenEndpoint(RequestContext req, ResponseContext res) async { + await req.parse(); + + var grantType = _getParam(req, 'grant_type', body: true); + + if (grantType != 'authorization_code') + throw new AngelHttpException.badRequest( + message: "Invalid grant_type; expected 'authorization_code'."); + + var code = _getParam(req, 'code', body: true); + var redirectUri = _getParam(req, 'redirect_uri', body: true); + + var response = + await exchangeAuthCodeForAccessToken(code, redirectUri, req, res); + return {'token_type': TokenType.bearer}..addAll(response.toJson()); + } + + Future handleFormSubmission(RequestContext req, ResponseContext res) async { + await req.parse(); + + // Ensure client ID + var clientId = _getParam(req, 'client_id', body: true); + + // Find client + var client = await findClient(clientId); + + if (client == null) + throw new AuthorizationException(ErrorResponse.invalidClient); + + // Verify client secret + var clientSecret = _getParam(req, 'client_secret', body: true); + + if (!await verifyClient(client, clientSecret)) + throw new AuthorizationException(ErrorResponse.invalidClient); + + // Grab redirect URI + var redirectUri = _getParam(req, 'redirect_uri', body: true); + + // Grab scopes + var scopes = _getScopes(req, body: true); + + var state = req.query['state']?.toString() ?? ''; + + var authCode = await authCodeGrant( + client, redirectUri, req.properties['user'], scopes, state); + res.headers['content-type'] = 'application/x-www-form-urlencoded'; + res.write('code=' + Uri.encodeComponent(authCode)); + if (state.isNotEmpty) res.write('&state=' + Uri.encodeComponent(state)); + } +} diff --git a/lib/src/strategy.dart b/lib/src/strategy.dart new file mode 100644 index 00000000..bdcd6129 --- /dev/null +++ b/lib/src/strategy.dart @@ -0,0 +1,3 @@ +import 'package:angel_auth/angel_auth.dart'; +import 'server.dart'; + diff --git a/lib/src/token_type.dart b/lib/src/token_type.dart new file mode 100644 index 00000000..2f7de7f9 --- /dev/null +++ b/lib/src/token_type.dart @@ -0,0 +1,3 @@ +abstract class TokenType { + static const String bearer = 'bearer', mac = 'mac'; +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index f0b368b1..1241c0bf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,12 +1,13 @@ -author: "Tobe O " -description: "angel_auth strategy for in-house OAuth2 login." -homepage: "https://github.com/thosakwe/oauth2_server.git" -name: "angel_auth_oauth2_server" -version: "0.0.0" +name: angel_oauth2 +author: Tobe O +description: angel_auth strategy for in-house OAuth2 login. +homepage: https://github.com/thosakwe/oauth2_server.git +version: 1.0.0-alpha +environment: + sdk: ">=1.19.0" dependencies: - angel_auth: "^1.0.0" + angel_auth: ^1.1.0-alpha dev_dependencies: - angel_test: "^1.0.0-dev" - http: "^0.11.3+9" - oauth2: "^1.0.2" - test: "^0.12.18+1" + angel_test: ^1.1.0-alpha + oauth2: ^1.0.0 + test: ^0.12.0 diff --git a/test/auth_code_test.dart b/test/auth_code_test.dart new file mode 100644 index 00000000..91f43cf4 --- /dev/null +++ b/test/auth_code_test.dart @@ -0,0 +1,171 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:angel_framework/angel_framework.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()..lazyParseBodies = true; + app.configuration['properties'] = app.configuration; + app.inject('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 = await app.startServer(); + var url = 'http://${http.address.address}:${http.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('auth code', () { + oauth2.AuthorizationCodeGrant createGrant() => + new oauth2.AuthorizationCodeGrant( + pseudoApplication.id, + authorizationEndpoint, + tokenEndpoint, + secret: pseudoApplication.secret, + ); + + test('show authorization form', () async { + var grant = createGrant(); + var url = grant.getAuthorizationUrl(redirectUri, state: 'hello'); + var response = await testClient.client.get(url); + print('Body: ${response.body}'); + expect( + response.body, + JSON.encode( + 'Hello ${pseudoApplication.id}:${pseudoApplication.secret}')); + }); + + test('preserves state', () async { + var grant = createGrant(); + var url = grant.getAuthorizationUrl(redirectUri, state: 'goodbye'); + var response = await testClient.client.get(url); + print('Body: ${response.body}'); + expect(JSON.decode(response.body)['state'], 'goodbye'); + }); + + test('sends auth code', () async { + var grant = createGrant(); + var url = grant.getAuthorizationUrl(redirectUri); + var response = await testClient.client.get(url); + print('Body: ${response.body}'); + expect( + JSON.decode(response.body), + allOf( + isMap, + predicate((Map m) => m.containsKey('code'), 'contains "code"'), + ), + ); + }); + + test('exchange code for token', () async { + var grant = createGrant(); + var url = grant.getAuthorizationUrl(redirectUri); + var response = await testClient.client.get(url); + print('Body: ${response.body}'); + + var authCode = JSON.decode(response.body)['code']; + var client = await grant.handleAuthorizationCode(authCode); + expect(client.credentials.accessToken, authCode + '_access'); + }); + + test('can send refresh token', () async { + var grant = createGrant(); + var url = grant.getAuthorizationUrl(redirectUri, state: 'can_refresh'); + var response = await testClient.client.get(url); + print('Body: ${response.body}'); + + var authCode = JSON.decode(response.body)['code']; + var client = await grant.handleAuthorizationCode(authCode); + expect(client.credentials.accessToken, authCode + '_access'); + expect(client.credentials.canRefresh, isTrue); + expect(client.credentials.refreshToken, authCode + '_refresh'); + }); + }); +} + +class _Server extends Server { + final Uuid _uuid = new Uuid(); + + @override + FutureOr findClient(String clientId) { + return clientId == pseudoApplication.id ? pseudoApplication : null; + } + + @override + Future verifyClient( + PseudoApplication client, String clientSecret) async { + return client.secret == clientSecret; + } + + @override + Future authorize( + PseudoApplication client, + String redirectUri, + Iterable scopes, + String state, + RequestContext req, + ResponseContext res) async { + if (state == 'hello') + return 'Hello ${pseudoApplication.id}:${pseudoApplication.secret}'; + + var authCode = _uuid.v4(); + var authCodes = req.grab>('authCodes'); + authCodes[authCode] = state; + + res.headers['content-type'] = 'application/json'; + var result = {'code': authCode}; + if (state?.isNotEmpty == true) result['state'] = state; + return result; + } + + @override + Future authCodeGrant(PseudoApplication client, String redirectUri, + Map user, Iterable scopes, String state) { + throw new UnsupportedError('Nope'); + } + + @override + Future exchangeAuthCodeForAccessToken( + String authCode, + String redirectUri, + RequestContext req, + ResponseContext res) async { + var authCodes = req.grab>('authCodes'); + var state = authCodes[authCode]; + var refreshToken = state == 'can_refresh' ? '${authCode}_refresh' : null; + return new AuthorizationCodeResponse('${authCode}_access', + refreshToken: refreshToken); + } +} diff --git a/test/common.dart b/test/common.dart new file mode 100644 index 00000000..14f0afd4 --- /dev/null +++ b/test/common.dart @@ -0,0 +1,8 @@ +const PseudoApplication pseudoApplication = + const PseudoApplication('foo', 'bar', 'http://foo.bar/baz'); + +class PseudoApplication { + final String id, secret, redirectUri; + + const PseudoApplication(this.id, this.secret, this.redirectUri); +} From 27770c1a69bf27576d79c25de907cc2662cb45be Mon Sep 17 00:00:00 2001 From: Tobe O Date: Thu, 28 Sep 2017 22:17:00 -0400 Subject: [PATCH 07/39] analysis --- .travis.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .travis.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..de2210c9 --- /dev/null +++ b/.travis.yml @@ -0,0 +1 @@ +language: dart \ No newline at end of file From 257d3ce316fbd34ce7cf8aa5853c99f63320d3b2 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Mon, 16 Oct 2017 02:38:46 -0400 Subject: [PATCH 08/39] All grants ready --- README.md | 21 +- lib/angel_oauth2.dart | 1 + lib/src/auth_code.dart | 2 - lib/src/exception.dart | 72 ++++-- lib/src/response.dart | 23 +- lib/src/server.dart | 405 +++++++++++++++++++++++++----- lib/src/strategy.dart | 3 - lib/src/token_type.dart | 3 +- pubspec.yaml | 2 +- test/auth_code_test.dart | 14 +- test/client_credentials_test.dart | 105 ++++++++ test/common.dart | 12 + test/implicit_grant_test.dart | 69 +++++ test/password_test.dart | 114 +++++++++ 14 files changed, 733 insertions(+), 113 deletions(-) delete mode 100644 lib/src/auth_code.dart delete mode 100644 lib/src/strategy.dart create mode 100644 test/client_credentials_test.dart create mode 100644 test/implicit_grant_test.dart create mode 100644 test/password_test.dart diff --git a/README.md b/README.md index a5b7fd1c..21430c35 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# auth_oauth2 +# oauth2 A class containing handlers that can be used within [Angel](https://angel-dart.github.io/) to build a spec-compliant OAuth 2.0 server. @@ -21,7 +21,7 @@ Define a server class as such: ```dart import 'package:angel_oauth2/angel_oauth2.dart' as oauth2; -class MyServer extends oauth2.Server {} +class MyServer extends oauth2.AuthorizationServer {} ``` Then, implement the `findClient` and `verifyClient` to ensure that the @@ -29,7 +29,7 @@ server class can not only identify a client application via a `client_id`, but that it can also verify its identity via a `client_secret`. ```dart -class _Server extends Server { +class _Server extends AuthorizationServer { final Uuid _uuid = new Uuid(); @override @@ -50,7 +50,7 @@ authorization endpoint. In most cases, you will want to show a dialog: ```dart @override -Future authorize( +Future requestAuthorizationCode( PseudoApplication client, String redirectUri, Iterable scopes, @@ -87,4 +87,15 @@ void pseudoCode() { } ``` -Naturally, \ No newline at end of file +The `authorizationEndpoint` and `tokenEndpoint` handle all OAuth2 grant types. + +## Other Grants +By default, all OAuth2 grant methods will throw a `405 Method Not Allowed` error. +To support any specific grant type, all you need to do is implement the method. +The following are available, not including authorization code grant support (mentioned above): +* `implicitGrant` +* `resourceOwnerPasswordCredentialsGrant` +* `clientCredentialsGrant` + +Read the [OAuth2 specification](https://tools.ietf.org/html/rfc6749) +for in-depth information on each grant type. \ No newline at end of file diff --git a/lib/angel_oauth2.dart b/lib/angel_oauth2.dart index 17ecd775..6f67dab1 100644 --- a/lib/angel_oauth2.dart +++ b/lib/angel_oauth2.dart @@ -1,3 +1,4 @@ +export 'src/exception.dart'; export 'src/response.dart'; export 'src/server.dart'; export 'src/token_type.dart'; \ No newline at end of file diff --git a/lib/src/auth_code.dart b/lib/src/auth_code.dart deleted file mode 100644 index 0f9a5b3e..00000000 --- a/lib/src/auth_code.dart +++ /dev/null @@ -1,2 +0,0 @@ -library auth_oauth2_server.src.auth_code; - diff --git a/lib/src/exception.dart b/lib/src/exception.dart index 0d0c0c5f..3f33f4c8 100644 --- a/lib/src/exception.dart +++ b/lib/src/exception.dart @@ -1,36 +1,62 @@ import 'package:angel_http_exception/angel_http_exception.dart'; +/// An Angel-friendly wrapper around OAuth2 [ErrorResponse] instances. class AuthorizationException extends AngelHttpException { final ErrorResponse errorResponse; AuthorizationException(this.errorResponse, - {StackTrace stackTrace, int statusCode}) - : super(errorResponse, + {StackTrace stackTrace, int statusCode, error}) + : super(error ?? errorResponse, stackTrace: stackTrace, message: '', statusCode: statusCode ?? 401); + + @override + Map toJson() { + var m = { + 'error': errorResponse.code, + 'error_description': errorResponse.description, + }; + + if (errorResponse.uri != null) + m['error_uri'] = errorResponse.uri.toString(); + + return m; + } } +/// Represents an OAuth2 authentication error. class ErrorResponse { - final String code, description; + /// The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. + static const String invalidRequest = 'invalid_request'; - // Taken from https://www.docusign.com/p/RESTAPIGuide/Content/OAuth2/OAuth2%20Response%20Codes.htm - // TODO: Use original error messages - static const ErrorResponse invalidRequest = const ErrorResponse( - 'invalid_request', - 'The request was malformed, or contains unsupported parameters.'), - invalidClient = const ErrorResponse( - 'invalid_client', 'The client authentication failed.'), - invalidGrant = const ErrorResponse( - 'invalid_grant', 'The provided authorization is invalid.'), - unauthorizedClient = const ErrorResponse('unauthorized_client', - 'The client application is not allowed to use this grant_type.'), - unauthorizedGrantType = const ErrorResponse('unsupported_grant_type', - 'A grant_type other than “password” was used in the request.'), - invalidScope = const ErrorResponse( - 'invalid_scope', 'One or more of the scopes you provided was invalid.'), - unsupportedTokenType = const ErrorResponse('unsupported_token_type', - 'The client tried to revoke an access token on a server not supporting this feature.'), - invalidToken = const ErrorResponse( - 'invalid_token', 'The presented token is invalid.'); + /// The client is not authorized to request an authorization code using this method. + static const String unauthorizedClient = 'unauthorized_client'; - const ErrorResponse(this.code, this.description); + /// The resource owner or authorization server denied the request. + static const String accessDenied = 'access_denied'; + + /// The authorization server does not support obtaining an authorization code using this method. + static const String unsupportedResponseType = 'unsupported_response_type'; + + /// The requested scope is invalid, unknown, or malformed. + static const String invalidScope = 'invalid_scope'; + + /// The authorization server encountered an unexpected condition that prevented it from fulfilling the request. + static const String serverError = 'server_error'; + + /// The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server. + static const String temporarilyUnavailable = 'temporarily_unavailable'; + + /// A short string representing the error. + final String code; + + /// A relatively detailed description of the source of the error. + final String description; + + /// An optional [Uri] directing users to more information about the error. + final Uri uri; + + /// The exact value received from the client, if a "state" parameter was present in the client authorization request. + final String state; + + const ErrorResponse(this.code, this.description, this.state, {this.uri}); } diff --git a/lib/src/response.dart b/lib/src/response.dart index c77407b6..fe26c1df 100644 --- a/lib/src/response.dart +++ b/lib/src/response.dart @@ -1,12 +1,25 @@ -class AuthorizationCodeResponse { +/// Represents an OAuth2 authorization token. +class AuthorizationTokenResponse { + /// The string that third parties should use to act on behalf of the user in question. final String accessToken; + + /// An optional key that can be used to refresh the [accessToken] past its expiration. final String refreshToken; - const AuthorizationCodeResponse(this.accessToken, {this.refreshToken}); + /// An optional, but recommended integer that signifies the time left until the [accessToken] expires. + final int expiresIn; - Map toJson() { - var map = {'access_token': accessToken}; + /// Optional, if identical to the scope requested by the client; otherwise, required. + final Iterable scope; + + const AuthorizationTokenResponse(this.accessToken, + {this.refreshToken, this.expiresIn, this.scope}); + + Map toJson() { + var map = {'access_token': accessToken}; if (refreshToken?.isNotEmpty == true) map['refresh_token'] = refreshToken; + if (expiresIn != null) map['expires_in'] = expiresIn; + if (scope != null) map['scope'] = scope.toList(); return map; } -} \ No newline at end of file +} diff --git a/lib/src/server.dart b/lib/src/server.dart index 103633da..d42883c8 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -1,16 +1,29 @@ import 'dart:async'; +import 'dart:convert'; import 'package:angel_framework/angel_framework.dart'; import 'exception.dart'; import 'response.dart'; import 'token_type.dart'; -String _getParam(RequestContext req, String name, {bool body: false}) { +/// A request handler that performs an arbitrary authorization token grant. +typedef Future ExtensionGrant( + RequestContext req, ResponseContext res); + +String _getParam(RequestContext req, String name, String state, + {bool body: false}) { var map = body == true ? req.body : req.query; var value = map.containsKey(name) ? map[name]?.toString() : null; - if (value?.isNotEmpty != true) - throw new AngelHttpException.badRequest( - message: "Missing required parameter '$name'."); + if (value?.isNotEmpty != true) { + throw new AuthorizationException( + new ErrorResponse( + ErrorResponse.invalidRequest, + 'Missing required parameter "$name".', + state, + ), + statusCode: 400, + ); + } return value; } @@ -20,100 +33,366 @@ Iterable _getScopes(RequestContext req, {bool body: false}) { return map['scope']?.toString()?.split(' ') ?? []; } -abstract class Server { - const Server(); +/// An OAuth2 authorization server, which issues access tokens to third parties. +abstract class AuthorizationServer { + const AuthorizationServer(); + + static const String _internalServerError = + 'An internal server error occurred.'; + + /// A [Map] of custom authorization token grants. Use this to handle custom grant types, perhaps even your own. + Map get extensionGrants => {}; /// Finds the [Client] application associated with the given [clientId]. FutureOr findClient(String clientId); + // TODO: Is this ever used??? + /// Verify that a [client] is the one identified by the [clientSecret]. Future verifyClient(Client client, String clientSecret); - Future authCodeGrant(Client client, String redirectUri, User user, - Iterable scopes, String state); + /// 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. + requestAuthorizationCode( + Client client, + String redirectUri, + Iterable scopes, + String state, + RequestContext req, + ResponseContext res) { + throw new AuthorizationException( + new ErrorResponse( + ErrorResponse.unsupportedResponseType, + 'Authorization code grants are not supported.', + state, + ), + statusCode: 405, + ); + } - authorize(Client client, String redirectUri, Iterable scopes, - String state, RequestContext req, ResponseContext res); + /// Create an implicit authorization token. + /// + /// Note that in cases where this is called, there is no guarantee + /// that the user agent has not been compromised. + Future implicitGrant( + Client client, + String redirectUri, + Iterable scopes, + String state, + RequestContext req, + ResponseContext res) { + throw new AuthorizationException( + new ErrorResponse( + ErrorResponse.unsupportedResponseType, + 'Authorization code grants are not supported.', + state, + ), + statusCode: 405, + ); + } - Future exchangeAuthCodeForAccessToken( + /// Exchanges an authorization code for an authorization token. + Future exchangeAuthorizationCodeForToken( String authCode, String redirectUri, RequestContext req, - ResponseContext res); + ResponseContext res) { + throw new AuthorizationException( + new ErrorResponse( + ErrorResponse.unsupportedResponseType, + 'Authorization code grants are not supported.', + req.query['state'] ?? '', + ), + statusCode: 405, + ); + } + /// Refresh an authorization token. + Future refreshAuthorizationToken( + Client client, + String refreshToken, + Iterable scopes, + RequestContext req, + ResponseContext res) { + throw new AuthorizationException( + new ErrorResponse( + ErrorResponse.unsupportedResponseType, + 'Refreshing authorization tokens is not supported.', + req.body['state'] ?? '', + ), + statusCode: 405, + ); + } + + /// Issue an authorization token to a user after authenticating them via [username] and [password]. + Future resourceOwnerPasswordCredentialsGrant( + Client client, + String username, + String password, + Iterable scopes, + RequestContext req, + ResponseContext res) { + throw new AuthorizationException( + new ErrorResponse( + ErrorResponse.unsupportedResponseType, + 'Resource owner password credentials grants are not supported.', + req.body['state'] ?? '', + ), + statusCode: 405, + ); + } + + /// Performs a client credentials grant. Only use this in situations where the client is 100% trusted. + Future clientCredentialsGrant( + Client client, RequestContext req, ResponseContext res) { + throw new AuthorizationException( + new ErrorResponse( + ErrorResponse.unsupportedResponseType, + 'Client credentials grants are not supported.', + req.body['state'] ?? '', + ), + statusCode: 405, + ); + } + + /// A request handler that invokes the correct logic, depending on which type + /// of grant the client is requesting. Future authorizationEndpoint(RequestContext req, ResponseContext res) async { - var responseType = _getParam(req, 'response_type'); + String state = ''; - if (responseType != 'code') - throw new AngelHttpException.badRequest( - message: "Invalid response_type, expected 'code'."); + try { + state = req.query['state']?.toString() ?? ''; + var responseType = _getParam(req, 'response_type', state); - // Ensure client ID - var clientId = _getParam(req, 'client_id'); + if (responseType == 'code') { + // Ensure client ID + // TODO: Handle confidential clients + var clientId = _getParam(req, 'client_id', state); - // Find client - var client = await findClient(clientId); + // Find client + var client = await findClient(clientId); - if (client == null) - throw new AuthorizationException(ErrorResponse.invalidClient); + if (client == null) { + throw new AuthorizationException(new ErrorResponse( + ErrorResponse.unauthorizedClient, + 'Unknown client "$clientId".', + state, + )); + } - // Grab redirect URI - var redirectUri = _getParam(req, 'redirect_uri'); + // Grab redirect URI + var redirectUri = _getParam(req, 'redirect_uri', state); - // Grab scopes - var scopes = _getScopes(req); + // Grab scopes + var scopes = _getScopes(req); - var state = req.query['state']?.toString() ?? ''; + return await requestAuthorizationCode( + client, redirectUri, scopes, state, req, res); + } - return await authorize(client, redirectUri, scopes, state, req, res); + if (responseType == 'token') { + var clientId = _getParam(req, 'client_id', state); + var client = await findClient(clientId); + + if (client == null) { + throw new AuthorizationException(new ErrorResponse( + ErrorResponse.unauthorizedClient, + 'Unknown client "$clientId".', + state, + )); + } + + var redirectUri = _getParam(req, 'redirect_uri', state); + + // Grab scopes + var scopes = _getScopes(req); + var token = + await implicitGrant(client, redirectUri, scopes, state, req, res); + + Uri target; + + try { + target = Uri.parse(redirectUri); + var queryParameters = {}; + + queryParameters.addAll({ + 'access_token': token.accessToken, + 'token_type': 'bearer', + 'state': state, + }); + + if (token.expiresIn != null) + queryParameters['expires_in'] = token.expiresIn.toString(); + + if (token.scope != null) + queryParameters['scope'] = token.scope.join(' '); + + var fragment = queryParameters.keys + .fold(new StringBuffer(), (buf, k) { + if (buf.isNotEmpty) buf.write('&'); + return buf + ..write( + '$k=' + Uri.encodeComponent(queryParameters[k]), + ); + }).toString(); + + target = target.replace(fragment: fragment); + res.redirect(target.toString()); + return false; + } on FormatException { + throw new AuthorizationException( + new ErrorResponse( + ErrorResponse.invalidRequest, + 'Invalid URI provided as "redirect_uri" parameter', + state, + ), + statusCode: 400); + } + } + + throw new AuthorizationException( + new ErrorResponse( + ErrorResponse.invalidRequest, + 'Invalid or no "response_type" parameter provided', + state, + ), + statusCode: 400); + } on AngelHttpException { + rethrow; + } catch (e, st) { + throw new AuthorizationException( + new ErrorResponse( + ErrorResponse.serverError, + _internalServerError, + state, + ), + error: e, + statusCode: 500, + stackTrace: st, + ); + } } + static final RegExp _rgxBasic = new RegExp(r'Basic ([^$]+)'); + static final RegExp _rgxBasicAuth = new RegExp(r'([^:]*):([^$]*)'); + + /// A request handler that either exchanges authorization codes for authorization tokens, + /// or refreshes authorization tokens. Future tokenEndpoint(RequestContext req, ResponseContext res) async { - await req.parse(); + String state = ''; + Client client; - var grantType = _getParam(req, 'grant_type', body: true); + try { + AuthorizationTokenResponse response; + await req.parse(); - if (grantType != 'authorization_code') - throw new AngelHttpException.badRequest( - message: "Invalid grant_type; expected 'authorization_code'."); + state = req.body['state'] ?? ''; - var code = _getParam(req, 'code', body: true); - var redirectUri = _getParam(req, 'redirect_uri', body: true); + var grantType = _getParam(req, 'grant_type', state, body: true); - var response = - await exchangeAuthCodeForAccessToken(code, redirectUri, req, res); - return {'token_type': TokenType.bearer}..addAll(response.toJson()); - } + if (grantType != 'authorization_code') { + var match = + _rgxBasic.firstMatch(req.headers.value('authorization') ?? ''); - Future handleFormSubmission(RequestContext req, ResponseContext res) async { - await req.parse(); + if (match != null) { + match = _rgxBasicAuth + .firstMatch(new String.fromCharCodes(BASE64URL.decode(match[1]))); + } - // Ensure client ID - var clientId = _getParam(req, 'client_id', body: true); + if (match == null) { + throw new AuthorizationException( + new ErrorResponse( + ErrorResponse.unauthorizedClient, + 'Invalid or no "Authorization" header.', + state, + ), + statusCode: 400, + ); + } else { + var clientId = match[1], clientSecret = match[2]; + client = await findClient(clientId); - // Find client - var client = await findClient(clientId); + if (client == null) { + throw new AuthorizationException( + new ErrorResponse( + ErrorResponse.unauthorizedClient, + 'Invalid "client_id" parameter.', + state, + ), + statusCode: 401, + ); + } - if (client == null) - throw new AuthorizationException(ErrorResponse.invalidClient); + if (!await verifyClient(client, clientSecret)) { + throw new AuthorizationException( + new ErrorResponse( + ErrorResponse.unauthorizedClient, + 'Invalid "client_secret" parameter.', + state, + ), + statusCode: 401, + ); + } + } + } - // Verify client secret - var clientSecret = _getParam(req, 'client_secret', body: true); + if (grantType == 'authorization_code') { + var code = _getParam(req, 'code', state, body: true); + var redirectUri = _getParam(req, 'redirect_uri', state, body: true); + response = await exchangeAuthorizationCodeForToken( + code, redirectUri, req, res); + } else if (grantType == 'refresh_token') { + var refreshToken = _getParam(req, 'refresh_token', state, body: true); + var scopes = _getScopes(req); + response = await refreshAuthorizationToken( + client, refreshToken, scopes, req, res); + } else if (grantType == 'password') { + var username = _getParam(req, 'username', state, body: true); + var password = _getParam(req, 'password', state, body: true); + var scopes = _getScopes(req); + response = await resourceOwnerPasswordCredentialsGrant( + client, username, password, scopes, req, res); + } else if (grantType == 'client_credentials') { + response = await clientCredentialsGrant(client, req, res); - if (!await verifyClient(client, clientSecret)) - throw new AuthorizationException(ErrorResponse.invalidClient); + if (response.refreshToken != null) { + // Remove refresh token + response = new AuthorizationTokenResponse( + response.accessToken, + expiresIn: response.expiresIn, + scope: response.scope, + ); + } + } else if (extensionGrants.containsKey(grantType)) { + response = await extensionGrants[grantType](req, res); + } - // Grab redirect URI - var redirectUri = _getParam(req, 'redirect_uri', body: true); + if (response != null) { + return {'token_type': AuthorizationTokenType.bearer} + ..addAll(response.toJson()); + } - // Grab scopes - var scopes = _getScopes(req, body: true); - - var state = req.query['state']?.toString() ?? ''; - - var authCode = await authCodeGrant( - client, redirectUri, req.properties['user'], scopes, state); - res.headers['content-type'] = 'application/x-www-form-urlencoded'; - res.write('code=' + Uri.encodeComponent(authCode)); - if (state.isNotEmpty) res.write('&state=' + Uri.encodeComponent(state)); + throw new AuthorizationException( + new ErrorResponse( + ErrorResponse.invalidRequest, + 'Invalid or no "grant_type" parameter provided', + state, + ), + statusCode: 400, + ); + } on AngelHttpException { + rethrow; + } catch (e, st) { + throw new AuthorizationException( + new ErrorResponse( + ErrorResponse.serverError, + _internalServerError, + state, + ), + error: e, + statusCode: 500, + stackTrace: st, + ); + } } } diff --git a/lib/src/strategy.dart b/lib/src/strategy.dart deleted file mode 100644 index bdcd6129..00000000 --- a/lib/src/strategy.dart +++ /dev/null @@ -1,3 +0,0 @@ -import 'package:angel_auth/angel_auth.dart'; -import 'server.dart'; - diff --git a/lib/src/token_type.dart b/lib/src/token_type.dart index 2f7de7f9..3338828f 100644 --- a/lib/src/token_type.dart +++ b/lib/src/token_type.dart @@ -1,3 +1,4 @@ -abstract class TokenType { +/// The various types of OAuth2 authorization tokens. +abstract class AuthorizationTokenType { static const String bearer = 'bearer', mac = 'mac'; } \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 1241c0bf..cd10ecde 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ version: 1.0.0-alpha environment: sdk: ">=1.19.0" dependencies: - angel_auth: ^1.1.0-alpha + angel_framework: ^1.0.0-dev dev_dependencies: angel_test: ^1.1.0-alpha oauth2: ^1.0.0 diff --git a/test/auth_code_test.dart b/test/auth_code_test.dart index 91f43cf4..c8cf64c0 100644 --- a/test/auth_code_test.dart +++ b/test/auth_code_test.dart @@ -115,7 +115,7 @@ main() { }); } -class _Server extends Server { +class _Server extends AuthorizationServer { final Uuid _uuid = new Uuid(); @override @@ -130,7 +130,7 @@ class _Server extends Server { } @override - Future authorize( + Future requestAuthorizationCode( PseudoApplication client, String redirectUri, Iterable scopes, @@ -151,13 +151,7 @@ class _Server extends Server { } @override - Future authCodeGrant(PseudoApplication client, String redirectUri, - Map user, Iterable scopes, String state) { - throw new UnsupportedError('Nope'); - } - - @override - Future exchangeAuthCodeForAccessToken( + Future exchangeAuthorizationCodeForToken( String authCode, String redirectUri, RequestContext req, @@ -165,7 +159,7 @@ class _Server extends Server { var authCodes = req.grab>('authCodes'); var state = authCodes[authCode]; var refreshToken = state == 'can_refresh' ? '${authCode}_refresh' : null; - return new AuthorizationCodeResponse('${authCode}_access', + return new AuthorizationTokenResponse('${authCode}_access', refreshToken: refreshToken); } } diff --git a/test/client_credentials_test.dart b/test/client_credentials_test.dart new file mode 100644 index 00000000..06c074b6 --- /dev/null +++ b/test/client_credentials_test.dart @@ -0,0 +1,105 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_test/angel_test.dart'; +import 'package:angel_oauth2/angel_oauth2.dart'; +import 'package:angel_validate/angel_validate.dart'; +import 'package:test/test.dart'; +import 'common.dart'; + +main() { + TestClient client; + + setUp(() async { + var app = new Angel()..lazyParseBodies = true; + var oauth2 = new _AuthorizationServer(); + + app.group('/oauth2', (router) { + router + ..get('/authorize', oauth2.authorizationEndpoint) + ..post('/token', oauth2.tokenEndpoint); + }); + + app.errorHandler = (e, req, res) async { + res.json(e.toJson()); + }; + + client = await connectTo(app); + }); + + tearDown(() => client.close()); + + test('authenticate via client credentials', () async { + var response = await client.post( + '/oauth2/token', + headers: { + 'Authorization': 'Basic ' + BASE64URL.encode('foo:bar'.codeUnits), + }, + body: { + 'grant_type': 'client_credentials', + }, + ); + + print('Response: ${response.body}'); + + expect(response, allOf( + hasStatus(200), + hasContentType(ContentType.JSON), + hasValidBody(new Validator({ + 'token_type': equals('bearer'), + 'access_token': equals('foo'), + })), + )); + }); + + test('force correct id', () async { + var response = await client.post( + '/oauth2/token', + headers: { + 'Authorization': 'Basic ' + BASE64URL.encode('fooa:bar'.codeUnits), + }, + body: { + 'grant_type': 'client_credentials', + }, + ); + + print('Response: ${response.body}'); + expect(response, hasStatus(401)); + }); + + test('force correct secret', () async { + var response = await client.post( + '/oauth2/token', + headers: { + 'Authorization': 'Basic ' + BASE64URL.encode('foo:bara'.codeUnits), + }, + body: { + 'grant_type': 'client_credentials', + }, + ); + + print('Response: ${response.body}'); + expect(response, hasStatus(401)); + }); +} + +class _AuthorizationServer + extends AuthorizationServer { + @override + PseudoApplication findClient(String clientId) { + return clientId == pseudoApplication.id ? pseudoApplication : null; + } + + @override + Future verifyClient( + PseudoApplication client, String clientSecret) async { + return client.secret == clientSecret; + } + + @override + Future clientCredentialsGrant( + PseudoApplication client, RequestContext req, ResponseContext res) async { + return new AuthorizationTokenResponse('foo'); + } +} diff --git a/test/common.dart b/test/common.dart index 14f0afd4..62c8adc4 100644 --- a/test/common.dart +++ b/test/common.dart @@ -6,3 +6,15 @@ class PseudoApplication { const PseudoApplication(this.id, this.secret, this.redirectUri); } + +const List pseudoUsers = const [ + const PseudoUser(username: 'foo', password: 'bar'), + const PseudoUser(username: 'michael', password: 'jackson'), + const PseudoUser(username: 'jon', password: 'skeet'), +]; + +class PseudoUser { + final String username, password; + + const PseudoUser({this.username, this.password}); +} \ No newline at end of file diff --git a/test/implicit_grant_test.dart b/test/implicit_grant_test.dart new file mode 100644 index 00000000..a15b9cdd --- /dev/null +++ b/test/implicit_grant_test.dart @@ -0,0 +1,69 @@ +import 'dart:async'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_test/angel_test.dart'; +import 'package:angel_oauth2/angel_oauth2.dart'; +import 'package:angel_validate/angel_validate.dart'; +import 'package:test/test.dart'; +import 'common.dart'; + +main() { + TestClient client; + + setUp(() async { + var app = new Angel()..lazyParseBodies = true; + var oauth2 = new _AuthorizationServer(); + + app.group('/oauth2', (router) { + router + ..get('/authorize', oauth2.authorizationEndpoint) + ..post('/token', oauth2.tokenEndpoint); + }); + + app.errorHandler = (e, req, res) async { + res.json(e.toJson()); + }; + + client = await connectTo(app); + }); + + tearDown(() => client.close()); + + test('authenticate via implicit grant', () async { + var response = await client.get( + '/oauth2/authorize?response_type=token&client_id=foo&redirect_uri=http://foo.com&state=bar', + ); + + print('Headers: ${response.headers}'); + expect( + response, + allOf( + hasStatus(302), + hasHeader('location', 'http://foo.com#access_token=foo&token_type=bearer&state=bar'), + )); + }); +} + +class _AuthorizationServer + extends AuthorizationServer { + @override + PseudoApplication findClient(String clientId) { + return clientId == pseudoApplication.id ? pseudoApplication : null; + } + + @override + Future verifyClient( + PseudoApplication client, String clientSecret) async { + return client.secret == clientSecret; + } + + @override + Future implicitGrant( + PseudoApplication client, + String redirectUri, + Iterable scopes, + String state, + RequestContext req, + ResponseContext res) async { + return new AuthorizationTokenResponse('foo'); + } +} diff --git a/test/password_test.dart b/test/password_test.dart new file mode 100644 index 00000000..a0e8562a --- /dev/null +++ b/test/password_test.dart @@ -0,0 +1,114 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_test/angel_test.dart'; +import 'package:angel_oauth2/angel_oauth2.dart'; +import 'package:angel_validate/angel_validate.dart'; +import 'package:test/test.dart'; +import 'common.dart'; + +main() { + TestClient client; + + setUp(() async { + var app = new Angel()..lazyParseBodies = true; + var oauth2 = new _AuthorizationServer(); + + app.group('/oauth2', (router) { + router + ..get('/authorize', oauth2.authorizationEndpoint) + ..post('/token', oauth2.tokenEndpoint); + }); + + app.errorHandler = (e, req, res) async { + res.json(e.toJson()); + }; + + client = await connectTo(app); + }); + + tearDown(() => client.close()); + + test('authenticate via username+password', () async { + var response = await client.post( + '/oauth2/token', + headers: { + 'Authorization': 'Basic ' + BASE64URL.encode('foo:bar'.codeUnits), + }, + body: { + 'grant_type': 'password', + 'username': 'michael', + 'password': 'jackson', + }, + ); + + print('Response: ${response.body}'); + + expect(response, allOf( + hasStatus(200), + hasContentType(ContentType.JSON), + hasValidBody(new Validator({ + 'token_type': equals('bearer'), + 'access_token': equals('foo'), + })), + )); + }); + + test('force correct username+password', () async { + var response = await client.post( + '/oauth2/token', + headers: { + 'Authorization': 'Basic ' + BASE64URL.encode('foo:bar'.codeUnits), + }, + body: { + 'grant_type': 'password', + 'username': 'michael', + 'password': 'jordan', + }, + ); + + print('Response: ${response.body}'); + expect(response, hasStatus(401)); + }); +} + +class _AuthorizationServer + extends AuthorizationServer { + @override + PseudoApplication findClient(String clientId) { + return clientId == pseudoApplication.id ? pseudoApplication : null; + } + + @override + Future verifyClient( + PseudoApplication client, String clientSecret) async { + return client.secret == clientSecret; + } + + @override + Future resourceOwnerPasswordCredentialsGrant( + PseudoApplication client, + String username, + String password, + Iterable scopes, + RequestContext req, + ResponseContext res) async { + var user = pseudoUsers.firstWhere( + (u) => u.username == username && u.password == password, + orElse: () => null); + + if (user == null) { + throw new AuthorizationException( + new ErrorResponse( + ErrorResponse.accessDenied, + 'Invalid username or password.', + req.body['state'] ?? '', + ), + statusCode: 401, + ); + } + + return new AuthorizationTokenResponse('foo'); + } +} From 975aca1df5aabca98dd1f01d3a1df6751ab9662b Mon Sep 17 00:00:00 2001 From: Tobe O Date: Mon, 16 Oct 2017 02:41:01 -0400 Subject: [PATCH 09/39] README --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 21430c35..63f82cdc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,7 @@ # oauth2 +[![Pub](https://img.shields.io/pub/v/angel_oauth2.svg)](https://pub.dartlang.org/packages/angel_oauth2) +[![build status](https://travis-ci.org/angel-dart/oauth2.svg)](https://travis-ci.org/angel-dart/oauth2) + A class containing handlers that can be used within [Angel](https://angel-dart.github.io/) to build a spec-compliant OAuth 2.0 server. From 0fb8c68c0cf6bce241ef539be0070cc53791766e Mon Sep 17 00:00:00 2001 From: Tobe O Date: Mon, 16 Oct 2017 12:46:01 -0400 Subject: [PATCH 10/39] more tests --- test/password_test.dart | 112 +++++++++++++++++++++++----------------- 1 file changed, 66 insertions(+), 46 deletions(-) diff --git a/test/password_test.dart b/test/password_test.dart index a0e8562a..ba21f389 100644 --- a/test/password_test.dart +++ b/test/password_test.dart @@ -1,75 +1,85 @@ import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; import 'package:angel_framework/angel_framework.dart'; -import 'package:angel_test/angel_test.dart'; import 'package:angel_oauth2/angel_oauth2.dart'; -import 'package:angel_validate/angel_validate.dart'; +import 'package:logging/logging.dart'; +import 'package:oauth2/oauth2.dart' as oauth2; import 'package:test/test.dart'; import 'common.dart'; main() { - TestClient client; + Angel app; + Uri tokenEndpoint; setUp(() async { - var app = new Angel()..lazyParseBodies = true; - var oauth2 = new _AuthorizationServer(); + app = new Angel()..lazyParseBodies = true; + var auth = new _AuthorizationServer(); app.group('/oauth2', (router) { router - ..get('/authorize', oauth2.authorizationEndpoint) - ..post('/token', oauth2.tokenEndpoint); + ..get('/authorize', auth.authorizationEndpoint) + ..post('/token', auth.tokenEndpoint); }); app.errorHandler = (e, req, res) async { res.json(e.toJson()); }; - client = await connectTo(app); + app.logger = new Logger('password_test')..onRecord.listen(print); + + var server = await app.startServer(); + var url = 'http://${server.address.address}:${server.port}'; + tokenEndpoint = Uri.parse('$url/oauth2/token'); }); - tearDown(() => client.close()); + tearDown(() => app.close()); test('authenticate via username+password', () async { - var response = await client.post( - '/oauth2/token', - headers: { - 'Authorization': 'Basic ' + BASE64URL.encode('foo:bar'.codeUnits), - }, - body: { - 'grant_type': 'password', - 'username': 'michael', - 'password': 'jackson', - }, + var client = await oauth2.resourceOwnerPasswordGrant( + tokenEndpoint, + 'michael', + 'jackson', + identifier: 'foo', + secret: 'bar', ); - - print('Response: ${response.body}'); - - expect(response, allOf( - hasStatus(200), - hasContentType(ContentType.JSON), - hasValidBody(new Validator({ - 'token_type': equals('bearer'), - 'access_token': equals('foo'), - })), - )); + print(client.credentials.toJson()); + client.close(); + expect(client.credentials.accessToken, 'foo'); + expect(client.credentials.refreshToken, 'bar'); }); test('force correct username+password', () async { - var response = await client.post( - '/oauth2/token', - headers: { - 'Authorization': 'Basic ' + BASE64URL.encode('foo:bar'.codeUnits), - }, - body: { - 'grant_type': 'password', - 'username': 'michael', - 'password': 'jordan', - }, - ); + oauth2.Client client; - print('Response: ${response.body}'); - expect(response, hasStatus(401)); + try { + client = await oauth2.resourceOwnerPasswordGrant( + tokenEndpoint, + 'michael', + 'jordan', + identifier: 'foo', + secret: 'bar', + ); + + throw new StateError('should fail'); + } on oauth2.AuthorizationException catch (e) { + expect(e.error, ErrorResponse.accessDenied); + } finally { + client?.close(); + } + }); + + test('can refresh token', () async { + var client = await oauth2.resourceOwnerPasswordGrant( + tokenEndpoint, + 'michael', + 'jackson', + identifier: 'foo', + secret: 'bar', + ); + client = await client.refreshCredentials(); + print(client.credentials.toJson()); + client.close(); + expect(client.credentials.accessToken, 'baz'); + expect(client.credentials.refreshToken, 'bar'); }); } @@ -86,6 +96,16 @@ class _AuthorizationServer return client.secret == clientSecret; } + @override + Future refreshAuthorizationToken( + PseudoApplication client, + String refreshToken, + Iterable scopes, + RequestContext req, + ResponseContext res) async { + return new AuthorizationTokenResponse('baz', refreshToken: 'bar'); + } + @override Future resourceOwnerPasswordCredentialsGrant( PseudoApplication client, @@ -109,6 +129,6 @@ class _AuthorizationServer ); } - return new AuthorizationTokenResponse('foo'); + return new AuthorizationTokenResponse('foo', refreshToken: 'bar'); } } From 06c4535baca4cdb61abbaeead72fdd15cabb9b52 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Mon, 16 Oct 2017 12:52:12 -0400 Subject: [PATCH 11/39] +1 --- lib/src/exception.dart | 2 +- lib/src/server.dart | 16 ++++++++-------- pubspec.yaml | 2 +- test/client_credentials_test.dart | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/src/exception.dart b/lib/src/exception.dart index 3f33f4c8..3e140784 100644 --- a/lib/src/exception.dart +++ b/lib/src/exception.dart @@ -7,7 +7,7 @@ class AuthorizationException extends AngelHttpException { AuthorizationException(this.errorResponse, {StackTrace stackTrace, int statusCode, error}) : super(error ?? errorResponse, - stackTrace: stackTrace, message: '', statusCode: statusCode ?? 401); + stackTrace: stackTrace, message: '', statusCode: statusCode ?? 400); @override Map toJson() { diff --git a/lib/src/server.dart b/lib/src/server.dart index d42883c8..07ec42a5 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -66,7 +66,7 @@ abstract class AuthorizationServer { 'Authorization code grants are not supported.', state, ), - statusCode: 405, + statusCode: 400, ); } @@ -87,7 +87,7 @@ abstract class AuthorizationServer { 'Authorization code grants are not supported.', state, ), - statusCode: 405, + statusCode: 400, ); } @@ -103,7 +103,7 @@ abstract class AuthorizationServer { 'Authorization code grants are not supported.', req.query['state'] ?? '', ), - statusCode: 405, + statusCode: 400, ); } @@ -120,7 +120,7 @@ abstract class AuthorizationServer { 'Refreshing authorization tokens is not supported.', req.body['state'] ?? '', ), - statusCode: 405, + statusCode: 400, ); } @@ -138,7 +138,7 @@ abstract class AuthorizationServer { 'Resource owner password credentials grants are not supported.', req.body['state'] ?? '', ), - statusCode: 405, + statusCode: 400, ); } @@ -151,7 +151,7 @@ abstract class AuthorizationServer { 'Client credentials grants are not supported.', req.body['state'] ?? '', ), - statusCode: 405, + statusCode: 400, ); } @@ -319,7 +319,7 @@ abstract class AuthorizationServer { 'Invalid "client_id" parameter.', state, ), - statusCode: 401, + statusCode: 400, ); } @@ -330,7 +330,7 @@ abstract class AuthorizationServer { 'Invalid "client_secret" parameter.', state, ), - statusCode: 401, + statusCode: 400, ); } } diff --git a/pubspec.yaml b/pubspec.yaml index cd10ecde..75ae825a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: angel_oauth2 author: Tobe O description: angel_auth strategy for in-house OAuth2 login. homepage: https://github.com/thosakwe/oauth2_server.git -version: 1.0.0-alpha +version: 1.0.0-alpha+1 environment: sdk: ">=1.19.0" dependencies: diff --git a/test/client_credentials_test.dart b/test/client_credentials_test.dart index 06c074b6..22fa6042 100644 --- a/test/client_credentials_test.dart +++ b/test/client_credentials_test.dart @@ -65,7 +65,7 @@ main() { ); print('Response: ${response.body}'); - expect(response, hasStatus(401)); + expect(response, hasStatus(400)); }); test('force correct secret', () async { @@ -80,7 +80,7 @@ main() { ); print('Response: ${response.body}'); - expect(response, hasStatus(401)); + expect(response, hasStatus(400)); }); } From 8e090e0c6229def139db66e0ee32bc7b1da48a49 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Thu, 7 Dec 2017 01:21:50 -0500 Subject: [PATCH 12/39] Bump to 1.0.0 --- .idea/runConfigurations/tests_in_auth_oauth2_server.xml | 8 ++++++++ pubspec.yaml | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 .idea/runConfigurations/tests_in_auth_oauth2_server.xml diff --git a/.idea/runConfigurations/tests_in_auth_oauth2_server.xml b/.idea/runConfigurations/tests_in_auth_oauth2_server.xml new file mode 100644 index 00000000..cc52d764 --- /dev/null +++ b/.idea/runConfigurations/tests_in_auth_oauth2_server.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 75ae825a..7863ab17 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,8 +1,8 @@ name: angel_oauth2 author: Tobe O description: angel_auth strategy for in-house OAuth2 login. -homepage: https://github.com/thosakwe/oauth2_server.git -version: 1.0.0-alpha+1 +homepage: https://github.com/angel-dart/oauth2.git +version: 1.0.0 environment: sdk: ">=1.19.0" dependencies: From aca5489e8dd1a1ca8a37e57b46816799680691b6 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Mon, 9 Jul 2018 11:38:29 -0400 Subject: [PATCH 13/39] dart2_constant --- .idea/auth_oauth2_server.iml | 1 + CHANGELOG.md | 3 +++ lib/src/server.dart | 4 ++-- pubspec.yaml | 3 ++- test/auth_code_test.dart | 17 +++++++++-------- test/client_credentials_test.dart | 11 +++++------ test/password_test.dart | 3 ++- 7 files changed, 24 insertions(+), 18 deletions(-) create mode 100644 CHANGELOG.md diff --git a/.idea/auth_oauth2_server.iml b/.idea/auth_oauth2_server.iml index 0fd729f3..ae9af975 100644 --- a/.idea/auth_oauth2_server.iml +++ b/.idea/auth_oauth2_server.iml @@ -3,6 +3,7 @@ + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..2c05c775 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# 1.0.1 +* Dart2 updates + backwards compatibility assurance. +* Added support for the `device_code` grant: https://github.com/angel-dart/oauth2/issues/7 \ No newline at end of file diff --git a/lib/src/server.dart b/lib/src/server.dart index 07ec42a5..489b5c65 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -1,6 +1,6 @@ import 'dart:async'; -import 'dart:convert'; import 'package:angel_framework/angel_framework.dart'; +import 'package:dart2_constant/convert.dart'; import 'exception.dart'; import 'response.dart'; import 'token_type.dart'; @@ -296,7 +296,7 @@ abstract class AuthorizationServer { if (match != null) { match = _rgxBasicAuth - .firstMatch(new String.fromCharCodes(BASE64URL.decode(match[1]))); + .firstMatch(new String.fromCharCodes(base64Url.decode(match[1]))); } if (match == null) { diff --git a/pubspec.yaml b/pubspec.yaml index 7863ab17..957c3ce5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,9 +4,10 @@ description: angel_auth strategy for in-house OAuth2 login. homepage: https://github.com/angel-dart/oauth2.git version: 1.0.0 environment: - sdk: ">=1.19.0" + sdk: ">=1.8.0 <3.0.0" dependencies: angel_framework: ^1.0.0-dev + dart2_constant: ^1.0.0 dev_dependencies: angel_test: ^1.1.0-alpha oauth2: ^1.0.0 diff --git a/test/auth_code_test.dart b/test/auth_code_test.dart index c8cf64c0..bb82e4b7 100644 --- a/test/auth_code_test.dart +++ b/test/auth_code_test.dart @@ -1,8 +1,8 @@ import 'dart:async'; -import 'dart:convert'; import 'package:angel_framework/angel_framework.dart'; import 'package:angel_oauth2/angel_oauth2.dart'; import 'package:angel_test/angel_test.dart'; +import 'package:dart2_constant/convert.dart'; import 'package:logging/logging.dart'; import 'package:oauth2/oauth2.dart' as oauth2; import 'package:test/test.dart'; @@ -34,8 +34,9 @@ main() { if (rec.stackTrace != null) print(rec.stackTrace); }); - var http = await app.startServer(); - var url = 'http://${http.address.address}:${http.port}'; + 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'); @@ -63,7 +64,7 @@ main() { print('Body: ${response.body}'); expect( response.body, - JSON.encode( + json.encode( 'Hello ${pseudoApplication.id}:${pseudoApplication.secret}')); }); @@ -72,7 +73,7 @@ main() { var url = grant.getAuthorizationUrl(redirectUri, state: 'goodbye'); var response = await testClient.client.get(url); print('Body: ${response.body}'); - expect(JSON.decode(response.body)['state'], 'goodbye'); + expect(json.decode(response.body)['state'], 'goodbye'); }); test('sends auth code', () async { @@ -81,7 +82,7 @@ main() { var response = await testClient.client.get(url); print('Body: ${response.body}'); expect( - JSON.decode(response.body), + json.decode(response.body), allOf( isMap, predicate((Map m) => m.containsKey('code'), 'contains "code"'), @@ -95,7 +96,7 @@ main() { var response = await testClient.client.get(url); print('Body: ${response.body}'); - var authCode = JSON.decode(response.body)['code']; + var authCode = json.decode(response.body)['code']; var client = await grant.handleAuthorizationCode(authCode); expect(client.credentials.accessToken, authCode + '_access'); }); @@ -106,7 +107,7 @@ main() { var response = await testClient.client.get(url); print('Body: ${response.body}'); - var authCode = JSON.decode(response.body)['code']; + var authCode = json.decode(response.body)['code']; var client = await grant.handleAuthorizationCode(authCode); expect(client.credentials.accessToken, authCode + '_access'); expect(client.credentials.canRefresh, isTrue); diff --git a/test/client_credentials_test.dart b/test/client_credentials_test.dart index 22fa6042..b82a6445 100644 --- a/test/client_credentials_test.dart +++ b/test/client_credentials_test.dart @@ -1,10 +1,9 @@ import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; import 'package:angel_framework/angel_framework.dart'; import 'package:angel_test/angel_test.dart'; import 'package:angel_oauth2/angel_oauth2.dart'; import 'package:angel_validate/angel_validate.dart'; +import 'package:dart2_constant/convert.dart'; import 'package:test/test.dart'; import 'common.dart'; @@ -34,7 +33,7 @@ main() { var response = await client.post( '/oauth2/token', headers: { - 'Authorization': 'Basic ' + BASE64URL.encode('foo:bar'.codeUnits), + 'Authorization': 'Basic ' + base64Url.encode('foo:bar'.codeUnits), }, body: { 'grant_type': 'client_credentials', @@ -45,7 +44,7 @@ main() { expect(response, allOf( hasStatus(200), - hasContentType(ContentType.JSON), + hasContentType('application/json'), hasValidBody(new Validator({ 'token_type': equals('bearer'), 'access_token': equals('foo'), @@ -57,7 +56,7 @@ main() { var response = await client.post( '/oauth2/token', headers: { - 'Authorization': 'Basic ' + BASE64URL.encode('fooa:bar'.codeUnits), + 'Authorization': 'Basic ' + base64Url.encode('fooa:bar'.codeUnits), }, body: { 'grant_type': 'client_credentials', @@ -72,7 +71,7 @@ main() { var response = await client.post( '/oauth2/token', headers: { - 'Authorization': 'Basic ' + BASE64URL.encode('foo:bara'.codeUnits), + 'Authorization': 'Basic ' + base64Url.encode('foo:bara'.codeUnits), }, body: { 'grant_type': 'client_credentials', diff --git a/test/password_test.dart b/test/password_test.dart index ba21f389..9baea1d5 100644 --- a/test/password_test.dart +++ b/test/password_test.dart @@ -26,7 +26,8 @@ main() { app.logger = new Logger('password_test')..onRecord.listen(print); - var server = await app.startServer(); + var http = new AngelHttp(app); + var server = await http.startServer(); var url = 'http://${server.address.address}:${server.port}'; tokenEndpoint = Uri.parse('$url/oauth2/token'); }); From 0a0a5b1e575ece4626c0ee3dce50f6107b4e2ddb Mon Sep 17 00:00:00 2001 From: Tobe O Date: Mon, 9 Jul 2018 12:28:46 -0400 Subject: [PATCH 14/39] Dart 2 fixes --- lib/src/server.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/server.dart b/lib/src/server.dart index 489b5c65..0e979988 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -368,7 +368,7 @@ abstract class AuthorizationServer { } if (response != null) { - return {'token_type': AuthorizationTokenType.bearer} + return {'token_type': AuthorizationTokenType.bearer} ..addAll(response.toJson()); } From 24fb8d2b419d3ce530a1585e852d9d5047f61091 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Mon, 9 Jul 2018 12:37:40 -0400 Subject: [PATCH 15/39] Bump version --- .gitignore | 1 + CHANGELOG.md | 5 ++--- pubspec.yaml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index b4d6e266..dbce896b 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,4 @@ pubspec.lock # Directory created by dartdoc # If you don't generate documentation locally you can remove this line. doc/api/ +.dart_tool \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c05c775..631da9f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,2 @@ -# 1.0.1 -* Dart2 updates + backwards compatibility assurance. -* Added support for the `device_code` grant: https://github.com/angel-dart/oauth2/issues/7 \ No newline at end of file +# 1.0.0+1 +* Dart2 updates + backwards compatibility assurance. \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 957c3ce5..4cd61fda 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: angel_oauth2 author: Tobe O description: angel_auth strategy for in-house OAuth2 login. homepage: https://github.com/angel-dart/oauth2.git -version: 1.0.0 +version: 1.0.0+1 environment: sdk: ">=1.8.0 <3.0.0" dependencies: From 004e1c02ba221c0e0b6b94249b9c01bbe3a16136 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Mon, 9 Jul 2018 12:40:06 -0400 Subject: [PATCH 16/39] Travis --- .travis.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index de2210c9..a9e2c109 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1 +1,4 @@ -language: dart \ No newline at end of file +language: dart +dart: + - dev + - stable \ No newline at end of file From bfc8a32fd14366268a578ebf673b6e3b1cf2c63b Mon Sep 17 00:00:00 2001 From: Tobe O Date: Mon, 9 Jul 2018 12:42:06 -0400 Subject: [PATCH 17/39] Require http_exception --- pubspec.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pubspec.yaml b/pubspec.yaml index 4cd61fda..5eb6dce3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,6 +7,7 @@ environment: sdk: ">=1.8.0 <3.0.0" dependencies: angel_framework: ^1.0.0-dev + angel_http_exception: ^1.0.0 dart2_constant: ^1.0.0 dev_dependencies: angel_test: ^1.1.0-alpha From 410201acf635e15d6e68e5b3e7926ef5960bee04 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Thu, 8 Nov 2018 10:32:36 -0500 Subject: [PATCH 18/39] Angel 2 support --- analysis_options.yaml | 3 +- lib/angel_oauth2.dart | 2 +- lib/src/exception.dart | 17 ++++---- lib/src/server.dart | 68 +++++++++++++++++-------------- lib/src/token_type.dart | 2 +- pubspec.yaml | 14 +++---- test/auth_code_test.dart | 35 ++++++++++++---- test/client_credentials_test.dart | 20 +++++---- test/common.dart | 2 +- test/implicit_grant_test.dart | 5 ++- test/password_test.dart | 6 ++- 11 files changed, 105 insertions(+), 69 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 518eb901..eae1e42a 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,2 +1,3 @@ analyzer: - strong-mode: true \ No newline at end of file + strong-mode: + implicit-casts: false \ No newline at end of file diff --git a/lib/angel_oauth2.dart b/lib/angel_oauth2.dart index 6f67dab1..36e10f39 100644 --- a/lib/angel_oauth2.dart +++ b/lib/angel_oauth2.dart @@ -1,4 +1,4 @@ export 'src/exception.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/exception.dart b/lib/src/exception.dart index 3e140784..3b4da075 100644 --- a/lib/src/exception.dart +++ b/lib/src/exception.dart @@ -11,15 +11,15 @@ class AuthorizationException extends AngelHttpException { @override Map toJson() { - var m = { - 'error': errorResponse.code, - 'error_description': errorResponse.description, - }; + var m = { + 'error': errorResponse.code, + 'error_description': errorResponse.description, + }; - if (errorResponse.uri != null) - m['error_uri'] = errorResponse.uri.toString(); + if (errorResponse.uri != null) + m['error_uri'] = errorResponse.uri.toString(); - return m; + return m; } } @@ -59,4 +59,7 @@ class ErrorResponse { final String state; const ErrorResponse(this.code, this.description, this.state, {this.uri}); + + @override + String toString() => 'OAuth2 error ($code): $description'; } diff --git a/lib/src/server.dart b/lib/src/server.dart index 0e979988..0da45100 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -9,9 +9,9 @@ import 'token_type.dart'; typedef Future ExtensionGrant( RequestContext req, ResponseContext res); -String _getParam(RequestContext req, String name, String state, - {bool body: false}) { - var map = body == true ? req.body : req.query; +Future _getParam(RequestContext req, String name, String state, + {bool body: false}) async { + var map = body == true ? await req.parseBody() : await req.parseQuery(); var value = map.containsKey(name) ? map[name]?.toString() : null; if (value?.isNotEmpty != true) { @@ -28,8 +28,9 @@ String _getParam(RequestContext req, String name, String state, return value; } -Iterable _getScopes(RequestContext req, {bool body: false}) { - var map = body == true ? req.body : req.query; +Future> _getScopes(RequestContext req, + {bool body: false}) async { + var map = body == true ? await req.parseBody() : await req.parseQuery(); return map['scope']?.toString()?.split(' ') ?? []; } @@ -46,7 +47,6 @@ abstract class AuthorizationServer { /// Finds the [Client] application associated with the given [clientId]. FutureOr findClient(String clientId); - // TODO: Is this ever used??? /// Verify that a [client] is the one identified by the [clientSecret]. Future verifyClient(Client client, String clientSecret); @@ -101,7 +101,7 @@ abstract class AuthorizationServer { new ErrorResponse( ErrorResponse.unsupportedResponseType, 'Authorization code grants are not supported.', - req.query['state'] ?? '', + req.uri.queryParameters['state'] ?? '', ), statusCode: 400, ); @@ -113,12 +113,13 @@ abstract class AuthorizationServer { String refreshToken, Iterable scopes, RequestContext req, - ResponseContext res) { + ResponseContext res) async { + var body = await req.parseBody(); throw new AuthorizationException( new ErrorResponse( ErrorResponse.unsupportedResponseType, 'Refreshing authorization tokens is not supported.', - req.body['state'] ?? '', + body['state']?.toString() ?? '', ), statusCode: 400, ); @@ -131,12 +132,13 @@ abstract class AuthorizationServer { String password, Iterable scopes, RequestContext req, - ResponseContext res) { + ResponseContext res) async { + var body = await req.parseBody(); throw new AuthorizationException( new ErrorResponse( ErrorResponse.unsupportedResponseType, 'Resource owner password credentials grants are not supported.', - req.body['state'] ?? '', + body['state']?.toString() ?? '', ), statusCode: 400, ); @@ -144,12 +146,13 @@ abstract class AuthorizationServer { /// Performs a client credentials grant. Only use this in situations where the client is 100% trusted. Future clientCredentialsGrant( - Client client, RequestContext req, ResponseContext res) { + Client client, RequestContext req, ResponseContext res) async { + var body = await req.parseBody(); throw new AuthorizationException( new ErrorResponse( ErrorResponse.unsupportedResponseType, 'Client credentials grants are not supported.', - req.body['state'] ?? '', + body['state']?.toString() ?? '', ), statusCode: 400, ); @@ -161,13 +164,14 @@ abstract class AuthorizationServer { String state = ''; try { - state = req.query['state']?.toString() ?? ''; - var responseType = _getParam(req, 'response_type', state); + var query = await req.parseQuery(); + state = query['state']?.toString() ?? ''; + var responseType = await _getParam(req, 'response_type', state); if (responseType == 'code') { // Ensure client ID // TODO: Handle confidential clients - var clientId = _getParam(req, 'client_id', state); + var clientId = await _getParam(req, 'client_id', state); // Find client var client = await findClient(clientId); @@ -181,17 +185,17 @@ abstract class AuthorizationServer { } // Grab redirect URI - var redirectUri = _getParam(req, 'redirect_uri', state); + var redirectUri = await _getParam(req, 'redirect_uri', state); // Grab scopes - var scopes = _getScopes(req); + var scopes = await _getScopes(req); return await requestAuthorizationCode( client, redirectUri, scopes, state, req, res); } if (responseType == 'token') { - var clientId = _getParam(req, 'client_id', state); + var clientId = await _getParam(req, 'client_id', state); var client = await findClient(clientId); if (client == null) { @@ -202,10 +206,10 @@ abstract class AuthorizationServer { )); } - var redirectUri = _getParam(req, 'redirect_uri', state); + var redirectUri = await _getParam(req, 'redirect_uri', state); // Grab scopes - var scopes = _getScopes(req); + var scopes = await _getScopes(req); var token = await implicitGrant(client, redirectUri, scopes, state, req, res); @@ -284,11 +288,11 @@ abstract class AuthorizationServer { try { AuthorizationTokenResponse response; - await req.parse(); + var body = await req.parseBody(); - state = req.body['state'] ?? ''; + state = body['state']?.toString() ?? ''; - var grantType = _getParam(req, 'grant_type', state, body: true); + var grantType = await _getParam(req, 'grant_type', state, body: true); if (grantType != 'authorization_code') { var match = @@ -337,19 +341,21 @@ abstract class AuthorizationServer { } if (grantType == 'authorization_code') { - var code = _getParam(req, 'code', state, body: true); - var redirectUri = _getParam(req, 'redirect_uri', state, body: true); + var code = await _getParam(req, 'code', state, body: true); + var redirectUri = + await _getParam(req, 'redirect_uri', state, body: true); response = await exchangeAuthorizationCodeForToken( code, redirectUri, req, res); } else if (grantType == 'refresh_token') { - var refreshToken = _getParam(req, 'refresh_token', state, body: true); - var scopes = _getScopes(req); + var refreshToken = + await _getParam(req, 'refresh_token', state, body: true); + var scopes = await _getScopes(req); response = await refreshAuthorizationToken( client, refreshToken, scopes, req, res); } else if (grantType == 'password') { - var username = _getParam(req, 'username', state, body: true); - var password = _getParam(req, 'password', state, body: true); - var scopes = _getScopes(req); + var username = await _getParam(req, 'username', state, body: true); + var password = await _getParam(req, 'password', state, body: true); + var scopes = await _getScopes(req); response = await resourceOwnerPasswordCredentialsGrant( client, username, password, scopes, req, res); } else if (grantType == 'client_credentials') { diff --git a/lib/src/token_type.dart b/lib/src/token_type.dart index 3338828f..73748a90 100644 --- a/lib/src/token_type.dart +++ b/lib/src/token_type.dart @@ -1,4 +1,4 @@ /// The various types of OAuth2 authorization tokens. abstract class AuthorizationTokenType { static const String bearer = 'bearer', mac = 'mac'; -} \ No newline at end of file +} diff --git a/pubspec.yaml b/pubspec.yaml index 5eb6dce3..57f19f0c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,15 +1,15 @@ name: angel_oauth2 author: Tobe O -description: angel_auth strategy for in-house OAuth2 login. +description: A class containing handlers that can be used within Angel to build a spec-compliant OAuth 2.0 server. homepage: https://github.com/angel-dart/oauth2.git -version: 1.0.0+1 +version: 2.0.0 environment: - sdk: ">=1.8.0 <3.0.0" + sdk: ">=2.0.0-dev <3.0.0" dependencies: - angel_framework: ^1.0.0-dev + angel_framework: ^2.0.0-alpha angel_http_exception: ^1.0.0 - dart2_constant: ^1.0.0 dev_dependencies: - angel_test: ^1.1.0-alpha + angel_test: ^2.0.0-alpha oauth2: ^1.0.0 - test: ^0.12.0 + test: ^1.0.0 + uuid: ^1.0.0 diff --git a/test/auth_code_test.dart b/test/auth_code_test.dart index bb82e4b7..24ed0533 100644 --- a/test/auth_code_test.dart +++ b/test/auth_code_test.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'dart:collection'; 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:dart2_constant/convert.dart'; @@ -15,9 +17,9 @@ main() { TestClient testClient; setUp(() async { - app = new Angel()..lazyParseBodies = true; + app = new Angel(); app.configuration['properties'] = app.configuration; - app.inject('authCodes', {}); + app.container.registerSingleton(new AuthCodes()); var server = new _Server(); @@ -96,7 +98,7 @@ main() { var response = await testClient.client.get(url); print('Body: ${response.body}'); - var authCode = json.decode(response.body)['code']; + var authCode = json.decode(response.body)['code'].toString(); var client = await grant.handleAuthorizationCode(authCode); expect(client.credentials.accessToken, authCode + '_access'); }); @@ -107,7 +109,7 @@ main() { var response = await testClient.client.get(url); print('Body: ${response.body}'); - var authCode = json.decode(response.body)['code']; + var authCode = json.decode(response.body)['code'].toString(); var client = await grant.handleAuthorizationCode(authCode); expect(client.credentials.accessToken, authCode + '_access'); expect(client.credentials.canRefresh, isTrue); @@ -141,8 +143,8 @@ class _Server extends AuthorizationServer { if (state == 'hello') return 'Hello ${pseudoApplication.id}:${pseudoApplication.secret}'; - var authCode = _uuid.v4(); - var authCodes = req.grab>('authCodes'); + var authCode = _uuid.v4() as String; + var authCodes = req.container.make(); authCodes[authCode] = state; res.headers['content-type'] = 'application/json'; @@ -157,10 +159,29 @@ class _Server extends AuthorizationServer { String redirectUri, RequestContext req, ResponseContext res) async { - var authCodes = req.grab>('authCodes'); + var authCodes = req.container.make(); var state = authCodes[authCode]; var refreshToken = state == 'can_refresh' ? '${authCode}_refresh' : null; return new AuthorizationTokenResponse('${authCode}_access', refreshToken: refreshToken); } } + +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); +} diff --git a/test/client_credentials_test.dart b/test/client_credentials_test.dart index b82a6445..939591f5 100644 --- a/test/client_credentials_test.dart +++ b/test/client_credentials_test.dart @@ -11,7 +11,7 @@ main() { TestClient client; setUp(() async { - var app = new Angel()..lazyParseBodies = true; + var app = new Angel(); var oauth2 = new _AuthorizationServer(); app.group('/oauth2', (router) { @@ -42,14 +42,16 @@ main() { print('Response: ${response.body}'); - expect(response, allOf( - hasStatus(200), - hasContentType('application/json'), - hasValidBody(new Validator({ - 'token_type': equals('bearer'), - 'access_token': equals('foo'), - })), - )); + expect( + response, + allOf( + hasStatus(200), + hasContentType('application/json'), + hasValidBody(new Validator({ + 'token_type': equals('bearer'), + 'access_token': equals('foo'), + })), + )); }); test('force correct id', () async { diff --git a/test/common.dart b/test/common.dart index 62c8adc4..9ac194f8 100644 --- a/test/common.dart +++ b/test/common.dart @@ -17,4 +17,4 @@ class PseudoUser { final String username, password; const PseudoUser({this.username, this.password}); -} \ No newline at end of file +} diff --git a/test/implicit_grant_test.dart b/test/implicit_grant_test.dart index a15b9cdd..caed71d3 100644 --- a/test/implicit_grant_test.dart +++ b/test/implicit_grant_test.dart @@ -10,7 +10,7 @@ main() { TestClient client; setUp(() async { - var app = new Angel()..lazyParseBodies = true; + var app = new Angel(); var oauth2 = new _AuthorizationServer(); app.group('/oauth2', (router) { @@ -38,7 +38,8 @@ main() { response, allOf( hasStatus(302), - hasHeader('location', 'http://foo.com#access_token=foo&token_type=bearer&state=bar'), + hasHeader('location', + 'http://foo.com#access_token=foo&token_type=bearer&state=bar'), )); }); } diff --git a/test/password_test.dart b/test/password_test.dart index 9baea1d5..b96ecfa8 100644 --- a/test/password_test.dart +++ b/test/password_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; import 'package:angel_oauth2/angel_oauth2.dart'; import 'package:logging/logging.dart'; import 'package:oauth2/oauth2.dart' as oauth2; @@ -11,7 +12,7 @@ main() { Uri tokenEndpoint; setUp(() async { - app = new Angel()..lazyParseBodies = true; + app = new Angel(); var auth = new _AuthorizationServer(); app.group('/oauth2', (router) { @@ -120,11 +121,12 @@ class _AuthorizationServer orElse: () => null); if (user == null) { + var body = await req.parseBody(); throw new AuthorizationException( new ErrorResponse( ErrorResponse.accessDenied, 'Invalid username or password.', - req.body['state'] ?? '', + body['state']?.toString() ?? '', ), statusCode: 401, ); From 0a1b6937dc113732954d7078c712face3bc2096b Mon Sep 17 00:00:00 2001 From: Tobe O Date: Thu, 8 Nov 2018 10:33:01 -0500 Subject: [PATCH 19/39] changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 631da9f6..5a765423 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,2 +1,5 @@ +# 2.0.0 +* Angel 2 support. + # 1.0.0+1 * Dart2 updates + backwards compatibility assurance. \ No newline at end of file From af26bbe9fe2394e851aba3f3c1190597bbfefb93 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Thu, 8 Nov 2018 10:33:51 -0500 Subject: [PATCH 20/39] deps --- lib/src/server.dart | 2 +- pubspec.yaml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/src/server.dart b/lib/src/server.dart index 0da45100..0804da44 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -1,6 +1,6 @@ import 'dart:async'; +import 'dart:convert'; import 'package:angel_framework/angel_framework.dart'; -import 'package:dart2_constant/convert.dart'; import 'exception.dart'; import 'response.dart'; import 'token_type.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index 57f19f0c..02e59302 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,7 +9,9 @@ dependencies: angel_framework: ^2.0.0-alpha angel_http_exception: ^1.0.0 dev_dependencies: + angel_validate: ^2.0.0-alpha angel_test: ^2.0.0-alpha + logging: oauth2: ^1.0.0 test: ^1.0.0 uuid: ^1.0.0 From cd0f5fa51e1f9d236c01ae57539e512fd07d962f Mon Sep 17 00:00:00 2001 From: Tobe O Date: Thu, 8 Nov 2018 10:34:49 -0500 Subject: [PATCH 21/39] remove dart2constant --- test/auth_code_test.dart | 2 +- test/client_credentials_test.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/auth_code_test.dart b/test/auth_code_test.dart index 24ed0533..1c7ca8d7 100644 --- a/test/auth_code_test.dart +++ b/test/auth_code_test.dart @@ -1,10 +1,10 @@ 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:dart2_constant/convert.dart'; import 'package:logging/logging.dart'; import 'package:oauth2/oauth2.dart' as oauth2; import 'package:test/test.dart'; diff --git a/test/client_credentials_test.dart b/test/client_credentials_test.dart index 939591f5..9d756e9e 100644 --- a/test/client_credentials_test.dart +++ b/test/client_credentials_test.dart @@ -1,9 +1,9 @@ import 'dart:async'; +import 'dart:convert'; import 'package:angel_framework/angel_framework.dart'; import 'package:angel_test/angel_test.dart'; import 'package:angel_oauth2/angel_oauth2.dart'; import 'package:angel_validate/angel_validate.dart'; -import 'package:dart2_constant/convert.dart'; import 'package:test/test.dart'; import 'common.dart'; From 867a899d2f4ee7097e3ae480e25bdcbc5ef1e9ae Mon Sep 17 00:00:00 2001 From: Tobe O Date: Fri, 14 Dec 2018 01:55:36 -0500 Subject: [PATCH 22/39] patch --- lib/src/server.dart | 32 +++++++++++++++++++++++--------- test/password_test.dart | 2 +- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/lib/src/server.dart b/lib/src/server.dart index 0804da44..730ad9f1 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -11,8 +11,15 @@ typedef Future ExtensionGrant( Future _getParam(RequestContext req, String name, String state, {bool body: false}) async { - var map = body == true ? await req.parseBody() : await req.parseQuery(); - var value = map.containsKey(name) ? map[name]?.toString() : null; + Map data; + + if (body == true) { + data = await req.parseBody().then((_) => req.bodyAsMap); + } else { + data = req.queryParameters; + } + + var value = data.containsKey(name) ? data[name]?.toString() : null; if (value?.isNotEmpty != true) { throw new AuthorizationException( @@ -30,8 +37,15 @@ Future _getParam(RequestContext req, String name, String state, Future> _getScopes(RequestContext req, {bool body: false}) async { - var map = body == true ? await req.parseBody() : await req.parseQuery(); - return map['scope']?.toString()?.split(' ') ?? []; + Map data; + + if (body == true) { + data = await req.parseBody().then((_) => req.bodyAsMap); + } else { + data = req.queryParameters; + } + + return data['scope']?.toString()?.split(' ') ?? []; } /// An OAuth2 authorization server, which issues access tokens to third parties. @@ -114,7 +128,7 @@ abstract class AuthorizationServer { Iterable scopes, RequestContext req, ResponseContext res) async { - var body = await req.parseBody(); + var body = await req.parseBody().then((_) => req.bodyAsMap); throw new AuthorizationException( new ErrorResponse( ErrorResponse.unsupportedResponseType, @@ -133,7 +147,7 @@ abstract class AuthorizationServer { Iterable scopes, RequestContext req, ResponseContext res) async { - var body = await req.parseBody(); + var body = await req.parseBody().then((_) => req.bodyAsMap); throw new AuthorizationException( new ErrorResponse( ErrorResponse.unsupportedResponseType, @@ -147,7 +161,7 @@ abstract class AuthorizationServer { /// Performs a client credentials grant. Only use this in situations where the client is 100% trusted. Future clientCredentialsGrant( Client client, RequestContext req, ResponseContext res) async { - var body = await req.parseBody(); + var body = await req.parseBody().then((_) => req.bodyAsMap); throw new AuthorizationException( new ErrorResponse( ErrorResponse.unsupportedResponseType, @@ -164,7 +178,7 @@ abstract class AuthorizationServer { String state = ''; try { - var query = await req.parseQuery(); + var query = req.queryParameters; state = query['state']?.toString() ?? ''; var responseType = await _getParam(req, 'response_type', state); @@ -288,7 +302,7 @@ abstract class AuthorizationServer { try { AuthorizationTokenResponse response; - var body = await req.parseBody(); + var body = await req.parseBody().then((_) => req.bodyAsMap); state = body['state']?.toString() ?? ''; diff --git a/test/password_test.dart b/test/password_test.dart index b96ecfa8..b980ab15 100644 --- a/test/password_test.dart +++ b/test/password_test.dart @@ -121,7 +121,7 @@ class _AuthorizationServer orElse: () => null); if (user == null) { - var body = await req.parseBody(); + var body = await req.parseBody().then((_) => req.bodyAsMap); throw new AuthorizationException( new ErrorResponse( ErrorResponse.accessDenied, From 90141e628620694109a274eb57ac6568bcc3eda4 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Fri, 14 Dec 2018 02:10:53 -0500 Subject: [PATCH 23/39] device code response class --- lib/src/response.dart | 48 +++++++++++++++++++++++++++++++++++++++++++ lib/src/server.dart | 6 +++--- 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/lib/src/response.dart b/lib/src/response.dart index fe26c1df..aacf5dd3 100644 --- a/lib/src/response.dart +++ b/lib/src/response.dart @@ -23,3 +23,51 @@ class AuthorizationTokenResponse { return map; } } + +/// Represents the response for an OAuth2 `device_code` request. +class DeviceCodeResponse { + /// REQUIRED. The device verification code. + final String deviceCode; + + /// REQUIRED. The end-user verification code. + final String userCode; + + /// REQUIRED. The end-user verification URI on the authorization + /// server. The URI should be short and easy to remember as end users + /// will be asked to manually type it into their user-agent. + final Uri verificationUri; + + /// OPTIONAL. A verification URI that includes the [userCode] (or + /// other information with the same function as the [userCode]), + /// designed for non-textual transmission. + final Uri verificationUriComplete; + + /// OPTIONAL. The minimum amount of time in seconds that the client + /// SHOULD wait between polling requests to the token endpoint. If no + /// value is provided, clients MUST use 5 as the default. + final int interval; + + /// The lifetime, in *seconds* of the [deviceCode] and [userCode]. + final int expiresIn; + + const DeviceCodeResponse( + this.deviceCode, this.userCode, this.verificationUri, this.expiresIn, + {this.verificationUriComplete, this.interval}); + + Map toJson() { + var out = { + 'device_code': deviceCode, + 'user_code': userCode, + 'verification_uri': verificationUri.toString(), + }; + + if (verificationUriComplete != null) { + out['verification_uri_complete'] = verificationUriComplete.toString(); + } + + if (interval != null) out['interval'] = interval; + if (expiresIn != null) out['expires_in'] = expiresIn; + + return out; + } +} diff --git a/lib/src/server.dart b/lib/src/server.dart index 730ad9f1..4141f6c5 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -6,7 +6,7 @@ import 'response.dart'; import 'token_type.dart'; /// A request handler that performs an arbitrary authorization token grant. -typedef Future ExtensionGrant( +typedef FutureOr ExtensionGrant( RequestContext req, ResponseContext res); Future _getParam(RequestContext req, String name, String state, @@ -62,12 +62,12 @@ abstract class AuthorizationServer { FutureOr findClient(String clientId); /// Verify that a [client] is the one identified by the [clientSecret]. - Future verifyClient(Client client, String clientSecret); + FutureOr verifyClient(Client client, String clientSecret); /// 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. - requestAuthorizationCode( + FutureOr requestAuthorizationCode( Client client, String redirectUri, Iterable scopes, From b48f2f064f8c4d3138a57b6438e9f3d248fa49d2 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Fri, 14 Dec 2018 02:12:36 -0500 Subject: [PATCH 24/39] base device code stub --- lib/src/server.dart | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/lib/src/server.dart b/lib/src/server.dart index 4141f6c5..8ed48ca0 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -88,7 +88,7 @@ abstract class AuthorizationServer { /// /// Note that in cases where this is called, there is no guarantee /// that the user agent has not been compromised. - Future implicitGrant( + FutureOr implicitGrant( Client client, String redirectUri, Iterable scopes, @@ -106,7 +106,7 @@ abstract class AuthorizationServer { } /// Exchanges an authorization code for an authorization token. - Future exchangeAuthorizationCodeForToken( + FutureOr exchangeAuthorizationCodeForToken( String authCode, String redirectUri, RequestContext req, @@ -122,7 +122,7 @@ abstract class AuthorizationServer { } /// Refresh an authorization token. - Future refreshAuthorizationToken( + FutureOr refreshAuthorizationToken( Client client, String refreshToken, Iterable scopes, @@ -140,7 +140,7 @@ abstract class AuthorizationServer { } /// Issue an authorization token to a user after authenticating them via [username] and [password]. - Future resourceOwnerPasswordCredentialsGrant( + FutureOr resourceOwnerPasswordCredentialsGrant( Client client, String username, String password, @@ -159,7 +159,7 @@ abstract class AuthorizationServer { } /// Performs a client credentials grant. Only use this in situations where the client is 100% trusted. - Future clientCredentialsGrant( + FutureOr clientCredentialsGrant( Client client, RequestContext req, ResponseContext res) async { var body = await req.parseBody().then((_) => req.bodyAsMap); throw new AuthorizationException( @@ -172,6 +172,19 @@ abstract class AuthorizationServer { ); } + FutureOr deviceCodeGrant(Client client, + Iterable scopes, RequestContext req, ResponseContext res) async { + var body = await req.parseBody().then((_) => req.bodyAsMap); + throw new AuthorizationException( + new ErrorResponse( + ErrorResponse.unsupportedResponseType, + 'Device code grants are not supported.', + body['state']?.toString() ?? '', + ), + statusCode: 400, + ); + } + /// A request handler that invokes the correct logic, depending on which type /// of grant the client is requesting. Future authorizationEndpoint(RequestContext req, ResponseContext res) async { From 7b42d97b45f78809d07c9278fb070a5cebb737af Mon Sep 17 00:00:00 2001 From: Tobe O Date: Fri, 14 Dec 2018 02:15:07 -0500 Subject: [PATCH 25/39] handle initial device code --- lib/src/server.dart | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/lib/src/server.dart b/lib/src/server.dart index 8ed48ca0..0fc35372 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -10,7 +10,7 @@ typedef FutureOr ExtensionGrant( RequestContext req, ResponseContext res); Future _getParam(RequestContext req, String name, String state, - {bool body: false}) async { + {bool body: false, bool throwIfEmpty: true}) async { Map data; if (body == true) { @@ -21,7 +21,7 @@ Future _getParam(RequestContext req, String name, String state, var value = data.containsKey(name) ? data[name]?.toString() : null; - if (value?.isNotEmpty != true) { + if (value?.isNotEmpty != true && throwIfEmpty) { throw new AuthorizationException( new ErrorResponse( ErrorResponse.invalidRequest, @@ -172,6 +172,7 @@ abstract class AuthorizationServer { ); } + /// Performs a device code grant. FutureOr deviceCodeGrant(Client client, Iterable scopes, RequestContext req, ResponseContext res) async { var body = await req.parseBody().then((_) => req.bodyAsMap); @@ -319,7 +320,7 @@ abstract class AuthorizationServer { state = body['state']?.toString() ?? ''; - var grantType = await _getParam(req, 'grant_type', state, body: true); + var grantType = await _getParam(req, 'grant_type', state, body: true, throwIfEmpty: false); if (grantType != 'authorization_code') { var match = @@ -398,6 +399,11 @@ abstract class AuthorizationServer { } } else if (extensionGrants.containsKey(grantType)) { response = await extensionGrants[grantType](req, res); + } else if (grantType == null) { + // This is a device code grant. + var scopes = await _getScopes(req, body: true); + var deviceCodeResponse = await deviceCodeGrant(client, scopes, req, res); + return deviceCodeResponse.toJson(); } if (response != null) { From 2e50f7f71bcc660085b37f9f62e50a2060c76925 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Fri, 14 Dec 2018 02:24:32 -0500 Subject: [PATCH 26/39] device code done --- lib/src/exception.dart | 22 ++++++++++++++++++++++ lib/src/server.dart | 30 +++++++++++++++++++++++++++--- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/lib/src/exception.dart b/lib/src/exception.dart index 3b4da075..357e066a 100644 --- a/lib/src/exception.dart +++ b/lib/src/exception.dart @@ -46,6 +46,28 @@ class ErrorResponse { /// The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server. static const String temporarilyUnavailable = 'temporarily_unavailable'; + /// The authorization request is still pending as the end user hasn't + /// yet completed the user interaction steps (Section 3.3). The + /// client SHOULD repeat the Access Token Request to the token + /// endpoint (a process known as polling). Before each new request + /// the client MUST wait at least the number of seconds specified by + /// the "interval" parameter of the Device Authorization Response (see + /// Section 3.2), or 5 seconds if none was provided, and respect any + /// increase in the polling interval required by the "slow_down" + /// error. + static const String authorizationPending = 'authorization_pending'; + + /// A variant of "authorization_pending", the authorization request is + /// still pending and polling should continue, but the interval MUST + /// be increased by 5 seconds for this and all subsequent requests. + static const String slowDown = 'slow_down'; + + /// The "device_code" has expired and the device flow authorization + /// session has concluded. The client MAY commence a new Device + /// Authorization Request but SHOULD wait for user interaction before + /// restarting to avoid unnecessary polling. + static const String expiredToken = 'expired_token'; + /// A short string representing the error. final String code; diff --git a/lib/src/server.dart b/lib/src/server.dart index 0fc35372..ca28c3b5 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -173,7 +173,7 @@ abstract class AuthorizationServer { } /// Performs a device code grant. - FutureOr deviceCodeGrant(Client client, + FutureOr requestDeviceCode(Client client, Iterable scopes, RequestContext req, ResponseContext res) async { var body = await req.parseBody().then((_) => req.bodyAsMap); throw new AuthorizationException( @@ -186,6 +186,24 @@ abstract class AuthorizationServer { ); } + /// Produces an authorization token from a given device code. + FutureOr exchangeDeviceCodeForToken( + Client client, + String deviceCode, + String state, + RequestContext req, + ResponseContext res) async { + var body = await req.parseBody().then((_) => req.bodyAsMap); + throw new AuthorizationException( + new ErrorResponse( + ErrorResponse.unsupportedResponseType, + 'Device code grants are not supported.', + body['state']?.toString() ?? '', + ), + statusCode: 400, + ); + } + /// A request handler that invokes the correct logic, depending on which type /// of grant the client is requesting. Future authorizationEndpoint(RequestContext req, ResponseContext res) async { @@ -320,7 +338,8 @@ abstract class AuthorizationServer { state = body['state']?.toString() ?? ''; - var grantType = await _getParam(req, 'grant_type', state, body: true, throwIfEmpty: false); + var grantType = await _getParam(req, 'grant_type', state, + body: true, throwIfEmpty: false); if (grantType != 'authorization_code') { var match = @@ -402,8 +421,13 @@ abstract class AuthorizationServer { } else if (grantType == null) { // This is a device code grant. var scopes = await _getScopes(req, body: true); - var deviceCodeResponse = await deviceCodeGrant(client, scopes, req, res); + var deviceCodeResponse = + await requestDeviceCode(client, scopes, req, res); return deviceCodeResponse.toJson(); + } else if (grantType == 'urn:ietf:params:oauth:grant-type:device_code') { + var deviceCode = await _getParam(req, 'device_code', state, body: true); + response = await exchangeDeviceCodeForToken( + client, deviceCode, state, req, res); } if (response != null) { From 7978d8c78aa403cf1bc02d2146f418cb6966b37d Mon Sep 17 00:00:00 2001 From: Tobe O Date: Fri, 14 Dec 2018 02:40:37 -0500 Subject: [PATCH 27/39] get code tests --- lib/src/server.dart | 32 +++++++++- test/device_code_test.dart | 122 +++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 test/device_code_test.dart diff --git a/lib/src/server.dart b/lib/src/server.dart index ca28c3b5..dd5d7a8c 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -341,7 +341,9 @@ abstract class AuthorizationServer { var grantType = await _getParam(req, 'grant_type', state, body: true, throwIfEmpty: false); - if (grantType != 'authorization_code') { + if (grantType != 'authorization_code' && + grantType != 'urn:ietf:params:oauth:grant-type:device_code' && + grantType != null) { var match = _rgxBasic.firstMatch(req.headers.value('authorization') ?? ''); @@ -420,11 +422,39 @@ abstract class AuthorizationServer { response = await extensionGrants[grantType](req, res); } else if (grantType == null) { // This is a device code grant. + var clientId = await _getParam(req, 'client_id', state, body: true); + client = await findClient(clientId); + + if (client == null) { + throw new AuthorizationException( + new ErrorResponse( + ErrorResponse.unauthorizedClient, + 'Invalid "client_id" parameter.', + state, + ), + statusCode: 400, + ); + } + var scopes = await _getScopes(req, body: true); var deviceCodeResponse = await requestDeviceCode(client, scopes, req, res); return deviceCodeResponse.toJson(); } else if (grantType == 'urn:ietf:params:oauth:grant-type:device_code') { + var clientId = await _getParam(req, 'client_id', state, body: true); + client = await findClient(clientId); + + if (client == null) { + throw new AuthorizationException( + new ErrorResponse( + ErrorResponse.unauthorizedClient, + 'Invalid "client_id" parameter.', + state, + ), + statusCode: 400, + ); + } + var deviceCode = await _getParam(req, 'device_code', state, body: true); response = await exchangeDeviceCodeForToken( client, deviceCode, state, req, res); diff --git a/test/device_code_test.dart b/test/device_code_test.dart new file mode 100644 index 00000000..3bd08929 --- /dev/null +++ b/test/device_code_test.dart @@ -0,0 +1,122 @@ +import 'dart:async'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_test/angel_test.dart'; +import 'package:angel_oauth2/angel_oauth2.dart'; +import 'package:angel_validate/angel_validate.dart'; +import 'package:logging/logging.dart'; +import 'package:test/test.dart'; +import 'common.dart'; + +main() { + TestClient client; + + setUp(() async { + var app = new Angel(); + var oauth2 = new _AuthorizationServer(); + + app.group('/oauth2', (router) { + router + ..get('/authorize', oauth2.authorizationEndpoint) + ..post('/token', oauth2.tokenEndpoint); + }); + + app.logger = new Logger('angel_oauth2') + ..onRecord.listen((rec) { + print(rec); + if (rec.error != null) print(rec.error); + if (rec.stackTrace != null) print(rec.stackTrace); + }); + + app.errorHandler = (e, req, res) async { + res.json(e.toJson()); + }; + + client = await connectTo(app); + }); + + tearDown(() => client.close()); + + group('get initial code', () { + test('invalid client id', () async { + var response = await client.post('/oauth2/token', body: { + 'client_id': 'barr', + }); + print(response.body); + expect(response, hasStatus(400)); + }); + + test('valid client id, no scopes', () async { + var response = await client.post('/oauth2/token', body: { + 'client_id': 'foo', + }); + print(response.body); + expect( + response, + allOf( + hasStatus(200), + isJson({ + "device_code": "foo", + "user_code": "bar", + "verification_uri": "https://regiostech.com?scopes", + "expires_in": 3600 + }), + )); + }); + + test('valid client id, with scopes', () async { + var response = await client.post('/oauth2/token', body: { + 'client_id': 'foo', + 'scope': 'bar baz quux', + }); + print(response.body); + expect( + response, + allOf( + hasStatus(200), + isJson({ + "device_code": "foo", + "user_code": "bar", + "verification_uri": Uri.parse("https://regiostech.com").replace( + queryParameters: {'scopes': 'bar,baz,quux'}).toString(), + "expires_in": 3600 + }), + )); + }); + }); +} + +class _AuthorizationServer + extends AuthorizationServer { + @override + PseudoApplication findClient(String clientId) { + return clientId == pseudoApplication.id ? pseudoApplication : null; + } + + @override + Future verifyClient( + PseudoApplication client, String clientSecret) async { + return client.secret == clientSecret; + } + + @override + FutureOr requestDeviceCode(PseudoApplication client, + Iterable scopes, RequestContext req, ResponseContext res) { + return new DeviceCodeResponse( + 'foo', + 'bar', + Uri.parse('https://regiostech.com') + .replace(queryParameters: {'scopes': scopes.join(',')}), + 3600); + } + + @override + Future implicitGrant( + PseudoApplication client, + String redirectUri, + Iterable scopes, + String state, + RequestContext req, + ResponseContext res) async { + return new AuthorizationTokenResponse('foo'); + } +} From 86db7256010ba4ebb551d532592e704d051b7c0c Mon Sep 17 00:00:00 2001 From: Tobe O Date: Sat, 15 Dec 2018 02:19:35 -0500 Subject: [PATCH 28/39] device code tests complete --- CHANGELOG.md | 4 +++ pubspec.yaml | 2 +- test/device_code_test.dart | 58 +++++++++++++++++++++++++++++++++++--- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a765423..3856cae5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 2.1.0 +* Updates +* Support `device_code` grants. + # 2.0.0 * Angel 2 support. diff --git a/pubspec.yaml b/pubspec.yaml index 02e59302..1f34196d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: angel_oauth2 author: Tobe O description: A class containing handlers that can be used within Angel to build a spec-compliant OAuth 2.0 server. homepage: https://github.com/angel-dart/oauth2.git -version: 2.0.0 +version: 2.1.0 environment: sdk: ">=2.0.0-dev <3.0.0" dependencies: diff --git a/test/device_code_test.dart b/test/device_code_test.dart index 3bd08929..4691f0b5 100644 --- a/test/device_code_test.dart +++ b/test/device_code_test.dart @@ -83,6 +83,50 @@ main() { )); }); }); + + group('get token', () { + test('valid device code + timing', () async { + var response = await client.post('/oauth2/token', body: { + 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', + 'client_id': 'foo', + 'device_code': 'bar', + }); + + print(response.body); + expect( + response, + allOf( + hasStatus(200), + isJson({"token_type": "bearer", "access_token": "foo"}), + )); + }); + + // The rationale for only testing one possible error response is that + // they all only differ in terms of the `code` string sent down, + // which is chosen by the end user. + // + // The logic for throwing errors and turning them into responses + // has already been tested. + test('failure', () async { + var response = await client.post('/oauth2/token', body: { + 'grant_type': 'urn:ietf:params:oauth:grant-type:device_code', + 'client_id': 'foo', + 'device_code': 'brute', + }); + + print(response.body); + expect( + response, + allOf( + hasStatus(400), + isJson({ + "error": "slow_down", + "error_description": + "Ho, brother! Ho, whoa, whoa, whoa now! You got too much dip on your chip!" + }), + )); + }); + }); } class _AuthorizationServer @@ -110,13 +154,19 @@ class _AuthorizationServer } @override - Future implicitGrant( + FutureOr exchangeDeviceCodeForToken( PseudoApplication client, - String redirectUri, - Iterable scopes, + String deviceCode, String state, RequestContext req, - ResponseContext res) async { + ResponseContext res) { + if (deviceCode == 'brute') { + throw new AuthorizationException(new ErrorResponse( + ErrorResponse.slowDown, + "Ho, brother! Ho, whoa, whoa, whoa now! You got too much dip on your chip!", + state)); + } + return new AuthorizationTokenResponse('foo'); } } From d1213e779a59764370546f129a13c6f3dc538e44 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Sat, 15 Dec 2018 02:21:16 -0500 Subject: [PATCH 29/39] readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 63f82cdc..7aab1348 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,7 @@ The following are available, not including authorization code grant support (men * `implicitGrant` * `resourceOwnerPasswordCredentialsGrant` * `clientCredentialsGrant` +* `deviceCode` Read the [OAuth2 specification](https://tools.ietf.org/html/rfc6749) for in-depth information on each grant type. \ No newline at end of file From 71611f7f6a6e56b18e02360bc6117e32f95a8e07 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Sat, 15 Dec 2018 02:45:40 -0500 Subject: [PATCH 30/39] add pkce class --- README.md | 6 ++-- lib/angel_oauth2.dart | 3 +- lib/src/exception.dart | 3 ++ lib/src/pkce.dart | 67 ++++++++++++++++++++++++++++++++++++++++++ lib/src/server.dart | 1 + 5 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 lib/src/pkce.dart diff --git a/README.md b/README.md index 7aab1348..1bc66f12 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ In your `pubspec.yaml`: ```yaml dependencies: - angel_oauth2: ^1.0.0 + angel_oauth2: ^2.0.0 ``` # Usage @@ -49,7 +49,7 @@ class _Server extends AuthorizationServer { ``` Next, write some logic to be executed whenever a user visits the -authorization endpoint. In most cases, you will want to show a dialog: +authorization endpoint. In many cases, you will want to show a dialog: ```dart @override @@ -99,7 +99,7 @@ The following are available, not including authorization code grant support (men * `implicitGrant` * `resourceOwnerPasswordCredentialsGrant` * `clientCredentialsGrant` -* `deviceCode` +* `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 diff --git a/lib/angel_oauth2.dart b/lib/angel_oauth2.dart index 36e10f39..9c3494f2 100644 --- a/lib/angel_oauth2.dart +++ b/lib/angel_oauth2.dart @@ -1,4 +1,5 @@ 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'; \ No newline at end of file diff --git a/lib/src/exception.dart b/lib/src/exception.dart index 357e066a..8d1b67f4 100644 --- a/lib/src/exception.dart +++ b/lib/src/exception.dart @@ -28,6 +28,9 @@ class ErrorResponse { /// The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed. static const String invalidRequest = 'invalid_request'; + /// The `code_verifier` given by the client does not match the expected value. + static const String invalidGrant = 'invalid_grant'; + /// The client is not authorized to request an authorization code using this method. static const String unauthorizedClient = 'unauthorized_client'; diff --git a/lib/src/pkce.dart b/lib/src/pkce.dart new file mode 100644 index 00000000..f2c710f7 --- /dev/null +++ b/lib/src/pkce.dart @@ -0,0 +1,67 @@ +import 'dart:convert'; +import 'package:crypto/crypto.dart'; +import 'exception.dart'; + +/// A class that facilitates verification of challenges for +/// [Proof Key for Code Exchange](https://oauth.net/2/pkce/). +class Pkce { + /// A [String] defining how to handle the [codeChallenge]. + final String codeChallengeMethod; + + /// The proof key that is used to secure public clients. + final String codeChallenge; + + Pkce(this.codeChallengeMethod, this.codeChallenge) { + assert(codeChallengeMethod == 'plain' || codeChallengeMethod == 's256', + "The `code_challenge_method` parameter must be either 'plain' or 's256'."); + } + + /// Attempts to parse a [codeChallenge] and [codeChallengeMethod] from a [Map]. + factory Pkce.fromJson(Map data, {String state, Uri uri}) { + var codeChallenge = data['code_challenge']?.toString(); + var codeChallengeMethod = + data['code_challenge_method']?.toString() ?? 'plain'; + + if (codeChallengeMethod != 'plain' && codeChallengeMethod != 's256') { + throw new AuthorizationException(new ErrorResponse( + ErrorResponse.invalidRequest, + "The `code_challenge_method` parameter must be either 'plain' or 's256'.", + state, + uri: uri)); + } else if (codeChallenge?.isNotEmpty != true) { + throw new AuthorizationException(new ErrorResponse( + ErrorResponse.invalidRequest, + 'Missing `code_challenge` parameter.', + state, + uri: uri)); + } + + return new Pkce(codeChallengeMethod, codeChallenge); + } + + /// Returns [true] if the [codeChallengeMethod] is `plain`. + bool get isPlain => codeChallengeMethod == 'plain'; + + /// Returns [true] if the [codeChallengeMethod] is `s256`. + bool get isS256 => codeChallengeMethod == 's256'; + + /// Determines if a given [codeVerifier] is valid. + void validate(String codeVerifier, {String state, Uri uri}) { + String foreignChallenge; + + if (isS256) { + foreignChallenge = + base64Url.encode(sha256.convert(ascii.encode(codeChallenge)).bytes); + } else { + foreignChallenge = codeChallenge; + } + + if (foreignChallenge != codeChallenge) { + throw new AuthorizationException( + new ErrorResponse(ErrorResponse.invalidGrant, + "The given `code_verifier` parameter is invalid.", state, + uri: uri), + ); + } + } +} diff --git a/lib/src/server.dart b/lib/src/server.dart index dd5d7a8c..712c74c7 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:angel_framework/angel_framework.dart'; import 'exception.dart'; +import 'pkce.dart'; import 'response.dart'; import 'token_type.dart'; From 32da3a16b51005adf9468c6db2822974f89e491a Mon Sep 17 00:00:00 2001 From: Tobe O Date: Sat, 15 Dec 2018 02:48:34 -0500 Subject: [PATCH 31/39] pkce factories injected --- lib/src/pkce.dart | 8 ++++++++ lib/src/server.dart | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/lib/src/pkce.dart b/lib/src/pkce.dart index f2c710f7..35bae15a 100644 --- a/lib/src/pkce.dart +++ b/lib/src/pkce.dart @@ -64,4 +64,12 @@ class Pkce { ); } } + + /// Creates a JSON-serializable representation of this instance. + Map toJson() { + return { + 'code_challenge': codeChallenge, + 'code_challenge_method': codeChallengeMethod + }; + } } diff --git a/lib/src/server.dart b/lib/src/server.dart index 712c74c7..c8aa690e 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -215,6 +215,10 @@ abstract class AuthorizationServer { state = query['state']?.toString() ?? ''; var responseType = await _getParam(req, 'response_type', state); + req.container.registerLazySingleton((_) { + return new Pkce.fromJson(req.queryParameters, state: state); + }); + if (responseType == 'code') { // Ensure client ID // TODO: Handle confidential clients @@ -339,6 +343,10 @@ abstract class AuthorizationServer { state = body['state']?.toString() ?? ''; + req.container.registerLazySingleton((_) { + return new Pkce.fromJson(req.bodyAsMap, state: state); + }); + var grantType = await _getParam(req, 'grant_type', state, body: true, throwIfEmpty: false); From ac2c7691ea01ff465d1b4e5d2ac9f857480753e7 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Sat, 15 Dec 2018 03:39:04 -0500 Subject: [PATCH 32/39] 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); +} From 2d9630243f0d08fa57da854427f7cc609fd7d498 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Sat, 15 Dec 2018 03:39:42 -0500 Subject: [PATCH 33/39] add crypto dep --- pubspec.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pubspec.yaml b/pubspec.yaml index 1f34196d..17f3f634 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -8,6 +8,7 @@ environment: dependencies: angel_framework: ^2.0.0-alpha angel_http_exception: ^1.0.0 + crypto: ^2.0.0 dev_dependencies: angel_validate: ^2.0.0-alpha angel_test: ^2.0.0-alpha From e960bdd49f866566212e576494c4b78f2d676213 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Thu, 2 May 2019 03:28:38 -0400 Subject: [PATCH 34/39] Bump to 2.2.0 --- CHANGELOG.md | 4 + README.md | 12 +-- analysis_options.yaml | 7 +- example/main.dart | 15 ++-- lib/src/exception.dart | 4 +- lib/src/pkce.dart | 10 +-- lib/src/server.dart | 118 +++++++++++++++--------------- pubspec.yaml | 3 +- test/auth_code_test.dart | 17 +++-- test/client_credentials_test.dart | 8 +- test/common.dart | 10 +-- test/device_code_test.dart | 12 +-- test/implicit_grant_test.dart | 6 +- test/password_test.dart | 18 ++--- test/pkce_test.dart | 22 +++--- 15 files changed, 135 insertions(+), 131 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c52cb561..076791ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 2.2.0 +* Pass `client` to `exchangeAuthorizationCodeForToken`. +* Apply `package:pedantic`. + # 2.1.0 * Updates * Support `device_code` grants. diff --git a/README.md b/README.md index c255aa0b..a32fb001 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ but that it can also verify its identity via a `client_secret`. ```dart class _Server extends AuthorizationServer { - final Uuid _uuid = new Uuid(); + final Uuid _uuid = Uuid(); @override FutureOr findClient(String clientId) { @@ -80,7 +80,7 @@ Future exchangeAuthCodeForAccessToken( String redirectUri, RequestContext req, ResponseContext res) async { - return new AuthorizationCodeResponse('foo', refreshToken: 'bar'); + return AuthorizationCodeResponse('foo', refreshToken: 'bar'); } ``` @@ -137,7 +137,7 @@ Future requestAuthorizationCode( // 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. + // create a [Pkce] object, and verify the client. return await getAuthCodeSomehow(client, pkce.codeChallenge, pkce.codeChallengeMethod); } @@ -159,14 +159,14 @@ Future exchangeAuthorizationCodeForToken( var codeChallenge = await getTheChallenge(); var codeChallengeMethod = await getTheChallengeMethod(); - // Make a new [Pkce] object. - var pkce = new Pkce(codeChallengeMethod, codeChallenge); + // Make a [Pkce] object. + var pkce = 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('...'); + return AuthorizationTokenResponse('...'); } ``` \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml index eae1e42a..085be64d 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,3 +1,8 @@ +include: package:pedantic/analysis_options.yaml analyzer: strong-mode: - implicit-casts: false \ No newline at end of file + implicit-casts: false +linter: + rules: + - unnecessary_const + - unnecessary_new \ No newline at end of file diff --git a/example/main.dart b/example/main.dart index 98e63af6..58527be7 100644 --- a/example/main.dart +++ b/example/main.dart @@ -4,9 +4,9 @@ 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]+)$'); + var app = Angel(); + var oauth2 = _ExampleAuthorizationServer(); + var _rgxBearer = RegExp(r'^[Bb]earer ([^\n\s]+)$'); app.group('/auth', (router) { router @@ -40,13 +40,13 @@ class _ExampleAuthorizationServer @override FutureOr findClient(String clientId) { // TODO: Add your code to find the app associated with a client ID. - throw new UnimplementedError(); + throw UnimplementedError(); } @override FutureOr verifyClient(ThirdPartyApp client, String clientSecret) { // TODO: Add your code to verify a client secret, if given one. - throw new UnimplementedError(); + throw UnimplementedError(); } @override @@ -58,17 +58,18 @@ class _ExampleAuthorizationServer 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(); + throw UnimplementedError(); } @override FutureOr exchangeAuthorizationCodeForToken( + ThirdPartyApp client, 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(); + throw UnimplementedError(); } } diff --git a/lib/src/exception.dart b/lib/src/exception.dart index 8d1b67f4..0264bd92 100644 --- a/lib/src/exception.dart +++ b/lib/src/exception.dart @@ -52,7 +52,7 @@ class ErrorResponse { /// The authorization request is still pending as the end user hasn't /// yet completed the user interaction steps (Section 3.3). The /// client SHOULD repeat the Access Token Request to the token - /// endpoint (a process known as polling). Before each new request + /// endpoint (a process known as polling). Before each request /// the client MUST wait at least the number of seconds specified by /// the "interval" parameter of the Device Authorization Response (see /// Section 3.2), or 5 seconds if none was provided, and respect any @@ -66,7 +66,7 @@ class ErrorResponse { static const String slowDown = 'slow_down'; /// The "device_code" has expired and the device flow authorization - /// session has concluded. The client MAY commence a new Device + /// session has concluded. The client MAY commence a Device /// Authorization Request but SHOULD wait for user interaction before /// restarting to avoid unnecessary polling. static const String expiredToken = 'expired_token'; diff --git a/lib/src/pkce.dart b/lib/src/pkce.dart index 67b06cfd..3a347f9c 100644 --- a/lib/src/pkce.dart +++ b/lib/src/pkce.dart @@ -23,20 +23,20 @@ class Pkce { data['code_challenge_method']?.toString() ?? 'plain'; if (codeChallengeMethod != 'plain' && codeChallengeMethod != 's256') { - throw new AuthorizationException(new ErrorResponse( + throw AuthorizationException(ErrorResponse( ErrorResponse.invalidRequest, "The `code_challenge_method` parameter must be either 'plain' or 's256'.", state, uri: uri)); } else if (codeChallenge?.isNotEmpty != true) { - throw new AuthorizationException(new ErrorResponse( + throw AuthorizationException(ErrorResponse( ErrorResponse.invalidRequest, 'Missing `code_challenge` parameter.', state, uri: uri)); } - return new Pkce(codeChallengeMethod, codeChallenge); + return Pkce(codeChallengeMethod, codeChallenge); } /// Returns [true] if the [codeChallengeMethod] is `plain`. @@ -57,8 +57,8 @@ class Pkce { } if (foreignChallenge != codeChallenge) { - throw new AuthorizationException( - new ErrorResponse(ErrorResponse.invalidGrant, + throw AuthorizationException( + ErrorResponse(ErrorResponse.invalidGrant, "The given `code_verifier` parameter is invalid.", state, uri: uri), ); diff --git a/lib/src/server.dart b/lib/src/server.dart index f69dd852..6efd94a3 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -11,7 +11,7 @@ typedef FutureOr ExtensionGrant( RequestContext req, ResponseContext res); Future _getParam(RequestContext req, String name, String state, - {bool body: false, bool throwIfEmpty: true}) async { + {bool body = false, bool throwIfEmpty = true}) async { Map data; if (body == true) { @@ -23,8 +23,8 @@ Future _getParam(RequestContext req, String name, String state, var value = data.containsKey(name) ? data[name]?.toString() : null; if (value?.isNotEmpty != true && throwIfEmpty) { - throw new AuthorizationException( - new ErrorResponse( + throw AuthorizationException( + ErrorResponse( ErrorResponse.invalidRequest, 'Missing required parameter "$name".', state, @@ -37,7 +37,7 @@ Future _getParam(RequestContext req, String name, String state, } Future> _getScopes(RequestContext req, - {bool body: false}) async { + {bool body = false}) async { Map data; if (body == true) { @@ -67,23 +67,19 @@ abstract class AuthorizationServer { /// Retrieves the PKCE `code_verifier` parameter from a [RequestContext], or throws. Future getPkceCodeVerifier(RequestContext req, - {bool body: true, String state, Uri uri}) async { + {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, + throw AuthorizationException(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, + throw AuthorizationException(ErrorResponse(ErrorResponse.invalidRequest, + "The `code_verifier` parameter must be a string.", state, uri: uri)); } else { return codeVerifier as String; @@ -100,8 +96,8 @@ abstract class AuthorizationServer { String state, RequestContext req, ResponseContext res) { - throw new AuthorizationException( - new ErrorResponse( + throw AuthorizationException( + ErrorResponse( ErrorResponse.unsupportedResponseType, 'Authorization code grants are not supported.', state, @@ -121,8 +117,8 @@ abstract class AuthorizationServer { String state, RequestContext req, ResponseContext res) { - throw new AuthorizationException( - new ErrorResponse( + throw AuthorizationException( + ErrorResponse( ErrorResponse.unsupportedResponseType, 'Authorization code grants are not supported.', state, @@ -133,12 +129,13 @@ abstract class AuthorizationServer { /// Exchanges an authorization code for an authorization token. FutureOr exchangeAuthorizationCodeForToken( + Client client, String authCode, String redirectUri, RequestContext req, ResponseContext res) { - throw new AuthorizationException( - new ErrorResponse( + throw AuthorizationException( + ErrorResponse( ErrorResponse.unsupportedResponseType, 'Authorization code grants are not supported.', req.uri.queryParameters['state'] ?? '', @@ -155,8 +152,8 @@ abstract class AuthorizationServer { RequestContext req, ResponseContext res) async { var body = await req.parseBody().then((_) => req.bodyAsMap); - throw new AuthorizationException( - new ErrorResponse( + throw AuthorizationException( + ErrorResponse( ErrorResponse.unsupportedResponseType, 'Refreshing authorization tokens is not supported.', body['state']?.toString() ?? '', @@ -174,8 +171,8 @@ abstract class AuthorizationServer { RequestContext req, ResponseContext res) async { var body = await req.parseBody().then((_) => req.bodyAsMap); - throw new AuthorizationException( - new ErrorResponse( + throw AuthorizationException( + ErrorResponse( ErrorResponse.unsupportedResponseType, 'Resource owner password credentials grants are not supported.', body['state']?.toString() ?? '', @@ -188,8 +185,8 @@ abstract class AuthorizationServer { FutureOr clientCredentialsGrant( Client client, RequestContext req, ResponseContext res) async { var body = await req.parseBody().then((_) => req.bodyAsMap); - throw new AuthorizationException( - new ErrorResponse( + throw AuthorizationException( + ErrorResponse( ErrorResponse.unsupportedResponseType, 'Client credentials grants are not supported.', body['state']?.toString() ?? '', @@ -202,8 +199,8 @@ abstract class AuthorizationServer { FutureOr requestDeviceCode(Client client, Iterable scopes, RequestContext req, ResponseContext res) async { var body = await req.parseBody().then((_) => req.bodyAsMap); - throw new AuthorizationException( - new ErrorResponse( + throw AuthorizationException( + ErrorResponse( ErrorResponse.unsupportedResponseType, 'Device code grants are not supported.', body['state']?.toString() ?? '', @@ -220,8 +217,8 @@ abstract class AuthorizationServer { RequestContext req, ResponseContext res) async { var body = await req.parseBody().then((_) => req.bodyAsMap); - throw new AuthorizationException( - new ErrorResponse( + throw AuthorizationException( + ErrorResponse( ErrorResponse.unsupportedResponseType, 'Device code grants are not supported.', body['state']?.toString() ?? '', @@ -241,19 +238,18 @@ abstract class AuthorizationServer { var responseType = await _getParam(req, 'response_type', state); req.container.registerLazySingleton((_) { - return new Pkce.fromJson(req.queryParameters, state: state); + return Pkce.fromJson(req.queryParameters, state: state); }); if (responseType == 'code') { // Ensure client ID - // TODO: Handle confidential clients var clientId = await _getParam(req, 'client_id', state); // Find client var client = await findClient(clientId); if (client == null) { - throw new AuthorizationException(new ErrorResponse( + throw AuthorizationException(ErrorResponse( ErrorResponse.unauthorizedClient, 'Unknown client "$clientId".', state, @@ -275,7 +271,7 @@ abstract class AuthorizationServer { var client = await findClient(clientId); if (client == null) { - throw new AuthorizationException(new ErrorResponse( + throw AuthorizationException(ErrorResponse( ErrorResponse.unauthorizedClient, 'Unknown client "$clientId".', state, @@ -307,8 +303,8 @@ abstract class AuthorizationServer { if (token.scope != null) queryParameters['scope'] = token.scope.join(' '); - var fragment = queryParameters.keys - .fold(new StringBuffer(), (buf, k) { + var fragment = + queryParameters.keys.fold(StringBuffer(), (buf, k) { if (buf.isNotEmpty) buf.write('&'); return buf ..write( @@ -320,8 +316,8 @@ abstract class AuthorizationServer { res.redirect(target.toString()); return false; } on FormatException { - throw new AuthorizationException( - new ErrorResponse( + throw AuthorizationException( + ErrorResponse( ErrorResponse.invalidRequest, 'Invalid URI provided as "redirect_uri" parameter', state, @@ -330,8 +326,8 @@ abstract class AuthorizationServer { } } - throw new AuthorizationException( - new ErrorResponse( + throw AuthorizationException( + ErrorResponse( ErrorResponse.invalidRequest, 'Invalid or no "response_type" parameter provided', state, @@ -340,8 +336,8 @@ abstract class AuthorizationServer { } on AngelHttpException { rethrow; } catch (e, st) { - throw new AuthorizationException( - new ErrorResponse( + throw AuthorizationException( + ErrorResponse( ErrorResponse.serverError, _internalServerError, state, @@ -353,8 +349,8 @@ abstract class AuthorizationServer { } } - static final RegExp _rgxBasic = new RegExp(r'Basic ([^$]+)'); - static final RegExp _rgxBasicAuth = new RegExp(r'([^:]*):([^$]*)'); + static final RegExp _rgxBasic = RegExp(r'Basic ([^$]+)'); + static final RegExp _rgxBasicAuth = RegExp(r'([^:]*):([^$]*)'); /// A request handler that either exchanges authorization codes for authorization tokens, /// or refreshes authorization tokens. @@ -369,7 +365,7 @@ abstract class AuthorizationServer { state = body['state']?.toString() ?? ''; req.container.registerLazySingleton((_) { - return new Pkce.fromJson(req.bodyAsMap, state: state); + return Pkce.fromJson(req.bodyAsMap, state: state); }); var grantType = await _getParam(req, 'grant_type', state, @@ -383,12 +379,12 @@ abstract class AuthorizationServer { if (match != null) { match = _rgxBasicAuth - .firstMatch(new String.fromCharCodes(base64Url.decode(match[1]))); + .firstMatch(String.fromCharCodes(base64Url.decode(match[1]))); } if (match == null) { - throw new AuthorizationException( - new ErrorResponse( + throw AuthorizationException( + ErrorResponse( ErrorResponse.unauthorizedClient, 'Invalid or no "Authorization" header.', state, @@ -400,8 +396,8 @@ abstract class AuthorizationServer { client = await findClient(clientId); if (client == null) { - throw new AuthorizationException( - new ErrorResponse( + throw AuthorizationException( + ErrorResponse( ErrorResponse.unauthorizedClient, 'Invalid "client_id" parameter.', state, @@ -411,8 +407,8 @@ abstract class AuthorizationServer { } if (!await verifyClient(client, clientSecret)) { - throw new AuthorizationException( - new ErrorResponse( + throw AuthorizationException( + ErrorResponse( ErrorResponse.unauthorizedClient, 'Invalid "client_secret" parameter.', state, @@ -428,7 +424,7 @@ abstract class AuthorizationServer { var redirectUri = await _getParam(req, 'redirect_uri', state, body: true); response = await exchangeAuthorizationCodeForToken( - code, redirectUri, req, res); + client, code, redirectUri, req, res); } else if (grantType == 'refresh_token') { var refreshToken = await _getParam(req, 'refresh_token', state, body: true); @@ -446,7 +442,7 @@ abstract class AuthorizationServer { if (response.refreshToken != null) { // Remove refresh token - response = new AuthorizationTokenResponse( + response = AuthorizationTokenResponse( response.accessToken, expiresIn: response.expiresIn, scope: response.scope, @@ -460,8 +456,8 @@ abstract class AuthorizationServer { client = await findClient(clientId); if (client == null) { - throw new AuthorizationException( - new ErrorResponse( + throw AuthorizationException( + ErrorResponse( ErrorResponse.unauthorizedClient, 'Invalid "client_id" parameter.', state, @@ -479,8 +475,8 @@ abstract class AuthorizationServer { client = await findClient(clientId); if (client == null) { - throw new AuthorizationException( - new ErrorResponse( + throw AuthorizationException( + ErrorResponse( ErrorResponse.unauthorizedClient, 'Invalid "client_id" parameter.', state, @@ -499,8 +495,8 @@ abstract class AuthorizationServer { ..addAll(response.toJson()); } - throw new AuthorizationException( - new ErrorResponse( + throw AuthorizationException( + ErrorResponse( ErrorResponse.invalidRequest, 'Invalid or no "grant_type" parameter provided', state, @@ -510,8 +506,8 @@ abstract class AuthorizationServer { } on AngelHttpException { rethrow; } catch (e, st) { - throw new AuthorizationException( - new ErrorResponse( + throw AuthorizationException( + ErrorResponse( ErrorResponse.serverError, _internalServerError, state, diff --git a/pubspec.yaml b/pubspec.yaml index 17f3f634..9d151528 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: angel_oauth2 author: Tobe O description: A class containing handlers that can be used within Angel to build a spec-compliant OAuth 2.0 server. homepage: https://github.com/angel-dart/oauth2.git -version: 2.1.0 +version: 2.2.0 environment: sdk: ">=2.0.0-dev <3.0.0" dependencies: @@ -14,5 +14,6 @@ dev_dependencies: angel_test: ^2.0.0-alpha logging: oauth2: ^1.0.0 + pedantic: ^1.0.0 test: ^1.0.0 uuid: ^1.0.0 diff --git a/test/auth_code_test.dart b/test/auth_code_test.dart index 1c7ca8d7..cc8a613e 100644 --- a/test/auth_code_test.dart +++ b/test/auth_code_test.dart @@ -17,11 +17,11 @@ main() { TestClient testClient; setUp(() async { - app = new Angel(); + app = Angel(); app.configuration['properties'] = app.configuration; - app.container.registerSingleton(new AuthCodes()); + app.container.registerSingleton(AuthCodes()); - var server = new _Server(); + var server = _Server(); app.group('/oauth2', (router) { router @@ -29,14 +29,14 @@ main() { ..post('/token', server.tokenEndpoint); }); - app.logger = new Logger('angel') + app.logger = 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 http = AngelHttp(app); var s = await http.startServer(); var url = 'http://${s.address.address}:${s.port}'; authorizationEndpoint = Uri.parse('$url/oauth2/authorize'); @@ -52,7 +52,7 @@ main() { group('auth code', () { oauth2.AuthorizationCodeGrant createGrant() => - new oauth2.AuthorizationCodeGrant( + oauth2.AuthorizationCodeGrant( pseudoApplication.id, authorizationEndpoint, tokenEndpoint, @@ -119,7 +119,7 @@ main() { } class _Server extends AuthorizationServer { - final Uuid _uuid = new Uuid(); + final Uuid _uuid = Uuid(); @override FutureOr findClient(String clientId) { @@ -155,6 +155,7 @@ class _Server extends AuthorizationServer { @override Future exchangeAuthorizationCodeForToken( + PseudoApplication client, String authCode, String redirectUri, RequestContext req, @@ -162,7 +163,7 @@ class _Server extends AuthorizationServer { var authCodes = req.container.make(); var state = authCodes[authCode]; var refreshToken = state == 'can_refresh' ? '${authCode}_refresh' : null; - return new AuthorizationTokenResponse('${authCode}_access', + return AuthorizationTokenResponse('${authCode}_access', refreshToken: refreshToken); } } diff --git a/test/client_credentials_test.dart b/test/client_credentials_test.dart index 9d756e9e..8ce1b6ce 100644 --- a/test/client_credentials_test.dart +++ b/test/client_credentials_test.dart @@ -11,8 +11,8 @@ main() { TestClient client; setUp(() async { - var app = new Angel(); - var oauth2 = new _AuthorizationServer(); + var app = Angel(); + var oauth2 = _AuthorizationServer(); app.group('/oauth2', (router) { router @@ -47,7 +47,7 @@ main() { allOf( hasStatus(200), hasContentType('application/json'), - hasValidBody(new Validator({ + hasValidBody(Validator({ 'token_type': equals('bearer'), 'access_token': equals('foo'), })), @@ -101,6 +101,6 @@ class _AuthorizationServer @override Future clientCredentialsGrant( PseudoApplication client, RequestContext req, ResponseContext res) async { - return new AuthorizationTokenResponse('foo'); + return AuthorizationTokenResponse('foo'); } } diff --git a/test/common.dart b/test/common.dart index 9ac194f8..ba248d68 100644 --- a/test/common.dart +++ b/test/common.dart @@ -1,5 +1,5 @@ const PseudoApplication pseudoApplication = - const PseudoApplication('foo', 'bar', 'http://foo.bar/baz'); + PseudoApplication('foo', 'bar', 'http://foo.bar/baz'); class PseudoApplication { final String id, secret, redirectUri; @@ -7,10 +7,10 @@ class PseudoApplication { const PseudoApplication(this.id, this.secret, this.redirectUri); } -const List pseudoUsers = const [ - const PseudoUser(username: 'foo', password: 'bar'), - const PseudoUser(username: 'michael', password: 'jackson'), - const PseudoUser(username: 'jon', password: 'skeet'), +const List pseudoUsers = [ + PseudoUser(username: 'foo', password: 'bar'), + PseudoUser(username: 'michael', password: 'jackson'), + PseudoUser(username: 'jon', password: 'skeet'), ]; class PseudoUser { diff --git a/test/device_code_test.dart b/test/device_code_test.dart index 4691f0b5..d327fbff 100644 --- a/test/device_code_test.dart +++ b/test/device_code_test.dart @@ -11,8 +11,8 @@ main() { TestClient client; setUp(() async { - var app = new Angel(); - var oauth2 = new _AuthorizationServer(); + var app = Angel(); + var oauth2 = _AuthorizationServer(); app.group('/oauth2', (router) { router @@ -20,7 +20,7 @@ main() { ..post('/token', oauth2.tokenEndpoint); }); - app.logger = new Logger('angel_oauth2') + app.logger = Logger('angel_oauth2') ..onRecord.listen((rec) { print(rec); if (rec.error != null) print(rec.error); @@ -145,7 +145,7 @@ class _AuthorizationServer @override FutureOr requestDeviceCode(PseudoApplication client, Iterable scopes, RequestContext req, ResponseContext res) { - return new DeviceCodeResponse( + return DeviceCodeResponse( 'foo', 'bar', Uri.parse('https://regiostech.com') @@ -161,12 +161,12 @@ class _AuthorizationServer RequestContext req, ResponseContext res) { if (deviceCode == 'brute') { - throw new AuthorizationException(new ErrorResponse( + throw AuthorizationException(ErrorResponse( ErrorResponse.slowDown, "Ho, brother! Ho, whoa, whoa, whoa now! You got too much dip on your chip!", state)); } - return new AuthorizationTokenResponse('foo'); + return AuthorizationTokenResponse('foo'); } } diff --git a/test/implicit_grant_test.dart b/test/implicit_grant_test.dart index caed71d3..49ae44b4 100644 --- a/test/implicit_grant_test.dart +++ b/test/implicit_grant_test.dart @@ -10,8 +10,8 @@ main() { TestClient client; setUp(() async { - var app = new Angel(); - var oauth2 = new _AuthorizationServer(); + var app = Angel(); + var oauth2 = _AuthorizationServer(); app.group('/oauth2', (router) { router @@ -65,6 +65,6 @@ class _AuthorizationServer String state, RequestContext req, ResponseContext res) async { - return new AuthorizationTokenResponse('foo'); + return AuthorizationTokenResponse('foo'); } } diff --git a/test/password_test.dart b/test/password_test.dart index b980ab15..0dae0a84 100644 --- a/test/password_test.dart +++ b/test/password_test.dart @@ -12,8 +12,8 @@ main() { Uri tokenEndpoint; setUp(() async { - app = new Angel(); - var auth = new _AuthorizationServer(); + app = Angel(); + var auth = _AuthorizationServer(); app.group('/oauth2', (router) { router @@ -25,9 +25,9 @@ main() { res.json(e.toJson()); }; - app.logger = new Logger('password_test')..onRecord.listen(print); + app.logger = Logger('password_test')..onRecord.listen(print); - var http = new AngelHttp(app); + var http = AngelHttp(app); var server = await http.startServer(); var url = 'http://${server.address.address}:${server.port}'; tokenEndpoint = Uri.parse('$url/oauth2/token'); @@ -61,7 +61,7 @@ main() { secret: 'bar', ); - throw new StateError('should fail'); + throw StateError('should fail'); } on oauth2.AuthorizationException catch (e) { expect(e.error, ErrorResponse.accessDenied); } finally { @@ -105,7 +105,7 @@ class _AuthorizationServer Iterable scopes, RequestContext req, ResponseContext res) async { - return new AuthorizationTokenResponse('baz', refreshToken: 'bar'); + return AuthorizationTokenResponse('baz', refreshToken: 'bar'); } @override @@ -122,8 +122,8 @@ class _AuthorizationServer if (user == null) { var body = await req.parseBody().then((_) => req.bodyAsMap); - throw new AuthorizationException( - new ErrorResponse( + throw AuthorizationException( + ErrorResponse( ErrorResponse.accessDenied, 'Invalid username or password.', body['state']?.toString() ?? '', @@ -132,6 +132,6 @@ class _AuthorizationServer ); } - return new AuthorizationTokenResponse('foo', refreshToken: 'bar'); + return AuthorizationTokenResponse('foo', refreshToken: 'bar'); } } diff --git a/test/pkce_test.dart b/test/pkce_test.dart index c7506d2d..ae806ed3 100644 --- a/test/pkce_test.dart +++ b/test/pkce_test.dart @@ -6,21 +6,19 @@ 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; + Uri authorizationEndpoint, tokenEndpoint; TestClient testClient; setUp(() async { - app = new Angel(); - app.container.registerSingleton(new AuthCodes()); + app = Angel(); + app.container.registerSingleton(AuthCodes()); - var server = new _Server(); + var server = _Server(); app.group('/oauth2', (router) { router @@ -28,19 +26,18 @@ main() { ..post('/token', server.tokenEndpoint); }); - app.logger = new Logger('angel') + app.logger = 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 http = 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); }); @@ -228,8 +225,6 @@ main() { } class _Server extends AuthorizationServer { - final Uuid _uuid = new Uuid(); - @override FutureOr findClient(String clientId) { return pseudoApplication; @@ -255,14 +250,15 @@ class _Server extends AuthorizationServer { @override Future exchangeAuthorizationCodeForToken( + PseudoApplication client, String authCode, String redirectUri, RequestContext req, ResponseContext res) async { var codeVerifier = await getPkceCodeVerifier(req); - var pkce = new Pkce('plain', 'hello'); + var pkce = Pkce('plain', 'hello'); pkce.validate(codeVerifier); - return new AuthorizationTokenResponse('yes'); + return AuthorizationTokenResponse('yes'); } } From 3a152d97eb39cfeff7b94635d7b7aedeffc9f72e Mon Sep 17 00:00:00 2001 From: Tobe O Date: Thu, 2 May 2019 03:28:48 -0400 Subject: [PATCH 35/39] Format --- lib/src/pkce.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/src/pkce.dart b/lib/src/pkce.dart index 3a347f9c..713bda83 100644 --- a/lib/src/pkce.dart +++ b/lib/src/pkce.dart @@ -29,10 +29,8 @@ class Pkce { state, uri: uri)); } else if (codeChallenge?.isNotEmpty != true) { - throw AuthorizationException(ErrorResponse( - ErrorResponse.invalidRequest, - 'Missing `code_challenge` parameter.', - state, + throw AuthorizationException(ErrorResponse(ErrorResponse.invalidRequest, + 'Missing `code_challenge` parameter.', state, uri: uri)); } From 961324980d5676e6896bd92d588c57c127cbf1b8 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Thu, 2 May 2019 03:31:02 -0400 Subject: [PATCH 36/39] Publish 2.2.0 --- lib/src/server.dart | 2 +- pubspec.yaml | 4 ++-- test/auth_code_test.dart | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/src/server.dart b/lib/src/server.dart index 6efd94a3..19d805d5 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -313,7 +313,7 @@ abstract class AuthorizationServer { }).toString(); target = target.replace(fragment: fragment); - res.redirect(target.toString()); + await res.redirect(target.toString()); return false; } on FormatException { throw AuthorizationException( diff --git a/pubspec.yaml b/pubspec.yaml index 9d151528..2a09e6e3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,7 +6,7 @@ version: 2.2.0 environment: sdk: ">=2.0.0-dev <3.0.0" dependencies: - angel_framework: ^2.0.0-alpha + angel_framework: ^2.0.0-rc.0 angel_http_exception: ^1.0.0 crypto: ^2.0.0 dev_dependencies: @@ -16,4 +16,4 @@ dev_dependencies: oauth2: ^1.0.0 pedantic: ^1.0.0 test: ^1.0.0 - uuid: ^1.0.0 + uuid: ^2.0.0 diff --git a/test/auth_code_test.dart b/test/auth_code_test.dart index cc8a613e..ff714e1f 100644 --- a/test/auth_code_test.dart +++ b/test/auth_code_test.dart @@ -143,7 +143,7 @@ class _Server extends AuthorizationServer { if (state == 'hello') return 'Hello ${pseudoApplication.id}:${pseudoApplication.secret}'; - var authCode = _uuid.v4() as String; + var authCode = _uuid.v4(); var authCodes = req.container.make(); authCodes[authCode] = state; From eb31a1a2c974ca9595f97ba18dc860a61052e3e0 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Fri, 3 May 2019 02:14:08 -0400 Subject: [PATCH 37/39] +1 --- CHANGELOG.md | 3 +++ lib/src/server.dart | 3 +-- pubspec.yaml | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 076791ef..8fd6d44f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +# 2.2.0+1 +* Parse+verify client for `authorization_code`. + # 2.2.0 * Pass `client` to `exchangeAuthorizationCodeForToken`. * Apply `package:pedantic`. diff --git a/lib/src/server.dart b/lib/src/server.dart index 19d805d5..457cc319 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -371,8 +371,7 @@ abstract class AuthorizationServer { var grantType = await _getParam(req, 'grant_type', state, body: true, throwIfEmpty: false); - if (grantType != 'authorization_code' && - grantType != 'urn:ietf:params:oauth:grant-type:device_code' && + if (grantType != 'urn:ietf:params:oauth:grant-type:device_code' && grantType != null) { var match = _rgxBasic.firstMatch(req.headers.value('authorization') ?? ''); diff --git a/pubspec.yaml b/pubspec.yaml index 2a09e6e3..2c33082e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: angel_oauth2 author: Tobe O description: A class containing handlers that can be used within Angel to build a spec-compliant OAuth 2.0 server. homepage: https://github.com/angel-dart/oauth2.git -version: 2.2.0 +version: 2.2.0+1 environment: sdk: ">=2.0.0-dev <3.0.0" dependencies: From 2afe4deab0256f334cab0840a0308fc008b220c4 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Fri, 3 May 2019 03:24:24 -0400 Subject: [PATCH 38/39] implicit --- CHANGELOG.md | 3 + example/main.dart | 3 +- lib/src/server.dart | 126 +++++++++++----------------------- pubspec.yaml | 2 +- test/auth_code_test.dart | 9 ++- test/implicit_grant_test.dart | 9 ++- test/pkce_test.dart | 3 +- 7 files changed, 62 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd6d44f..5c550e2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +# 2.3.0 +* Remove `implicitGrant`, and inline it into `requestAuthorizationCode`. + # 2.2.0+1 * Parse+verify client for `authorization_code`. diff --git a/example/main.dart b/example/main.dart index 58527be7..0da19454 100644 --- a/example/main.dart +++ b/example/main.dart @@ -56,7 +56,8 @@ class _ExampleAuthorizationServer Iterable scopes, String state, RequestContext req, - ResponseContext res) { + ResponseContext res, + bool implicit) { // TODO: In many cases, here you will render a view displaying to the user which scopes are being requested. throw UnimplementedError(); } diff --git a/lib/src/server.dart b/lib/src/server.dart index 457cc319..3a103ec7 100644 --- a/lib/src/server.dart +++ b/lib/src/server.dart @@ -89,34 +89,18 @@ abstract class AuthorizationServer { /// 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. - FutureOr requestAuthorizationCode( - Client client, - String redirectUri, - Iterable scopes, - String state, - RequestContext req, - ResponseContext res) { - throw AuthorizationException( - ErrorResponse( - ErrorResponse.unsupportedResponseType, - 'Authorization code grants are not supported.', - state, - ), - statusCode: 400, - ); - } - - /// Create an implicit authorization token. /// - /// Note that in cases where this is called, there is no guarantee - /// that the user agent has not been compromised. - FutureOr implicitGrant( + /// If [implicit] is `true`, then the client is requesting an *implicit grant*. + /// Be aware of the security implications of this - do not handle them exactly + /// the same. + FutureOr requestAuthorizationCode( Client client, String redirectUri, Iterable scopes, String state, RequestContext req, - ResponseContext res) { + ResponseContext res, + bool implicit) { throw AuthorizationException( ErrorResponse( ErrorResponse.unsupportedResponseType, @@ -227,9 +211,39 @@ abstract class AuthorizationServer { ); } + /// Returns the [Uri] that a client can be redirected to in the case of an implicit grant. + Uri completeImplicitGrant(AuthorizationTokenResponse token, Uri redirectUri, + {String state}) { + var queryParameters = {}; + + queryParameters.addAll({ + 'access_token': token.accessToken, + 'token_type': 'bearer', + }); + + if (state != null) queryParameters['state'] = state; + + if (token.expiresIn != null) + queryParameters['expires_in'] = token.expiresIn.toString(); + + if (token.scope != null) queryParameters['scope'] = token.scope.join(' '); + + var fragment = + queryParameters.keys.fold(StringBuffer(), (buf, k) { + if (buf.isNotEmpty) buf.write('&'); + return buf + ..write( + '$k=' + Uri.encodeComponent(queryParameters[k]), + ); + }).toString(); + + return redirectUri.replace(fragment: fragment); + } + /// A request handler that invokes the correct logic, depending on which type /// of grant the client is requesting. - Future authorizationEndpoint(RequestContext req, ResponseContext res) async { + Future authorizationEndpoint( + RequestContext req, ResponseContext res) async { String state = ''; try { @@ -241,7 +255,7 @@ abstract class AuthorizationServer { return Pkce.fromJson(req.queryParameters, state: state); }); - if (responseType == 'code') { + if (responseType == 'code' || responseType == 'token') { // Ensure client ID var clientId = await _getParam(req, 'client_id', state); @@ -262,68 +276,8 @@ abstract class AuthorizationServer { // Grab scopes var scopes = await _getScopes(req); - return await requestAuthorizationCode( - client, redirectUri, scopes, state, req, res); - } - - if (responseType == 'token') { - var clientId = await _getParam(req, 'client_id', state); - var client = await findClient(clientId); - - if (client == null) { - throw AuthorizationException(ErrorResponse( - ErrorResponse.unauthorizedClient, - 'Unknown client "$clientId".', - state, - )); - } - - var redirectUri = await _getParam(req, 'redirect_uri', state); - - // Grab scopes - var scopes = await _getScopes(req); - var token = - await implicitGrant(client, redirectUri, scopes, state, req, res); - - Uri target; - - try { - target = Uri.parse(redirectUri); - var queryParameters = {}; - - queryParameters.addAll({ - 'access_token': token.accessToken, - 'token_type': 'bearer', - 'state': state, - }); - - if (token.expiresIn != null) - queryParameters['expires_in'] = token.expiresIn.toString(); - - if (token.scope != null) - queryParameters['scope'] = token.scope.join(' '); - - var fragment = - queryParameters.keys.fold(StringBuffer(), (buf, k) { - if (buf.isNotEmpty) buf.write('&'); - return buf - ..write( - '$k=' + Uri.encodeComponent(queryParameters[k]), - ); - }).toString(); - - target = target.replace(fragment: fragment); - await res.redirect(target.toString()); - return false; - } on FormatException { - throw AuthorizationException( - ErrorResponse( - ErrorResponse.invalidRequest, - 'Invalid URI provided as "redirect_uri" parameter', - state, - ), - statusCode: 400); - } + return await requestAuthorizationCode(client, redirectUri, scopes, + state, req, res, responseType == 'token'); } throw AuthorizationException( diff --git a/pubspec.yaml b/pubspec.yaml index 2c33082e..395ad3fc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: angel_oauth2 author: Tobe O description: A class containing handlers that can be used within Angel to build a spec-compliant OAuth 2.0 server. homepage: https://github.com/angel-dart/oauth2.git -version: 2.2.0+1 +version: 2.3.0 environment: sdk: ">=2.0.0-dev <3.0.0" dependencies: diff --git a/test/auth_code_test.dart b/test/auth_code_test.dart index ff714e1f..043305c3 100644 --- a/test/auth_code_test.dart +++ b/test/auth_code_test.dart @@ -139,7 +139,14 @@ class _Server extends AuthorizationServer { Iterable scopes, String state, RequestContext req, - ResponseContext res) async { + ResponseContext res, + bool implicit) async { + if (implicit) { + // Throw the default error on an implicit grant attempt. + return super.requestAuthorizationCode( + client, redirectUri, scopes, state, req, res, implicit); + } + if (state == 'hello') return 'Hello ${pseudoApplication.id}:${pseudoApplication.secret}'; diff --git a/test/implicit_grant_test.dart b/test/implicit_grant_test.dart index 49ae44b4..c1dba844 100644 --- a/test/implicit_grant_test.dart +++ b/test/implicit_grant_test.dart @@ -58,13 +58,16 @@ class _AuthorizationServer } @override - Future implicitGrant( + Future requestAuthorizationCode( PseudoApplication client, String redirectUri, Iterable scopes, String state, RequestContext req, - ResponseContext res) async { - return AuthorizationTokenResponse('foo'); + ResponseContext res, + bool implicit) async { + var tok = AuthorizationTokenResponse('foo'); + var uri = completeImplicitGrant(tok, Uri.parse(redirectUri), state: state); + return res.redirect(uri); } } diff --git a/test/pkce_test.dart b/test/pkce_test.dart index ae806ed3..d8d901bc 100644 --- a/test/pkce_test.dart +++ b/test/pkce_test.dart @@ -243,7 +243,8 @@ class _Server extends AuthorizationServer { Iterable scopes, String state, RequestContext req, - ResponseContext res) async { + ResponseContext res, + bool implicit) async { req.container.make(); return {'code': 'ok'}; } From 0bef42c4b9e3cbc800bb1b7b89b736baff814b9f Mon Sep 17 00:00:00 2001 From: Tobe O Date: Fri, 3 May 2019 03:29:59 -0400 Subject: [PATCH 39/39] Fix PKCE test --- test/pkce_test.dart | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/test/pkce_test.dart b/test/pkce_test.dart index d8d901bc..53426bfb 100644 --- a/test/pkce_test.dart +++ b/test/pkce_test.dart @@ -1,6 +1,5 @@ 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'; @@ -153,7 +152,7 @@ main() { userInfo: '${pseudoApplication.id}:${pseudoApplication.secret}'); var response = await testClient.post(url.toString(), headers: { 'accept': 'application/json', - 'authorization': 'Basic ' + base64Url.encode(ascii.encode(url.userInfo)) + // 'authorization': 'Basic ' + base64Url.encode(ascii.encode(url.userInfo)) }, body: { 'grant_type': 'authorization_code', 'client_id': 'freddie mercury', @@ -175,7 +174,7 @@ main() { userInfo: '${pseudoApplication.id}:${pseudoApplication.secret}'); var response = await testClient.post(url.toString(), headers: { 'accept': 'application/json', - 'authorization': 'Basic ' + base64Url.encode(ascii.encode(url.userInfo)) + // 'authorization': 'Basic ' + base64Url.encode(ascii.encode(url.userInfo)) }, body: { 'grant_type': 'authorization_code', 'client_id': 'freddie mercury', @@ -202,7 +201,7 @@ main() { userInfo: '${pseudoApplication.id}:${pseudoApplication.secret}'); var response = await testClient.post(url.toString(), headers: { 'accept': 'application/json', - 'authorization': 'Basic ' + base64Url.encode(ascii.encode(url.userInfo)) + // 'authorization': 'Basic ' + base64Url.encode(ascii.encode(url.userInfo)) }, body: { 'grant_type': 'authorization_code', 'client_id': 'freddie mercury',