Skip to content

Commit bc59513

Browse files
grdsdevclaude
andcommitted
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]>
1 parent ebc4c20 commit bc59513

File tree

8 files changed

+797
-0
lines changed

8 files changed

+797
-0
lines changed

packages/gotrue/lib/gotrue.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export 'src/types/auth_exception.dart';
88
export 'src/types/auth_response.dart' hide ToSnakeCase;
99
export 'src/types/auth_state.dart';
1010
export 'src/types/gotrue_async_storage.dart';
11+
export 'src/types/jwt.dart';
1112
export 'src/types/mfa.dart';
1213
export 'src/types/types.dart';
1314
export 'src/types/session.dart';
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import 'dart:convert';
2+
import 'dart:typed_data';
3+
4+
/// Base64URL encoding and decoding utilities for JWT operations.
5+
/// Extracted and adapted from RFC 4648 specification.
6+
class Base64Url {
7+
static const String _chars =
8+
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
9+
static const int _bits = 6;
10+
11+
/// Decodes a base64url encoded string to bytes
12+
///
13+
/// [input] The base64url encoded string to decode
14+
/// [loose] If true, allows lenient parsing that doesn't strictly validate padding
15+
static Uint8List decode(String input, {bool loose = false}) {
16+
// Remove padding characters
17+
String string = input.replaceAll('=', '');
18+
19+
// Build character lookup table
20+
final Map<String, int> codes = {};
21+
for (int i = 0; i < _chars.length; i++) {
22+
codes[_chars[i]] = i;
23+
}
24+
25+
// For loose mode or when there's actual content, skip strict validation
26+
// The validation below will catch actual errors during decoding
27+
if (!loose && string.isNotEmpty) {
28+
final remainder = (string.length * _bits) % 8;
29+
// Allow if remainder is 0 or if it's 2 or 4 (valid base64 partial bytes)
30+
if (remainder != 0 && remainder != 2 && remainder != 4) {
31+
throw FormatException('Invalid base64url string length');
32+
}
33+
}
34+
35+
// Calculate output size
36+
final int outputLength = (string.length * _bits) ~/ 8;
37+
final Uint8List out = Uint8List(outputLength);
38+
39+
// Decode the string
40+
int bits = 0; // Number of bits currently in the buffer
41+
int buffer = 0; // Bits waiting to be written out, MSB first
42+
int written = 0; // Next byte to write
43+
44+
for (int i = 0; i < string.length; i++) {
45+
final String char = string[i];
46+
final int? value = codes[char];
47+
48+
if (value == null) {
49+
throw FormatException('Invalid character in base64url string: $char');
50+
}
51+
52+
// Append the bits to the buffer
53+
buffer = (buffer << _bits) | value;
54+
bits += _bits;
55+
56+
// Write out some bits if the buffer has a byte's worth
57+
if (bits >= 8) {
58+
bits -= 8;
59+
out[written++] = 0xff & (buffer >> bits);
60+
}
61+
}
62+
63+
// Verify that we have received just enough bits
64+
if (bits >= _bits || (0xff & (buffer << (8 - bits))) != 0) {
65+
if (!loose) {
66+
throw FormatException('Unexpected end of base64url data');
67+
}
68+
}
69+
70+
return out;
71+
}
72+
73+
/// Encodes bytes to a base64url encoded string
74+
///
75+
/// [data] The bytes to encode
76+
/// [pad] If true, adds padding characters to the output
77+
static String encode(List<int> data, {bool pad = false}) {
78+
final int mask = (1 << _bits) - 1;
79+
String out = '';
80+
81+
int bits = 0; // Number of bits currently in the buffer
82+
int buffer = 0; // Bits waiting to be written out, MSB first
83+
84+
for (int i = 0; i < data.length; i++) {
85+
// Slurp data into the buffer
86+
buffer = (buffer << 8) | (0xff & data[i]);
87+
bits += 8;
88+
89+
// Write out as much as we can
90+
while (bits > _bits) {
91+
bits -= _bits;
92+
out += _chars[mask & (buffer >> bits)];
93+
}
94+
}
95+
96+
// Handle partial character
97+
if (bits > 0) {
98+
out += _chars[mask & (buffer << (_bits - bits))];
99+
}
100+
101+
// Add padding characters until we hit a byte boundary
102+
if (pad) {
103+
while ((out.length * _bits) % 8 != 0) {
104+
out += '=';
105+
}
106+
}
107+
108+
return out;
109+
}
110+
111+
/// Decodes a base64url string to a UTF-8 string
112+
static String decodeToString(String input, {bool loose = false}) {
113+
final bytes = decode(input, loose: loose);
114+
return utf8.decode(bytes);
115+
}
116+
117+
/// Encodes a UTF-8 string to base64url
118+
static String encodeFromString(String input, {bool pad = false}) {
119+
final bytes = utf8.encode(input);
120+
return encode(bytes, pad: pad);
121+
}
122+
}

packages/gotrue/lib/src/gotrue_client.dart

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1336,4 +1336,51 @@ class GoTrueClient {
13361336
);
13371337
return exception;
13381338
}
1339+
1340+
/// Gets the claims from a JWT token.
1341+
///
1342+
/// This method verifies the JWT by calling [getUser] to validate against the server.
1343+
/// It supports both symmetric (HS256) and asymmetric (RS256, ES256) JWTs.
1344+
///
1345+
/// [jwt] The JWT token to get claims from. If not provided, uses the current session's access token.
1346+
///
1347+
/// Returns a [GetClaimsResponse] containing the JWT claims, or throws an [AuthException] on error.
1348+
///
1349+
/// Note: This is an experimental API and may change in future versions.
1350+
Future<GetClaimsResponse> getClaims([String? jwt]) async {
1351+
try {
1352+
String token = jwt ?? '';
1353+
1354+
if (token.isEmpty) {
1355+
final session = currentSession;
1356+
if (session == null) {
1357+
throw AuthSessionMissingException('No session found');
1358+
}
1359+
token = session.accessToken;
1360+
}
1361+
1362+
// Decode the JWT to get the payload
1363+
final decoded = decodeJwt(token);
1364+
1365+
// Validate expiration
1366+
validateExp(decoded.payload.exp);
1367+
1368+
// Verify the JWT by calling getUser
1369+
// This works for both symmetric and asymmetric JWTs
1370+
final userResponse = await getUser(token);
1371+
if (userResponse.user == null) {
1372+
throw AuthException('Failed to verify JWT');
1373+
}
1374+
1375+
// If getUser succeeds, the JWT is valid and we can trust the claims
1376+
return GetClaimsResponse(claims: decoded.payload.claims);
1377+
} on AuthException {
1378+
rethrow;
1379+
} catch (error) {
1380+
throw AuthUnknownException(
1381+
message: 'Unknown error occurred while getting claims',
1382+
originalError: error,
1383+
);
1384+
}
1385+
}
13391386
}

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, loose: true);
54+
final header = JwtHeader.fromJson(json.decode(headerJson));
55+
56+
// Decode payload
57+
final payloadJson = Base64Url.decodeToString(rawPayload, loose: true);
58+
final payload = JwtPayload.fromJson(json.decode(payloadJson));
59+
60+
// Decode signature
61+
final signature = Base64Url.decode(rawSignature, loose: true);
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)