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);
+}