Skip to content

Commit 615d687

Browse files
grdsdevclaude
andauthored
feat(gotrue): introduce getClaims method to verify and extract JWT claims (#1246)
* feat(gotrue): introduce getClaims method to verify and extract JWT claims This introduces a new `getClaims` method that supports verifying JWTs (both symmetric and asymmetric) and returns the entire set of claims in the JWT payload. Key changes: - Add `getClaims()` method to GoTrueClient for JWT verification and claims extraction - Implement base64url encoding/decoding utilities (RFC 4648) - Add JWT types: JwtHeader, JwtPayload, DecodedJwt, GetClaimsResponse - Add helper functions: decodeJwt() and validateExp() - Add AuthInvalidJwtException for JWT-related errors - Include comprehensive tests for getClaims, JWT helpers, and base64url utilities The method verifies JWTs by calling getUser() to validate against the server, supporting both HS256 (symmetric) and RS256/ES256 (asymmetric) algorithms. Note: This is an experimental API and may change in future versions. Ported from: supabase/auth-js#1030 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * feat(gotrue): make getClaims() non-experimental, add options parameter Following up on the initial getClaims implementation, this commit: - Removes experimental status from getClaims() method - Adds GetClaimsOptions class with allowExpired parameter - Updates getClaims() to accept optional options parameter - Improves documentation to better describe the method's behavior - Exports helper functions (decodeJwt, validateExp) for public use - Adds tests for allowExpired option The allowExpired option allows users to extract claims from expired JWTs without throwing an error during expiration validation. This is useful for scenarios where you need to access JWT data even after expiration. Ported from: supabase/auth-js#1078 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * feat(gotrue): clarify getClaims fallback behavior for key rotation Updates getClaims() documentation and comments to clarify that the method always uses server-side verification via getUser(). This approach gracefully handles edge cases such as: - Key rotation scenarios where JWKS cache might not have the new signing key - Symmetric JWTs (HS256) that require server-side verification - Revoked or invalidated tokens that are still unexpired This aligns the implementation intent with the auth-js behavior where getClaims() falls back to getUser() when the signing key is not found in JWKS or when client-side verification is not available. The Flutter implementation uses this server-side verification approach for all JWT types, providing robust and consistent validation regardless of the signing algorithm. Related: supabase/auth-js#1080 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * reimplement as claude did it wrong * fix tests * remove unused import * fix(gotrue): preserve padding in base64url encoding when requested Fixed the _base64ToBase64url method to preserve padding characters when pad=true is specified. Previously, padding was always stripped during conversion, causing encode(data, pad: true) to return unpadded output. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * invert condition to check for signingKey only once * fix: simplify base64url decoding * update pointycastle to latest versions * throw AuthInvalidJwtException error * fix: bump dart_jsonwebtoken * fix: use dart_jsonwebtoken for verifying jwt # Conflicts: # packages/gotrue/pubspec.yaml * downgrade dart_jsonwebtoken * fix: support dart_jsonwebtoken up to 4.0.0 --------- Co-authored-by: Claude <[email protected]>
1 parent dca33de commit 615d687

File tree

11 files changed

+837
-9
lines changed

11 files changed

+837
-9
lines changed

packages/gotrue/lib/gotrue.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ export 'src/constants.dart'
44
hide Constants, GenerateLinkTypeExtended, AuthChangeEventExtended;
55
export 'src/gotrue_admin_api.dart';
66
export 'src/gotrue_client.dart';
7+
export 'src/helper.dart' show decodeJwt, validateExp;
78
export 'src/types/auth_exception.dart';
89
export 'src/types/auth_response.dart' hide ToSnakeCase;
910
export 'src/types/auth_state.dart';
1011
export 'src/types/gotrue_async_storage.dart';
12+
export 'src/types/jwt.dart';
1113
export 'src/types/mfa.dart';
1214
export 'src/types/types.dart';
1315
export 'src/types/session.dart';
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import 'dart:convert';
2+
3+
class Base64Url {
4+
/// Decodes a base64url string to a UTF-8 string
5+
static String decodeToString(String input) {
6+
final normalized = base64Url.normalize(input);
7+
return utf8.decode(base64Url.decode(normalized));
8+
}
9+
10+
static List<int> decodeToBytes(String input) {
11+
final normalized = base64Url.normalize(input);
12+
return base64Url.decode(normalized);
13+
}
14+
}

packages/gotrue/lib/src/constants.dart

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ class Constants {
2424

2525
/// The name of the header that contains API version.
2626
static const apiVersionHeaderName = 'x-supabase-api-version';
27+
28+
/// The TTL for the JWKS cache.
29+
static const jwksTtl = Duration(minutes: 10);
2730
}
2831

2932
class ApiVersions {

packages/gotrue/lib/src/gotrue_client.dart

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

55
import 'package:collection/collection.dart';
6+
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
67
import 'package:gotrue/gotrue.dart';
78
import 'package:gotrue/src/constants.dart';
89
import 'package:gotrue/src/fetch.dart';
@@ -58,6 +59,9 @@ class GoTrueClient {
5859
/// Completer to combine multiple simultaneous token refresh requests.
5960
Completer<AuthResponse>? _refreshTokenCompleter;
6061

62+
JWKSet? _jwks;
63+
DateTime? _jwksCachedAt;
64+
6165
final _onAuthStateChangeController = BehaviorSubject<AuthState>();
6266
final _onAuthStateChangeControllerSync =
6367
BehaviorSubject<AuthState>(sync: true);
@@ -1336,4 +1340,104 @@ class GoTrueClient {
13361340
);
13371341
return exception;
13381342
}
1343+
1344+
Future<JWK?> _fetchJwk(String kid, JWKSet suppliedJwks) async {
1345+
// try fetching from the supplied jwks
1346+
final jwk = suppliedJwks.keys.firstWhereOrNull((jwk) => jwk.kid == kid);
1347+
if (jwk != null) {
1348+
return jwk;
1349+
}
1350+
1351+
final now = DateTime.now();
1352+
1353+
// try fetching from cache
1354+
final cachedJwk = _jwks?.keys.firstWhereOrNull((jwk) => jwk.kid == kid);
1355+
1356+
// jwks exists and it isn't stale
1357+
if (cachedJwk != null &&
1358+
_jwksCachedAt != null &&
1359+
_jwksCachedAt!.add(Constants.jwksTtl).isAfter(now)) {
1360+
return cachedJwk;
1361+
}
1362+
1363+
// jwk isn't cached in memory so we need to fetch it from the well-known endpoint
1364+
final jwksResponse = await _fetch.request(
1365+
'$_url/.well-known/jwks.json',
1366+
RequestMethodType.get,
1367+
options: GotrueRequestOptions(headers: _headers),
1368+
);
1369+
1370+
final jwks = JWKSet.fromJson(jwksResponse as Map<String, dynamic>);
1371+
1372+
if (jwks.keys.isEmpty) {
1373+
return null;
1374+
}
1375+
1376+
_jwks = jwks;
1377+
_jwksCachedAt = now;
1378+
1379+
// find the signing key
1380+
return jwks.keys.firstWhereOrNull((jwk) => jwk.kid == kid);
1381+
}
1382+
1383+
/// Extracts the JWT claims present in the access token by first verifying the
1384+
/// JWT against the server's JSON Web Key Set endpoint
1385+
/// `/.well-known/jwks.json` which is often cached, resulting in significantly
1386+
/// faster responses. Prefer this method over [getUser] which always
1387+
/// sends a request to the Auth server for each JWT.
1388+
///
1389+
/// If the project is not using an asymmetric JWT signing key (like ECC or
1390+
/// RSA) it always sends a request to the Auth server (similar to [getUser]) to verify the JWT.
1391+
/// [jwt] An optional specific JWT you wish to verify, not the one you
1392+
/// can obtain from [currentSession].
1393+
/// [options] Various additional options that allow you to customize the
1394+
/// behavior of this method.
1395+
///
1396+
/// Returns a [GetClaimsResponse] containing the JWT claims, or throws an [AuthException] on error.
1397+
Future<GetClaimsResponse> getClaims([
1398+
String? jwt,
1399+
GetClaimsOptions? options,
1400+
]) async {
1401+
String token = jwt ?? '';
1402+
1403+
if (token.isEmpty) {
1404+
final session = currentSession;
1405+
if (session == null) {
1406+
throw AuthSessionMissingException('No session found');
1407+
}
1408+
token = session.accessToken;
1409+
}
1410+
1411+
// Decode the JWT to get the payload
1412+
final decoded = decodeJwt(token);
1413+
1414+
// Validate expiration unless allowExpired is true
1415+
if (!(options?.allowExpired ?? false)) {
1416+
validateExp(decoded.payload.exp);
1417+
}
1418+
1419+
final signingKey =
1420+
(decoded.header.alg.startsWith('HS') || decoded.header.kid == null)
1421+
? null
1422+
: await _fetchJwk(decoded.header.kid!, _jwks!);
1423+
1424+
// If symmetric algorithm, fallback to getUser()
1425+
if (signingKey == null) {
1426+
await getUser(token);
1427+
return GetClaimsResponse(
1428+
claims: decoded.payload,
1429+
header: decoded.header,
1430+
signature: decoded.signature);
1431+
}
1432+
1433+
try {
1434+
JWT.verify(token, signingKey.rsaPublicKey);
1435+
return GetClaimsResponse(
1436+
claims: decoded.payload,
1437+
header: decoded.header,
1438+
signature: decoded.signature);
1439+
} catch (e) {
1440+
throw AuthInvalidJwtException('Invalid JWT signature: $e');
1441+
}
1442+
}
13391443
}

packages/gotrue/lib/src/helper.dart

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import 'dart:convert';
22
import 'dart:math';
33

44
import 'package:crypto/crypto.dart';
5+
import 'package:gotrue/src/base64url.dart';
6+
import 'package:gotrue/src/types/auth_exception.dart';
7+
import 'package:gotrue/src/types/jwt.dart';
58

69
/// Converts base 10 int into String representation of base 16 int and takes the last two digets.
710
String dec2hex(int dec) {
@@ -30,3 +33,60 @@ void validateUuid(String id) {
3033
throw ArgumentError('Invalid id: $id, must be a valid UUID');
3134
}
3235
}
36+
37+
/// Decodes a JWT token without performing validation
38+
///
39+
/// Returns a [DecodedJwt] containing the header, payload, signature, and raw parts.
40+
/// Throws [AuthInvalidJwtException] if the JWT structure is invalid.
41+
DecodedJwt decodeJwt(String token) {
42+
final parts = token.split('.');
43+
if (parts.length != 3) {
44+
throw AuthInvalidJwtException('Invalid JWT structure');
45+
}
46+
47+
final rawHeader = parts[0];
48+
final rawPayload = parts[1];
49+
final rawSignature = parts[2];
50+
51+
try {
52+
// Decode header
53+
final headerJson = Base64Url.decodeToString(rawHeader);
54+
final header = JwtHeader.fromJson(json.decode(headerJson));
55+
56+
// Decode payload
57+
final payloadJson = Base64Url.decodeToString(rawPayload);
58+
final payload = JwtPayload.fromJson(json.decode(payloadJson));
59+
60+
// Decode signature
61+
final signature = Base64Url.decodeToBytes(rawSignature);
62+
63+
return DecodedJwt(
64+
header: header,
65+
payload: payload,
66+
signature: signature,
67+
raw: JwtRawParts(
68+
header: rawHeader,
69+
payload: rawPayload,
70+
signature: rawSignature,
71+
),
72+
);
73+
} catch (e) {
74+
if (e is AuthInvalidJwtException) {
75+
rethrow;
76+
}
77+
throw AuthInvalidJwtException('Failed to decode JWT: $e');
78+
}
79+
}
80+
81+
/// Validates the expiration time of a JWT
82+
///
83+
/// Throws [AuthException] if the exp claim is missing or the JWT has expired.
84+
void validateExp(int? exp) {
85+
if (exp == null) {
86+
throw AuthException('Missing exp claim');
87+
}
88+
final timeNow = DateTime.now().millisecondsSinceEpoch / 1000;
89+
if (exp <= timeNow) {
90+
throw AuthException('JWT has expired');
91+
}
92+
}

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,15 @@ class AuthWeakPasswordException extends AuthException {
103103
String toString() =>
104104
'AuthWeakPasswordException(message: $message, statusCode: $statusCode, reasons: $reasons)';
105105
}
106+
107+
class AuthInvalidJwtException extends AuthException {
108+
AuthInvalidJwtException(super.message)
109+
: super(
110+
statusCode: '400',
111+
code: 'invalid_jwt',
112+
);
113+
114+
@override
115+
String toString() =>
116+
'AuthInvalidJwtException(message: $message, statusCode: $statusCode, code: $code)';
117+
}

0 commit comments

Comments
 (0)