From 4a14b795f476e009f693422a4ffdf20c53b2121e Mon Sep 17 00:00:00 2001 From: Tobe O Date: Thu, 28 Sep 2017 22:16:44 -0400 Subject: [PATCH] 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); +}