Skip to content
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
26 changes: 24 additions & 2 deletions .github/workflows/dart-package-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand All @@ -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
10 changes: 10 additions & 0 deletions .github/workflows/gotrue.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions infra/gotrue/docker-compose.jwk.yml
Original file line number Diff line number Diff line change
@@ -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"}]'
14 changes: 14 additions & 0 deletions packages/gotrue/.env.example
Original file line number Diff line number Diff line change
@@ -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"}]
18 changes: 15 additions & 3 deletions packages/gotrue/lib/src/gotrue_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ 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';
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';
Expand Down Expand Up @@ -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 <JWK>[]),
);

// If symmetric algorithm, fallback to getUser()
if (signingKey == null) {
Expand All @@ -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');
}
Expand Down
9 changes: 0 additions & 9 deletions packages/gotrue/lib/src/types/jwt.dart
Original file line number Diff line number Diff line change
@@ -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')
Expand Down Expand Up @@ -266,9 +262,4 @@ class JWK {
}
return json;
}

RSAPublicKey get rsaPublicKey {
final bytes = utf8.encode(json.encode(toJson()));
return RSAPublicKey.bytes(bytes);
}
}
6 changes: 5 additions & 1 deletion packages/gotrue/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
32 changes: 32 additions & 0 deletions packages/gotrue/test/get_claims_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 1 addition & 7 deletions packages/gotrue/test/src/gotrue_admin_mfa_api_test.dart
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

Expand Down
10 changes: 3 additions & 7 deletions packages/gotrue/test/src/gotrue_admin_oauth_api_test.dart
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
60 changes: 56 additions & 4 deletions packages/gotrue/test/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<dynamic> keysArray = json.decode(jwtKeysJson) as List<dynamic>;
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<String, dynamic>;
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
Expand Down