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(auth): Add support for Supabase IDP #192

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
23 changes: 11 additions & 12 deletions packages/celest/lib/src/auth/auth_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,24 +61,19 @@ 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.
///
/// When using Supabase as your identity provider, users are managed entirely
/// 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;
}

Expand Down Expand Up @@ -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'),
});

Expand All @@ -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;
}
15 changes: 15 additions & 0 deletions packages/celest/lib/src/config/env.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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)';
}
50 changes: 50 additions & 0 deletions packages/celest/lib/src/core/context.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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<V extends Object>(ContextKey<V> key) {
if (key.read(this) case final value?) {
return (this, value);
Expand Down Expand Up @@ -148,6 +192,12 @@ abstract interface class ContextKey<V extends Object> {
/// The context key for the current [User].
static const ContextKey<User> principal = _PrincipalContextKey();

/// The context key for the context [http.Client].
static const ContextKey<http.Client> httpClient = ContextKey('http client');

/// The context key for for the context [Logger].
static const ContextKey<Logger> logger = ContextKey('logger');

/// Reads the value for `this` from the given [context].
V? read(Context context);

Expand Down
7 changes: 7 additions & 0 deletions packages/celest/lib/src/runtime/auth/auth_middleware.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -56,6 +58,7 @@ final class _OneOfAuthMiddleware extends AuthMiddleware {

@override
Future<User?> authenticate(shelf.Request request) async {
(Object, StackTrace)? internalError;
for (final middleware in middlewares) {
try {
final user = await middleware.authenticate(request);
Expand All @@ -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');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,6 @@ final class FirebaseAuthMiddleware extends AuthMiddleware {
if (token == null) {
return null;
}
return _tokenVerifier.verifyIdToken(token);
return _tokenVerifier.verify(token);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -39,15 +39,8 @@ final class FirebasePublicKeyStore {

Map<String, X509Certificate>? _publicKeys;
Future<Map<String, X509Certificate>> _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'
Expand Down Expand Up @@ -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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ final class FirebaseTokenVerifier {
return _decoder.convert(decoded);
}

/// Verifies the given [idToken] and returns the user identified by the token.
Future<User> verifyIdToken(String idToken) async {
final (header, payload, signature) = switch (idToken.split('.')) {
/// Verifies the given [token] and returns the user identified by the token.
Future<User> verify(String token) async {
final (header, payload, signature) = switch (token.split('.')) {
[final header, final payload, final signature] => (
header,
payload,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<User?> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Object?> 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<User> 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<String, Object?>;
if (jsonResp case {'user': final Map<String, Object?> 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;
}
}
}
Loading
Loading