Add 'packages/oauth2/' from commit '0bef42c4b9e3cbc800bb1b7b89b736baff814b9f'

git-subtree-dir: packages/oauth2
git-subtree-mainline: 71aa3464b9
git-subtree-split: 0bef42c4b9
This commit is contained in:
Tobe O 2020-02-15 18:22:20 -05:00
commit a7842bddd8
26 changed files with 2126 additions and 0 deletions

BIN
packages/oauth2/.DS_Store vendored Normal file

Binary file not shown.

65
packages/oauth2/.gitignore vendored Normal file
View file

@ -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

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.dart_tool" />
<excludeFolder url="file://$MODULE_DIR$/.pub" />
<excludeFolder url="file://$MODULE_DIR$/build" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="Dart SDK" level="project" />
<orderEntry type="library" name="Dart Packages" level="project" />
</component>
</module>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/auth_oauth2_server.iml" filepath="$PROJECT_DIR$/.idea/auth_oauth2_server.iml" />
</modules>
</component>
</project>

View file

@ -0,0 +1,8 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="tests in auth_oauth2_server" type="DartTestRunConfigurationType" factoryName="Dart Test" singleton="true" nameIsGenerated="true">
<option name="filePath" value="$PROJECT_DIR$" />
<option name="scope" value="FOLDER" />
<option name="testRunnerOptions" value="-j 4" />
<method />
</configuration>
</component>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View file

@ -0,0 +1,4 @@
language: dart
dart:
- dev
- stable

View file

@ -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.

21
packages/oauth2/LICENSE Normal file
View file

@ -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.

172
packages/oauth2/README.md Normal file
View file

@ -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<Client, User> {}
```
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<PseudoApplication, Map> {
final Uuid _uuid = Uuid();
@override
FutureOr<PseudoApplication> findClient(String clientId) {
return clientId == pseudoApplication.id ? pseudoApplication : null;
}
@override
Future<bool> 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<String> 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<AuthorizationCodeResponse> 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<String> 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<Pkce>();
// 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<AuthorizationTokenResponse> 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('...');
}
```

View file

@ -0,0 +1,8 @@
include: package:pedantic/analysis_options.yaml
analyzer:
strong-mode:
implicit-casts: false
linter:
rules:
- unnecessary_const
- unnecessary_new

View file

@ -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<ThirdPartyApp, User> {
@override
FutureOr<ThirdPartyApp> findClient(String clientId) {
// TODO: Add your code to find the app associated with a client ID.
throw UnimplementedError();
}
@override
FutureOr<bool> 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<String> 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<AuthorizationTokenResponse> 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();
}
}

View file

@ -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';

View file

@ -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';
}

View file

@ -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<String, dynamic> toJson() {
return {
'code_challenge': codeChallenge,
'code_challenge_method': codeChallengeMethod
};
}
}

View file

