Add 'packages/oauth2/' from commit '0bef42c4b9e3cbc800bb1b7b89b736baff814b9f'
git-subtree-dir: packages/oauth2 git-subtree-mainline:71aa3464b9
git-subtree-split:0bef42c4b9
This commit is contained in:
commit
a7842bddd8
26 changed files with 2126 additions and 0 deletions
BIN
packages/oauth2/.DS_Store
vendored
Normal file
BIN
packages/oauth2/.DS_Store
vendored
Normal file
Binary file not shown.
65
packages/oauth2/.gitignore
vendored
Normal file
65
packages/oauth2/.gitignore
vendored
Normal 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
|
15
packages/oauth2/.idea/auth_oauth2_server.iml
Normal file
15
packages/oauth2/.idea/auth_oauth2_server.iml
Normal 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>
|
8
packages/oauth2/.idea/modules.xml
Normal file
8
packages/oauth2/.idea/modules.xml
Normal 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>
|
|
@ -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>
|
6
packages/oauth2/.idea/vcs.xml
Normal file
6
packages/oauth2/.idea/vcs.xml
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
4
packages/oauth2/.travis.yml
Normal file
4
packages/oauth2/.travis.yml
Normal file
|
@ -0,0 +1,4 @@
|
|||
language: dart
|
||||
dart:
|
||||
- dev
|
||||
- stable
|
20
packages/oauth2/CHANGELOG.md
Normal file
20
packages/oauth2/CHANGELOG.md
Normal 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
21
packages/oauth2/LICENSE
Normal 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
172
packages/oauth2/README.md
Normal 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('...');
|
||||
}
|
||||
```
|
8
packages/oauth2/analysis_options.yaml
Normal file
8
packages/oauth2/analysis_options.yaml
Normal file
|
@ -0,0 +1,8 @@
|
|||
include: package:pedantic/analysis_options.yaml
|
||||
analyzer:
|
||||
strong-mode:
|
||||
implicit-casts: false
|
||||
linter:
|
||||
rules:
|
||||
- unnecessary_const
|
||||
- unnecessary_new
|
76
packages/oauth2/example/main.dart
Normal file
76
packages/oauth2/example/main.dart
Normal 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();
|
||||
}
|
||||
}
|
5
packages/oauth2/lib/angel_oauth2.dart
Normal file
5
packages/oauth2/lib/angel_oauth2.dart
Normal 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';
|
90
packages/oauth2/lib/src/exception.dart
Normal file
90
packages/oauth2/lib/src/exception.dart
Normal 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';
|
||||
}
|
73
packages/oauth2/lib/src/pkce.dart
Normal file
73
packages/oauth2/lib/src/pkce.dart
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
73
packages/oauth2/lib/src/response.dart
Normal file
73
packages/oauth2/lib/src/response.dart
Normal 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;
|
||||
}
|
||||
}
|
474
packages/oauth2/lib/src/server.dart
Normal file
474
packages/oauth2/lib/src/server.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
4
packages/oauth2/lib/src/token_type.dart
Normal file
4
packages/oauth2/lib/src/token_type.dart
Normal file
|
@ -0,0 +1,4 @@
|
|||
/// The various types of OAuth2 authorization tokens.
|
||||
abstract class AuthorizationTokenType {
|
||||
static const String bearer = 'bearer', mac = 'mac';
|
||||
}
|
19
packages/oauth2/pubspec.yaml
Normal file
19
packages/oauth2/pubspec.yaml
Normal 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
|
195
packages/oauth2/test/auth_code_test.dart
Normal file
195
packages/oauth2/test/auth_code_test.dart
Normal 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);
|
||||
}
|
106
packages/oauth2/test/client_credentials_test.dart
Normal file
106
packages/oauth2/test/client_credentials_test.dart
Normal 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');
|
||||
}
|
||||
}
|
20
packages/oauth2/test/common.dart
Normal file
20
packages/oauth2/test/common.dart
Normal 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});
|
||||
}
|
172
packages/oauth2/test/device_code_test.dart
Normal file
172
packages/oauth2/test/device_code_test.dart
Normal 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');
|
||||
}
|
||||
}
|
73
packages/oauth2/test/implicit_grant_test.dart
Normal file
73
packages/oauth2/test/implicit_grant_test.dart
Normal 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);
|
||||
}
|
||||
}
|
137
packages/oauth2/test/password_test.dart
Normal file
137
packages/oauth2/test/password_test.dart
Normal 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');
|
||||
}
|
||||
}
|
282
packages/oauth2/test/pkce_test.dart
Normal file
282
packages/oauth2/test/pkce_test.dart
Normal 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);
|
||||
}
|
Loading…
Reference in a new issue