Migrated angel_auth to NNBD

This commit is contained in:
thomashii 2021-03-21 07:51:20 +08:00
parent fdf1532074
commit 5302f635ac
13 changed files with 257 additions and 236 deletions

View file

@ -11,7 +11,7 @@
* Added merge_map and updated to 2.0.0 * Added merge_map and updated to 2.0.0
* Added mock_request and updated to 2.0.0 * Added mock_request and updated to 2.0.0
* Updated angel_framework to 4.0.0 (Revisit TODO) * Updated angel_framework to 4.0.0 (Revisit TODO)
* Updated angel_auth to 4.0.0 (todo) * Updated angel_auth to 4.0.0
* Updated angel_configuration to 4.0.0 (todo) * Updated angel_configuration to 4.0.0 (todo)
# 3.0.0 (Non NNBD) # 3.0.0 (Non NNBD)

View file

@ -3,11 +3,11 @@ import 'package:angel_auth/angel_auth.dart';
import 'package:angel_framework/angel_framework.dart'; import 'package:angel_framework/angel_framework.dart';
import 'package:angel_framework/http.dart'; import 'package:angel_framework/http.dart';
main() async { void main() async {
var app = Angel(); var app = Angel();
var auth = AngelAuth<User>(); var auth = AngelAuth<User?>();
auth.serializer = (user) => user.id; auth.serializer = (user) => user!.id;
auth.deserializer = (id) => fetchAUserByIdSomehow(id); auth.deserializer = (id) => fetchAUserByIdSomehow(id);
@ -30,7 +30,7 @@ main() async {
} }
class User { class User {
String id, username, password; String? id, username, password;
} }
Future<User> fetchAUserByIdSomehow(id) async { Future<User> fetchAUserByIdSomehow(id) async {

View file

@ -26,11 +26,11 @@ String decodeBase64(String str) {
class AuthToken { class AuthToken {
final SplayTreeMap<String, String> _header = final SplayTreeMap<String, String> _header =
SplayTreeMap.from({"alg": "HS256", "typ": "JWT"}); SplayTreeMap.from({'alg': 'HS256', 'typ': 'JWT'});
String ipAddress; String? ipAddress;
DateTime issuedAt; late DateTime issuedAt;
num lifeSpan; num? lifeSpan;
var userId; var userId;
Map<String, dynamic> payload = {}; Map<String, dynamic> payload = {};
@ -38,11 +38,14 @@ class AuthToken {
{this.ipAddress, {this.ipAddress,
this.lifeSpan = -1, this.lifeSpan = -1,
this.userId, this.userId,
DateTime issuedAt, DateTime? issuedAt,
Map payload = const {}}) { Map payload = const {}}) {
this.issuedAt = issuedAt ?? DateTime.now(); this.issuedAt = issuedAt ?? DateTime.now();
this.payload.addAll( this.payload.addAll(payload?.keys?.fold(
payload?.keys?.fold({}, (out, k) => out..[k.toString()] = payload[k]) ?? {},
((out, k) => out..[k.toString()] = payload[k])
as Map<String, dynamic>? Function(
Map<String, dynamic>?, dynamic)) ??
{}); {});
} }
@ -51,37 +54,40 @@ class AuthToken {
factory AuthToken.fromMap(Map data) { factory AuthToken.fromMap(Map data) {
return AuthToken( return AuthToken(
ipAddress: data["aud"].toString(), ipAddress: data['aud'].toString(),
lifeSpan: data["exp"] as num, lifeSpan: data['exp'] as num?,
issuedAt: DateTime.parse(data["iat"].toString()), issuedAt: DateTime.parse(data['iat'].toString()),
userId: data["sub"], userId: data['sub'],
payload: data["pld"] as Map ?? {}); payload: data['pld'] as Map? ?? {});
} }
factory AuthToken.parse(String jwt) { factory AuthToken.parse(String jwt) {
var split = jwt.split("."); var split = jwt.split('.');
if (split.length != 3) if (split.length != 3) {
throw AngelHttpException.notAuthenticated(message: "Invalid JWT."); throw AngelHttpException.notAuthenticated(message: 'Invalid JWT.');
}
var payloadString = decodeBase64(split[1]); var payloadString = decodeBase64(split[1]);
return AuthToken.fromMap(json.decode(payloadString) as Map); return AuthToken.fromMap(json.decode(payloadString) as Map);
} }
factory AuthToken.validate(String jwt, Hmac hmac) { factory AuthToken.validate(String jwt, Hmac hmac) {
var split = jwt.split("."); var split = jwt.split('.');
if (split.length != 3) if (split.length != 3) {
throw AngelHttpException.notAuthenticated(message: "Invalid JWT."); throw AngelHttpException.notAuthenticated(message: 'Invalid JWT.');
}
// var headerString = decodeBase64(split[0]); // var headerString = decodeBase64(split[0]);
var payloadString = decodeBase64(split[1]); var payloadString = decodeBase64(split[1]);
var data = split[0] + "." + split[1]; var data = split[0] + '.' + split[1];
var signature = base64Url.encode(hmac.convert(data.codeUnits).bytes); var signature = base64Url.encode(hmac.convert(data.codeUnits).bytes);
if (signature != split[2]) if (signature != split[2]) {
throw AngelHttpException.notAuthenticated( throw AngelHttpException.notAuthenticated(
message: "JWT payload does not match hashed version."); message: 'JWT payload does not match hashed version.');
}
return AuthToken.fromMap(json.decode(payloadString) as Map); return AuthToken.fromMap(json.decode(payloadString) as Map);
} }
@ -89,9 +95,9 @@ class AuthToken {
String serialize(Hmac hmac) { String serialize(Hmac hmac) {
var headerString = base64Url.encode(json.encode(_header).codeUnits); var headerString = base64Url.encode(json.encode(_header).codeUnits);
var payloadString = base64Url.encode(json.encode(toJson()).codeUnits); var payloadString = base64Url.encode(json.encode(toJson()).codeUnits);
var data = headerString + "." + payloadString; var data = headerString + '.' + payloadString;
var signature = hmac.convert(data.codeUnits).bytes; var signature = hmac.convert(data.codeUnits).bytes;
return data + "." + base64Url.encode(signature); return data + '.' + base64Url.encode(signature);
} }
Map toJson() { Map toJson() {
@ -114,11 +120,12 @@ SplayTreeMap _splayify(Map map) {
return SplayTreeMap.from(data); return SplayTreeMap.from(data);
} }
_splay(value) { dynamic _splay(value) {
if (value is Iterable) { if (value is Iterable) {
return value.map(_splay).toList(); return value.map(_splay).toList();
} else if (value is Map) } else if (value is Map) {
return _splayify(value); return _splayify(value);
else } else {
return value; return value;
} }
}

View file

@ -1,15 +1,14 @@
import 'package:charcode/ascii.dart'; import 'package:charcode/ascii.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:meta/meta.dart'; import 'package:quiver/core.dart';
import 'package:quiver_hashcode/hashcode.dart';
/// A common class containing parsing and validation logic for third-party authentication configuration. /// A common class containing parsing and validation logic for third-party authentication configuration.
class ExternalAuthOptions { class ExternalAuthOptions {
/// The user's identifier, otherwise known as an "application id". /// The user's identifier, otherwise known as an "application id".
final String clientId; final String? clientId;
/// The user's secret, other known as an "application secret". /// The user's secret, other known as an "application secret".
final String clientSecret; final String? clientSecret;
/// The user's redirect URI. /// The user's redirect URI.
final Uri redirectUri; final Uri redirectUri;
@ -27,9 +26,9 @@ class ExternalAuthOptions {
} }
factory ExternalAuthOptions( factory ExternalAuthOptions(
{@required String clientId, {required String? clientId,
@required String clientSecret, required String? clientSecret,
@required redirectUri, required redirectUri,
Iterable<String> scopes = const []}) { Iterable<String> scopes = const []}) {
if (redirectUri is String) { if (redirectUri is String) {
return ExternalAuthOptions._( return ExternalAuthOptions._(
@ -51,8 +50,8 @@ class ExternalAuthOptions {
/// * `redirect_uri` /// * `redirect_uri`
factory ExternalAuthOptions.fromMap(Map map) { factory ExternalAuthOptions.fromMap(Map map) {
return ExternalAuthOptions( return ExternalAuthOptions(
clientId: map['client_id'] as String, clientId: map['client_id'] as String?,
clientSecret: map['client_secret'] as String, clientSecret: map['client_secret'] as String?,
redirectUri: map['redirect_uri'], redirectUri: map['redirect_uri'],
scopes: map['scopes'] is Iterable scopes: map['scopes'] is Iterable
? ((map['scopes'] as Iterable).map((x) => x.toString())) ? ((map['scopes'] as Iterable).map((x) => x.toString()))
@ -73,10 +72,10 @@ class ExternalAuthOptions {
/// Creates a copy of this object, with the specified changes. /// Creates a copy of this object, with the specified changes.
ExternalAuthOptions copyWith( ExternalAuthOptions copyWith(
{String clientId, {String? clientId,
String clientSecret, String? clientSecret,
redirectUri, redirectUri,
Iterable<String> scopes}) { Iterable<String>? scopes}) {
return ExternalAuthOptions( return ExternalAuthOptions(
clientId: clientId ?? this.clientId, clientId: clientId ?? this.clientId,
clientSecret: clientSecret ?? this.clientSecret, clientSecret: clientSecret ?? this.clientSecret,
@ -111,14 +110,14 @@ class ExternalAuthOptions {
/// If no [asteriskCount] is given, then the number of asterisks will equal the length of /// If no [asteriskCount] is given, then the number of asterisks will equal the length of
/// the actual [clientSecret]. /// the actual [clientSecret].
@override @override
String toString({bool obscureSecret = true, int asteriskCount}) { String toString({bool obscureSecret = true, int? asteriskCount}) {
String secret; String? secret;
if (!obscureSecret) { if (!obscureSecret) {
secret = clientSecret; secret = clientSecret;
} else { } else {
var codeUnits = var codeUnits =
List<int>.filled(asteriskCount ?? clientSecret.length, $asterisk); List<int>.filled(asteriskCount ?? clientSecret!.length, $asterisk);
secret = String.fromCharCodes(codeUnits); secret = String.fromCharCodes(codeUnits);
} }

View file

@ -4,12 +4,12 @@ import 'package:angel_framework/angel_framework.dart';
/// Forces Basic authentication over the requested resource, with the given [realm] name, if no JWT is present. /// Forces Basic authentication over the requested resource, with the given [realm] name, if no JWT is present.
/// ///
/// [realm] defaults to `'angel_auth'`. /// [realm] defaults to `'angel_auth'`.
RequestHandler forceBasicAuth<User>({String realm}) { RequestHandler forceBasicAuth<User>({String? realm}) {
return (RequestContext req, ResponseContext res) async { return (RequestContext req, ResponseContext res) async {
if (req.container.has<User>()) if (req.container!.has<User>()) {
return true; return true;
else if (req.container.has<Future<User>>()) { } else if (req.container!.has<Future<User>>()) {
await req.container.makeAsync<User>(); await req.container!.makeAsync<User>();
return true; return true;
} }
@ -26,16 +26,18 @@ RequestHandler requireAuthentication<User>() {
if (throwError) { if (throwError) {
res.statusCode = 403; res.statusCode = 403;
throw AngelHttpException.forbidden(); throw AngelHttpException.forbidden();
} else } else {
return false; return false;
} }
}
if (req.container.has<User>() || req.method == 'OPTIONS') if (req.container!.has<User>() || req.method == 'OPTIONS') {
return true; return true;
else if (req.container.has<Future<User>>()) { } else if (req.container!.has<Future<User>>()) {
await req.container.makeAsync<User>(); await req.container!.makeAsync<User>();
return true; return true;
} else } else {
return _reject(res); return _reject(res);
}
}; };
} }

View file

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

View file

@ -9,12 +9,12 @@ import 'strategy.dart';
/// Handles authentication within an Angel application. /// Handles authentication within an Angel application.
class AngelAuth<User> { class AngelAuth<User> {
Hmac _hs256; Hmac? _hs256;
int _jwtLifeSpan; int? _jwtLifeSpan;
final StreamController<User> _onLogin = StreamController<User>(), final StreamController<User?> _onLogin = StreamController<User>(),
_onLogout = StreamController<User>(); _onLogout = StreamController<User>();
Math.Random _random = Math.Random.secure(); final Math.Random _random = Math.Random.secure();
final RegExp _rgxBearer = RegExp(r"^Bearer"); final RegExp _rgxBearer = RegExp(r'^Bearer');
/// If `true` (default), then JWT's will be stored and retrieved from a `token` cookie. /// If `true` (default), then JWT's will be stored and retrieved from a `token` cookie.
final bool allowCookie; final bool allowCookie;
@ -29,7 +29,7 @@ class AngelAuth<User> {
/// A domain to restrict emitted cookies to. /// A domain to restrict emitted cookies to.
/// ///
/// Only applies if [allowCookie] is `true`. /// Only applies if [allowCookie] is `true`.
final String cookieDomain; final String? cookieDomain;
/// A path to restrict emitted cookies to. /// A path to restrict emitted cookies to.
/// ///
@ -48,19 +48,19 @@ class AngelAuth<User> {
Map<String, AuthStrategy<User>> strategies = {}; Map<String, AuthStrategy<User>> strategies = {};
/// Serializes a user into a unique identifier associated only with one identity. /// Serializes a user into a unique identifier associated only with one identity.
FutureOr Function(User) serializer; FutureOr Function(User)? serializer;
/// Deserializes a unique identifier into its associated identity. In most cases, this is a user object or model instance. /// Deserializes a unique identifier into its associated identity. In most cases, this is a user object or model instance.
FutureOr<User> Function(Object) deserializer; FutureOr<User> Function(Object?)? deserializer;
/// Fires the result of [deserializer] whenever a user signs in to the application. /// Fires the result of [deserializer] whenever a user signs in to the application.
Stream<User> get onLogin => _onLogin.stream; Stream<User?> get onLogin => _onLogin.stream;
/// Fires `req.user`, which is usually the result of [deserializer], whenever a user signs out of the application. /// Fires `req.user`, which is usually the result of [deserializer], whenever a user signs out of the application.
Stream<User> get onLogout => _onLogout.stream; Stream<User?> get onLogout => _onLogout.stream;
/// The [Hmac] being used to encode JWT's. /// The [Hmac] being used to encode JWT's.
Hmac get hmac => _hs256; Hmac? get hmac => _hs256;
String _randomString( String _randomString(
{int length = 32, {int length = 32,
@ -73,10 +73,10 @@ class AngelAuth<User> {
/// `jwtLifeSpan` - should be in *milliseconds*. /// `jwtLifeSpan` - should be in *milliseconds*.
AngelAuth( AngelAuth(
{String jwtKey, {String? jwtKey,
this.serializer, this.serializer,
this.deserializer, this.deserializer,
num jwtLifeSpan, num? jwtLifeSpan,
this.allowCookie = true, this.allowCookie = true,
this.allowTokenInQuery = true, this.allowTokenInQuery = true,
this.enforceIp = true, this.enforceIp = true,
@ -92,21 +92,24 @@ class AngelAuth<User> {
/// Configures an Angel server to decode and validate JSON Web tokens on demand, /// Configures an Angel server to decode and validate JSON Web tokens on demand,
/// whenever an instance of [User] is injected. /// whenever an instance of [User] is injected.
Future<void> configureServer(Angel app) async { Future<void> configureServer(Angel app) async {
if (serializer == null) if (serializer == null) {
throw StateError( throw StateError(
'An `AngelAuth` plug-in was called without its `serializer` being set. All authentication will fail.'); 'An `AngelAuth` plug-in was called without its `serializer` being set. All authentication will fail.');
if (deserializer == null) }
if (deserializer == null) {
throw StateError( throw StateError(
'An `AngelAuth` plug-in was called without its `deserializer` being set. All authentication will fail.'); 'An `AngelAuth` plug-in was called without its `deserializer` being set. All authentication will fail.');
}
app.container.registerSingleton(this); app.container!.registerSingleton(this);
if (runtimeType != AngelAuth) if (runtimeType != AngelAuth) {
app.container.registerSingleton(this, as: AngelAuth); app.container!.registerSingleton(this, as: AngelAuth);
}
if (!app.container.has<_AuthResult<User>>()) { if (!app.container!.has<_AuthResult<User>>()) {
app.container app.container!
.registerLazySingleton<Future<_AuthResult<User>>>((container) async { .registerLazySingleton<Future<_AuthResult<User>>>((container) async {
var req = container.make<RequestContext>(); var req = container.make<RequestContext>()!;
var res = container.make<ResponseContext>(); var res = container.make<ResponseContext>();
var result = await _decodeJwt(req, res); var result = await _decodeJwt(req, res);
if (result != null) { if (result != null) {
@ -116,20 +119,19 @@ class AngelAuth<User> {
} }
}); });
app.container.registerLazySingleton<Future<User>>((container) async { app.container!.registerLazySingleton<Future<User>>((container) async {
var result = await container.makeAsync<_AuthResult<User>>(); var result = await container.makeAsync<_AuthResult<User>>()!;
return result.user; return result.user;
}); });
app.container.registerLazySingleton<Future<AuthToken>>((container) async { app.container!
var result = await container.makeAsync<_AuthResult<User>>(); .registerLazySingleton<Future<AuthToken>>((container) async {
var result = await container.makeAsync<_AuthResult<User>>()!;
return result.token; return result.token;
}); });
} }
if (reviveTokenEndpoint != null) {
app.post(reviveTokenEndpoint, reviveJwt); app.post(reviveTokenEndpoint, reviveJwt);
}
app.shutdownHooks.add((_) { app.shutdownHooks.add((_) {
_onLogin.close(); _onLogin.close();
@ -137,17 +139,17 @@ class AngelAuth<User> {
} }
void _apply( void _apply(
RequestContext req, ResponseContext res, AuthToken token, User user) { RequestContext req, ResponseContext? res, AuthToken token, User user) {
if (!req.container.has<User>()) { if (!req.container!.has<User>()) {
req.container.registerSingleton<User>(user); req.container!.registerSingleton<User>(user);
} }
if (!req.container.has<AuthToken>()) { if (!req.container!.has<AuthToken>()) {
req.container.registerSingleton<AuthToken>(token); req.container!.registerSingleton<AuthToken>(token);
} }
if (allowCookie == true) { if (allowCookie == true) {
_addProtectedCookie(res, 'token', token.serialize(_hs256)); _addProtectedCookie(res!, 'token', token.serialize(_hs256!));
} }
} }
@ -174,7 +176,7 @@ class AngelAuth<User> {
/// ``` /// ```
@deprecated @deprecated
Future decodeJwt(RequestContext req, ResponseContext res) async { Future decodeJwt(RequestContext req, ResponseContext res) async {
if (req.method == "POST" && req.path == reviveTokenEndpoint) { if (req.method == 'POST' && req.path == reviveTokenEndpoint) {
return await reviveJwt(req, res); return await reviveJwt(req, res);
} else { } else {
await _decodeJwt(req, res); await _decodeJwt(req, res);
@ -182,28 +184,30 @@ class AngelAuth<User> {
} }
} }
Future<_AuthResult<User>> _decodeJwt( Future<_AuthResult<User>?> _decodeJwt(
RequestContext req, ResponseContext res) async { RequestContext req, ResponseContext? res) async {
String jwt = getJwt(req); var jwt = getJwt(req);
if (jwt != null) { if (jwt != null) {
var token = AuthToken.validate(jwt, _hs256); var token = AuthToken.validate(jwt, _hs256!);
if (enforceIp) { if (enforceIp) {
if (req.ip != null && req.ip != token.ipAddress) if (req.ip != token.ipAddress) {
throw AngelHttpException.forbidden( throw AngelHttpException.forbidden(
message: "JWT cannot be accessed from this IP address."); 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()))
throw AngelHttpException.forbidden(message: "Expired JWT.");
} }
var user = await deserializer(token.userId); if (token.lifeSpan! > -1) {
var expiry =
token.issuedAt.add(Duration(milliseconds: token.lifeSpan!.toInt()));
if (!expiry.isAfter(DateTime.now())) {
throw AngelHttpException.forbidden(message: "Expired JWT.");
}
}
var user = await deserializer!(token.userId);
_apply(req, res, token, user); _apply(req, res, token, user);
return _AuthResult(user, token); return _AuthResult(user, token);
} }
@ -212,19 +216,20 @@ class AngelAuth<User> {
} }
/// Retrieves a JWT from a request, if any was sent at all. /// Retrieves a JWT from a request, if any was sent at all.
String getJwt(RequestContext req) { String? getJwt(RequestContext req) {
if (req.headers.value("Authorization") != null) { if (req.headers!.value("Authorization") != null) {
final authHeader = req.headers.value("Authorization"); final authHeader = req.headers!.value("Authorization")!;
// Allow Basic auth to fall through // Allow Basic auth to fall through
if (_rgxBearer.hasMatch(authHeader)) if (_rgxBearer.hasMatch(authHeader)) {
return authHeader.replaceAll(_rgxBearer, "").trim(); return authHeader.replaceAll(_rgxBearer, "").trim();
}
} else if (allowCookie && } else if (allowCookie &&
req.cookies.any((cookie) => cookie.name == "token")) { req.cookies!.any((cookie) => cookie.name == "token")) {
return req.cookies.firstWhere((cookie) => cookie.name == "token").value; return req.cookies!.firstWhere((cookie) => cookie.name == "token").value;
} else if (allowTokenInQuery && } else if (allowTokenInQuery &&
req.uri.queryParameters['token'] is String) { req.uri!.queryParameters['token'] is String) {
return req.uri.queryParameters['token']?.toString(); return req.uri!.queryParameters['token']?.toString();
} }
return null; return null;
@ -243,10 +248,10 @@ class AngelAuth<User> {
cookie.secure = true; cookie.secure = true;
} }
if (_jwtLifeSpan > 0) { if (_jwtLifeSpan! > 0) {
cookie.maxAge ??= _jwtLifeSpan < 0 ? -1 : _jwtLifeSpan ~/ 1000; cookie.maxAge ??= _jwtLifeSpan! < 0 ? -1 : _jwtLifeSpan! ~/ 1000;
cookie.expires ??= cookie.expires ??=
DateTime.now().add(Duration(milliseconds: _jwtLifeSpan)); DateTime.now().add(Duration(milliseconds: _jwtLifeSpan!));
} }
cookie.domain ??= cookieDomain; cookie.domain ??= cookieDomain;
@ -261,22 +266,23 @@ class AngelAuth<User> {
var jwt = getJwt(req); var jwt = getJwt(req);
if (jwt == null) { if (jwt == null) {
var body = await req.parseBody().then((_) => req.bodyAsMap); var body = await req.parseBody().then((_) => req.bodyAsMap!);
jwt = body['token']?.toString(); jwt = body['token']?.toString();
} }
if (jwt == null) { if (jwt == null) {
throw AngelHttpException.forbidden(message: "No JWT provided"); throw AngelHttpException.forbidden(message: "No JWT provided");
} else { } else {
var token = AuthToken.validate(jwt, _hs256); var token = AuthToken.validate(jwt, _hs256!);
if (enforceIp) { if (enforceIp) {
if (req.ip != token.ipAddress) if (req.ip != token.ipAddress) {
throw AngelHttpException.forbidden( throw AngelHttpException.forbidden(
message: "JWT cannot be accessed from this IP address."); message: "JWT cannot be accessed from this IP address.");
} }
}
if (token.lifeSpan > -1) { if (token.lifeSpan! > -1) {
var expiry = token.issuedAt var expiry = token.issuedAt
.add(Duration(milliseconds: token.lifeSpan.toInt())); .add(Duration(milliseconds: token.lifeSpan!.toInt()));
if (!expiry.isAfter(DateTime.now())) { if (!expiry.isAfter(DateTime.now())) {
//print( //print(
@ -287,11 +293,11 @@ class AngelAuth<User> {
} }
if (allowCookie) { if (allowCookie) {
_addProtectedCookie(res, 'token', token.serialize(_hs256)); _addProtectedCookie(res, 'token', token.serialize(_hs256!));
} }
final data = await deserializer(token.userId); final data = await deserializer!(token.userId);
return {'data': data, 'token': token.serialize(_hs256)}; return {'data': data, 'token': token.serialize(_hs256!)};
} }
} catch (e) { } catch (e) {
if (e is AngelHttpException) rethrow; if (e is AngelHttpException) rethrow;
@ -307,14 +313,14 @@ class AngelAuth<User> {
/// or a `401 Not Authenticated` is thrown, if it is the last one. /// 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. /// Any other result is considered an authenticated user, and terminates the loop.
RequestHandler authenticate(type, [AngelAuthOptions<User> options]) { RequestHandler authenticate(type, [AngelAuthOptions<User>? options]) {
return (RequestContext req, ResponseContext res) async { return (RequestContext req, ResponseContext res) async {
List<String> names = []; var names = <String>[];
var arr = type is Iterable var arr = type is Iterable
? type.map((x) => x.toString()).toList() ? type.map((x) => x.toString()).toList()
: [type.toString()]; : [type.toString()];
for (String t in arr) { for (var t in arr) {
var n = t var n = t
.split(',') .split(',')
.map((s) => s.trim()) .map((s) => s.trim())
@ -323,34 +329,34 @@ class AngelAuth<User> {
names.addAll(n); names.addAll(n);
} }
for (int i = 0; i < names.length; i++) { for (var i = 0; i < names.length; i++) {
var name = names[i]; var name = names[i];
var strategy = strategies[name] ??= var strategy = strategies[name] ??=
throw ArgumentError('No strategy "$name" found.'); throw ArgumentError('No strategy "$name" found.');
var hasExisting = req.container.has<User>(); var hasExisting = req.container!.has<User>();
var result = hasExisting var result = hasExisting
? req.container.make<User>() ? req.container!.make<User>()
: await strategy.authenticate(req, res, options); : await strategy.authenticate(req, res, options!);
if (result == true) if (result == true) {
return result; return result;
else if (result != false && result != null) { } else if (result != false && result != null) {
var userId = await serializer(result); var userId = await serializer!(result);
// Create JWT // Create JWT
var token = AuthToken( var token = AuthToken(
userId: userId, lifeSpan: _jwtLifeSpan, ipAddress: req.ip); userId: userId, lifeSpan: _jwtLifeSpan, ipAddress: req.ip);
var jwt = token.serialize(_hs256); var jwt = token.serialize(_hs256!);
if (options?.tokenCallback != null) { if (options?.tokenCallback != null) {
if (!req.container.has<User>()) { if (!req.container!.has<User>()) {
req.container.registerSingleton<User>(result); req.container!.registerSingleton<User>(result);
} }
var r = await options.tokenCallback(req, res, token, result); var r = await options!.tokenCallback!(req, res, token, result);
if (r != null) return r; if (r != null) return r;
jwt = token.serialize(_hs256); jwt = token.serialize(_hs256!);
} }
_apply(req, res, token, result); _apply(req, res, token, result);
@ -360,17 +366,17 @@ class AngelAuth<User> {
} }
if (options?.callback != null) { if (options?.callback != null) {
return await options.callback(req, res, jwt); return await options!.callback!(req, res, jwt);
} }
if (options?.successRedirect?.isNotEmpty == true) { if (options?.successRedirect?.isNotEmpty == true) {
await res.redirect(options.successRedirect); await res.redirect(options!.successRedirect);
return false; return false;
} else if (options?.canRespondWithJson != false && } else if (options?.canRespondWithJson != false &&
req.accepts('application/json')) { req.accepts('application/json')) {
var user = hasExisting var user = hasExisting
? result ? result
: await deserializer(await serializer(result)); : await deserializer!(await serializer!(result));
_onLogin.add(user); _onLogin.add(user);
return {"data": user, "token": jwt}; return {"data": user, "token": jwt};
} }
@ -381,47 +387,48 @@ class AngelAuth<User> {
// Check if not redirect // Check if not redirect
if (res.statusCode == 301 || if (res.statusCode == 301 ||
res.statusCode == 302 || res.statusCode == 302 ||
res.headers.containsKey('location')) res.headers.containsKey('location')) {
return false; return false;
else if (options?.failureRedirect != null) { } else if (options?.failureRedirect != null) {
await res.redirect(options.failureRedirect); await res.redirect(options!.failureRedirect);
return false; return false;
} else } else {
throw AngelHttpException.notAuthenticated(); throw AngelHttpException.notAuthenticated();
} }
} }
}
}; };
} }
/// Log a user in on-demand. /// Log a user in on-demand.
Future login(AuthToken token, RequestContext req, ResponseContext res) async { Future login(AuthToken token, RequestContext req, ResponseContext res) async {
var user = await deserializer(token.userId); var user = await deserializer!(token.userId);
_apply(req, res, token, user); _apply(req, res, token, user);
_onLogin.add(user); _onLogin.add(user);
if (allowCookie) { if (allowCookie) {
_addProtectedCookie(res, 'token', token.serialize(_hs256)); _addProtectedCookie(res, 'token', token.serialize(_hs256!));
} }
} }
/// Log a user in on-demand. /// Log a user in on-demand.
Future loginById(userId, RequestContext req, ResponseContext res) async { Future loginById(userId, RequestContext req, ResponseContext res) async {
var user = await deserializer(userId); var user = await deserializer!(userId);
var token = var token =
AuthToken(userId: userId, lifeSpan: _jwtLifeSpan, ipAddress: req.ip); AuthToken(userId: userId, lifeSpan: _jwtLifeSpan, ipAddress: req.ip);
_apply(req, res, token, user); _apply(req, res, token, user);
_onLogin.add(user); _onLogin.add(user);
if (allowCookie) { if (allowCookie) {
_addProtectedCookie(res, 'token', token.serialize(_hs256)); _addProtectedCookie(res, 'token', token.serialize(_hs256!));
} }
} }
/// Log an authenticated user out. /// Log an authenticated user out.
RequestHandler logout([AngelAuthOptions<User> options]) { RequestHandler logout([AngelAuthOptions<User>? options]) {
return (RequestContext req, ResponseContext res) async { return (RequestContext req, ResponseContext res) async {
if (req.container.has<User>()) { if (req.container!.has<User>()) {
var user = req.container.make<User>(); var user = req.container!.make<User>();
_onLogout.add(user); _onLogout.add(user);
} }
@ -432,7 +439,7 @@ class AngelAuth<User> {
if (options != null && if (options != null &&
options.successRedirect != null && options.successRedirect != null &&
options.successRedirect.isNotEmpty) { options.successRedirect!.isNotEmpty) {
await res.redirect(options.successRedirect); await res.redirect(options.successRedirect);
} }

View file

@ -4,15 +4,16 @@ import 'package:angel_framework/angel_framework.dart';
import '../options.dart'; import '../options.dart';
import '../strategy.dart'; import '../strategy.dart';
bool _validateString(String str) => str != null && str.isNotEmpty; bool _validateString(String? str) => str != null && str.isNotEmpty;
/// Determines the validity of an incoming username and password. /// Determines the validity of an incoming username and password.
typedef FutureOr<User> LocalAuthVerifier<User>( // typedef FutureOr<User> LocalAuthVerifier<User>(String? username, String? password);
String username, String password); typedef LocalAuthVerifier<User> = FutureOr<User> Function(
String? username, String? password);
class LocalAuthStrategy<User> extends AuthStrategy<User> { class LocalAuthStrategy<User> extends AuthStrategy<User> {
RegExp _rgxBasic = RegExp(r'^Basic (.+)$', caseSensitive: false); final RegExp _rgxBasic = RegExp(r'^Basic (.+)$', caseSensitive: false);
RegExp _rgxUsrPass = RegExp(r'^([^:]+):(.+)$'); final RegExp _rgxUsrPass = RegExp(r'^([^:]+):(.+)$');
LocalAuthVerifier<User> verifier; LocalAuthVerifier<User> verifier;
String usernameField; String usernameField;
@ -23,33 +24,32 @@ class LocalAuthStrategy<User> extends AuthStrategy<User> {
String realm; String realm;
LocalAuthStrategy(this.verifier, LocalAuthStrategy(this.verifier,
{String this.usernameField = 'username', {this.usernameField = 'username',
String this.passwordField = 'password', this.passwordField = 'password',
String this.invalidMessage = this.invalidMessage = 'Please provide a valid username and password.',
'Please provide a valid username and password.', this.allowBasic = true,
bool this.allowBasic = true, this.forceBasic = false,
bool this.forceBasic = false, this.realm = 'Authentication is required.'});
String this.realm = 'Authentication is required.'});
@override @override
Future<User> authenticate(RequestContext req, ResponseContext res, Future<User?> authenticate(RequestContext req, ResponseContext res,
[AngelAuthOptions options_]) async { [AngelAuthOptions? options_]) async {
AngelAuthOptions options = options_ ?? AngelAuthOptions(); var options = options_ ?? AngelAuthOptions();
User verificationResult; User? verificationResult;
if (allowBasic) { if (allowBasic) {
String authHeader = req.headers.value('authorization') ?? ""; var authHeader = req.headers!.value('authorization') ?? '';
if (_rgxBasic.hasMatch(authHeader)) { if (_rgxBasic.hasMatch(authHeader)) {
String base64AuthString = _rgxBasic.firstMatch(authHeader).group(1); var base64AuthString = _rgxBasic.firstMatch(authHeader)!.group(1)!;
String authString = var authString = String.fromCharCodes(base64.decode(base64AuthString));
String.fromCharCodes(base64.decode(base64AuthString));
if (_rgxUsrPass.hasMatch(authString)) { if (_rgxUsrPass.hasMatch(authString)) {
Match usrPassMatch = _rgxUsrPass.firstMatch(authString); Match usrPassMatch = _rgxUsrPass.firstMatch(authString)!;
verificationResult = verificationResult =
await verifier(usrPassMatch.group(1), usrPassMatch.group(2)); await verifier(usrPassMatch.group(1), usrPassMatch.group(2));
} else } else {
throw AngelHttpException.badRequest(errors: [invalidMessage]); throw AngelHttpException.badRequest(errors: [invalidMessage]);
}
if (verificationResult == false || verificationResult == null) { if (verificationResult == false || verificationResult == null) {
res res
@ -68,27 +68,29 @@ class LocalAuthStrategy<User> extends AuthStrategy<User> {
.parseBody() .parseBody()
.then((_) => req.bodyAsMap) .then((_) => req.bodyAsMap)
.catchError((_) => <String, dynamic>{}); .catchError((_) => <String, dynamic>{});
if (body != null) {
if (_validateString(body[usernameField]?.toString()) && if (_validateString(body[usernameField]?.toString()) &&
_validateString(body[passwordField]?.toString())) { _validateString(body[passwordField]?.toString())) {
verificationResult = await verifier( verificationResult = await verifier(
body[usernameField]?.toString(), body[passwordField]?.toString()); body[usernameField]?.toString(), body[passwordField]?.toString());
} }
} }
}
if (verificationResult == false || verificationResult == null) { if (verificationResult == false || verificationResult == null) {
if (options.failureRedirect != null && if (options.failureRedirect != null &&
options.failureRedirect.isNotEmpty) { options.failureRedirect!.isNotEmpty) {
await res.redirect(options.failureRedirect, code: 401); await res.redirect(options.failureRedirect, code: 401);
return null; return null;
} }
if (forceBasic) { if (forceBasic) {
res.headers['www-authenticate'] = 'Basic realm="$realm"'; res.headers['www-authenticate'] = 'Basic realm="$realm"';
throw AngelHttpException.notAuthenticated(); return null;
} }
return null; return null;
} else if (verificationResult != null && verificationResult != false) { } else if (verificationResult != false) {
return verificationResult; return verificationResult;
} else { } else {
throw AngelHttpException.notAuthenticated(); throw AngelHttpException.notAuthenticated();

View file

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

View file

@ -1,11 +1,11 @@
name: angel_auth name: angel_auth
description: A complete authentication plugin for Angel. Includes support for stateless JWT tokens, Basic Auth, and more. description: A complete authentication plugin for Angel. Includes support for stateless JWT tokens, Basic Auth, and more.
version: 3.0.0 version: 4.0.0
author: Tobe O <thosakwe@gmail.com> author: Tobe O <thosakwe@gmail.com>
homepage: https://github.com/angel-dart/angel_auth homepage: https://github.com/angel-dart/angel_auth
publish_to: none publish_to: none
environment: environment:
sdk: ">=2.10.0 <3.0.0" sdk: '>=2.12.0 <3.0.0'
dependencies: dependencies:
angel_framework: angel_framework:
git: git:
@ -17,7 +17,7 @@ dependencies:
crypto: ^3.0.0 crypto: ^3.0.0
http_parser: ^4.0.0 http_parser: ^4.0.0
meta: ^1.3.0 meta: ^1.3.0
quiver_hashcode: ^3.0.0+1 quiver: ^3.0.0
dev_dependencies: dev_dependencies:
http: ^0.13.1 http: ^0.13.1
io: ^1.0.0 io: ^1.0.0

View file

@ -3,20 +3,22 @@ import 'package:angel_auth/angel_auth.dart';
import 'package:angel_framework/angel_framework.dart'; import 'package:angel_framework/angel_framework.dart';
import 'package:angel_framework/http.dart'; import 'package:angel_framework/http.dart';
import 'dart:convert'; import 'dart:convert';
import 'package:collection/collection.dart' show IterableExtension;
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:io/ansi.dart'; import 'package:io/ansi.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
import 'package:collection/collection.dart';
class User extends Model { class User extends Model {
String username, password; String? username, password;
User({this.username, this.password}); User({this.username, this.password});
static User parse(Map map) { static User parse(Map map) {
return User( return User(
username: map['username'] as String, username: map['username'] as String?,
password: map['password'] as String, password: map['password'] as String?,
); );
} }
@ -31,27 +33,27 @@ class User extends Model {
} }
} }
main() { void main() {
Angel app; Angel? app;
AngelHttp angelHttp; late AngelHttp angelHttp;
AngelAuth<User> auth; AngelAuth<User?> auth;
http.Client client; http.Client? client;
HttpServer server; HttpServer server;
String url; String? url;
setUp(() async { setUp(() async {
hierarchicalLoggingEnabled = true; hierarchicalLoggingEnabled = true;
app = Angel(); app = Angel();
angelHttp = AngelHttp(app); angelHttp = AngelHttp(app);
app.use('/users', MapService()); app!.use('/users', MapService());
var oldErrorHandler = app.errorHandler; var oldErrorHandler = app!.errorHandler;
app.errorHandler = (e, req, res) { app!.errorHandler = (e, req, res) {
app.logger.severe(e.message, e, e.stackTrace ?? StackTrace.current); app!.logger!.severe(e.message, e, e.stackTrace ?? StackTrace.current);
return oldErrorHandler(e, req, res); return oldErrorHandler(e, req, res);
}; };
app.logger = Logger('angel_auth') app!.logger = Logger('angel_auth')
..level = Level.FINEST ..level = Level.FINEST
..onRecord.listen((rec) { ..onRecord.listen((rec) {
print(rec); print(rec);
@ -65,28 +67,30 @@ main() {
} }
}); });
await app await app!
.findService('users') .findService('users')!
.create({'username': 'jdoe1', 'password': 'password'}); .create({'username': 'jdoe1', 'password': 'password'});
auth = AngelAuth<User>(); auth = AngelAuth<User?>();
auth.serializer = (u) => u.id; auth.serializer = (u) => u!.id;
auth.deserializer = auth.deserializer =
(id) async => await app.findService('users').read(id) as User; (id) async => await app!.findService('users')!.read(id) as User;
await app.configure(auth.configureServer); await app!.configure(auth.configureServer);
auth.strategies['local'] = LocalAuthStrategy((username, password) async { auth.strategies['local'] = LocalAuthStrategy((username, password) async {
var users = await app var users = await app!
.findService('users') .findService('users')!
.index() .index()
.then((it) => it.map<User>((m) => User.parse(m as Map)).toList()); .then((it) => it.map<User>((m) => User.parse(m as Map)).toList());
return users.firstWhere(
(user) => user.username == username && user.password == password, var result = users.firstWhereOrNull(
orElse: () => null); (user) => user.username == username && user.password == password);
return Future.value(result);
}); });
app.post( app!.post(
'/login', '/login',
auth.authenticate('local', auth.authenticate('local',
AngelAuthOptions(callback: (req, res, token) { AngelAuthOptions(callback: (req, res, token) {
@ -95,10 +99,10 @@ main() {
..close(); ..close();
}))); })));
app.chain([ app!.chain([
(req, res) { (req, res) {
if (!req.container.has<User>()) { if (!req.container!.has<User>()) {
req.container.registerSingleton<User>( req.container!.registerSingleton<User>(
User(username: req.params['name']?.toString())); User(username: req.params['name']?.toString()));
} }
return true; return true;
@ -114,7 +118,7 @@ main() {
}); });
tearDown(() async { tearDown(() async {
client.close(); client!.close();
await angelHttp.close(); await angelHttp.close();
app = null; app = null;
client = null; client = null;
@ -122,7 +126,7 @@ main() {
}); });
test('login', () async { test('login', () async {
final response = await client.post(Uri.parse('$url/login'), final response = await client!.post(Uri.parse('$url/login'),
body: {'username': 'jdoe1', 'password': 'password'}); body: {'username': 'jdoe1', 'password': 'password'});
print('Response: ${response.body}'); print('Response: ${response.body}');
expect(response.body, equals('Hello!')); expect(response.body, equals('Hello!'));
@ -132,7 +136,7 @@ main() {
: null); : null);
test('preserve existing user', () async { test('preserve existing user', () async {
final response = await client.post(Uri.parse('$url/existing/foo'), final response = await client!.post(Uri.parse('$url/existing/foo'),
body: {'username': 'jdoe1', 'password': 'password'}, body: {'username': 'jdoe1', 'password': 'password'},
headers: {'accept': 'application/json'}); headers: {'accept': 'application/json'});
print('Response: ${response.body}'); print('Response: ${response.body}');

View file

@ -7,17 +7,17 @@ import 'package:http/http.dart' as http;
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
final AngelAuth<Map<String, String>> auth = AngelAuth<Map<String, String>>(); final AngelAuth<Map<String, String>?> auth = AngelAuth<Map<String, String>?>();
var headers = <String, String>{'accept': 'application/json'}; var headers = <String, String>{'accept': 'application/json'};
var localOpts = AngelAuthOptions<Map<String, String>>( var localOpts = AngelAuthOptions<Map<String, String>>(
failureRedirect: '/failure', successRedirect: '/success'); failureRedirect: '/failure', successRedirect: '/success');
Map<String, String> sampleUser = {'hello': 'world'}; Map<String, String> sampleUser = {'hello': 'world'};
Future<Map<String, String>> verifier(String username, String password) async { Future<Map<String, String>> verifier(String? username, String? password) async {
if (username == 'username' && password == 'password') { if (username == 'username' && password == 'password') {
return sampleUser; return sampleUser;
} else { } else {
return null; throw ArgumentError('Unexpected type for data');
} }
} }
@ -31,10 +31,10 @@ Future wireAuth(Angel app) async {
void main() async { void main() async {
Angel app; Angel app;
AngelHttp angelHttp; late AngelHttp angelHttp;
http.Client client; http.Client? client;
String url; String? url;
String basicAuthUrl; String? basicAuthUrl;
setUp(() async { setUp(() async {
client = http.Client(); client = http.Client();
@ -72,7 +72,7 @@ void main() async {
}); });
test('can use "auth" as middleware', () async { test('can use "auth" as middleware', () async {
var response = await client.get(Uri.parse('$url/success'), var response = await client!.get(Uri.parse('$url/success'),
headers: {'Accept': 'application/json'}); headers: {'Accept': 'application/json'});
print(response.body); print(response.body);
expect(response.statusCode, equals(403)); expect(response.statusCode, equals(403));
@ -80,7 +80,7 @@ void main() async {
test('successRedirect', () async { test('successRedirect', () async {
var postData = {'username': 'username', 'password': 'password'}; var postData = {'username': 'username', 'password': 'password'};
var response = await client.post(Uri.parse('$url/login'), var response = await client!.post(Uri.parse('$url/login'),
body: json.encode(postData), body: json.encode(postData),
headers: {'content-type': 'application/json'}); headers: {'content-type': 'application/json'});
expect(response.statusCode, equals(302)); expect(response.statusCode, equals(302));
@ -89,7 +89,7 @@ void main() async {
test('failureRedirect', () async { test('failureRedirect', () async {
var postData = {'username': 'password', 'password': 'username'}; var postData = {'username': 'password', 'password': 'username'};
var response = await client.post(Uri.parse('$url/login'), var response = await client!.post(Uri.parse('$url/login'),
body: json.encode(postData), body: json.encode(postData),
headers: {'content-type': 'application/json'}); headers: {'content-type': 'application/json'});
print('Login response: ${response.body}'); print('Login response: ${response.body}');
@ -99,13 +99,13 @@ void main() async {
test('allow basic', () async { test('allow basic', () async {
var authString = base64.encode('username:password'.runes.toList()); var authString = base64.encode('username:password'.runes.toList());
var response = await client.get(Uri.parse('$url/hello'), var response = await client!.get(Uri.parse('$url/hello'),
headers: {'authorization': 'Basic $authString'}); headers: {'authorization': 'Basic $authString'});
expect(response.body, equals('"Woo auth"')); expect(response.body, equals('"Woo auth"'));
}); });
test('allow basic via URL encoding', () async { test('allow basic via URL encoding', () async {
var response = await client.get(Uri.parse('$basicAuthUrl/hello')); var response = await client!.get(Uri.parse('$basicAuthUrl/hello'));
expect(response.body, equals('"Woo auth"')); expect(response.body, equals('"Woo auth"'));
}); });
@ -113,7 +113,7 @@ void main() async {
auth.strategies.clear(); auth.strategies.clear();
auth.strategies['local'] = auth.strategies['local'] =
LocalAuthStrategy(verifier, forceBasic: true, realm: 'test'); LocalAuthStrategy(verifier, forceBasic: true, realm: 'test');
var response = await client.get(Uri.parse('$url/hello'), headers: { var response = await client!.get(Uri.parse('$url/hello'), headers: {
'accept': 'application/json', 'accept': 'application/json',
'content-type': 'application/json' 'content-type': 'application/json'
}); });

View file

@ -6,7 +6,7 @@ import 'package:test/test.dart';
const Duration threeDays = const Duration(days: 3); const Duration threeDays = const Duration(days: 3);
void main() { void main() {
Cookie defaultCookie; late Cookie defaultCookie;
var auth = AngelAuth( var auth = AngelAuth(
secureCookies: true, secureCookies: true,
cookieDomain: 'SECURE', cookieDomain: 'SECURE',
@ -21,7 +21,7 @@ void main() {
test('sets expires', () { test('sets expires', () {
var now = DateTime.now(); var now = DateTime.now();
var expiry = auth.protectCookie(defaultCookie).expires; var expiry = auth.protectCookie(defaultCookie).expires!;
var diff = expiry.difference(now); var diff = expiry.difference(now);
expect(diff.inSeconds, threeDays.inSeconds); expect(diff.inSeconds, threeDays.inSeconds);
}); });