Skip to content

Commit c20ec04

Browse files
committed
fix(gotrue): fix getClaims JWT token decoding
- update tests - add jose_plus package - update CI and docker infra
1 parent 8e0993c commit c20ec04

File tree

11 files changed

+166
-33
lines changed

11 files changed

+166
-33
lines changed

.github/workflows/dart-package-test.yml

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ on:
2020
required: false
2121
type: string
2222
description: 'The directory containing docker-compose.yml (e.g., infra/gotrue)'
23+
docker-compose-files:
24+
required: false
25+
type: string
26+
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.'
2327
test-concurrency:
2428
required: false
2529
type: number
@@ -84,7 +88,16 @@ jobs:
8488
if: ${{ inputs.needs-docker }}
8589
run: |
8690
cd ../../${{ inputs.docker-compose-dir }}
87-
docker compose up -d
91+
COMPOSE_FILES='${{ inputs.docker-compose-files }}'
92+
if [ -n "${COMPOSE_FILES//[[:space:]]/}" ]; then
93+
COMPOSE_ARGS=""
94+
for f in $COMPOSE_FILES; do
95+
COMPOSE_ARGS="$COMPOSE_ARGS -f $f"
96+
done
97+
docker compose $COMPOSE_ARGS up -d
98+
else
99+
docker compose up -d
100+
fi
88101
89102
- name: Wait for services to be ready
90103
if: ${{ inputs.needs-docker }}
@@ -102,4 +115,13 @@ jobs:
102115
if: ${{ inputs.needs-docker && always() }}
103116
run: |
104117
cd ../../${{ inputs.docker-compose-dir }}
105-
docker compose down
118+
COMPOSE_FILES='${{ inputs.docker-compose-files }}'
119+
if [ -n "${COMPOSE_FILES//[[:space:]]/}" ]; then
120+
COMPOSE_ARGS=""
121+
for f in $COMPOSE_FILES; do
122+
COMPOSE_ARGS="$COMPOSE_ARGS -f $f"
123+
done
124+
docker compose $COMPOSE_ARGS down
125+
else
126+
docker compose down
127+
fi

.github/workflows/gotrue.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,13 @@ jobs:
3131
needs-docker: true
3232
docker-compose-dir: infra/gotrue
3333
test-concurrency: 1
34+
35+
test-jwks:
36+
uses: ./.github/workflows/dart-package-test.yml
37+
with:
38+
package-name: gotrue
39+
working-directory: packages/gotrue
40+
needs-docker: true
41+
docker-compose-dir: infra/gotrue
42+
docker-compose-files: docker-compose.yml docker-compose.jwk.yml
43+
test-concurrency: 1
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
services:
2+
gotrue:
3+
# Minimal override for asymmetric JWT signing + JWKS publishing.
4+
# Used with: docker compose -f docker-compose.yml -f docker-compose.jwk.yml up
5+
environment:
6+
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"}]'

packages/gotrue/.env.example

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# GoTrue test environment configuration
2+
# Copy this file to .env and configure for your test setup
3+
4+
# Default GoTrue URL and anon key
5+
GOTRUE_URL=http://localhost:9998
6+
GOTRUE_TOKEN=anonKey
7+
8+
# Symmetric JWT signing (HS256) - used by default docker-compose.yml
9+
GOTRUE_JWT_SECRET=37c304f8-51aa-419a-a1af-06154e63707a
10+
11+
# Asymmetric JWT signing (ES256) - used when docker-compose.jwk.yml is active
12+
# Uncomment this line when running tests with JWK setup:
13+
# docker compose -f infra/gotrue/docker-compose.yml -f infra/gotrue/docker-compose.jwk.yml up
14+
# 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"}]

packages/gotrue/lib/src/gotrue_client.dart

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@ import 'dart:convert';
33
import 'dart:math';
44

