From d7ae175f7d1d8db02f9a4a16aeb943d5e81a58db Mon Sep 17 00:00:00 2001 From: Marcus Twichel Date: Mon, 25 Nov 2024 14:10:03 -0700 Subject: [PATCH 1/4] feat(dart_frog_auth): add cookie authentication --- .../lib/src/dart_frog_auth.dart | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/dart_frog_auth/lib/src/dart_frog_auth.dart b/packages/dart_frog_auth/lib/src/dart_frog_auth.dart index 39f3028d1..c97a62b7c 100644 --- a/packages/dart_frog_auth/lib/src/dart_frog_auth.dart +++ b/packages/dart_frog_auth/lib/src/dart_frog_auth.dart @@ -16,6 +16,17 @@ extension on Map { String? bearer() => authorization('Bearer'); String? basic() => authorization('Basic'); + Map? 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 @@ -154,3 +165,51 @@ Middleware bearerAuthentication({ 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({ + required Future Function( + RequestContext context, + Map 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); + }; +} From 89f9ed19c8db762d3df8834eb3c882307c5b6255 Mon Sep 17 00:00:00 2001 From: Marcus Twichel Date: Mon, 25 Nov 2024 14:15:23 -0700 Subject: [PATCH 2/4] feat: add tests to additions --- .../test/src/dart_frog_auth_test.dart | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/packages/dart_frog_auth/test/src/dart_frog_auth_test.dart b/packages/dart_frog_auth/test/src/dart_frog_auth_test.dart index 4e07cd3f1..45bfe082a 100644 --- a/packages/dart_frog_auth/test/src/dart_frog_auth_test.dart +++ b/packages/dart_frog_auth/test/src/dart_frog_auth_test.dart @@ -456,4 +456,95 @@ 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().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().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().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)); + }); + }); } From 17e7aca24592741fa13cd5c11a9402c27cd947b9 Mon Sep 17 00:00:00 2001 From: Marcus Twichel Date: Mon, 25 Nov 2024 14:16:32 -0700 Subject: [PATCH 3/4] fix fomatting --- packages/dart_frog_auth/test/src/dart_frog_auth_test.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/dart_frog_auth/test/src/dart_frog_auth_test.dart b/packages/dart_frog_auth/test/src/dart_frog_auth_test.dart index 45bfe082a..5bb8478a7 100644 --- a/packages/dart_frog_auth/test/src/dart_frog_auth_test.dart +++ b/packages/dart_frog_auth/test/src/dart_frog_auth_test.dart @@ -520,9 +520,8 @@ void main() { HttpStatus.ok, ), ); - final captured = verify(() => context.provide<_User>(captureAny())) - .captured - .single; + final captured = + verify(() => context.provide<_User>(captureAny())).captured.single; expect( (captured as _User Function()).call(), equals(user), From 60a22013be24da862322f0d453bdf88fe39129f7 Mon Sep 17 00:00:00 2001 From: Marcus Twichel Date: Mon, 25 Nov 2024 15:04:52 -0700 Subject: [PATCH 4/4] add note about cookie authentication to docs --- docs/docs/advanced/authentication.md | 48 ++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/docs/docs/advanced/authentication.md b/docs/docs/advanced/authentication.md index 0bda381f8..0105d3999 100644 --- a/docs/docs/advanced/authentication.md +++ b/docs/docs/advanced/authentication.md @@ -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 @@ -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( + authenticator: (context, cookies) { + final userRepository = context.read(); + 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(); + 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.