add: adding auth package

This commit is contained in:
Patrick Stewart 2024-12-15 03:59:26 -07:00
parent 2446120319
commit 5c925c481d
28 changed files with 2292 additions and 2 deletions

72
packages/auth/.gitignore vendored Normal file
View file

@ -0,0 +1,72 @@
# See https://www.dartlang.org/tools/private-files.html
# Files and directories created by pub
.dart_tool
.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 template
# See https://www.dartlang.org/tools/private-files.html
# Files and directories created by pub
# SDK 1.20 and later (no longer creates packages directories)
# Older SDK versions
# (Include if the minimum SDK version specified in pubsepc.yaml is earlier than 1.20)
.project
.buildlog
**/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
# Directory created by dartdoc
# Don't commit pubspec lock file
# (Library packages only! Remove pattern if developing an application package)
### 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:
## VsCode
.vscode/
## File-based project format:
*.iws
## Plugin-specific files:
# IntelliJ
.idea/
/out/
.idea_modules/
# JIRA plugin
atlassian-ide-plugin.xml
# Crashlytics plugin (for Android Studio and IntelliJ)
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
.DS_Store

12
packages/auth/AUTHORS.md Normal file
View file

@ -0,0 +1,12 @@
Primary Authors
===============
* __[Thomas Hii](dukefirehawk.apps@gmail.com)__
Thomas is the current maintainer of the code base. He has refactored and migrated the
code base to support NNBD.
* __[Tobe O](thosakwe@gmail.com)__
Tobe has written much of the original code prior to NNBD migration. He has moved on and
is no longer involved with the project.

193
packages/auth/CHANGELOG.md Normal file
View file

@ -0,0 +1,193 @@
# Change Log
## 8.2.0
* Require Dart >= 3.3
* Updated `lints` to 4.0.0
## 8.1.1
* Updated repository link
## 8.1.0
* Updated `lints` to 3.0.0
## 8.0.0
* Require Dart >= 3.0
* Upgraded `http` to 1.0.0
* Fixed failed `successRedirect` test case
* Fixed failed `failureRedirect` test case
* Fixed failed `login` test case
* Fixed failed `force basic` test case
* Added `example1` and `example2`
## 7.0.1
* Fixed linter warnings
## 7.0.0
* Require Dart >= 2.17
## 6.0.0
* Require Dart >= 2.16
## 5.0.0
* Skipped release
## 4.1.2
* Fixed `requireAuthentication` to work correctly with null-safety type
## 4.1.1
* Changed `userId` field of `AuthToken` to String type
* Changed `serializer` return value to String type
* Changed `deserializer` input parameter to String type
## 4.1.0
* Updated linter to `package:lints`
## 4.0.5
* Added support for verifier function to return an empty Map instead of null
* Fixed `canRespondWithJson` option to return data in the response body when set to true
## 4.0.4
* Changed `serializer` and `deserializer` parameters to be required
* Fixed HTTP basic authentication
* All 31 unit tests passed
## 4.0.3
* Fixed "failureRedirect" unit test
## 4.0.2
* Added MirrorsReflector to unit test
## 4.0.1
* Updated README
## 4.0.0
* Migrated to support Dart >= 2.12 NNBD
## 3.0.0
* Migrated to work with Dart >= 2.12 Non NNBD
## 2.1.5+1
* Fix error in popup page.
## 2.1.5
* Modify `_apply` to honor an existing `User` over `Future<User>`.
## 2.1.4
* Deprecate `decodeJwt`, in favor of asynchronous injections.
## 2.1.3
* Use `await` on redirects, etc.
## 2.1.2
* Change empty cookie string to have double quotes (thanks @korsvanloon).
## 2.1.1
* Added `scopes` to `ExternalAuthOptions`.
## 2.1.0
* Added `ExternalAuthOptions`.
## 2.0.4
* `successRedirect` was previously explicitly returning a `200`; remove this and allow the default `302`.
## 2.0.3
* Updates for streaming parse of request bodies.
## 2.0.2
* Handle `null` return in `authenticate` + `failureRedirect`.
## 2.0.1
* Add generic parameter to `options` on `AuthStrategy.authenticate`.
## 2.0.0+1
* Meta update to improve Pub score.
## 2.0.0
* Made `AuthStrategy` generic.
* `PlatformAuth.strategies` is now a `Map<String, AuthStrategy<User>>`.
* Removed `AuthStrategy.canLogout`.
* Made `AngelAuthTokenCallback` generic.
## 2.0.0-alpha
* Depend on Dart 2 and Angel 2.
* Remove `dart2_constant`.
* Remove `requireAuth`.
* Remove `userKey`, instead favoring generic parameters.
## 1.2.0
* Deprecate `requireAuth`, in favor of `requireAuthentication`.
* Allow configuring of the `userKey`.
* Deprecate `middlewareName`.
## 1.1.1+6
* Fix a small logic bug that prevented `LocalAuthStrategy`
from correctly propagating the authenticated user when
using `Basic` auth.
## 1.1.1+5
* Prevent duplication of cookies.
* Regenerate the JWT if `tokenCallback` is called.
## 1.1.1+4
* Patched `logout` to properly erase cookies
* Fixed checking of expired tokens.
## 1.1.1+3
* `authenticate` returns the current user, if one is present.
## 1.1.1+2
* `_apply` now always sends a `token` cookie.
## 1.1.1+1
* Update `protectCookie` to only send `maxAge` when it is not `-1`.
## 1.1.1
* Added `protectCookie`, to better protect data sent in cookies.
## 1.1.0+2
* `LocalAuthStrategy` returns `true` on `Basic` authentication.
## 1.1.0+1
* Modified `LocalAuthStrategy`'s handling of `Basic` authentication.

29
packages/auth/LICENSE Normal file
View file

@ -0,0 +1,29 @@
BSD 3-Clause License
Copyright (c) 2021, dukefirehawk.com
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

84
packages/auth/README.md Normal file
View file