55
import 'package:collection/collection.dart';
6-
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
76
import 'package:gotrue/gotrue.dart';
87
import 'package:gotrue/src/constants.dart';
98
import 'package:gotrue/src/fetch.dart';
109
import 'package:gotrue/src/helper.dart';
1110
import 'package:gotrue/src/types/auth_response.dart';
1211
import 'package:gotrue/src/types/fetch_options.dart';
1312
import 'package:http/http.dart';
13+
import 'package:jose_plus/jose.dart' as jose;
1414
import 'package:jwt_decode/jwt_decode.dart';
1515
import 'package:logging/logging.dart';
1616
import 'package:meta/meta.dart';
@@ -1417,7 +1417,10 @@ class GoTrueClient {
14171417
final signingKey =
14181418
(decoded.header.alg.startsWith('HS') || decoded.header.kid == null)
14191419
? null
1420-
: await _fetchJwk(decoded.header.kid!, _jwks!);
1420+
: await _fetchJwk(
1421+
decoded.header.kid!,
1422+
_jwks ?? JWKSet(keys: const <JWK>[]),
1423+
);
14211424

14221425
// If symmetric algorithm, fallback to getUser()
14231426
if (signingKey == null) {
@@ -1429,11 +1432,20 @@ class GoTrueClient {
14291432
}
14301433

14311434
try {
1432-
JWT.verify(token, signingKey.rsaPublicKey);
1435+
final keyStore = jose.JsonWebKeyStore();
1436+
keyStore.addKey(jose.JsonWebKey.fromJson(signingKey.toJson()));
1437+
1438+
final jwt = jose.JsonWebToken.unverified(token);
1439+
final isValid = await jwt.verify(keyStore);
1440+
if (!isValid) {
1441+
throw AuthInvalidJwtException('Invalid JWT signature');
1442+
}
14331443
return GetClaimsResponse(
14341444
claims: decoded.payload,
14351445
header: decoded.header,
14361446
signature: decoded.signature);
1447+
} on AuthInvalidJwtException {
1448+
rethrow;
14371449
} catch (e) {
14381450
throw AuthInvalidJwtException('Invalid JWT signature: $e');
14391451
}

packages/gotrue/lib/src/types/jwt.dart

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
import 'dart:convert';
2-
3-
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
4-
51
/// JWT Header structure
62
class JwtHeader {
73
/// Algorithm used to sign the JWT (e.g., 'RS256', 'ES256', 'HS256')
@@ -266,9 +262,4 @@ class JWK {
266262
}
267263
return json;
268264
}
269-
270-
RSAPublicKey get rsaPublicKey {
271-
final bytes = utf8.encode(json.encode(toJson()));
272-
return RSAPublicKey.bytes(bytes);
273-
}
274265
}

packages/gotrue/pubspec.yaml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,23 @@ dependencies:
1212
collection: ^1.15.0
1313
crypto: ^3.0.2
1414
http: ">=0.13.0 <2.0.0"
15+
jose_plus: ^0.4.7
1516
jwt_decode: ^0.3.1
1617
retry: ^3.1.0
1718
rxdart: ">=0.27.7 <0.29.0"
1819
meta: ^1.7.0
1920
logging: ^1.2.0
2021
web: ">=0.5.0 <2.0.0"
21-
dart_jsonwebtoken: ">=2.17.0 <4.0.0"
2222

2323
dev_dependencies:
2424
dotenv: ^4.1.0
25+
dart_jsonwebtoken: ">=2.17.0 <4.0.0"
2526
lints: ^3.0.0
2627
test: ^1.16.4
2728
otp: ^3.1.3
2829

2930
false_secrets:
3031
- /infra/docker-compose.yml
32+
- /infra/gotrue/docker-compose.jwk.yml
33+
- /packages/gotrue/.env
34+
- /packages/gotrue/.env.example

packages/gotrue/test/get_claims_test.dart

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,38 @@ void main() {
165165
expect(claimsResponse.claims.claims['email'], newEmail);
166166
});
167167

168+
test('getClaims() verifies asymmetric JWT via JWKS (ES*/RS*)', () async {
169+
final response = await client.signUp(
170+
email: newEmail,
171+
password: password,
172+
);
173+
174+
expect(response.session, isNotNull);
175+
final accessToken = response.session!.accessToken;
176+
177+
final decoded = decodeJwt(accessToken);
178+
179+
// The default local test stack uses HS* signing (via GOTRUE_JWT_SECRET).
180+
// This test is meant to exercise the JWKS verification path, so we skip
181+
// unless the server is issuing asymmetric JWTs with a kid.
182+
if (decoded.header.alg.startsWith('HS')) {
183+
markTestSkipped(
184+
'Server is issuing HS* JWTs; Skipping Asymmetric JWKS verification test.',
185+
);
186+
return;
187+
}
188+
189+
expect(decoded.header.kid, isNotNull);
190+
191+
// First call should fetch /.well-known/jwks.json and verify.
192+
final claimsResponse1 = await client.getClaims(accessToken);
193+
expect(claimsResponse1.claims.claims['email'], newEmail);
194+
195+
// Second call should succeed too (exercise cached JWKS path).
196+
final claimsResponse2 = await client.getClaims(accessToken);
197+
expect(claimsResponse2.claims.claims['email'], newEmail);
198+
});
199+
168200
test('getClaims() contains all standard JWT claims', () async {
169201
final response = await client.signUp(
170202
email: newEmail,

packages/gotrue/test/src/gotrue_admin_mfa_api_test.dart

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
21
import 'package:dotenv/dotenv.dart';
32
import 'package:gotrue/gotrue.dart';
43
import 'package:http/http.dart' as http;
@@ -12,12 +11,7 @@ void main() {
1211
env.load(); // Load env variables from .env file
1312

1413
final gotrueUrl = env['GOTRUE_URL'] ?? 'http://localhost:9998';
15-
final serviceRoleToken = JWT(
16-
{'role': 'service_role'},
17-
).sign(
18-
SecretKey(
19-
env['GOTRUE_JWT_SECRET'] ?? '37c304f8-51aa-419a-a1af-06154e63707a'),
20-
);
14+
final serviceRoleToken = getServiceRoleToken(env);
2115

2216
late GoTrueClient client;
2317

packages/gotrue/test/src/gotrue_admin_oauth_api_test.dart

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,17 @@
1-
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
21
import 'package:dotenv/dotenv.dart';
32
import 'package:gotrue/gotrue.dart';
43
import 'package:http/http.dart' as http;
54
import 'package:test/test.dart';
65

6+
import '../utils.dart';
7+
78
void main() {
89
final env = DotEnv();
910

1011
env.load(); // Load env variables from .env file
1112

1213
final gotrueUrl = env['GOTRUE_URL'] ?? 'http://localhost:9998';
13-
final serviceRoleToken = JWT(
14-
{'role': 'service_role'},
15-
).sign(
16-
SecretKey(
17-
env['GOTRUE_JWT_SECRET'] ?? '37c304f8-51aa-419a-a1af-06154e63707a'),
18-
);
14+
final serviceRoleToken = getServiceRoleToken(env);
1915

2016
late GoTrueClient client;
2117

0 commit comments

Comments
 (0)