Skip to content

Commit

Permalink
feat(auth): Add support for Supabase IDP (#192)
Browse files Browse the repository at this point in the history
Adds support for using Supabase as an external auth provider.
  • Loading branch information
dnys1 authored Oct 7, 2024
1 parent d3323e6 commit 6aff962
Show file tree
Hide file tree
Showing 11 changed files with 242 additions and 28 deletions.
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

0 comments on commit 6aff962

Please sign in to comment.