@ -0,0 +1,84 @@
# Angel3 Anthentication
![Pub Version (including pre-releases)](https://img.shields.io/pub/v/platform_auth?include_prereleases)
[![Null Safety](https://img.shields.io/badge/null-safety-brightgreen)](https://dart.dev/null-safety)
[![Discord](https://img.shields.io/discord/1060322353214660698)](https://discord.gg/3X6bxTUdCM)
[![License](https://img.shields.io/github/license/dart-backend/angel)](https://github.com/dart-backend/angel/tree/master/packages/auth/LICENSE)
A complete authentication plugin for Angel3. Inspired by Passport. More details in the [User Guide](https://angel3-docs.dukefirehawk.com/guides/authentication).
## Bundled Strategies
* Local (with and without Basic Auth)
* Find other strategies (Twitter, Google, OAuth2, etc.) on pub
## Example
Ensure you have read the [User Guide](https://angel3-docs.dukefirehawk.com/guides/authentication).
```dart
configureServer(Angel app) async {
var auth = PlatformAuth<User>(
serializer: (user) => user.id ?? '',
deserializer: (id) => fetchAUserByIdSomehow(id
);
auth.strategies['local'] = LocalAuthStrategy(...);
// POST route to handle username+password
app.post('/local', auth.authenticate('local'));
// Using Angel's asynchronous injections, we can parse the JWT
// on demand. It won't be parsed until we check.
app.get('/profile', ioc((User user) {
print(user.description);
}));
// Use a comma to try multiple strategies!!!
//
// Each strategy is run sequentially. If one succeeds, the loop ends.
// Authentication failures will just cause the loop to continue.
//
// If the last strategy throws an authentication failure, then
// a `401 Not Authenticated` is thrown.
var chainedHandler = auth.authenticate(
['basic','facebook'],
authOptions
);
// Apply angel_auth-specific configuration.
await app.configure(auth.configureServer);
}
```
## Default Authentication Callback
A frequent use case within SPA's is opening OAuth login endpoints in a separate window. [`angel3_client`](https://pub.dev/packages/angel3_client) provides a facility for this, which works perfectly with the default callback provided in this package.
```dart
configureServer(Angel app) async {
var handler = auth.authenticate(
'facebook',
AngelAuthOptions(callback: confirmPopupAuthentication()));
app.get('/auth/facebook', handler);
// Use a comma to try multiple strategies!!!
//
// Each strategy is run sequentially. If one succeeds, the loop ends.
// Authentication failures will just cause the loop to continue.
//
// If the last strategy throws an authentication failure, then
// a `401 Not Authenticated` is thrown.
var chainedHandler = auth.authenticate(
['basic','facebook'],
authOptions
);
}
```
This renders a simple HTML page that fires the user's JWT as a `token` event in `window.opener`. `angel3_client` [exposes this as a Stream](https://pub.dev/documentation/angel3_client/latest/):
```dart
app.authenticateViaPopup('/auth/google').listen((jwt) {
// Do something with the JWT
});
```

View file

@ -0,0 +1 @@
include: package:lints/recommended.yaml

View file

@ -0,0 +1,22 @@
### Load landing page
GET http://localhost:3000/ HTTP/1.1
### login (call_back)
POST http://localhost:3000/login HTTP/1.1
Content-Type: application/json
Authorization: Basic jdoe1:password
### Success redirect (local)
POST http://localhost:3000/login HTTP/1.1
Content-Type: application/json
Authorization: Basic username:password
### Failure redirect (local)
POST http://localhost:3000/login HTTP/1.1
Content-Type: application/json
Authorization: Basic password:username
### Force basic
GET http://localhost:3000/hello HTTP/1.1
Content-Type: application/json
Accept:application/json

View file

@ -0,0 +1,38 @@
import 'dart:async';
import 'package:platform_auth/auth.dart';
import 'package:platform_foundation/core.dart';
import 'package:platform_foundation/http.dart';
void main() async {
var app = Application();
var auth = PlatformAuth<User>(
serializer: (user) => user.id ?? '',
deserializer: (id) => fetchAUserByIdSomehow(id));
// Middleware to decode JWT's and inject a user object...
await app.configure(auth.configureServer);
auth.strategies['local'] = LocalAuthStrategy((username, password) {
// Retrieve a user somehow...
// If authentication succeeds, return a User object.
//
// Otherwise, return `null`.
return null;
});
app.post('/auth/local', auth.authenticate('local'));
var http = PlatformHttp(app);
await http.startServer('127.0.0.1', 3000);
print('Listening at http://127.0.0.1:3000');
}
class User {
String? id, username, password;
}
Future<User> fetchAUserByIdSomehow(String id) async {
// Fetch a user somehow...
throw UnimplementedError();
}

View file

@ -0,0 +1,113 @@
import 'package:platform_auth/auth.dart';
import 'package:platform_container/mirrors.dart';
import 'package:platform_foundation/core.dart';
import 'package:platform_foundation/http.dart';
import 'package:collection/collection.dart' show IterableExtension;
import 'package:io/ansi.dart';
import 'package:logging/logging.dart';
import 'package:collection/collection.dart';
class User extends Model {
String? username, password;
User({this.username, this.password});
static User parse(Map<String, dynamic> map) {
return User(
username: map['username'],
password: map['password'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'username': username,
'password': password,
'created_at': createdAt?.toIso8601String(),
'updated_at': updatedAt?.toIso8601String()
};
}
}
/*
* Backend for callback test cases
*/
void main() async {
hierarchicalLoggingEnabled = true;
Application app = Application(reflector: MirrorsReflector());
PlatformHttp angelHttp = PlatformHttp(app);
app.use('/users', MapService());
var oldErrorHandler = app.errorHandler;
app.errorHandler = (e, req, res) {
app.logger.severe(e.message, e, e.stackTrace ?? StackTrace.current);
return oldErrorHandler(e, req, res);
};
app.logger = Logger('platform_auth')
..level = Level.FINEST
..onRecord.listen((rec) {
print(rec);
if (rec.error != null) {
print(yellow.wrap(rec.error.toString()));
}
if (rec.stackTrace != null) {
print(yellow.wrap(rec.stackTrace.toString()));
}
});
await app
.findService('users')
?.create({'username': 'jdoe1', 'password': 'password'});
var auth = PlatformAuth<User>(
serializer: (u) => u.id ?? '',
deserializer: (id) async =>
await app.findService('users')?.read(id) as User);
//auth.serializer = (u) => u.id;
//auth.deserializer =
// (id) async => await app.findService('users')!.read(id) as User;
await app.configure(auth.configureServer);
auth.strategies['local'] = LocalAuthStrategy((username, password) async {
var users = await app
.findService('users')
?.index()
.then((it) => it.map<User>((m) => User.parse(m)).toList());
var result = users?.firstWhereOrNull(
(user) => user.username == username && user.password == password);
return Future.value(result);
}, allowBasic: true);
app.post(
'/login',
auth.authenticate('local', AngelAuthOptions(callback: (req, res, token) {
res
..write('Hello!')
..close();
})));
app.get('/', (req, res) => res.write("Hello"));
app.chain([
(req, res) {
if (!req.container!.has<User>()) {
req.container!.registerSingleton<User>(
User(username: req.params['name']?.toString()));
}
return true;
}
]).post(
'/existing/:name',
auth.authenticate('local'),
);
await angelHttp.startServer('127.0.0.1', 3000);
}

View file

@ -0,0 +1,69 @@
import 'dart:async';
import 'package:platform_auth/auth.dart';
import 'package:platform_container/mirrors.dart';
import 'package:platform_foundation/core.dart';
import 'package:platform_foundation/http.dart';
import 'package:logging/logging.dart';
final Map<String, String> sampleUser = {'hello': 'world'};
final PlatformAuth<Map<String, String>> auth =
PlatformAuth<Map<String, String>>(
serializer: (user) async => '1337',
deserializer: (id) async => sampleUser);
//var headers = <String, String>{'accept': 'application/json'};
var localOpts = AngelAuthOptions<Map<String, String>>(
failureRedirect: '/failure', successRedirect: '/success');
var localOpts2 =
AngelAuthOptions<Map<String, String>>(canRespondWithJson: false);
Future<Map<String, String>> verifier(String? username, String? password) async {
if (username == 'username' && password == 'password') {
return sampleUser;
} else {
return {};
}
}
Future wireAuth(Application app) async {
//auth.strategies['local'] = LocalAuthStrategy(verifier);
auth.strategies['local'] =
LocalAuthStrategy(verifier, forceBasic: true, realm: 'test');
await app.configure(auth.configureServer);
}
/*
* Backend for local test cases
*/
void main() async {
Application app = Application(reflector: MirrorsReflector());
PlatformHttp angelHttp = PlatformHttp(app, useZone: false);
await app.configure(wireAuth);
app.get('/hello', (req, res) {
// => 'Woo auth'
return 'Woo auth';
}, middleware: [auth.authenticate('local', localOpts2)]);
app.post('/login', (req, res) => 'This should not be shown',
middleware: [auth.authenticate('local', localOpts)]);
app.get('/success', (req, res) => 'yep', middleware: [
requireAuthentication<Map<String, String>>(),
]);
app.get('/failure', (req, res) => 'nope');
app.logger = Logger('local_test')
..onRecord.listen((rec) {
print(
'${rec.time}: ${rec.level.name}: ${rec.loggerName}: ${rec.message}');
if (rec.error != null) {
print(rec.error);
print(rec.stackTrace);
}
});
await angelHttp.startServer('127.0.0.1', 3000);
}

View file

@ -0,0 +1,10 @@
library platform_auth;
export 'src/middleware/require_auth.dart';
export 'src/strategies/strategies.dart';
export 'src/auth_token.dart';
export 'src/configuration.dart';
export 'src/options.dart';
export 'src/plugin.dart';
export 'src/popup_page.dart';
export 'src/strategy.dart';

View file

@ -0,0 +1,4 @@
/// Stand-alone JWT library.
library platform_auth.auth_token;
export 'src/auth_token.dart';

View file

@ -0,0 +1,138 @@
import 'dart:collection';
import 'package:platform_foundation/core.dart';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:logging/logging.dart';
/// Calls [BASE64URL], but also works for strings with lengths
/// that are *not* multiples of 4.
String decodeBase64(String str) {
var output = str.replaceAll('-', '+').replaceAll('_', '/');
switch (output.length % 4) {
case 0:
break;
case 2:
output += '==';
break;
case 3:
output += '=';
break;
default:
throw 'Illegal base64url string!"';
}
return utf8.decode(base64Url.decode(output));
}
class AuthToken {
static final _log = Logger('AuthToken');
final SplayTreeMap<String, String> _header =
SplayTreeMap.from({'alg': 'HS256', 'typ': 'JWT'});
String? ipAddress;
num lifeSpan;
String userId;
late DateTime issuedAt;
Map<String, dynamic> payload = {};
AuthToken(
{this.ipAddress,
this.lifeSpan = -1,
required this.userId,
DateTime? issuedAt,
Map<String, dynamic>? payload}) {
this.issuedAt = issuedAt ?? DateTime.now();
if (payload != null) {
this.payload.addAll(payload.keys
.fold({}, ((out, k) => out?..[k.toString()] = payload[k])) ??
{});
}
}
factory AuthToken.fromJson(String jsons) =>
AuthToken.fromMap(json.decode(jsons) as Map<String, dynamic>);
factory AuthToken.fromMap(Map<String, dynamic> data) {
return AuthToken(
ipAddress: data['aud'].toString(),
lifeSpan: data['exp'] as num,
issuedAt: DateTime.parse(data['iat'].toString()),
userId: data['sub'],
payload: data['pld']);
}
factory AuthToken.parse(String jwt) {
var split = jwt.split('.');
if (split.length != 3) {
_log.warning('Invalid JWT');
throw PlatformHttpException.notAuthenticated(message: 'Invalid JWT.');
}
var payloadString = decodeBase64(split[1]);
return AuthToken.fromMap(
json.decode(payloadString) as Map<String, dynamic>);
}
factory AuthToken.validate(String jwt, Hmac hmac) {
var split = jwt.split('.');
if (split.length != 3) {
_log.warning('Invalid JWT');
throw PlatformHttpException.notAuthenticated(message: 'Invalid JWT.');
}
// var headerString = decodeBase64(split[0]);
var payloadString = decodeBase64(split[1]);
var data = '${split[0]}.${split[1]}';
var signature = base64Url.encode(hmac.convert(data.codeUnits).bytes);
if (signature != split[2]) {
_log.warning('JWT payload does not match hashed version');
throw PlatformHttpException.notAuthenticated(
message: 'JWT payload does not match hashed version.');
}
return AuthToken.fromMap(
json.decode(payloadString) as Map<String, dynamic>);
}
String serialize(Hmac hmac) {
var headerString = base64Url.encode(json.encode(_header).codeUnits);
var payloadString = base64Url.encode(json.encode(toJson()).codeUnits);
var data = '$headerString.$payloadString';
var signature = hmac.convert(data.codeUnits).bytes;
return '$data.${base64Url.encode(signature)}';
}
Map<String, dynamic> toJson() {
return _splayify({
'iss': 'angel_auth',
'aud': ipAddress,
'exp': lifeSpan,
'iat': issuedAt.toIso8601String(),
'sub': userId,
'pld': _splayify(payload)
});
}
}
Map<String, dynamic> _splayify(Map<String, dynamic> map) {
var data = {};
map.forEach((k, v) {
data[k] = _splay(v);
});
return SplayTreeMap.from(data);
}
dynamic _splay(dynamic value) {
if (value is Iterable) {
return value.map(_splay).toList();
} else if (value is Map) {
return _splayify(value as Map<String, dynamic>);
} else {
return value;
}
}

View file

@ -0,0 +1,137 @@
import 'package:charcode/ascii.dart';
import 'package:collection/collection.dart';
import 'package:quiver/core.dart';
import 'package:logging/logging.dart';
/// A common class containing parsing and validation logic for third-party authentication configuration.
class ExternalAuthOptions {
static final _log = Logger('VirtualDirectory');
/// The user's identifier, otherwise known as an "application id".
final String clientId;
/// The user's secret, other known as an "application secret".
final String clientSecret;
/// The user's redirect URI.
final Uri redirectUri;
/// The scopes to be passed to the external server.
final Set<String> scopes;
ExternalAuthOptions._(
this.clientId, this.clientSecret, this.redirectUri, this.scopes);
factory ExternalAuthOptions(
{required String clientId,
required String clientSecret,
required redirectUri,
Iterable<String> scopes = const []}) {
if (redirectUri is String) {
return ExternalAuthOptions._(
clientId, clientSecret, Uri.parse(redirectUri), scopes.toSet());
} else if (redirectUri is Uri) {
return ExternalAuthOptions._(
clientId, clientSecret, redirectUri, scopes.toSet());
} else {
_log.severe('RedirectUri is not valid');
throw ArgumentError.value(
redirectUri, 'redirectUri', 'must be a String or Uri');
}
}
/// Returns a JSON-friendly representation of this object.
///
/// Parses the following fields:
/// * `client_id`
/// * `client_secret`
/// * `redirect_uri`
factory ExternalAuthOptions.fromMap(Map<String, dynamic> map) {
var clientId = map['client_id'];
var clientSecret = map['client_secret'];
if (clientId == null || clientSecret == null) {
_log.severe('clientId or clientSecret is null');
throw ArgumentError('Invalid clientId and/or clientSecret');
}
return ExternalAuthOptions(
clientId: clientId,
clientSecret: clientSecret,
redirectUri: map['redirect_uri'],
scopes: map['scopes'] is Iterable
? ((map['scopes'] as Iterable).map((x) => x.toString()))
: <String>[],
);
}
@override
int get hashCode => hash4(clientId, clientSecret, redirectUri, scopes);
@override
bool operator ==(other) =>
other is ExternalAuthOptions &&
other.clientId == clientId &&
other.clientSecret == other.clientSecret &&
other.redirectUri == other.redirectUri &&
const SetEquality<String>().equals(other.scopes, scopes);
/// Creates a copy of this object, with the specified changes.
ExternalAuthOptions copyWith(
{String? clientId,
String? clientSecret,
redirectUri,
Iterable<String> scopes = const []}) {
return ExternalAuthOptions(
clientId: clientId ?? this.clientId,
clientSecret: clientSecret ?? this.clientSecret,
redirectUri: redirectUri ?? this.redirectUri,
scopes: (scopes).followedBy(this.scopes),
);
}
/// Returns a JSON-friendly representation of this object.
///
/// Contains the following fields:
/// * `client_id`
/// * `client_secret`
/// * `redirect_uri`
///
/// If [obscureSecret] is `true` (default), then the [clientSecret] will
/// be replaced by the string `<redacted>`.
Map<String, dynamic> toJson({bool obscureSecret = true}) {
return {
'client_id': clientId,
'client_secret': obscureSecret ? '<redacted>' : clientSecret,
'redirect_uri': redirectUri.toString(),
'scopes': scopes.toList(),
};
}
/// Returns a [String] representation of this object.
///
/// If [obscureText] is `true` (default), then the [clientSecret] will be
/// replaced by asterisks in the output.
///
/// If no [asteriskCount] is given, then the number of asterisks will equal the length of
/// the actual [clientSecret].
@override
String toString({bool obscureSecret = true, int? asteriskCount}) {
String? secret;
if (!obscureSecret) {
secret = clientSecret;
} else {
var codeUnits =
List<int>.filled(asteriskCount ?? clientSecret.length, $asterisk);
secret = String.fromCharCodes(codeUnits);
}
var b = StringBuffer('ExternalAuthOptions(');
b.write('clientId=$clientId');
b.write(', clientSecret=$secret');
b.write(', redirectUri=$redirectUri');
b.write(', scopes=${scopes.toList()}');
b.write(')');
return b.toString();
}
}

View file

@ -0,0 +1,51 @@
import 'dart:async';
import 'package:platform_foundation/core.dart';
/// Forces Basic authentication over the requested resource, with the given [realm] name, if no JWT is present.
///
/// [realm] defaults to `'platform_auth'`.
RequestHandler forceBasicAuth<User>({String? realm}) {
return (RequestContext req, ResponseContext res) async {
if (req.container != null) {
var reqContainer = req.container!;
if (reqContainer.has<User>()) {
return true;
} else if (reqContainer.has<Future<User>>()) {
await reqContainer.makeAsync<User>();
return true;
}
}
res.headers['www-authenticate'] = 'Basic realm="${realm ?? 'angel_auth'}"';
throw PlatformHttpException.notAuthenticated();
};
}
/// Restricts access to a resource via authentication.
RequestHandler requireAuthentication<User>() {
return (RequestContext req, ResponseContext res,
{bool throwError = true}) async {
bool reject(ResponseContext res) {
if (throwError) {
res.statusCode = 403;
throw PlatformHttpException.forbidden();
} else {
return false;
}
}
if (req.container != null) {
var reqContainer = req.container!;
if (reqContainer.has<User>() || req.method == 'OPTIONS') {
return true;
} else if (reqContainer.has<Future<User>>()) {
await reqContainer.makeAsync<User>();
return true;
} else {
return reject(res);
}
} else {
return reject(res);
}
};
}

View file

@ -0,0 +1,30 @@
import 'dart:async';
import 'package:platform_foundation/core.dart';
import 'auth_token.dart';
typedef AngelAuthCallback = FutureOr Function(
RequestContext req, ResponseContext res, String token);
typedef AngelAuthTokenCallback<User> = FutureOr Function(
RequestContext req, ResponseContext res, AuthToken token, User user);
class AngelAuthOptions<User> {
AngelAuthCallback? callback;
AngelAuthTokenCallback<User>? tokenCallback;
String? successRedirect;
String? failureRedirect;
/// If `false` (default: `true`), then successful authentication will return `true` and allow the
/// execution of subsequent handlers, just like any other middleware.
///
/// Works well with `Basic` authentication.
bool canRespondWithJson;
AngelAuthOptions(
{this.callback,
this.tokenCallback,
this.canRespondWithJson = true,
this.successRedirect,
this.failureRedirect});
}

View file

@ -0,0 +1,518 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:platform_foundation/core.dart';
import 'package:crypto/crypto.dart';
import 'package:logging/logging.dart';
import 'auth_token.dart';
import 'options.dart';
import 'strategy.dart';
/// Handles authentication within an Angel application.
class PlatformAuth<User> {
final _log = Logger('PlatformAuth');
late Hmac _hs256;
late int _jwtLifeSpan;
final StreamController<User> _onLogin = StreamController<User>(),
_onLogout = StreamController<User>();
final Random _random = Random.secure();
final RegExp _rgxBearer = RegExp(r'^Bearer');
/// If `true` (default), then JWT's will be stored and retrieved from a `token` cookie.
final bool allowCookie;
/// If `true` (default), then users can include a JWT in the query string as `token`.
final bool allowTokenInQuery;
/// Whether emitted cookies should have the `secure` and `HttpOnly` flags,
/// as well as being restricted to a specific domain.
final bool secureCookies;
/// A domain to restrict emitted cookies to.
///
/// Only applies if [allowCookie] is `true`.
final String? cookieDomain;
/// A path to restrict emitted cookies to.
///
/// Only applies if [allowCookie] is `true`.
final String cookiePath;
/// If `true` (default), then JWT's will be considered invalid if used from a different IP than the first user's it was issued to.
///
/// This is a security provision. Even if a user's JWT is stolen, a remote attacker will not be able to impersonate anyone.
final bool enforceIp;
/// The endpoint to mount [reviveJwt] at. If `null`, then no revival route is mounted. Default: `/auth/token`.
String reviveTokenEndpoint;
/// A set of [AuthStrategy] instances used to authenticate users.
Map<String, AuthStrategy<User>> strategies = {};
/// Serializes a user into a unique identifier associated only with one identity.
FutureOr<String> Function(User) serializer;
/// Deserializes a unique identifier into its associated identity. In most cases, this is a user object or model instance.
FutureOr<User> Function(String) deserializer;
/// Fires the result of [deserializer] whenever a user signs in to the application.
Stream<User> get onLogin => _onLogin.stream;
/// Fires `req.user`, which is usually the result of [deserializer], whenever a user signs out of the application.
Stream<User> get onLogout => _onLogout.stream;
/// The [Hmac] being used to encode JWT's.
Hmac get hmac => _hs256;
String _randomString(
{int length = 32,
String validChars =
'ABCDEFHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_'}) {
var chars = <int>[];
while (chars.length < length) {
chars.add(_random.nextInt(validChars.length));
}
return String.fromCharCodes(chars);
}
/// `jwtLifeSpan` - should be in *milliseconds*.
PlatformAuth(
{String? jwtKey,
required this.serializer,
required this.deserializer,
num jwtLifeSpan = -1,
this.allowCookie = true,
this.allowTokenInQuery = true,
this.enforceIp = true,
this.cookieDomain,
this.cookiePath = '/',
this.secureCookies = true,
this.reviveTokenEndpoint = '/auth/token'})
: super() {
_hs256 = Hmac(sha256, (jwtKey ?? _randomString()).codeUnits);
_jwtLifeSpan = jwtLifeSpan.toInt();
}
/// Configures an Angel server to decode and validate JSON Web tokens on demand,
/// whenever an instance of [User] is injected.
Future<void> configureServer(Application app) async {
/*
if (serializer == null) {
throw StateError(
'An `PlatformAuth` plug-in was called without its `serializer` being set. All authentication will fail.');
}
if (deserializer == null) {
throw StateError(
'An `PlatformAuth` plug-in was called without its `deserializer` being set. All authentication will fail.');
}
if (app.container == null) {
_log.severe('Angel3 container is null');
throw StateError(
'Angel.container is null. All authentication will fail.');
}
*/
var appContainer = app.container;
appContainer.registerSingleton(this);
if (runtimeType != PlatformAuth) {
appContainer.registerSingleton(this, as: PlatformAuth);
}
if (!appContainer.has<_AuthResult<User>>()) {
appContainer
.registerLazySingleton<Future<_AuthResult<User>>>((container) async {
var req = container.make<RequestContext>();
var res = container.make<ResponseContext>();
//if (req == null || res == null) {
// _log.warning('RequestContext or responseContext is null');
// throw PlatformHttpException.forbidden();
//}
var result = await _decodeJwt(req, res);
if (result != null) {
return result;
} else {
_log.warning('JWT is null');
throw PlatformHttpException.forbidden();
}
});
appContainer.registerLazySingleton<Future<User>>((container) async {
var result = await container.makeAsync<_AuthResult<User>>();
return result.user;
});
appContainer.registerLazySingleton<Future<AuthToken>>((container) async {
var result = await container.makeAsync<_AuthResult<User>>();
return result.token;
});
}
app.post(reviveTokenEndpoint, _reviveJwt);
app.shutdownHooks.add((_) {
_onLogin.close();
});
}
void _apply(
RequestContext req, ResponseContext res, AuthToken token, User user) {
if (req.container == null) {
_log.severe('RequestContext.container is null');
throw StateError(
'RequestContext.container is not set. All authentication will fail.');
}
var reqContainer = req.container!;
if (!reqContainer.has<User>()) {
reqContainer.registerSingleton<User>(user);
}
if (!reqContainer.has<AuthToken>()) {
reqContainer.registerSingleton<AuthToken>(token);
}
if (allowCookie) {
_addProtectedCookie(res, 'token', token.serialize(_hs256));
}
}
/// DEPRECATED: A middleware that decodes a JWT from a request, and injects a corresponding user.
///
/// Now that `package:angel_framework` supports asynchronous injections, this middleware
/// is no longer directly necessary. Instead, call [configureServer]. You can then use
/// `makeAsync<User>`, or Angel's injections directly:
///
/// ```dart
/// var auth = PlatformAuth<User>(...);
/// await app.configure(auth.configureServer);
///
/// app.get('/hmm', (User user) async {
/// // `package:angel_auth` decodes the JWT on demand.
/// print(user.name);
/// });
///
/// @Expose('/my')
/// class MyController extends Controller {
/// @Expose('/hmm')
/// String getUsername(User user) => user.name
/// }
/// ```
/*
@deprecated
Future decodeJwt(RequestContext req, ResponseContext res) async {
if (req.method == 'POST' && req.path == reviveTokenEndpoint) {
return await _reviveJwt(req, res);
} else {
await _decodeJwt(req, res);
return true;
}
}
*/
Future<_AuthResult<User>?> _decodeJwt(
RequestContext req, ResponseContext res) async {
var jwt = getJwt(req);
if (jwt != null) {
var token = AuthToken.validate(jwt, _hs256);
if (enforceIp) {
if (req.ip != token.ipAddress) {
_log.warning('JWT cannot be accessed from this IP address');
throw PlatformHttpException.forbidden(
message: 'JWT cannot be accessed from this IP address.');
}
}
if (token.lifeSpan > -1) {
var expiry =
token.issuedAt.add(Duration(milliseconds: token.lifeSpan.toInt()));
if (!expiry.isAfter(DateTime.now())) {
_log.warning('Expired JWT');
throw PlatformHttpException.forbidden(message: 'Expired JWT.');
}
}
var user = await deserializer(token.userId);
_apply(req, res, token, user);
return _AuthResult(user, token);
}
return null;
}
/// Retrieves a JWT from a request, if any was sent at all.
String? getJwt(RequestContext req) {
if (req.headers?.value('Authorization') != null) {
final authHeader = req.headers?.value('Authorization');
if (authHeader != null) {
// Allow Basic auth to fall through
if (_rgxBearer.hasMatch(authHeader)) {
return authHeader.replaceAll(_rgxBearer, '').trim();
}
}
_log.info('RequestContext.headers is null');
} else if (allowCookie &&
req.cookies.any((cookie) => cookie.name == 'token')) {
return req.cookies.firstWhere((cookie) => cookie.name == 'token').value;
} else if (allowTokenInQuery) {
//&& req.uri?.queryParameters['token'] is String) {
if (req.uri != null) {
return req.uri?.queryParameters['token']?.toString();
}
}
return null;
}
void _addProtectedCookie(ResponseContext res, String name, String value) {
if (!res.cookies.any((c) => c.name == name)) {
res.cookies.add(protectCookie(Cookie(name, value)));
}
}
/// Applies security protections to a [cookie].
Cookie protectCookie(Cookie cookie) {
if (secureCookies != false) {
cookie.httpOnly = true;
cookie.secure = true;
}
var lifeSpan = _jwtLifeSpan;
if (lifeSpan > 0) {
cookie.maxAge ??= lifeSpan < 0 ? -1 : lifeSpan ~/ 1000;
cookie.expires ??= DateTime.now().add(Duration(milliseconds: lifeSpan));
}
cookie.domain ??= cookieDomain;
cookie.path ??= cookiePath;
return cookie;
}
/// Attempts to revive an expired (or still alive) JWT.
Future<Map<String, dynamic>> _reviveJwt(
RequestContext req, ResponseContext res) async {
try {
var jwt = getJwt(req);
if (jwt == null) {
var body = await req.parseBody().then((_) => req.bodyAsMap);
jwt = body['token']?.toString();
}
if (jwt == null) {
_log.warning('No JWT provided');
throw PlatformHttpException.forbidden(message: 'No JWT provided');
} else {
var token = AuthToken.validate(jwt, _hs256);
if (enforceIp) {
if (req.ip != token.ipAddress) {
_log.warning('WT cannot be accessed from this IP address');
throw PlatformHttpException.forbidden(
message: 'JWT cannot be accessed from this IP address.');
}
}
if (token.lifeSpan > -1) {
var expiry = token.issuedAt
.add(Duration(milliseconds: token.lifeSpan.toInt()));
if (!expiry.isAfter(DateTime.now())) {
//print(
// 'Token has indeed expired! Resetting assignment date to current timestamp...');
// Extend its lifespan by changing iat
token.issuedAt = DateTime.now();
}
}
if (allowCookie) {
_addProtectedCookie(res, 'token', token.serialize(_hs256));
}
final data = await deserializer(token.userId);
return {'data': data, 'token': token.serialize(_hs256)};
}
} catch (e) {
if (e is PlatformHttpException) {
rethrow;
}
_log.warning('Malformed JWT');
throw PlatformHttpException.badRequest(message: 'Malformed JWT');
}
}
/// Attempts to authenticate a user using one or more strategies.
///
/// [type] is a strategy name to try, or a `List` of such.
///
/// If a strategy returns `null` or `false`, either the next one is tried,
/// or a `401 Not Authenticated` is thrown, if it is the last one.
///
/// Any other result is considered an authenticated user, and terminates the loop.
RequestHandler authenticate(type, [AngelAuthOptions<User>? opt]) {
return (RequestContext req, ResponseContext res) async {
var authOption = opt ?? AngelAuthOptions<User>();
var names = <String>[];
var arr = type is Iterable
? type.map((x) => x.toString()).toList()
: [type.toString()];
for (var t in arr) {
var n = t
.split(',')
.map((s) => s.trim())
.where((String s) => s.isNotEmpty)
.toList();
names.addAll(n);
}
for (var i = 0; i < names.length; i++) {
var name = names[i];
var strategy = strategies[name];
if (strategy == null) {
_log.severe('No strategy "$name" found.');
throw ArgumentError('No strategy "$name" found.');
}
var reqContainer = req.container;
if (reqContainer == null) {
print('req.container is null');
}
var hasExisting = reqContainer?.has<User>() ?? false;
var result = hasExisting
? reqContainer?.make<User>()
: await strategy.authenticate(req, res, authOption);
if (result == true) {
return result;
} else if (result != null && result != false) {
//} else if (result != null && result is Map && result.isNotEmpty) {
var userId = await serializer(result);
// Create JWT
var token = AuthToken(
userId: userId, lifeSpan: _jwtLifeSpan, ipAddress: req.ip);
var jwt = token.serialize(_hs256);
if (authOption.tokenCallback != null) {
var hasUser = reqContainer?.has<User>() ?? false;
if (!hasUser) {
reqContainer?.registerSingleton<User>(result);
}
var r = await authOption.tokenCallback!(req, res, token, result);
if (r != null) return r;
jwt = token.serialize(_hs256);
}
_apply(req, res, token, result);
if (allowCookie) {
_addProtectedCookie(res, 'token', jwt);
}
// Options is not null
if (authOption.callback != null) {
return await authOption.callback!(req, res, jwt);
}
if (authOption.successRedirect?.isNotEmpty == true) {
await res.redirect(authOption.successRedirect);
return false;
} else if (authOption.canRespondWithJson &&
req.accepts('application/json')) {
var user = hasExisting
? result
: await deserializer(await serializer(result));
_onLogin.add(user);
return {'data': user, 'token': jwt};
}
return true;
} else {
if (i < names.length - 1) continue;
// Check if not redirect
if (res.statusCode == 301 ||
res.statusCode == 302 ||
res.headers.containsKey('location')) {
return false;
} else if (authOption.failureRedirect != null) {
await res.redirect(authOption.failureRedirect);
return false;
} else {
_log.warning('Not authenticated');
throw PlatformHttpException.notAuthenticated();
}
}
}
};
}
/// Log a user in on-demand.
Future login(AuthToken token, RequestContext req, ResponseContext res) async {
var user = await deserializer(token.userId);
_apply(req, res, token, user);
_onLogin.add(user);
if (allowCookie) {
_addProtectedCookie(res, 'token', token.serialize(_hs256));
}
}
/// Log a user in on-demand.
Future loginById(
String userId, RequestContext req, ResponseContext res) async {
var user = await deserializer(userId);
var token =
AuthToken(userId: userId, lifeSpan: _jwtLifeSpan, ipAddress: req.ip);
_apply(req, res, token, user);
_onLogin.add(user);
if (allowCookie) {
_addProtectedCookie(res, 'token', token.serialize(_hs256));
}
}
/// Log an authenticated user out.
RequestHandler logout([AngelAuthOptions<User>? options]) {
return (RequestContext req, ResponseContext res) async {
if (req.container?.has<User>() == true) {
var user = req.container?.make<User>();
if (user != null) {
_onLogout.add(user);
}
}
if (allowCookie == true) {
res.cookies.removeWhere((cookie) => cookie.name == 'token');
_addProtectedCookie(res, 'token', '""');
}
if (options != null &&
options.successRedirect != null &&
options.successRedirect!.isNotEmpty) {
await res.redirect(options.successRedirect);
}
return true;
};
}
}
class _AuthResult<User> {
final User user;
final AuthToken token;
_AuthResult(this.user, this.token);
}

View file

@ -0,0 +1,36 @@
import 'dart:convert';
import 'package:platform_foundation/core.dart';
import 'package:http_parser/http_parser.dart';
import 'options.dart';
/// Displays a default callback page to confirm authentication via popups.
AngelAuthCallback confirmPopupAuthentication({String eventName = 'token'}) {
return (req, ResponseContext res, String jwt) {
var evt = json.encode(eventName);
var detail = json.encode({'detail': jwt});
res
..contentType = MediaType('text', 'html')
..write('''
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Authentication Success</title>
<script>
var ev = new CustomEvent($evt, $detail);
window.opener.dispatchEvent(ev);
window.close();
</script>
</head>
<body>
<h1>Authentication Success</h1>
<p>
Now logging you in... If you continue to see this page, you may need to enable JavaScript.
</p>
</body>
</html>
''');
return false;
};
}

View file

@ -0,0 +1,138 @@
import 'dart:async';
import 'dart:convert';
import 'package:logging/logging.dart';
import 'package:platform_foundation/core.dart';
import '../options.dart';
import '../strategy.dart';
/// Determines the validity of an incoming username and password.
// typedef FutureOr<User> LocalAuthVerifier<User>(String? username, String? password);
typedef LocalAuthVerifier<User> = FutureOr<User?> Function(
String? username, String? password);
class LocalAuthStrategy<User> extends AuthStrategy<User> {
final _log = Logger('LocalAuthStrategy');
final RegExp _rgxBasic = RegExp(r'^Basic (.+)$', caseSensitive: false);
final RegExp _rgxUsrPass = RegExp(r'^([^:]+):(.+)$');
LocalAuthVerifier<User> verifier;
String usernameField;
String passwordField;
String invalidMessage;
final bool allowBasic;
final bool forceBasic;
String realm;
LocalAuthStrategy(this.verifier,
{this.usernameField = 'username',
this.passwordField = 'password',
this.invalidMessage = 'Please provide a valid username and password.',
this.allowBasic = false,
this.forceBasic = false,
this.realm = 'Authentication is required.'}) {
_log.info('Using LocalAuthStrategy');
}
@override
Future<User?> authenticate(RequestContext req, ResponseContext res,
[AngelAuthOptions? options]) async {
var localOptions = options ?? AngelAuthOptions();
User? verificationResult;
if (allowBasic) {
var authHeader = req.headers?.value('authorization') ?? '';
if (_rgxBasic.hasMatch(authHeader)) {
var base64AuthString = _rgxBasic.firstMatch(authHeader)?.group(1);
if (base64AuthString == null) {
return null;
}
var authString = String.fromCharCodes(base64.decode(base64AuthString));
if (_rgxUsrPass.hasMatch(authString)) {
Match usrPassMatch = _rgxUsrPass.firstMatch(authString)!;
verificationResult =
await verifier(usrPassMatch.group(1), usrPassMatch.group(2));
} else {
_log.warning('Bad request: $invalidMessage');
throw PlatformHttpException.badRequest(errors: [invalidMessage]);
}
if (verificationResult == null) {
res
..statusCode = 401
..headers['www-authenticate'] = 'Basic realm="$realm"';
await res.close();
return null;
}
//Allow non-null to pass through
//return verificationResult;
}
} else {
var body = await req
.parseBody()
.then((_) => req.bodyAsMap)
.catchError((_) => <String, dynamic>{});
if (_validateString(body[usernameField]?.toString()) &&
_validateString(body[passwordField]?.toString())) {
verificationResult = await verifier(
body[usernameField]?.toString(), body[passwordField]?.toString());
}
}
// User authentication succeeded can return Map(one element), User(non null) or true
if (verificationResult != null && verificationResult != false) {
if (verificationResult is Map && verificationResult.isNotEmpty) {
return verificationResult;
} else if (verificationResult is! Map) {
return verificationResult;
}
}
// Force basic if set
if (forceBasic) {
//res.headers['www-authenticate'] = 'Basic realm="$realm"';
res
..statusCode = 401
..headers['www-authenticate'] = 'Basic realm="$realm"';
await res.close();
return null;
}
// Redirect failed authentication
if (localOptions.failureRedirect != null &&
localOptions.failureRedirect!.isNotEmpty) {
await res.redirect(localOptions.failureRedirect, code: 401);
return null;
}
_log.info('Not authenticated');
throw PlatformHttpException.notAuthenticated();
/*
if (verificationResult is Map && verificationResult.isEmpty) {
if (localOptions.failureRedirect != null &&
localOptions.failureRedirect!.isNotEmpty) {
await res.redirect(localOptions.failureRedirect, code: 401);
return null;
}
if (forceBasic) {
res.headers['www-authenticate'] = 'Basic realm="$realm"';
return null;
}
return null;
} else if (verificationResult != false ||
(verificationResult is Map && verificationResult.isNotEmpty)) {
return verificationResult;
} else {
_log.info('Not authenticated');
throw PlatformHttpException.notAuthenticated();
}
*/
}
bool _validateString(String? str) => str != null && str.isNotEmpty;
}

View file

@ -0,0 +1 @@
export 'local.dart';

View file

@ -0,0 +1,10 @@
import 'dart:async';
import 'package:platform_foundation/core.dart';
import 'options.dart';
/// A function that handles login and signup for an Angel application.
abstract class AuthStrategy<User> {
/// Authenticates or rejects an incoming user.
FutureOr<User?> authenticate(RequestContext req, ResponseContext res,
[AngelAuthOptions<User>? options]);
}

View file

@ -0,0 +1,35 @@
name: platform_auth
description: A complete authentication plugin for Angel3. Includes support for stateless JWT tokens, Basic Auth, and more.
version: 8.2.0
homepage: https://angel3-framework.web.app/
repository: https://github.com/dart-backend/angel/tree/master/packages/auth
environment:
sdk: '>=3.3.0 <4.0.0'
dependencies:
platform_foundation: ^8.0.0
charcode: ^1.3.0
collection: ^1.17.0
crypto: ^3.0.0
http_parser: ^4.0.0
meta: ^1.9.0
quiver: ^3.2.0
logging: ^1.2.0
dev_dependencies:
platform_container: ^8.0.0
http: ^1.0.0
io: ^1.0.0
test: ^1.24.0
lints: ^4.0.0
# dependency_overrides:
# angel3_container:
# path: ../container/angel_container
# angel3_framework:
# path: ../framework
# angel3_http_exception:
# path: ../http_exception
# angel3_model:
# path: ../model
# angel3_route:
# path: ../route
# angel3_mock_request:
# path: ../mock_request

View file

@ -0,0 +1,34 @@
import 'package:platform_auth/src/auth_token.dart';
import 'package:crypto/crypto.dart';
import 'package:test/test.dart';
void main() async {
final hmac = Hmac(sha256, 'angel_auth'.codeUnits);
test('sample serialization', () {
var token = AuthToken(ipAddress: 'localhost', userId: 'thosakwe');
var jwt = token.serialize(hmac);
print(jwt);
var parsed = AuthToken.validate(jwt, hmac);
print(parsed.toJson());
expect(parsed.toJson()['aud'], equals(token.ipAddress));
expect(parsed.toJson()['sub'], equals(token.userId));
});
test('custom payload', () {
var token = AuthToken(ipAddress: 'localhost', userId: 'thosakwe', payload: {
'foo': 'bar',
'baz': {
'one': 1,
'franken': ['stein']
}
});
var jwt = token.serialize(hmac);
print(jwt);
var parsed = AuthToken.validate(jwt, hmac);
print(parsed.toJson());
expect(parsed.toJson()['pld'], equals(token.payload));
});
}

View file

@ -0,0 +1,153 @@
import 'dart:io';
import 'package:platform_auth/auth.dart';
import 'package:platform_container/mirrors.dart';
import 'package:platform_foundation/core.dart';
import 'package:platform_foundation/http.dart';
import 'dart:convert';
import 'package:collection/collection.dart' show IterableExtension;
import 'package:http/http.dart' as http;
import 'package:io/ansi.dart';
import 'package:logging/logging.dart';
import 'package:test/test.dart';
import 'package:collection/collection.dart';
class User extends Model {
String? username, password;
User({this.username, this.password});
static User parse(Map<String, dynamic> map) {
return User(
username: map['username'] as String?,
password: map['password'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'username': username,
'password': password,
'created_at': createdAt?.toIso8601String(),
'updated_at': updatedAt?.toIso8601String()
};
}
}
void main() {
late Application app;
late PlatformHttp angelHttp;
PlatformAuth<User> auth;
http.Client? client;
HttpServer server;
String? url;
String? encodedAuth;
setUp(() async {
hierarchicalLoggingEnabled = true;
app = Application(reflector: MirrorsReflector());
angelHttp = PlatformHttp(app);
app.use('/users', MapService());
var oldErrorHandler = app.errorHandler;
app.errorHandler = (e, req, res) {
app.logger.severe(e.message, e, e.stackTrace ?? StackTrace.current);
return oldErrorHandler(e, req, res);
};
app.logger = Logger('platform_auth')
..level = Level.FINEST
..onRecord.listen((rec) {
print(rec);
if (rec.error != null) {
print(yellow.wrap(rec.error.toString()));
}
if (rec.stackTrace != null) {
print(yellow.wrap(rec.stackTrace.toString()));
}
});
await app
.findService('users')
?.create({'username': 'jdoe1', 'password': 'password'});
auth = PlatformAuth<User>(
serializer: (u) => u.id ?? '',
deserializer: (id) async =>
await app.findService('users')?.read(id) as User);
//auth.serializer = (u) => u.id;
//auth.deserializer =
// (id) async => await app.findService('users')!.read(id) as User;
await app.configure(auth.configureServer);
auth.strategies['local'] = LocalAuthStrategy((username, password) async {
var users = await app
.findService('users')
?.index()
.then((it) => it.map<User>((m) => User.parse(m)).toList());
var result = users?.firstWhereOrNull(
(user) => user.username == username && user.password == password);
return Future.value(result);
}, allowBasic: true);
app.post(
'/login',
auth.authenticate('local',
AngelAuthOptions(callback: (req, res, token) {
res
..write('Hello!')
..close();
})));
app.chain([
(req, res) {
if (!req.container!.has<User>()) {
req.container!.registerSingleton<User>(
User(username: req.params['name']?.toString()));
}
return true;
}
]).post(
'/existing/:name',
auth.authenticate('local'),
);
encodedAuth = base64.encode(utf8.encode('jdoe1:password'));
client = http.Client();
server = await angelHttp.startServer();
url = 'http://${server.address.address}:${server.port}';
});
tearDown(() async {
client!.close();
await angelHttp.close();
//app = null;
client = null;
url = null;
});
test('login', () async {
final response = await client!.post(Uri.parse('$url/login'),
headers: {'Authorization': 'Basic $encodedAuth'});
print('Response: ${response.body}');
expect(response.body, equals('Hello!'));
},
skip: Platform.version.contains('2.0.0-dev')
? 'Blocked on https://github.com/dart-lang/sdk/issues/33594'
: null);
test('preserve existing user', () async {
final response = await client!.post(Uri.parse('$url/existing/foo'),
body: {'username': 'jdoe1', 'password': 'password'},
headers: {'accept': 'application/json'});
print('Response: ${response.body}');
print(response.headers);
expect(json.decode(response.body)['data']['username'], equals('foo'));
});
}

View file

@ -0,0 +1,164 @@
import 'package:platform_auth/auth.dart';
import 'package:test/test.dart';
void main() {
var options = ExternalAuthOptions(
clientId: 'foo',
clientSecret: 'bar',
redirectUri: 'http://example.com',
);
test('parses uri', () {
expect(options.redirectUri, Uri(scheme: 'http', host: 'example.com'));
});
group('copyWith', () {
test('empty produces exact copy', () {
expect(options.copyWith(), options);
});
test('all fields', () {
expect(
options.copyWith(
clientId: 'hey',
clientSecret: 'hello',
redirectUri: 'https://yes.no',
scopes: ['a', 'b'],
),
ExternalAuthOptions(
clientId: 'hey',
clientSecret: 'hello',
redirectUri: 'https://yes.no',
scopes: ['a', 'b'],
),
);
});
test('not equal to original if different', () {
expect(options.copyWith(clientId: 'hey'), isNot(options));
});
});
group('new()', () {
test('accepts uri', () {
expect(
ExternalAuthOptions(
clientId: 'foo',
clientSecret: 'bar',
redirectUri: Uri.parse('http://example.com'),
),
options,
);
});
test('accepts string', () {
expect(
ExternalAuthOptions(
clientId: 'foo',
clientSecret: 'bar',
redirectUri: 'http://example.com',
),
options,
);
});
test('rejects invalid redirectUri', () {
expect(
() => ExternalAuthOptions(
clientId: 'foo', clientSecret: 'bar', redirectUri: 24.5),
throwsArgumentError,
);
});
/* Deprecated as clientId and clientSecret cannot be null
test('ensures id not null', () {
expect(
() => ExternalAuthOptions(
clientId: null,
clientSecret: 'bar',
redirectUri: 'http://example.com'),
throwsArgumentError,
);
});
test('ensures secret not null', () {
expect(
() => ExternalAuthOptions(
clientId: 'foo',
clientSecret: null,
redirectUri: 'http://example.com'),
throwsArgumentError,
);
});
*/
});
group('fromMap()', () {
test('rejects invalid map', () {
expect(
() => ExternalAuthOptions.fromMap({'yes': 'no'}),
throwsArgumentError,
);
});
test('accepts correct map', () {
expect(
ExternalAuthOptions.fromMap({
'client_id': 'foo',
'client_secret': 'bar',
'redirect_uri': 'http://example.com',
}),
options,
);
});
});
group('toString()', () {
test('produces correct string', () {
expect(
options.toString(obscureSecret: false),
'ExternalAuthOptions(clientId=foo, clientSecret=bar, redirectUri=http://example.com, scopes=[])',
);
});
test('obscures secret', () {
expect(
options.toString(),
'ExternalAuthOptions(clientId=foo, clientSecret=***, redirectUri=http://example.com, scopes=[])',
);
});
test('asteriskCount', () {
expect(
options.toString(asteriskCount: 7),
'ExternalAuthOptions(clientId=foo, clientSecret=*******, redirectUri=http://example.com, scopes=[])',
);
});
});
group('toJson()', () {
test('obscures secret', () {
expect(
options.toJson(),
{
'client_id': 'foo',
'client_secret': '<redacted>',
'redirect_uri': 'http://example.com',
'scopes': [],
},
);
});
test('produces correct map', () {
expect(
options.toJson(obscureSecret: false),
{
'client_id': 'foo',
'client_secret': 'bar',
'redirect_uri': 'http://example.com',
'scopes': [],
},
);
});
});
}

View file

@ -0,0 +1,153 @@
import 'dart:async';
import 'package:platform_auth/auth.dart';
import 'package:platform_container/mirrors.dart';
import 'package:platform_foundation/core.dart';
import 'package:platform_foundation/http.dart';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:logging/logging.dart';
import 'package:test/test.dart';
final PlatformAuth<Map<String, String>> auth =
PlatformAuth<Map<String, String>>(
serializer: (user) async => '1337',
deserializer: (id) async => sampleUser);
//var headers = <String, String>{'accept': 'application/json'};
var localOpts = AngelAuthOptions<Map<String, String>>(
failureRedirect: '/failure', successRedirect: '/success');
var localOpts2 =
AngelAuthOptions<Map<String, String>>(canRespondWithJson: false);
Map<String, String> sampleUser = {'hello': 'world'};
Future<Map<String, String>> verifier(String? username, String? password) async {
if (username == 'username' && password == 'password') {
return sampleUser;
} else {
return {};
}
}
Future wireAuth(Application app) async {
//auth.serializer = (user) async => 1337;
//auth.deserializer = (id) async => sampleUser;
auth.strategies['local'] = LocalAuthStrategy(verifier, allowBasic: true);
await app.configure(auth.configureServer);
}
void main() async {
Application app;
late PlatformHttp angelHttp;
late http.Client client;
String? url;
String? basicAuthUrl;
setUp(() async {
client = http.Client();
app = Application(reflector: MirrorsReflector());
angelHttp = PlatformHttp(app, useZone: false);
await app.configure(wireAuth);
app.get('/hello', (req, res) {
// => 'Woo auth'
return 'Woo auth';
}, middleware: [auth.authenticate('local', localOpts2)]);
app.post('/login', (req, res) => 'This should not be shown',
middleware: [auth.authenticate('local', localOpts)]);
app.get('/success', (req, res) => 'yep', middleware: [
requireAuthentication<Map<String, String>>(),
]);
app.get('/failure', (req, res) => 'nope');
app.logger = Logger('local_test')
..onRecord.listen((rec) {
print(
'${rec.time}: ${rec.level.name}: ${rec.loggerName}: ${rec.message}');
if (rec.error != null) {
print(rec.error);
print(rec.stackTrace);
}
});
var server = await angelHttp.startServer('127.0.0.1', 0);
url = 'http://${server.address.host}:${server.port}';
basicAuthUrl =
'http://username:password@${server.address.host}:${server.port}';
});
tearDown(() async {
await angelHttp.close();
//client = null;
url = null;
basicAuthUrl = null;
});
test('can use "auth" as middleware', () async {
var response = await client.get(Uri.parse('$url/success'),
headers: {'Accept': 'application/json'});
print(response.body);
expect(response.statusCode, equals(403));
});
test('successRedirect', () async {
//var postData = {'username': 'username', 'password': 'password'};
var encodedAuth = base64.encode(utf8.encode('username:password'));
var response = await client.post(Uri.parse('$url/login'),
headers: {'Authorization': 'Basic $encodedAuth'});
expect(response.statusCode, equals(302));
expect(response.headers['location'], equals('/success'));
});
test('failureRedirect', () async {
//var postData = {'username': 'password', 'password': 'username'};
var encodedAuth = base64.encode(utf8.encode('password:username'));
var response = await client.post(Uri.parse('$url/login'),
headers: {'Authorization': 'Basic $encodedAuth'});
print('Status Code: ${response.statusCode}');
print(response.headers);
print(response.body);
expect(response.headers['location'], equals('/failure'));
expect(response.statusCode, equals(401));
});
test('basic auth without authorization', () async {
var response = await client.get(Uri.parse('$url/hello'));
print('Status Code: ${response.statusCode}');
print(response.headers);
print(response.body);
expect(response.statusCode, equals(401));
});
//test('allow basic', () async {
test('basic auth with authorization', () async {
var authString = base64.encode('username:password'.runes.toList());
var response = await client.get(Uri.parse('$url/hello'),
headers: {'authorization': 'Basic $authString'});
print(response.statusCode);
print(response.body);
expect(response.body, equals('"Woo auth"'));
});
test('allow basic via URL encoding', () async {
var response = await client.get(Uri.parse('$basicAuthUrl/hello'));
expect(response.body, equals('"Woo auth"'));
});
test('force basic', () async {
auth.strategies.clear();
auth.strategies['local'] =
LocalAuthStrategy(verifier, forceBasic: true, realm: 'test');
var response = await client.get(Uri.parse('$url/hello'), headers: {
'accept': 'application/json',
'content-type': 'application/json'
});
print('Header = ${response.headers}');
print('Body <${response.body}>');
var head = response.headers['www-authenticate'];
expect(head, equals('Basic realm="test"'));
});
}

View file

@ -0,0 +1,45 @@
import 'dart:io';
import 'package:platform_auth/auth.dart';
import 'package:test/test.dart';
const Duration threeDays = Duration(days: 3);
void main() {
late Cookie defaultCookie;
var auth = PlatformAuth(
secureCookies: true,
cookieDomain: 'SECURE',
jwtLifeSpan: threeDays.inMilliseconds,
serializer: (u) => u,
deserializer: (u) => u);
setUp(() => defaultCookie = Cookie('a', 'b'));
test('sets maxAge', () {
expect(auth.protectCookie(defaultCookie).maxAge, threeDays.inSeconds);
});
test('sets expires', () {
var now = DateTime.now();
var expiry = auth.protectCookie(defaultCookie).expires!;
var diff = expiry.difference(now);
expect(diff.inSeconds, threeDays.inSeconds);
});
test('sets httpOnly', () {
expect(auth.protectCookie(defaultCookie).httpOnly, true);
});
test('sets secure', () {
expect(auth.protectCookie(defaultCookie).secure, true);
});
test('sets domain', () {
expect(auth.protectCookie(defaultCookie).domain, 'SECURE');
});
test('preserves domain if present', () {
expect(auth.protectCookie(defaultCookie..domain = 'foo').domain, 'foo');
});
}

View file

@ -1,7 +1,7 @@
//import 'dart:convert';
//import 'dart:io';
//import 'package:angel3_framework/angel3_framework.dart';
//import 'package:angel3_framework/http.dart';
//import 'package:platform_foundation/core.dart';
//import 'package:platform_foundation/http.dart';
//import 'package:angel3_mock_request/angel3_mock_request.dart';
//import 'package:test/test.dart';