diff --git a/.gitignore b/.gitignore
index 427e911b..b4d6e266 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,28 +1,64 @@
+# 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
-.buildlog
.packages
-.project
.pub/
-.scripts-bin/
build/
-**/packages/
-
-# Files created by dart2js
-# (Most Dart developers will use pub build to compile Dart, use/modify these
-# rules if you intend to use dart2js directly
-# Convention is to use extension '.dart.js' for Dart compiled to Javascript to
-# differentiate from explicit Javascript files)
-*.dart.js
-*.part.js
-*.js.deps
-*.js.map
-*.info.json
+# 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/
-
-# Don't commit pubspec lock file
-# (Library packages only! Remove pattern if developing an application package)
-pubspec.lock
diff --git a/.idea/auth_oauth2_server.iml b/.idea/auth_oauth2_server.iml
new file mode 100644
index 00000000..0fd729f3
--- /dev/null
+++ b/.idea/auth_oauth2_server.iml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 00000000..2dde0608
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 00000000..35eb1ddf
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/README.md b/README.md
index 27295930..a5b7fd1c 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,90 @@
-# auth_oauth2_server
-angel_auth strategy for in-house OAuth2 login.
+# auth_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.
+
+# Installation
+In your `pubspec.yaml`:
+
+```yaml
+dependencies:
+ angel_oauth2: ^1.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.Server {}
+```
+
+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 Server {
+ final Uuid _uuid = new Uuid();
+
+ @override
+ FutureOr findClient(String clientId) {
+ return clientId == pseudoApplication.id ? pseudoApplication : null;
+ }
+
+ @override
+ Future verifyClient(
+ PseudoApplication client, String clientSecret) async {
+ return client.secret == clientSecret;
+ }
+}
+```
+
+Next, write some logic to be executed whenever a user visits the
+authorization endpoint. In most cases, you will want to show a dialog:
+
+```dart
+@override
+Future authorize(
+ PseudoApplication client,
+ String redirectUri,
+ Iterable scopes,
+ String state,
+ RequestContext req,
+ ResponseContext res) async {
+ res.render('dialog');
+}
+```
+
+Now, write logic that exchanges an authorization code for an access token,
+and optionally, a refresh token.
+
+```dart
+@override
+Future exchangeAuthCodeForAccessToken(
+ String authCode,
+ String redirectUri,
+ RequestContext req,
+ ResponseContext res) async {
+ return new 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);
+ });
+}
+```
+
+Naturally,
\ No newline at end of file
diff --git a/lib/angel_oauth2.dart b/lib/angel_oauth2.dart
new file mode 100644
index 00000000..17ecd775
--- /dev/null
+++ b/lib/angel_oauth2.dart
@@ -0,0 +1,3 @@
+export 'src/response.dart';
+export 'src/server.dart';
+export 'src/token_type.dart';
\ No newline at end of file
diff --git a/lib/angel_oauth2_server.dart b/lib/angel_oauth2_server.dart
deleted file mode 100644
index 23bc99ba..00000000
--- a/lib/angel_oauth2_server.dart
+++ /dev/null
@@ -1 +0,0 @@
-export 'src/server.dart';
\ No newline at end of file
diff --git a/lib/src/auth_code.dart b/lib/src/auth_code.dart
new file mode 100644
index 00000000..0f9a5b3e
--- /dev/null
+++ b/lib/src/auth_code.dart
@@ -0,0 +1,2 @@
+library auth_oauth2_server.src.auth_code;
+
diff --git a/lib/src/exception.dart b/lib/src/exception.dart
new file mode 100644
index 00000000..0d0c0c5f
--- /dev/null
+++ b/lib/src/exception.dart
@@ -0,0 +1,36 @@
+import 'package:angel_http_exception/angel_http_exception.dart';
+
+class AuthorizationException extends AngelHttpException {
+ final ErrorResponse errorResponse;
+
+ AuthorizationException(this.errorResponse,
+ {StackTrace stackTrace, int statusCode})
+ : super(errorResponse,
+ stackTrace: stackTrace, message: '', statusCode: statusCode ?? 401);
+}
+
+class ErrorResponse {
+ final String code, description;
+
+ // Taken from https://www.docusign.com/p/RESTAPIGuide/Content/OAuth2/OAuth2%20Response%20Codes.htm
+ // TODO: Use original error messages
+ static const ErrorResponse invalidRequest = const ErrorResponse(
+ 'invalid_request',
+ 'The request was malformed, or contains unsupported parameters.'),
+ invalidClient = const ErrorResponse(
+ 'invalid_client', 'The client authentication failed.'),
+ invalidGrant = const ErrorResponse(
+ 'invalid_grant', 'The provided authorization is invalid.'),
+ unauthorizedClient = const ErrorResponse('unauthorized_client',
+ 'The client application is not allowed to use this grant_type.'),
+ unauthorizedGrantType = const ErrorResponse('unsupported_grant_type',
+ 'A grant_type other than “password” was used in the request.'),
+ invalidScope = const ErrorResponse(
+ 'invalid_scope', 'One or more of the scopes you provided was invalid.'),
+ unsupportedTokenType = const ErrorResponse('unsupported_token_type',
+ 'The client tried to revoke an access token on a server not supporting this feature.'),
+ invalidToken = const ErrorResponse(
+ 'invalid_token', 'The presented token is invalid.');
+
+ const ErrorResponse(this.code, this.description);
+}
diff --git a/lib/src/grant.dart b/lib/src/grant.dart
deleted file mode 100644
index 5cdcaecc..00000000
--- a/lib/src/grant.dart
+++ /dev/null
@@ -1,3 +0,0 @@
-abstract class Grant {
-
-}
\ No newline at end of file
diff --git a/lib/src/response.dart b/lib/src/response.dart
new file mode 100644
index 00000000..c77407b6
--- /dev/null
+++ b/lib/src/response.dart
@@ -0,0 +1,12 @@
+class AuthorizationCodeResponse {
+ final String accessToken;
+ final String refreshToken;
+
+ const AuthorizationCodeResponse(this.accessToken, {this.refreshToken});
+
+ Map toJson() {
+ var map = {'access_token': accessToken};
+ if (refreshToken?.isNotEmpty == true) map['refresh_token'] = refreshToken;
+ return map;
+ }
+}
\ No newline at end of file
diff --git a/lib/src/server.dart b/lib/src/server.dart
index 29bd8c7c..103633da 100644
--- a/lib/src/server.dart
+++ b/lib/src/server.dart
@@ -1,12 +1,119 @@
-import 'package:angel_auth/angel_auth.dart';
+import 'dart:async';
import 'package:angel_framework/angel_framework.dart';
+import 'exception.dart';
+import 'response.dart';
+import 'token_type.dart';
-abstract class OAuth2Server {
- final AngelAuth auth;
+String _getParam(RequestContext req, String name, {bool body: false}) {
+ var map = body == true ? req.body : req.query;
+ var value = map.containsKey(name) ? map[name]?.toString() : null;
- RequestMiddleware verifyAuthToken() {
- return (req, res) async {
-
- };
+ if (value?.isNotEmpty != true)
+ throw new AngelHttpException.badRequest(
+ message: "Missing required parameter '$name'.");
+
+ return value;
+}
+
+Iterable _getScopes(RequestContext req, {bool body: false}) {
+ var map = body == true ? req.body : req.query;
+ return map['scope']?.toString()?.split(' ') ?? [];
+}
+
+abstract class Server {
+ const Server();
+
+ /// Finds the [Client] application associated with the given [clientId].
+ FutureOr findClient(String clientId);
+
+ Future verifyClient(Client client, String clientSecret);
+
+ Future authCodeGrant(Client client, String redirectUri, User user,
+ Iterable scopes, String state);
+
+ authorize(Client client, String redirectUri, Iterable scopes,
+ String state, RequestContext req, ResponseContext res);
+
+ Future exchangeAuthCodeForAccessToken(
+ String authCode,
+ String redirectUri,
+ RequestContext req,
+ ResponseContext res);
+
+ Future authorizationEndpoint(RequestContext req, ResponseContext res) async {
+ var responseType = _getParam(req, 'response_type');
+
+ if (responseType != 'code')
+ throw new AngelHttpException.badRequest(
+ message: "Invalid response_type, expected 'code'.");
+
+ // Ensure client ID
+ var clientId = _getParam(req, 'client_id');
+
+ // Find client
+ var client = await findClient(clientId);
+
+ if (client == null)
+ throw new AuthorizationException(ErrorResponse.invalidClient);
+
+ // Grab redirect URI
+ var redirectUri = _getParam(req, 'redirect_uri');
+
+ // Grab scopes
+ var scopes = _getScopes(req);
+
+ var state = req.query['state']?.toString() ?? '';
+
+ return await authorize(client, redirectUri, scopes, state, req, res);
}
-}
\ No newline at end of file
+
+ Future tokenEndpoint(RequestContext req, ResponseContext res) async {
+ await req.parse();
+
+ var grantType = _getParam(req, 'grant_type', body: true);
+
+ if (grantType != 'authorization_code')
+ throw new AngelHttpException.badRequest(
+ message: "Invalid grant_type; expected 'authorization_code'.");
+
+ var code = _getParam(req, 'code', body: true);
+ var redirectUri = _getParam(req, 'redirect_uri', body: true);
+
+ var response =
+ await exchangeAuthCodeForAccessToken(code, redirectUri, req, res);
+ return {'token_type': TokenType.bearer}..addAll(response.toJson());
+ }
+
+ Future handleFormSubmission(RequestContext req, ResponseContext res) async {
+ await req.parse();
+
+ // Ensure client ID
+ var clientId = _getParam(req, 'client_id', body: true);
+
+ // Find client
+ var client = await findClient(clientId);
+
+ if (client == null)
+ throw new AuthorizationException(ErrorResponse.invalidClient);
+
+ // Verify client secret
+ var clientSecret = _getParam(req, 'client_secret', body: true);
+
+ if (!await verifyClient(client, clientSecret))
+ throw new AuthorizationException(ErrorResponse.invalidClient);
+
+ // Grab redirect URI
+ var redirectUri = _getParam(req, 'redirect_uri', body: true);
+
+ // Grab scopes
+ var scopes = _getScopes(req, body: true);
+
+ var state = req.query['state']?.toString() ?? '';
+
+ var authCode = await authCodeGrant(
+ client, redirectUri, req.properties['user'], scopes, state);
+ res.headers['content-type'] = 'application/x-www-form-urlencoded';
+ res.write('code=' + Uri.encodeComponent(authCode));
+ if (state.isNotEmpty) res.write('&state=' + Uri.encodeComponent(state));
+ }
+}
diff --git a/lib/src/strategy.dart b/lib/src/strategy.dart
new file mode 100644
index 00000000..bdcd6129
--- /dev/null
+++ b/lib/src/strategy.dart
@@ -0,0 +1,3 @@
+import 'package:angel_auth/angel_auth.dart';
+import 'server.dart';
+
diff --git a/lib/src/token_type.dart b/lib/src/token_type.dart
new file mode 100644
index 00000000..2f7de7f9
--- /dev/null
+++ b/lib/src/token_type.dart
@@ -0,0 +1,3 @@
+abstract class TokenType {
+ static const String bearer = 'bearer', mac = 'mac';
+}
\ No newline at end of file
diff --git a/pubspec.yaml b/pubspec.yaml
index f0b368b1..1241c0bf 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,12 +1,13 @@
-author: "Tobe O "
-description: "angel_auth strategy for in-house OAuth2 login."
-homepage: "https://github.com/thosakwe/oauth2_server.git"
-name: "angel_auth_oauth2_server"
-version: "0.0.0"
+name: angel_oauth2
+author: Tobe O
+description: angel_auth strategy for in-house OAuth2 login.
+homepage: https://github.com/thosakwe/oauth2_server.git
+version: 1.0.0-alpha
+environment:
+ sdk: ">=1.19.0"
dependencies:
- angel_auth: "^1.0.0"
+ angel_auth: ^1.1.0-alpha
dev_dependencies:
- angel_test: "^1.0.0-dev"
- http: "^0.11.3+9"
- oauth2: "^1.0.2"
- test: "^0.12.18+1"
+ angel_test: ^1.1.0-alpha
+ oauth2: ^1.0.0
+ test: ^0.12.0
diff --git a/test/auth_code_test.dart b/test/auth_code_test.dart
new file mode 100644
index 00000000..91f43cf4
--- /dev/null
+++ b/test/auth_code_test.dart
@@ -0,0 +1,171 @@
+import 'dart:async';
+import 'dart:convert';
+import 'package:angel_framework/angel_framework.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 = new Angel()..lazyParseBodies = true;
+ app.configuration['properties'] = app.configuration;
+ app.inject('authCodes', {});
+
+ var server = new _Server();
+
+ app.group('/oauth2', (router) {
+ router
+ ..get('/authorize', server.authorizationEndpoint)
+ ..post('/token', server.tokenEndpoint);
+ });
+
+ app.logger = new Logger('angel')
+ ..onRecord.listen((rec) {
+ print(rec);
+ if (rec.error != null) print(rec.error);
+ if (rec.stackTrace != null) print(rec.stackTrace);
+ });
+
+ var http = await app.startServer();
+ var url = 'http://${http.address.address}:${http.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() =>
+ new 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'];
+ 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'];
+ 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 Server {
+ final Uuid _uuid = new Uuid();
+
+ @override
+ FutureOr findClient(String clientId) {
+ return clientId == pseudoApplication.id ? pseudoApplication : null;
+ }
+
+ @override
+ Future verifyClient(
+ PseudoApplication client, String clientSecret) async {
+ return client.secret == clientSecret;
+ }
+
+ @override
+ Future authorize(
+ PseudoApplication client,
+ String redirectUri,
+ Iterable scopes,
+ String state,
+ RequestContext req,
+ ResponseContext res) async {
+ if (state == 'hello')
+ return 'Hello ${pseudoApplication.id}:${pseudoApplication.secret}';
+
+ var authCode = _uuid.v4();
+ var authCodes = req.grab