diff --git a/.github/workflows/dart-package-test.yml b/.github/workflows/dart-package-test.yml index c730351a..d9cb2b19 100644 --- a/.github/workflows/dart-package-test.yml +++ b/.github/workflows/dart-package-test.yml @@ -20,6 +20,10 @@ on: required: false type: string description: 'The directory containing docker-compose.yml (e.g., infra/gotrue)' + docker-compose-files: + required: false + type: string + description: 'Optional whitespace/newline-separated list of docker compose files to pass via -f (e.g., "docker-compose.yml docker-compose.override.yml"). If omitted, runs `docker compose up` with default file discovery.' test-concurrency: required: false type: number @@ -84,7 +88,16 @@ jobs: if: ${{ inputs.needs-docker }} run: | cd ../../${{ inputs.docker-compose-dir }} - docker compose up -d + COMPOSE_FILES='${{ inputs.docker-compose-files }}' + if [ -n "${COMPOSE_FILES//[[:space:]]/}" ]; then + COMPOSE_ARGS="" + for f in $COMPOSE_FILES; do + COMPOSE_ARGS="$COMPOSE_ARGS -f $f" + done + docker compose $COMPOSE_ARGS up -d + else + docker compose up -d + fi - name: Wait for services to be ready if: ${{ inputs.needs-docker }} @@ -102,4 +115,13 @@ jobs: if: ${{ inputs.needs-docker && always() }} run: | cd ../../${{ inputs.docker-compose-dir }} - docker compose down + COMPOSE_FILES='${{ inputs.docker-compose-files }}' + if [ -n "${COMPOSE_FILES//[[:space:]]/}" ]; then + COMPOSE_ARGS="" + for f in $COMPOSE_FILES; do + COMPOSE_ARGS="$COMPOSE_ARGS -f $f" + done + docker compose $COMPOSE_ARGS down + else + docker compose down + fi diff --git a/.github/workflows/gotrue.yml b/.github/workflows/gotrue.yml index 69c9e44d..f73415ff 100644 --- a/.github/workflows/gotrue.yml +++ b/.github/workflows/gotrue.yml @@ -31,3 +31,13 @@ jobs: needs-docker: true docker-compose-dir: infra/gotrue test-concurrency: 1 + + test-jwks: + uses: ./.github/workflows/dart-package-test.yml + with: + package-name: gotrue + working-directory: packages/gotrue + needs-docker: true + docker-compose-dir: infra/gotrue + docker-compose-files: docker-compose.yml docker-compose.jwk.yml + test-concurrency: 1 diff --git a/infra/gotrue/docker-compose.jwk.yml b/infra/gotrue/docker-compose.jwk.yml new file mode 100644 index 00000000..d89c826c --- /dev/null +++ b/infra/gotrue/docker-compose.jwk.yml @@ -0,0 +1,6 @@ +services: + gotrue: + # Minimal override for asymmetric JWT signing + JWKS publishing. + # Used with: docker compose -f docker-compose.yml -f docker-compose.jwk.yml up + environment: + GOTRUE_JWT_KEYS: '[{"kty":"EC","kid":"23203d4b-184b-4915-bb30-e70047967f88","use":"sig","key_ops":["sign","verify"],"alg":"ES256","ext":true,"d":"sVoSxECYxh-gfZFCYU3U8vbjH2cHSwtc4_uDmhMRIUo","crv":"P-256","x":"uXsLvkycPMsWg8v-8CGqbwhqCG9YNrlQKFyZL96puXo","y":"xGyOad6_Dg0UpiTmpdOP1kn9W8LNM3afTpqAv2ZHM8M"}]' diff --git a/packages/gotrue/.env.example b/packages/gotrue/.env.example new file mode 100644 index 00000000..bdad29e4 --- /dev/null +++ b/packages/gotrue/.env.example @@ -0,0 +1,14 @@ +# GoTrue test environment configuration +# Copy this file to .env and configure for your test setup + +# Default GoTrue URL and anon key +GOTRUE_URL=http://localhost:9998 +GOTRUE_TOKEN=anonKey + +# Symmetric JWT signing (HS256) - used by default docker-compose.yml +GOTRUE_JWT_SECRET=37c304f8-51aa-419a-a1af-06154e63707a + +# Asymmetric JWT signing (ES256) - used when docker-compose.jwk.yml is active +# Uncomment this line when running tests with JWK setup: +# docker compose -f infra/gotrue/docker-compose.yml -f infra/gotrue/docker-compose.jwk.yml up +# GOTRUE_JWT_KEYS=[{"kty":"EC","kid":"23203d4b-184b-4915-bb30-e70047967f88","use":"sig","key_ops":["sign","verify"],"alg":"ES256","ext":true,"d":"sVoSxECYxh-gfZFCYU3U8vbjH2cHSwtc4_uDmhMRIUo","crv":"P-256","x":"uXsLvkycPMsWg8v-8CGqbwhqCG9YNrlQKFyZL96puXo","y":"xGyOad6_Dg0UpiTmpdOP1kn9W8LNM3afTpqAv2ZHM8M"}] diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart index d752047a..43332549 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'dart:math'; import 'package:collection/collection.dart'; -import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:gotrue/gotrue.dart'; import 'package:gotrue/src/constants.dart'; import 'package:gotrue/src/fetch.dart'; @@ -11,6 +10,7 @@ import 'package:gotrue/src/helper.dart'; import 'package:gotrue/src/types/auth_response.dart'; import 'package:gotrue/src/types/fetch_options.dart'; import 'package:http/http.dart'; +import 'package:jose_plus/jose.dart' as jose; import 'package:jwt_decode/jwt_decode.dart'; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; @@ -1417,7 +1417,10 @@ class GoTrueClient { final signingKey = (decoded.header.alg.startsWith('HS') || decoded.header.kid == null) ? null - : await _fetchJwk(decoded.header.kid!, _jwks!); + : await _fetchJwk( + decoded.header.kid!, + _jwks ?? JWKSet(keys: const []), + ); // If symmetric algorithm, fallback to getUser() if (signingKey == null) { @@ -1429,11 +1432,20 @@ class GoTrueClient { } try { - JWT.verify(token, signingKey.rsaPublicKey); + final keyStore = jose.JsonWebKeyStore(); + keyStore.addKey(jose.JsonWebKey.fromJson(signingKey.toJson())); + + final jwt = jose.JsonWebToken.unverified(token); + final isValid = await jwt.verify(keyStore); + if (!isValid) { + throw AuthInvalidJwtException('Invalid JWT signature'); + } return GetClaimsResponse( claims: decoded.payload, header: decoded.header, signature: decoded.signature); + } on AuthInvalidJwtException { + rethrow; } catch (e) { throw AuthInvalidJwtException('Invalid JWT signature: $e'); } diff --git a/packages/gotrue/lib/src/types/jwt.dart b/packages/gotrue/lib/src/types/jwt.dart index 17cea07d..d3e6ebc1 100644 --- a/packages/gotrue/lib/src/types/jwt.dart +++ b/packages/gotrue/lib/src/types/jwt.dart @@ -1,7 +1,3 @@ -import 'dart:convert'; - -import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; - /// JWT Header structure class JwtHeader { /// Algorithm used to sign the JWT (e.g., 'RS256', 'ES256', 'HS256') @@ -266,9 +262,4 @@ class JWK { } return json; } - - RSAPublicKey get rsaPublicKey { - final bytes = utf8.encode(json.encode(toJson())); - return RSAPublicKey.bytes(bytes); - } } diff --git a/packages/gotrue/pubspec.yaml b/packages/gotrue/pubspec.yaml index f3e08e80..e77043a2 100644 --- a/packages/gotrue/pubspec.yaml +++ b/packages/gotrue/pubspec.yaml @@ -12,19 +12,23 @@ dependencies: collection: ^1.15.0 crypto: ^3.0.2 http: ">=0.13.0 <2.0.0" + jose_plus: ^0.4.7 jwt_decode: ^0.3.1 retry: ^3.1.0 rxdart: ">=0.27.7 <0.29.0" meta: ^1.7.0 logging: ^1.2.0 web: ">=0.5.0 <2.0.0" - dart_jsonwebtoken: ">=2.17.0 <4.0.0" dev_dependencies: dotenv: ^4.1.0 + dart_jsonwebtoken: ">=2.17.0 <4.0.0" lints: ^3.0.0 test: ^1.16.4 otp: ^3.1.3 false_secrets: - /infra/docker-compose.yml + - /infra/gotrue/docker-compose.jwk.yml + - /packages/gotrue/.env + - /packages/gotrue/.env.example diff --git a/packages/gotrue/test/get_claims_test.dart b/packages/gotrue/test/get_claims_test.dart index 876bfc8e..bd471aa1 100644 --- a/packages/gotrue/test/get_claims_test.dart +++ b/packages/gotrue/test/get_claims_test.dart @@ -165,6 +165,38 @@ void main() { expect(claimsResponse.claims.claims['email'], newEmail); }); + test('getClaims() verifies asymmetric JWT via JWKS (ES*/RS*)', () async { + final response = await client.signUp( + email: newEmail, + password: password, + ); + + expect(response.session, isNotNull); + final accessToken = response.session!.accessToken; + + final decoded = decodeJwt(accessToken); + + // The default local test stack uses HS* signing (via GOTRUE_JWT_SECRET). + // This test is meant to exercise the JWKS verification path, so we skip + // unless the server is issuing asymmetric JWTs with a kid. + if (decoded.header.alg.startsWith('HS')) { + markTestSkipped( + 'Server is issuing HS* JWTs; Skipping Asymmetric JWKS verification test.', + ); + return; + } + + expect(decoded.header.kid, isNotNull); + + // First call should fetch /.well-known/jwks.json and verify. + final claimsResponse1 = await client.getClaims(accessToken); + expect(claimsResponse1.claims.claims['email'], newEmail); + + // Second call should succeed too (exercise cached JWKS path). + final claimsResponse2 = await client.getClaims(accessToken); + expect(claimsResponse2.claims.claims['email'], newEmail); + }); + test('getClaims() contains all standard JWT claims', () async { final response = await client.signUp( email: newEmail, diff --git a/packages/gotrue/test/src/gotrue_admin_mfa_api_test.dart b/packages/gotrue/test/src/gotrue_admin_mfa_api_test.dart index 112219b3..1fabc40a 100644 --- a/packages/gotrue/test/src/gotrue_admin_mfa_api_test.dart +++ b/packages/gotrue/test/src/gotrue_admin_mfa_api_test.dart @@ -1,4 +1,3 @@ -import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:dotenv/dotenv.dart'; import 'package:gotrue/gotrue.dart'; import 'package:http/http.dart' as http; @@ -12,12 +11,7 @@ void main() { env.load(); // Load env variables from .env file final gotrueUrl = env['GOTRUE_URL'] ?? 'http://localhost:9998'; - final serviceRoleToken = JWT( - {'role': 'service_role'}, - ).sign( - SecretKey( - env['GOTRUE_JWT_SECRET'] ?? '37c304f8-51aa-419a-a1af-06154e63707a'), - ); + final serviceRoleToken = getServiceRoleToken(env); late GoTrueClient client; diff --git a/packages/gotrue/test/src/gotrue_admin_oauth_api_test.dart b/packages/gotrue/test/src/gotrue_admin_oauth_api_test.dart index dfb16d82..389f29af 100644 --- a/packages/gotrue/test/src/gotrue_admin_oauth_api_test.dart +++ b/packages/gotrue/test/src/gotrue_admin_oauth_api_test.dart @@ -1,21 +1,17 @@ -import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:dotenv/dotenv.dart'; import 'package:gotrue/gotrue.dart'; import 'package:http/http.dart' as http; import 'package:test/test.dart'; +import '../utils.dart'; + void main() { final env = DotEnv(); env.load(); // Load env variables from .env file final gotrueUrl = env['GOTRUE_URL'] ?? 'http://localhost:9998'; - final serviceRoleToken = JWT( - {'role': 'service_role'}, - ).sign( - SecretKey( - env['GOTRUE_JWT_SECRET'] ?? '37c304f8-51aa-419a-a1af-06154e63707a'), - ); + final serviceRoleToken = getServiceRoleToken(env); late GoTrueClient client; diff --git a/packages/gotrue/test/utils.dart b/packages/gotrue/test/utils.dart index 10b4f5b9..b4dbec1f 100644 --- a/packages/gotrue/test/utils.dart +++ b/packages/gotrue/test/utils.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:dotenv/dotenv.dart'; import 'package:gotrue/gotrue.dart'; +import 'package:jose_plus/jose.dart' as jose; /// Email of a user with unverified factor const email1 = 'fake1@email.com'; @@ -39,15 +40,66 @@ String getNewPhone() { return '$timestamp'; } +/// Generates a service role JWT token for authentication with GoTrue. +/// +/// Supports two modes: +/// 1. Symmetric signing (HS256): Uses GOTRUE_JWT_SECRET +/// 2. Asymmetric signing (ES256/RS256): Uses GOTRUE_JWT_KEYS +/// +/// The mode is automatically detected based on the presence of GOTRUE_JWT_KEYS. String getServiceRoleToken(DotEnv env) { + final jwtKeys = env['GOTRUE_JWT_KEYS']; + + // If GOTRUE_JWT_KEYS is set, use asymmetric signing (ES256/RS256) + if (jwtKeys != null && jwtKeys.isNotEmpty) { + return _getServiceRoleTokenAsymmetric(jwtKeys); + } + + // Otherwise, use symmetric signing (HS256) + final secret = + env['GOTRUE_JWT_SECRET'] ?? '37c304f8-51aa-419a-a1af-06154e63707a'; + return _getServiceRoleTokenSymmetric(secret); +} + +/// Creates a service role token using symmetric HS256 signing. +String _getServiceRoleTokenSymmetric(String secret) { return JWT( { 'role': 'service_role', }, - ).sign( - SecretKey( - env['GOTRUE_JWT_SECRET'] ?? '37c304f8-51aa-419a-a1af-06154e63707a'), - ); + ).sign(SecretKey(secret)); +} + +/// Creates a service role token using asymmetric signing (ES256/RS256). +/// +/// [jwtKeysJson] should be a JSON array of JWKs (JSON Web Keys), typically from the GOTRUE_JWT_KEYS environment variable. +/// The first key in the array is used to sign the token. +String _getServiceRoleTokenAsymmetric(String jwtKeysJson) { + try { + final List keysArray = json.decode(jwtKeysJson) as List; + if (keysArray.isEmpty) { + throw Exception('Input json array has no JWT keys'); + } + + // Use the first key from the array + final keyData = keysArray.first as Map; + final jwk = jose.JsonWebKey.fromJson(keyData); + + // Create JWT claims + final claims = jose.JsonWebTokenClaims.fromJson({ + 'role': 'service_role', + }); + + // Create and sign the token + final builder = jose.JsonWebSignatureBuilder() + ..jsonContent = claims.toJson() + ..addRecipient(jwk, algorithm: keyData['alg'] as String?); + + final jws = builder.build(); + return jws.toCompactSerialization(); + } catch (e) { + throw Exception('Failed to create asymmetric service role token: $e'); + } } /// Construct session data for a given expiration date