From 6aff9624bc0b1a93b8210fe2677d5cf7105886ba Mon Sep 17 00:00:00 2001 From: Dillon Nys <24740863+dnys1@users.noreply.github.com> Date: Mon, 7 Oct 2024 08:00:22 -0700 Subject: [PATCH] feat(auth): Add support for Supabase IDP (#192) Adds support for using Supabase as an external auth provider. --- .../celest/lib/src/auth/auth_provider.dart | 23 ++-- packages/celest/lib/src/config/env.dart | 15 +++ packages/celest/lib/src/core/context.dart | 50 ++++++++ .../lib/src/runtime/auth/auth_middleware.dart | 7 ++ .../firebase/firebase_auth_middleware.dart | 2 +- .../firebase/firebase_public_key_store.dart | 13 +- .../firebase/firebase_token_verifier.dart | 6 +- .../supabase/supabase_auth_middleware.dart | 39 ++++++ .../supabase/supabase_token_verifier.dart | 112 ++++++++++++++++++ .../celest/lib/src/runtime/http/logging.dart | 2 +- packages/celest/lib/src/runtime/serve.dart | 1 + 11 files changed, 242 insertions(+), 28 deletions(-) create mode 100644 packages/celest/lib/src/runtime/auth/supabase/supabase_auth_middleware.dart create mode 100644 packages/celest/lib/src/runtime/auth/supabase/supabase_token_verifier.dart diff --git a/packages/celest/lib/src/auth/auth_provider.dart b/packages/celest/lib/src/auth/auth_provider.dart index 55c3be2..201ac63 100644 --- a/packages/celest/lib/src/auth/auth_provider.dart +++ b/packages/celest/lib/src/auth/auth_provider.dart @@ -61,13 +61,7 @@ sealed class ExternalAuthProvider implements AuthProvider { /// When using Firebase as your identity provider, users are managed entirely /// by Firebase. This provider is useful when you want to use Firebase's /// authentication system to manage your users. - /// - /// You may specify a custom environment variable for the [projectId] if - /// desired. If not provided, a default environment variable will be created - /// for you. - const factory ExternalAuthProvider.firebase({ - env projectId, - }) = _FirebaseExternalAuthProvider; + const factory ExternalAuthProvider.firebase() = _FirebaseExternalAuthProvider; /// A provider which enables Supabase as an external identity provider. /// @@ -75,10 +69,11 @@ sealed class ExternalAuthProvider implements AuthProvider { /// by Supabase. This provider is useful when you want to use Supabase's /// authentication system to manage your users. /// - /// You may specify a custom secret value for the [jwtSecret] if desired. If - /// not provided, a default secret will be created for you. + /// If [jwtSecret] is provided, it will be used to verify the JWT token. + /// Otherwise, a request will be made to the Supabase server to fetch the + /// user's information. const factory ExternalAuthProvider.supabase({ - secret jwtSecret, + secret? jwtSecret, }) = _SupabaseExternalAuthProvider; } @@ -126,6 +121,7 @@ final class _AppleAuthProvider extends AuthProvider { final class _FirebaseExternalAuthProvider extends ExternalAuthProvider { const _FirebaseExternalAuthProvider({ + // ignore: unused_element this.projectId = const env('FIREBASE_PROJECT_ID'), }); @@ -134,8 +130,11 @@ final class _FirebaseExternalAuthProvider extends ExternalAuthProvider { final class _SupabaseExternalAuthProvider extends ExternalAuthProvider { const _SupabaseExternalAuthProvider({ - this.jwtSecret = const secret('SUPABASE_JWT_SECRET'), + // ignore: unused_element + this.url = const env('SUPABASE_URL'), + this.jwtSecret, }); - final secret jwtSecret; + final env url; + final secret? jwtSecret; } diff --git a/packages/celest/lib/src/config/env.dart b/packages/celest/lib/src/config/env.dart index c222770..e61d36c 100644 --- a/packages/celest/lib/src/config/env.dart +++ b/packages/celest/lib/src/config/env.dart @@ -30,10 +30,22 @@ final class env extends ConfigurationValue { /// {@macro celest.config.environment_variable} const env(super.name) : super._(); + /// A static environment variable with a fixed value across all environments. + const factory env.static(String name, String value) = _staticEnv; + /// The active Celest environment. /// /// For example, `production`. static const env environment = env('CELEST_ENVIRONMENT'); + + @override + String toString() => 'env($name)'; +} + +final class _staticEnv extends env { + const _staticEnv(super.name, this.value); + + final String value; } /// {@template celest.config.secret} @@ -45,4 +57,7 @@ final class env extends ConfigurationValue { final class secret extends ConfigurationValue { /// {@macro celest.config.secret} const secret(super.name) : super._(); + + @override + String toString() => 'secret($name)'; } diff --git a/packages/celest/lib/src/core/context.dart b/packages/celest/lib/src/core/context.dart index 38da90b..42a9f6c 100644 --- a/packages/celest/lib/src/core/context.dart +++ b/packages/celest/lib/src/core/context.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io' show HandshakeException, HttpClient, SocketException; import 'package:celest/src/config/env.dart'; import 'package:celest/src/core/environment.dart'; @@ -7,6 +8,10 @@ import 'package:celest_core/_internal.dart'; // ignore: implementation_imports import 'package:celest_core/src/auth/user.dart'; import 'package:cloud_http/cloud_http.dart'; +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart' as http; +import 'package:http/retry.dart' as http; +import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; import 'package:platform/platform.dart'; import 'package:shelf/shelf.dart' as shelf; @@ -90,6 +95,45 @@ final class Context { }; } + /// The HTTP client for the current context. + http.Client get httpClient => + get(ContextKey.httpClient) ?? _defaultHttpClient; + + /// The default HTTP client. + static final http.Client _defaultHttpClient = http.RetryClient( + http.IOClient( + HttpClient() + ..idleTimeout = const Duration(seconds: 5) + ..connectionTimeout = const Duration(seconds: 5), + ), + retries: 3, + when: (response) { + return switch (response.statusCode) { + HttpStatus.gatewayTimeout || + HttpStatus.internalServerError || + HttpStatus.requestTimeout || + HttpStatus.serviceUnavailable => + true, + _ => false, + }; + }, + whenError: (error, stackTrace) { + context.logger.warning('HTTP client error', error, stackTrace); + return switch (error) { + SocketException() || HandshakeException() || TimeoutException() => true, + _ => false, + }; + }, + onRetry: (request, response, retryCount) { + context.logger.warning( + 'Retrying request to ${request.url} (retry=$retryCount)', + ); + }, + ); + + /// The logger for the current context. + Logger get logger => get(ContextKey.logger) ?? Logger.root; + (Context, V)? _get(ContextKey key) { if (key.read(this) case final value?) { return (this, value); @@ -148,6 +192,12 @@ abstract interface class ContextKey { /// The context key for the current [User]. static const ContextKey principal = _PrincipalContextKey(); + /// The context key for the context [http.Client]. + static const ContextKey httpClient = ContextKey('http client'); + + /// The context key for for the context [Logger]. + static const ContextKey logger = ContextKey('logger'); + /// Reads the value for `this` from the given [context]. V? read(Context context); diff --git a/packages/celest/lib/src/runtime/auth/auth_middleware.dart b/packages/celest/lib/src/runtime/auth/auth_middleware.dart index 6cc4093..a24c54e 100644 --- a/packages/celest/lib/src/runtime/auth/auth_middleware.dart +++ b/packages/celest/lib/src/runtime/auth/auth_middleware.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:celest/src/core/context.dart'; import 'package:celest_core/celest_core.dart'; import 'package:logging/logging.dart'; @@ -56,6 +58,7 @@ final class _OneOfAuthMiddleware extends AuthMiddleware { @override Future authenticate(shelf.Request request) async { + (Object, StackTrace)? internalError; for (final middleware in middlewares) { try { final user = await middleware.authenticate(request); @@ -68,9 +71,13 @@ final class _OneOfAuthMiddleware extends AuthMiddleware { e, st, ); + internalError ??= (e, st); continue; } } + if (internalError case (final error, final stackTrace)) { + Error.throwWithStackTrace(error, stackTrace); + } if (required) { throw const CloudException.unauthorized('Unauthorized'); } diff --git a/packages/celest/lib/src/runtime/auth/firebase/firebase_auth_middleware.dart b/packages/celest/lib/src/runtime/auth/firebase/firebase_auth_middleware.dart index 3b28e58..25529bc 100644 --- a/packages/celest/lib/src/runtime/auth/firebase/firebase_auth_middleware.dart +++ b/packages/celest/lib/src/runtime/auth/firebase/firebase_auth_middleware.dart @@ -45,6 +45,6 @@ final class FirebaseAuthMiddleware extends AuthMiddleware { if (token == null) { return null; } - return _tokenVerifier.verifyIdToken(token); + return _tokenVerifier.verify(token); } } diff --git a/packages/celest/lib/src/runtime/auth/firebase/firebase_public_key_store.dart b/packages/celest/lib/src/runtime/auth/firebase/firebase_public_key_store.dart index 42307ec..620ff67 100644 --- a/packages/celest/lib/src/runtime/auth/firebase/firebase_public_key_store.dart +++ b/packages/celest/lib/src/runtime/auth/firebase/firebase_public_key_store.dart @@ -4,9 +4,9 @@ library; import 'dart:async'; import 'dart:convert'; +import 'package:celest/src/core/context.dart'; import 'package:celest_core/celest_core.dart'; import 'package:http/http.dart' as http; -import 'package:http/retry.dart' as http; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; import 'package:x509/x509.dart' hide AlgorithmIdentifier; @@ -39,15 +39,8 @@ final class FirebasePublicKeyStore { Map? _publicKeys; Future> _loadPublicKeys() async { - final client = http.RetryClient( - http.Client(), - retries: 3, - onRetry: (request, response, retryCount) { - _logger.warning('Retrying request to ${request.url} ($retryCount)'); - }, - ); try { - final response = await client.get(_publicKeysUri); + final response = await context.httpClient.get(_publicKeysUri); if (response.statusCode != 200) { throw http.ClientException( 'Failed to load public keys: ${response.statusCode}\n' @@ -87,8 +80,6 @@ final class FirebasePublicKeyStore { } on Object catch (e, st) { _logger.severe('Failed to load public keys', e, st); throw CloudException.internalServerError('Failed to load public keys'); - } finally { - client.close(); } } } diff --git a/packages/celest/lib/src/runtime/auth/firebase/firebase_token_verifier.dart b/packages/celest/lib/src/runtime/auth/firebase/firebase_token_verifier.dart index 18af001..6eca520 100644 --- a/packages/celest/lib/src/runtime/auth/firebase/firebase_token_verifier.dart +++ b/packages/celest/lib/src/runtime/auth/firebase/firebase_token_verifier.dart @@ -30,9 +30,9 @@ final class FirebaseTokenVerifier { return _decoder.convert(decoded); } - /// Verifies the given [idToken] and returns the user identified by the token. - Future verifyIdToken(String idToken) async { - final (header, payload, signature) = switch (idToken.split('.')) { + /// Verifies the given [token] and returns the user identified by the token. + Future verify(String token) async { + final (header, payload, signature) = switch (token.split('.')) { [final header, final payload, final signature] => ( header, payload, diff --git a/packages/celest/lib/src/runtime/auth/supabase/supabase_auth_middleware.dart b/packages/celest/lib/src/runtime/auth/supabase/supabase_auth_middleware.dart new file mode 100644 index 0000000..5a6c35e --- /dev/null +++ b/packages/celest/lib/src/runtime/auth/supabase/supabase_auth_middleware.dart @@ -0,0 +1,39 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:celest/src/runtime/auth/auth_middleware.dart'; +import 'package:celest/src/runtime/auth/supabase/supabase_token_verifier.dart'; +import 'package:celest_core/celest_core.dart' show User; +import 'package:collection/collection.dart'; +import 'package:shelf/shelf.dart' show Request; + +/// {@template celest.runtime.supabase_auth_middleware} +/// A Celest authentication middleware which authenticates users with Supabase +/// as the external auth provider. +/// {@endtemplate} +final class SupabaseAuthMiddleware extends AuthMiddleware { + /// {@macro celest.runtime.supabase_auth_middleware} + SupabaseAuthMiddleware({ + required String url, + Uint8List? jwtSecret, + this.required = false, + }) : _tokenVerifier = SupabaseTokenVerifier(url: url, jwtSecret: jwtSecret); + + final SupabaseTokenVerifier _tokenVerifier; + + @override + final bool required; + + @override + Future authenticate(Request request) async { + final token = switch (request.headers['Authorization']?.split(' ')) { + [final type, final token] when equalsIgnoreAsciiCase(type, 'bearer') => + token, + _ => null, + }; + if (token == null) { + return null; + } + return _tokenVerifier.verify(token); + } +} diff --git a/packages/celest/lib/src/runtime/auth/supabase/supabase_token_verifier.dart b/packages/celest/lib/src/runtime/auth/supabase/supabase_token_verifier.dart new file mode 100644 index 0000000..1a7a33e --- /dev/null +++ b/packages/celest/lib/src/runtime/auth/supabase/supabase_token_verifier.dart @@ -0,0 +1,112 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:celest/celest.dart'; +import 'package:celest/src/runtime/auth/jwt/base64_raw_url.dart'; +import 'package:crypto_keys/crypto_keys.dart' + show AlgorithmIdentifier, Signature, SymmetricKey; + +/// {@template celest.runtime.supabase_token_verifier} +/// Verifies access tokens issued by Supabase by issuing a request to the +/// Supabase `/auth/v1/user` endpoint. +/// {@endtemplate} +final class SupabaseTokenVerifier { + /// Creates an instance of [SupabaseTokenVerifier] for the Supabase project + /// at the given [url]. + SupabaseTokenVerifier({ + required String url, + Uint8List? jwtSecret, + }) : _userUri = Uri.parse(url).resolve('./auth/v1/user'), + _jwtSecret = switch (jwtSecret) { + final secret? => SymmetricKey(keyValue: secret), + _ => null, + }; + + final Uri _userUri; + final SymmetricKey? _jwtSecret; + static final _decoder = utf8.decoder.fuse(json.decoder); + + Object? _decodeJwtPart(String part) { + final decoded = base64RawUrl.decode(part); + return _decoder.convert(decoded); + } + + User? _verifyLocally(String token) { + final jwtSecret = _jwtSecret; + if (jwtSecret == null) { + return null; + } + final (header, payload, signature) = switch (token.split('.')) { + [final header, final payload, final signature] => ( + header, + payload, + signature + ), + _ => throw const FormatException('Invalid JWT format'), + }; + final alg = switch (_decodeJwtPart(header)) { + { + 'alg': final String alg, + } => + AlgorithmIdentifier.getByJwaName(alg), + _ => throw const FormatException('Invalid JWT header'), + }; + if (alg == null || alg.name != 'sig/HMAC/SHA-256') { + throw FormatException('Invalid JWT algorithm: $alg'); + } + final data = utf8.encode('$header.$payload'); + final verifier = jwtSecret.createVerifier(alg); + final validSignature = verifier.verify( + data, + Signature(base64RawUrl.decode(signature)), + ); + if (!validSignature) { + throw const CloudException.unauthorized('Invalid JWT signature'); + } + final claims = switch (_decodeJwtPart(payload)) { + final Map payload => payload, + _ => throw const FormatException('Invalid JWT payload'), + }; + return User( + userId: claims['sub'] as String, + email: claims['email'] as String?, + ); + } + + /// Verifies the given [token] and returns the authenticated user. + Future verify(String token) async { + if (_verifyLocally(token) case final user?) { + return user; + } + context.logger.fine('Local Supabase verification skipped'); + final response = await context.httpClient.get( + _userUri, + headers: { + 'Accept': 'application/json', + 'Authorization': 'Bearer $token', + }, + ); + if (response.statusCode != 200) { + throw CloudException.fromHttpResponse(response); + } + try { + final jsonResp = jsonDecode(response.body) as Map; + if (jsonResp case {'user': final Map user}) { + final userId = user['id'] as String; + final email = user['email'] as String?; + final emailVerified = + (user['email_confirmed_at'] ?? user['confirmed_at']) != null; + return User( + userId: userId, + email: email, + emailVerified: emailVerified, + ); + } + // Shouldn't ever happened for a well-formed response. + throw FormatException('Bad user JSON: $jsonResp'); + } on Object catch (e, st) { + context.logger.severe('Failed to parse Supabase response', e, st); + rethrow; + } + } +} diff --git a/packages/celest/lib/src/runtime/http/logging.dart b/packages/celest/lib/src/runtime/http/logging.dart index b558b76..1f244ba 100644 --- a/packages/celest/lib/src/runtime/http/logging.dart +++ b/packages/celest/lib/src/runtime/http/logging.dart @@ -23,7 +23,7 @@ void configureLogging() { level: record.level.value, name: record.loggerName, zone: record.zone, - error: record.error, + error: record.error?.toString(), stackTrace: record.stackTrace, ); } diff --git a/packages/celest/lib/src/runtime/serve.dart b/packages/celest/lib/src/runtime/serve.dart index e1f349f..d496f92 100644 --- a/packages/celest/lib/src/runtime/serve.dart +++ b/packages/celest/lib/src/runtime/serve.dart @@ -21,6 +21,7 @@ import 'package:web_socket_channel/web_socket_channel.dart'; export 'auth/auth_middleware.dart'; export 'auth/firebase/firebase_auth_middleware.dart'; +export 'auth/supabase/supabase_auth_middleware.dart'; part 'targets.dart';