Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dart_frog_auth): add cookie authentication #1600

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 45 additions & 3 deletions docs/docs/advanced/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,7 @@ layering the foundation for more advanced authentication. See below for more det

## Dart Frog Auth

The authentication methods provided in `dart_frog_auth` are based on `Authorization` specification,
as defined in [`General HTTP`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication). Here you will find support
for `Basic` and `Bearer` authentications, which are common authentication methods used by many developers.
The authentication methods provided in `dart_frog_auth` use different HTTP headers depending on the method. Basic and Bearer authentication use the `Authorization` header, as defined in [`General HTTP`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication), while Cookie-based authentication uses the `Cookie` header, as defined in [`HTTP Cookies`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies). The package provides support for Basic, Bearer, and Cookie-based authentications, which are common authentication methods used by many developers.

## Basic Authentication

Expand Down Expand Up @@ -153,6 +151,50 @@ Response onRequest(RequestContext context) {

In the case of `null` being returned (unauthenticated), the middleware will automatically send an unauthorized `401` in the response.

### Cookie-based Authentication

To implement cookie-based authentication, you can use the `cookieAuthentication` middleware:

```dart
// routes/admin/_middleware.dart
import 'package:dart_frog/dart_frog.dart';
import 'package:dart_frog_auth/dart_frog_auth.dart';
import 'package:blog/user.dart';

Handler middleware(Handler handler) {
final userRepository = ...;
return handler
.use(requestLogger())
.use(
cookieAuthentication<User>(
authenticator: (context, cookies) {
final userRepository = context.read<UserRepository>();
return userRepository.fetchFromAccessCookies(cookies);
}
),
);
}
```

The `authenticator` parameter must be a function that receives two positional argument the
context and the cookies set in the cookie header and returns a user if any is found
for that token.

Just like in the basic and bearer methods, if a user is returned, it will be set in the request
context and can be read on request handlers, for example:

```dart
import 'package:dart_frog/dart_frog.dart';
import 'package:blog/user.dart';

Response onRequest(RequestContext context) {
final user = context.read<User>();
return Response.json(body: {'user': user.id});
}
```

In the case of `null` being returned (unauthenticated), the middleware will automatically send an unauthorized `401` in the response.

### Filtering Routes

In many instances, developers will want to apply authentication to some routes, while not to others.
Expand Down
59 changes: 59 additions & 0 deletions packages/dart_frog_auth/lib/src/dart_frog_auth.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ extension on Map<String, String> {

String? bearer() => authorization('Bearer');
String? basic() => authorization('Basic');
Map<String, String>? cookies() {
final cookieString = this['Cookie'];
if (cookieString == null) return null;

final cookiesEntries = cookieString.split('; ').map((cookie) {
final [key, value] = cookie.split('=');
return MapEntry(key, value);
});

return Map.fromEntries(cookiesEntries);
}
}

/// Function definition for the predicate function used by Dart Frog Auth
Expand Down Expand Up @@ -154,3 +165,51 @@ Middleware bearerAuthentication<T extends Object>({
return Response(statusCode: HttpStatus.unauthorized);
};
}

/// Authentication that uses the `Cookie` header.
///
/// Cookie authentication expects cookies to be sent in the format:
/// ```markdown
/// Cookie: key1=value1; key2=value2;
/// ```
///
/// This is typically done in web applications where the cookie is set with the
/// `Set-Cookie` header, then sent with every request by a browser.
///
/// The cookie format and contents are up to the user. Typically they will
/// identify a logged in session.
///
/// In order to use this middleware, you must provide a function that will
/// return a user object from the cookies and request context.
///
/// If the given function returns null for the given cookies,
/// the middleware will return a `401 Unauthorized` response.
///
/// By default, this middleware will apply to all routes. You can change this
/// behavior by providing a function that returns a boolean value based on the
/// [RequestContext]. If the function returns false, the middleware will not
/// apply to the route and the call will have no authentication validation.
Middleware cookieAuthentication<T extends Object>({
required Future<T?> Function(
RequestContext context,
Map<String, String> cookies,
) authenticator,
Applies applies = _defaultApplies,
}) {
return (handler) => (context) async {
if (!await applies(context)) {
return handler(context);
}

final cookies = context.request.headers.cookies();

if (cookies != null) {
final user = await authenticator(context, cookies);
if (user != null) {
return handler(context.provide(() => user));
}
}

return Response(statusCode: HttpStatus.unauthorized);
};
}
90 changes: 90 additions & 0 deletions packages/dart_frog_auth/test/src/dart_frog_auth_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -456,4 +456,94 @@ void main() {
});
});
});

group('$cookieAuthentication', () {
late RequestContext context;
late Request request;
_User? user;

setUp(() {
context = _MockRequestContext();
request = _MockRequest();
when(() => context.provide<_User>(any())).thenReturn(context);
when(() => request.headers).thenReturn({});
when(() => context.request).thenReturn(request);
});

test('returns 401 when Cookie header is not present', () async {
final middleware = cookieAuthentication<_User>(
authenticator: (_, __) async => user,
);
expect(
await middleware((_) async => Response())(context),
isA<Response>().having(
(r) => r.statusCode,
'statusCode',
HttpStatus.unauthorized,
),
);
});

test(
'returns 401 when Cookie header is present but no user is returned',
() async {
when(() => request.headers).thenReturn({'Cookie': 'session=abc123'});
final middleware = cookieAuthentication<_User>(
authenticator: (_, __) async => null,
);
expect(
await middleware((_) async => Response())(context),
isA<Response>().having(
(r) => r.statusCode,
'statusCode',
HttpStatus.unauthorized,
),
);
},
);

test(
'sets the user when everything is valid',
() async {
user = _User('');
when(() => request.headers).thenReturn({
'Cookie': 'session=abc123',
});
final middleware = cookieAuthentication<_User>(
authenticator: (_, __) async => user,
);
expect(
await middleware((_) async => Response())(context),
isA<Response>().having(
(r) => r.statusCode,
'statusCode',
HttpStatus.ok,
),
);
final captured =
verify(() => context.provide<_User>(captureAny())).captured.single;
expect(
(captured as _User Function()).call(),
equals(user),
);
},
);

test("skips routes that doesn't match the custom predicate", () async {
var called = false;

final middleware = cookieAuthentication<_User>(
authenticator: (_, __) async {
called = true;
return null;
},
applies: (_) async => false,
);

final response = await middleware((_) async => Response())(context);

expect(called, isFalse);
expect(response.statusCode, equals(HttpStatus.ok));
});
});
}