From 257d3ce316fbd34ce7cf8aa5853c99f63320d3b2 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Mon, 16 Oct 2017 02:38:46 -0400 Subject: [PATCH] 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'); + } +}