@ -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<String> scope;
const AuthorizationTokenResponse(this.accessToken,
{this.refreshToken, this.expiresIn, this.scope});
Map<String, dynamic> toJson() {
var map = <String, dynamic>{'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<String, dynamic> toJson() {
var out = <String, dynamic>{
'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;
}
}

View file

@ -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<AuthorizationTokenResponse> ExtensionGrant(
RequestContext req, ResponseContext res);
Future<String> _getParam(RequestContext req, String name, String state,
{bool body = false, bool throwIfEmpty = true}) async {
Map<String, dynamic> 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<Iterable<String>> _getScopes(RequestContext req,
{bool body = false}) async {
Map<String, dynamic> 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<Client, User> {
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<String, ExtensionGrant> get extensionGrants => {};
/// Finds the [Client] application associated with the given [clientId].
FutureOr<Client> findClient(String clientId);
/// Verify that a [client] is the one identified by the [clientSecret].
FutureOr<bool> verifyClient(Client client, String clientSecret);
/// Retrieves the PKCE `code_verifier` parameter from a [RequestContext], or throws.
Future<String> 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<void> requestAuthorizationCode(
Client client,
String redirectUri,
Iterable<String> 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<AuthorizationTokenResponse> 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<AuthorizationTokenResponse> refreshAuthorizationToken(
Client client,
String refreshToken,
Iterable<String> 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<AuthorizationTokenResponse> resourceOwnerPasswordCredentialsGrant(
Client client,
String username,
String password,
Iterable<String> 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<AuthorizationTokenResponse> 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<DeviceCodeResponse> requestDeviceCode(Client client,
Iterable<String> 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<AuthorizationTokenResponse> 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 = <String, String>{};
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>(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<void> 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<Pkce>((_) {
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<Pkce>((_) {
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 <String, dynamic>{'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,
);
}
}
}

View file

@ -0,0 +1,4 @@
/// The various types of OAuth2 authorization tokens.
abstract class AuthorizationTokenType {
static const String bearer = 'bearer', mac = 'mac';
}

View file

@ -0,0 +1,19 @@
name: angel_oauth2
author: Tobe O <thosakwe@gmail.com>
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

View file

@ -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<PseudoApplication, Map> {
final Uuid _uuid = Uuid();
@override
FutureOr<PseudoApplication> findClient(String clientId) {
return clientId == pseudoApplication.id ? pseudoApplication : null;
}
@override
Future<bool> verifyClient(
PseudoApplication client, String clientSecret) async {
return client.secret == clientSecret;
}
@override
Future requestAuthorizationCode(
PseudoApplication client,
String redirectUri,
Iterable<String> 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>();
authCodes[authCode] = state;
res.headers['content-type'] = 'application/json';
var result = {'code': authCode};
if (state?.isNotEmpty == true) result['state'] = state;
return result;
}
@override
Future<AuthorizationTokenResponse> exchangeAuthorizationCodeForToken(
PseudoApplication client,
String authCode,
String redirectUri,
RequestContext req,
ResponseContext res) async {
var authCodes = req.container.make<AuthCodes>();
var state = authCodes[authCode];
var refreshToken = state == 'can_refresh' ? '${authCode}_refresh' : null;
return AuthorizationTokenResponse('${authCode}_access',
refreshToken: refreshToken);
}
}
class AuthCodes extends MapBase<String, String> with MapMixin<String, String> {
var inner = <String, String>{};
@override
String operator [](Object key) => inner[key];
@override
void operator []=(String key, String value) => inner[key] = value;
@override
void clear() => inner.clear();
@override
Iterable<String> get keys => inner.keys;
@override
String remove(Object key) => inner.remove(key);
}

View file

@ -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<PseudoApplication, PseudoUser> {
@override
PseudoApplication findClient(String clientId) {
return clientId == pseudoApplication.id ? pseudoApplication : null;
}
@override
Future<bool> verifyClient(
PseudoApplication client, String clientSecret) async {
return client.secret == clientSecret;
}
@override
Future<AuthorizationTokenResponse> clientCredentialsGrant(
PseudoApplication client, RequestContext req, ResponseContext res) async {
return AuthorizationTokenResponse('foo');
}
}

View file

@ -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<PseudoUser> 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});
}

View file

@ -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<PseudoApplication, PseudoUser> {
@override
PseudoApplication findClient(String clientId) {
return clientId == pseudoApplication.id ? pseudoApplication : null;
}
@override
Future<bool> verifyClient(
PseudoApplication client, String clientSecret) async {
return client.secret == clientSecret;
}
@override
FutureOr<DeviceCodeResponse> requestDeviceCode(PseudoApplication client,
Iterable<String> scopes, RequestContext req, ResponseContext res) {
return DeviceCodeResponse(
'foo',
'bar',
Uri.parse('https://regiostech.com')
.replace(queryParameters: {'scopes': scopes.join(',')}),
3600);
}
@override
FutureOr<AuthorizationTokenResponse> 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');
}
}

View file

@ -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<PseudoApplication, PseudoUser> {
@override
PseudoApplication findClient(String clientId) {
return clientId == pseudoApplication.id ? pseudoApplication : null;
}
@override
Future<bool> verifyClient(
PseudoApplication client, String clientSecret) async {
return client.secret == clientSecret;
}
@override
Future<void> requestAuthorizationCode(
PseudoApplication client,
String redirectUri,
Iterable<String> 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);
}
}

View file

@ -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<PseudoApplication, PseudoUser> {
@override
PseudoApplication findClient(String clientId) {
return clientId == pseudoApplication.id ? pseudoApplication : null;
}
@override
Future<bool> verifyClient(
PseudoApplication client, String clientSecret) async {
return client.secret == clientSecret;
}
@override
Future<AuthorizationTokenResponse> refreshAuthorizationToken(
PseudoApplication client,
String refreshToken,
Iterable<String> scopes,
RequestContext req,
ResponseContext res) async {
return AuthorizationTokenResponse('baz', refreshToken: 'bar');
}
@override
Future<AuthorizationTokenResponse> resourceOwnerPasswordCredentialsGrant(
PseudoApplication client,
String username,
String password,
Iterable<String> 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');
}
}

View file

@ -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<PseudoApplication, Map> {
@override
FutureOr<PseudoApplication> findClient(String clientId) {
return pseudoApplication;
}
@override
Future<bool> verifyClient(
PseudoApplication client, String clientSecret) async {
return client.secret == clientSecret;
}
@override
Future requestAuthorizationCode(
PseudoApplication client,
String redirectUri,
Iterable<String> scopes,
String state,
RequestContext req,
ResponseContext res,
bool implicit) async {
req.container.make<Pkce>();
return {'code': 'ok'};
}
@override
Future<AuthorizationTokenResponse> 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<String, String> with MapMixin<String, String> {
var inner = <String, String>{};
@override
String operator [](Object key) => inner[key];
@override
void operator []=(String key, String value) => inner[key] = value;
@override
void clear() => inner.clear();
@override
Iterable<String> get keys => inner.keys;
@override
String remove(Object key) => inner.remove(key);
}