diff --git a/packages/oauth2/.DS_Store b/packages/oauth2/.DS_Store new file mode 100644 index 00000000..10f4d430 Binary files /dev/null and b/packages/oauth2/.DS_Store differ diff --git a/packages/oauth2/.gitignore b/packages/oauth2/.gitignore new file mode 100644 index 00000000..dbce896b --- /dev/null +++ b/packages/oauth2/.gitignore @@ -0,0 +1,65 @@ +# 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 +.packages +.pub/ +build/ +# 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/ +.dart_tool \ No newline at end of file diff --git a/packages/oauth2/.idea/auth_oauth2_server.iml b/packages/oauth2/.idea/auth_oauth2_server.iml new file mode 100644 index 00000000..ae9af975 --- /dev/null +++ b/packages/oauth2/.idea/auth_oauth2_server.iml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/oauth2/.idea/modules.xml b/packages/oauth2/.idea/modules.xml new file mode 100644 index 00000000..2dde0608 --- /dev/null +++ b/packages/oauth2/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/packages/oauth2/.idea/runConfigurations/tests_in_auth_oauth2_server.xml b/packages/oauth2/.idea/runConfigurations/tests_in_auth_oauth2_server.xml new file mode 100644 index 00000000..cc52d764 --- /dev/null +++ b/packages/oauth2/.idea/runConfigurations/tests_in_auth_oauth2_server.xml @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/packages/oauth2/.idea/vcs.xml b/packages/oauth2/.idea/vcs.xml new file mode 100644 index 00000000..35eb1ddf --- /dev/null +++ b/packages/oauth2/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/packages/oauth2/.travis.yml b/packages/oauth2/.travis.yml new file mode 100644 index 00000000..a9e2c109 --- /dev/null +++ b/packages/oauth2/.travis.yml @@ -0,0 +1,4 @@ +language: dart +dart: + - dev + - stable \ No newline at end of file diff --git a/packages/oauth2/CHANGELOG.md b/packages/oauth2/CHANGELOG.md new file mode 100644 index 00000000..5c550e2e --- /dev/null +++ b/packages/oauth2/CHANGELOG.md @@ -0,0 +1,20 @@ +# 2.3.0 +* Remove `implicitGrant`, and inline it into `requestAuthorizationCode`. + +# 2.2.0+1 +* Parse+verify client for `authorization_code`. + +# 2.2.0 +* Pass `client` to `exchangeAuthorizationCodeForToken`. +* Apply `package:pedantic`. + +# 2.1.0 +* Updates +* Support `device_code` grants. +* Add support for [PKCE](https://tools.ietf.org/html/rfc7636). + +# 2.0.0 +* Angel 2 support. + +# 1.0.0+1 +* Dart2 updates + backwards compatibility assurance. \ No newline at end of file diff --git a/packages/oauth2/LICENSE b/packages/oauth2/LICENSE new file mode 100644 index 00000000..3de28325 --- /dev/null +++ b/packages/oauth2/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/packages/oauth2/README.md b/packages/oauth2/README.md new file mode 100644 index 00000000..a32fb001 --- /dev/null +++ b/packages/oauth2/README.md @@ -0,0 +1,172 @@ +# 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, 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 +``` + +# 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.AuthorizationServer {} +``` + +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 AuthorizationServer { + final Uuid _uuid = 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 many cases, you will want to show a dialog: + +```dart +@override +Future requestAuthorizationCode( + 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 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); + }); +} +``` + +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` +* `deviceCodeGrant` + +Read the [OAuth2 specification](https://tools.ietf.org/html/rfc6749) +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 [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 [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 AuthorizationTokenResponse('...'); +} +``` \ No newline at end of file diff --git a/packages/oauth2/analysis_options.yaml b/packages/oauth2/analysis_options.yaml new file mode 100644 index 00000000..085be64d --- /dev/null +++ b/packages/oauth2/analysis_options.yaml @@ -0,0 +1,8 @@ +include: package:pedantic/analysis_options.yaml +analyzer: + strong-mode: + implicit-casts: false +linter: + rules: + - unnecessary_const + - unnecessary_new \ No newline at end of file diff --git a/packages/oauth2/example/main.dart b/packages/oauth2/example/main.dart new file mode 100644 index 00000000..0da19454 --- /dev/null +++ b/packages/oauth2/example/main.dart @@ -0,0 +1,76 @@ +// 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 = Angel(); + var oauth2 = _ExampleAuthorizationServer(); + var _rgxBearer = 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 UnimplementedError(); + } + + @override + FutureOr verifyClient(ThirdPartyApp client, String clientSecret) { + // TODO: Add your code to verify a client secret, if given one. + throw UnimplementedError(); + } + + @override + FutureOr requestAuthorizationCode( + ThirdPartyApp client, + String redirectUri, + Iterable scopes, + String state, + RequestContext req, + 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(); + } + + @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 UnimplementedError(); + } +} diff --git a/packages/oauth2/lib/angel_oauth2.dart b/packages/oauth2/lib/angel_oauth2.dart new file mode 100644 index 00000000..75d1e2c6 --- /dev/null +++ b/packages/oauth2/lib/angel_oauth2.dart @@ -0,0 +1,5 @@ +export 'src/exception.dart'; +export 'src/pkce.dart'; +export 'src/response.dart'; +export 'src/server.dart'; +export 'src/token_type.dart'; diff --git a/packages/oauth2/lib/src/exception.dart b/packages/oauth2/lib/src/exception.dart new file mode 100644 index 00000000..0264bd92 --- /dev/null +++ b/packages/oauth2/lib/src/exception.dart @@ -0,0 +1,90 @@ +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, error}) + : super(error ?? errorResponse, + stackTrace: stackTrace, message: '', statusCode: statusCode ?? 400); + + @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 { + /// 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'; + + /// 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'; + + /// 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 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 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; + + /// 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}); + + @override + String toString() => 'OAuth2 error ($code): $description'; +} diff --git a/packages/oauth2/lib/src/pkce.dart b/packages/oauth2/lib/src/pkce.dart new file mode 100644 index 00000000..713bda83 --- /dev/null +++ b/packages/oauth2/lib/src/pkce.dart @@ -0,0 +1,73 @@ +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 AuthorizationException(ErrorResponse( + ErrorResponse.invalidRequest, + "The `code_challenge_method` parameter must be either 'plain' or 's256'.", + state, + uri: uri)); + } else if (codeChallenge?.isNotEmpty != true) { + throw AuthorizationException(ErrorResponse(ErrorResponse.invalidRequest, + 'Missing `code_challenge` parameter.', state, + uri: uri)); + } + + return 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(codeVerifier)).bytes); + } else { + foreignChallenge = codeVerifier; + } + + if (foreignChallenge != codeChallenge) { + throw AuthorizationException( + ErrorResponse(ErrorResponse.invalidGrant, + "The given `code_verifier` parameter is invalid.", state, + uri: uri), + ); + } + } + + /// Creates a JSON-serializable representation of this instance. + Map toJson() { + return { + 'code_challenge': codeChallenge, + 'code_challenge_method': codeChallengeMethod + }; + } +} diff --git a/packages/oauth2/lib/src/response.dart b/packages/oauth2/lib/src/response.dart new file mode 100644 index 00000000..aacf5dd3 --- /dev/null +++ b/packages/oauth2/lib/src/response.dart @@ -0,0 +1,73 @@ +/// 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; + + /// An optional, but recommended integer that signifies the time left until the [accessToken] expires. + final int expiresIn; + + /// 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; + } +} + +/// 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/packages/oauth2/lib/src/server.dart b/packages/oauth2/lib/src/server.dart new file mode 100644 index 00000000..3a103ec7 --- /dev/null +++ b/packages/oauth2/lib/src/server.dart @@ -0,0 +1,474 @@ +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'; + +/// A request handler that performs an arbitrary authorization token grant. +typedef FutureOr ExtensionGrant( + RequestContext req, ResponseContext res); + +Future _getParam(RequestContext req, String name, String state, + {bool body = false, bool throwIfEmpty = true}) async { + 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 && throwIfEmpty) { + throw AuthorizationException( + ErrorResponse( + ErrorResponse.invalidRequest, + 'Missing required parameter "$name".', + state, + ), + statusCode: 400, + ); + } + + return value; +} + +Future> _getScopes(RequestContext req, + {bool body = false}) async { + 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. +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); + + /// 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 AuthorizationException(ErrorResponse(ErrorResponse.invalidRequest, + "Missing `code_verifier` parameter.", state, + uri: uri)); + } else if (codeVerifier is! String) { + throw AuthorizationException(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. + /// + /// 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, + bool implicit) { + throw AuthorizationException( + ErrorResponse( + ErrorResponse.unsupportedResponseType, + 'Authorization code grants are not supported.', + state, + ), + statusCode: 400, + ); + } + + /// Exchanges an authorization code for an authorization token. + FutureOr exchangeAuthorizationCodeForToken( + Client client, + String authCode, + String redirectUri, + RequestContext req, + ResponseContext res) { + throw AuthorizationException( + ErrorResponse( + ErrorResponse.unsupportedResponseType, + 'Authorization code grants are not supported.', + req.uri.queryParameters['state'] ?? '', + ), + statusCode: 400, + ); + } + + /// Refresh an authorization token. + FutureOr refreshAuthorizationToken( + Client client, + String refreshToken, + Iterable scopes, + RequestContext req, + ResponseContext res) async { + var body = await req.parseBody().then((_) => req.bodyAsMap); + throw AuthorizationException( + ErrorResponse( + ErrorResponse.unsupportedResponseType, + 'Refreshing authorization tokens is not supported.', + body['state']?.toString() ?? '', + ), + statusCode: 400, + ); + } + + /// Issue an authorization token to a user after authenticating them via [username] and [password]. + FutureOr resourceOwnerPasswordCredentialsGrant( + Client client, + String username, + String password, + Iterable scopes, + RequestContext req, + ResponseContext res) async { + var body = await req.parseBody().then((_) => req.bodyAsMap); + throw AuthorizationException( + ErrorResponse( + ErrorResponse.unsupportedResponseType, + 'Resource owner password credentials grants are not supported.', + body['state']?.toString() ?? '', + ), + statusCode: 400, + ); + } + + /// Performs a client credentials grant. Only use this in situations where the client is 100% trusted. + FutureOr clientCredentialsGrant( + Client client, RequestContext req, ResponseContext res) async { + var body = await req.parseBody().then((_) => req.bodyAsMap); + throw AuthorizationException( + ErrorResponse( + ErrorResponse.unsupportedResponseType, + 'Client credentials grants are not supported.', + body['state']?.toString() ?? '', + ), + statusCode: 400, + ); + } + + /// Performs a device code grant. + FutureOr requestDeviceCode(Client client, + Iterable scopes, RequestContext req, ResponseContext res) async { + var body = await req.parseBody().then((_) => req.bodyAsMap); + throw AuthorizationException( + ErrorResponse( + ErrorResponse.unsupportedResponseType, + 'Device code grants are not supported.', + body['state']?.toString() ?? '', + ), + statusCode: 400, + ); + } + + /// 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 AuthorizationException( + ErrorResponse( + ErrorResponse.unsupportedResponseType, + 'Device code grants are not supported.', + body['state']?.toString() ?? '', + ), + statusCode: 400, + ); + } + + /// 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 { + String state = ''; + + try { + var query = req.queryParameters; + state = query['state']?.toString() ?? ''; + var responseType = await _getParam(req, 'response_type', state); + + req.container.registerLazySingleton((_) { + return Pkce.fromJson(req.queryParameters, state: state); + }); + + if (responseType == 'code' || responseType == 'token') { + // Ensure client ID + var clientId = await _getParam(req, 'client_id', state); + + // Find client + var client = await findClient(clientId); + + if (client == null) { + throw AuthorizationException(ErrorResponse( + ErrorResponse.unauthorizedClient, + 'Unknown client "$clientId".', + state, + )); + } + + // Grab redirect URI + var redirectUri = await _getParam(req, 'redirect_uri', state); + + // Grab scopes + var scopes = await _getScopes(req); + + return await requestAuthorizationCode(client, redirectUri, scopes, + state, req, res, responseType == 'token'); + } + + throw AuthorizationException( + ErrorResponse( + ErrorResponse.invalidRequest, + 'Invalid or no "response_type" parameter provided', + state, + ), + statusCode: 400); + } on AngelHttpException { + rethrow; + } catch (e, st) { + throw AuthorizationException( + ErrorResponse( + ErrorResponse.serverError, + _internalServerError, + state, + ), + error: e, + statusCode: 500, + stackTrace: st, + ); + } + } + + 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. + Future tokenEndpoint(RequestContext req, ResponseContext res) async { + String state = ''; + Client client; + + try { + AuthorizationTokenResponse response; + var body = await req.parseBody().then((_) => req.bodyAsMap); + + state = body['state']?.toString() ?? ''; + + req.container.registerLazySingleton((_) { + return Pkce.fromJson(req.bodyAsMap, state: state); + }); + + var grantType = await _getParam(req, 'grant_type', state, + body: true, throwIfEmpty: false); + + if (grantType != 'urn:ietf:params:oauth:grant-type:device_code' && + grantType != null) { + var match = + _rgxBasic.firstMatch(req.headers.value('authorization') ?? ''); + + if (match != null) { + match = _rgxBasicAuth + .firstMatch(String.fromCharCodes(base64Url.decode(match[1]))); + } + + if (match == null) { + throw AuthorizationException( + ErrorResponse( + ErrorResponse.unauthorizedClient, + 'Invalid or no "Authorization" header.', + state, + ), + statusCode: 400, + ); + } else { + var clientId = match[1], clientSecret = match[2]; + client = await findClient(clientId); + + if (client == null) { + throw AuthorizationException( + ErrorResponse( + ErrorResponse.unauthorizedClient, + 'Invalid "client_id" parameter.', + state, + ), + statusCode: 400, + ); + } + + if (!await verifyClient(client, clientSecret)) { + throw AuthorizationException( + ErrorResponse( + ErrorResponse.unauthorizedClient, + 'Invalid "client_secret" parameter.', + state, + ), + statusCode: 400, + ); + } + } + } + + if (grantType == 'authorization_code') { + var code = await _getParam(req, 'code', state, body: true); + var redirectUri = + await _getParam(req, 'redirect_uri', state, body: true); + response = await exchangeAuthorizationCodeForToken( + client, code, redirectUri, req, res); + } else if (grantType == 'refresh_token') { + 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 = 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') { + response = await clientCredentialsGrant(client, req, res); + + if (response.refreshToken != null) { + // Remove refresh token + response = AuthorizationTokenResponse( + response.accessToken, + expiresIn: response.expiresIn, + scope: response.scope, + ); + } + } else if (extensionGrants.containsKey(grantType)) { + 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 AuthorizationException( + 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 AuthorizationException( + 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); + } + + if (response != null) { + return {'token_type': AuthorizationTokenType.bearer} + ..addAll(response.toJson()); + } + + throw AuthorizationException( + ErrorResponse( + ErrorResponse.invalidRequest, + 'Invalid or no "grant_type" parameter provided', + state, + ), + statusCode: 400, + ); + } on AngelHttpException { + rethrow; + } catch (e, st) { + throw AuthorizationException( + ErrorResponse( + ErrorResponse.serverError, + _internalServerError, + state, + ), + error: e, + statusCode: 500, + stackTrace: st, + ); + } + } +} diff --git a/packages/oauth2/lib/src/token_type.dart b/packages/oauth2/lib/src/token_type.dart new file mode 100644 index 00000000..73748a90 --- /dev/null +++ b/packages/oauth2/lib/src/token_type.dart @@ -0,0 +1,4 @@ +/// The various types of OAuth2 authorization tokens. +abstract class AuthorizationTokenType { + static const String bearer = 'bearer', mac = 'mac'; +} diff --git a/packages/oauth2/pubspec.yaml b/packages/oauth2/pubspec.yaml new file mode 100644 index 00000000..395ad3fc --- /dev/null +++ b/packages/oauth2/pubspec.yaml @@ -0,0 +1,19 @@ +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.3.0 +environment: + sdk: ">=2.0.0-dev <3.0.0" +dependencies: + angel_framework: ^2.0.0-rc.0 + angel_http_exception: ^1.0.0 + crypto: ^2.0.0 +dev_dependencies: + angel_validate: ^2.0.0-alpha + angel_test: ^2.0.0-alpha + logging: + oauth2: ^1.0.0 + pedantic: ^1.0.0 + test: ^1.0.0 + uuid: ^2.0.0 diff --git a/packages/oauth2/test/auth_code_test.dart b/packages/oauth2/test/auth_code_test.dart new file mode 100644 index 00000000..043305c3 --- /dev/null +++ b/packages/oauth2/test/auth_code_test.dart @@ -0,0 +1,195 @@ +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 = Angel(); + app.configuration['properties'] = app.configuration; + app.container.registerSingleton(AuthCodes()); + + var server = _Server(); + + app.group('/oauth2', (router) { + router + ..get('/authorize', server.authorizationEndpoint) + ..post('/token', server.tokenEndpoint); + }); + + 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 = 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('auth code', () { + oauth2.AuthorizationCodeGrant createGrant() => + 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'].toString(); + 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'].toString(); + 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 AuthorizationServer { + final Uuid _uuid = 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 requestAuthorizationCode( + PseudoApplication client, + String redirectUri, + Iterable scopes, + String state, + RequestContext req, + 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}'; + + var authCode = _uuid.v4(); + var authCodes = req.container.make(); + authCodes[authCode] = state; + + res.headers['content-type'] = 'application/json'; + var result = {'code': authCode}; + if (state?.isNotEmpty == true) result['state'] = state; + return result; + } + + @override + Future exchangeAuthorizationCodeForToken( + PseudoApplication client, + String authCode, + String redirectUri, + RequestContext req, + ResponseContext res) async { + var authCodes = req.container.make(); + var state = authCodes[authCode]; + var refreshToken = state == 'can_refresh' ? '${authCode}_refresh' : null; + return 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/packages/oauth2/test/client_credentials_test.dart b/packages/oauth2/test/client_credentials_test.dart new file mode 100644 index 00000000..8ce1b6ce --- /dev/null +++ b/packages/oauth2/test/client_credentials_test.dart @@ -0,0 +1,106 @@ +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:test/test.dart'; +import 'common.dart'; + +main() { + TestClient client; + + setUp(() async { + var app = Angel(); + var oauth2 = _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('application/json'), + hasValidBody(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(400)); + }); + + 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(400)); + }); +} + +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 AuthorizationTokenResponse('foo'); + } +} diff --git a/packages/oauth2/test/common.dart b/packages/oauth2/test/common.dart new file mode 100644 index 00000000..ba248d68 --- /dev/null +++ b/packages/oauth2/test/common.dart @@ -0,0 +1,20 @@ +const PseudoApplication pseudoApplication = + PseudoApplication('foo', 'bar', 'http://foo.bar/baz'); + +class PseudoApplication { + final String id, secret, redirectUri; + + const PseudoApplication(this.id, this.secret, this.redirectUri); +} + +const List pseudoUsers = [ + PseudoUser(username: 'foo', password: 'bar'), + PseudoUser(username: 'michael', password: 'jackson'), + PseudoUser(username: 'jon', password: 'skeet'), +]; + +class PseudoUser { + final String username, password; + + const PseudoUser({this.username, this.password}); +} diff --git a/packages/oauth2/test/device_code_test.dart b/packages/oauth2/test/device_code_test.dart new file mode 100644 index 00000000..d327fbff --- /dev/null +++ b/packages/oauth2/test/device_code_test.dart @@ -0,0 +1,172 @@ +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 = Angel(); + var oauth2 = _AuthorizationServer(); + + app.group('/oauth2', (router) { + router + ..get('/authorize', oauth2.authorizationEndpoint) + ..post('/token', oauth2.tokenEndpoint); + }); + + app.logger = 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 + }), + )); + }); + }); + + 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 + 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 DeviceCodeResponse( + 'foo', + 'bar', + Uri.parse('https://regiostech.com') + .replace(queryParameters: {'scopes': scopes.join(',')}), + 3600); + } + + @override + FutureOr exchangeDeviceCodeForToken( + PseudoApplication client, + String deviceCode, + String state, + RequestContext req, + ResponseContext res) { + if (deviceCode == 'brute') { + throw AuthorizationException(ErrorResponse( + ErrorResponse.slowDown, + "Ho, brother! Ho, whoa, whoa, whoa now! You got too much dip on your chip!", + state)); + } + + return AuthorizationTokenResponse('foo'); + } +} diff --git a/packages/oauth2/test/implicit_grant_test.dart b/packages/oauth2/test/implicit_grant_test.dart new file mode 100644 index 00000000..c1dba844 --- /dev/null +++ b/packages/oauth2/test/implicit_grant_test.dart @@ -0,0 +1,73 @@ +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 = Angel(); + var oauth2 = _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 requestAuthorizationCode( + PseudoApplication client, + String redirectUri, + Iterable scopes, + String state, + RequestContext req, + 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/packages/oauth2/test/password_test.dart b/packages/oauth2/test/password_test.dart new file mode 100644 index 00000000..0dae0a84 --- /dev/null +++ b/packages/oauth2/test/password_test.dart @@ -0,0 +1,137 @@ +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; +import 'package:test/test.dart'; +import 'common.dart'; + +main() { + Angel app; + Uri tokenEndpoint; + + setUp(() async { + app = Angel(); + var auth = _AuthorizationServer(); + + app.group('/oauth2', (router) { + router + ..get('/authorize', auth.authorizationEndpoint) + ..post('/token', auth.tokenEndpoint); + }); + + app.errorHandler = (e, req, res) async { + res.json(e.toJson()); + }; + + app.logger = Logger('password_test')..onRecord.listen(print); + + var http = AngelHttp(app); + var server = await http.startServer(); + var url = 'http://${server.address.address}:${server.port}'; + tokenEndpoint = Uri.parse('$url/oauth2/token'); + }); + + tearDown(() => app.close()); + + test('authenticate via username+password', () async { + var client = await oauth2.resourceOwnerPasswordGrant( + tokenEndpoint, + 'michael', + 'jackson', + identifier: 'foo', + secret: 'bar', + ); + print(client.credentials.toJson()); + client.close(); + expect(client.credentials.accessToken, 'foo'); + expect(client.credentials.refreshToken, 'bar'); + }); + + test('force correct username+password', () async { + oauth2.Client client; + + try { + client = await oauth2.resourceOwnerPasswordGrant( + tokenEndpoint, + 'michael', + 'jordan', + identifier: 'foo', + secret: 'bar', + ); + + throw 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'); + }); +} + +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 refreshAuthorizationToken( + PseudoApplication client, + String refreshToken, + Iterable scopes, + RequestContext req, + ResponseContext res) async { + return AuthorizationTokenResponse('baz', refreshToken: 'bar'); + } + + @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) { + var body = await req.parseBody().then((_) => req.bodyAsMap); + throw AuthorizationException( + ErrorResponse( + ErrorResponse.accessDenied, + 'Invalid username or password.', + body['state']?.toString() ?? '', + ), + statusCode: 401, + ); + } + + return AuthorizationTokenResponse('foo', refreshToken: 'bar'); + } +} diff --git a/packages/oauth2/test/pkce_test.dart b/packages/oauth2/test/pkce_test.dart new file mode 100644 index 00000000..53426bfb --- /dev/null +++ b/packages/oauth2/test/pkce_test.dart @@ -0,0 +1,282 @@ +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:logging/logging.dart'; +import 'package:test/test.dart'; +import 'common.dart'; + +main() { + Angel app; + Uri authorizationEndpoint, tokenEndpoint; + TestClient testClient; + + setUp(() async { + app = Angel(); + app.container.registerSingleton(AuthCodes()); + + var server = _Server(); + + app.group('/oauth2', (router) { + router + ..get('/authorize', server.authorizationEndpoint) + ..post('/token', server.tokenEndpoint); + }); + + 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 = 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'); + + 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 { + @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, + bool implicit) async { + req.container.make(); + return {'code': 'ok'}; + } + + @override + Future exchangeAuthorizationCodeForToken( + PseudoApplication client, + String authCode, + String redirectUri, + RequestContext req, + ResponseContext res) async { + var codeVerifier = await getPkceCodeVerifier(req); + var pkce = Pkce('plain', 'hello'); + pkce.validate(codeVerifier); + return 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); +}