diff --git a/packages/gotrue/lib/gotrue.dart b/packages/gotrue/lib/gotrue.dart index 0799ee52..7ace5ca1 100644 --- a/packages/gotrue/lib/gotrue.dart +++ b/packages/gotrue/lib/gotrue.dart @@ -4,10 +4,12 @@ export 'src/constants.dart' hide Constants, GenerateLinkTypeExtended, AuthChangeEventExtended; export 'src/gotrue_admin_api.dart'; export 'src/gotrue_client.dart'; +export 'src/helper.dart' show decodeJwt, validateExp; export 'src/types/auth_exception.dart'; export 'src/types/auth_response.dart' hide ToSnakeCase; export 'src/types/auth_state.dart'; export 'src/types/gotrue_async_storage.dart'; +export 'src/types/jwt.dart'; export 'src/types/mfa.dart'; export 'src/types/types.dart'; export 'src/types/session.dart'; diff --git a/packages/gotrue/lib/src/base64url.dart b/packages/gotrue/lib/src/base64url.dart new file mode 100644 index 00000000..c9ffa550 --- /dev/null +++ b/packages/gotrue/lib/src/base64url.dart @@ -0,0 +1,14 @@ +import 'dart:convert'; + +class Base64Url { + /// Decodes a base64url string to a UTF-8 string + static String decodeToString(String input) { + final normalized = base64Url.normalize(input); + return utf8.decode(base64Url.decode(normalized)); + } + + static List decodeToBytes(String input) { + final normalized = base64Url.normalize(input); + return base64Url.decode(normalized); + } +} diff --git a/packages/gotrue/lib/src/constants.dart b/packages/gotrue/lib/src/constants.dart index c44d5ff4..82437fea 100644 --- a/packages/gotrue/lib/src/constants.dart +++ b/packages/gotrue/lib/src/constants.dart @@ -24,6 +24,9 @@ class Constants { /// The name of the header that contains API version. static const apiVersionHeaderName = 'x-supabase-api-version'; + + /// The TTL for the JWKS cache. + static const jwksTtl = Duration(minutes: 10); } class ApiVersions { diff --git a/packages/gotrue/lib/src/gotrue_client.dart b/packages/gotrue/lib/src/gotrue_client.dart index 5c69bc13..44981749 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -3,6 +3,7 @@ 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'; @@ -58,6 +59,9 @@ class GoTrueClient { /// Completer to combine multiple simultaneous token refresh requests. Completer? _refreshTokenCompleter; + JWKSet? _jwks; + DateTime? _jwksCachedAt; + final _onAuthStateChangeController = BehaviorSubject(); final _onAuthStateChangeControllerSync = BehaviorSubject(sync: true); @@ -1336,4 +1340,104 @@ class GoTrueClient { ); return exception; } + + Future _fetchJwk(String kid, JWKSet suppliedJwks) async { + // try fetching from the supplied jwks + final jwk = suppliedJwks.keys.firstWhereOrNull((jwk) => jwk.kid == kid); + if (jwk != null) { + return jwk; + } + + final now = DateTime.now(); + + // try fetching from cache + final cachedJwk = _jwks?.keys.firstWhereOrNull((jwk) => jwk.kid == kid); + + // jwks exists and it isn't stale + if (cachedJwk != null && + _jwksCachedAt != null && + _jwksCachedAt!.add(Constants.jwksTtl).isAfter(now)) { + return cachedJwk; + } + + // jwk isn't cached in memory so we need to fetch it from the well-known endpoint + final jwksResponse = await _fetch.request( + '$_url/.well-known/jwks.json', + RequestMethodType.get, + options: GotrueRequestOptions(headers: _headers), + ); + + final jwks = JWKSet.fromJson(jwksResponse as Map); + + if (jwks.keys.isEmpty) { + return null; + } + + _jwks = jwks; + _jwksCachedAt = now; + + // find the signing key + return jwks.keys.firstWhereOrNull((jwk) => jwk.kid == kid); + } + + /// Extracts the JWT claims present in the access token by first verifying the + /// JWT against the server's JSON Web Key Set endpoint + /// `/.well-known/jwks.json` which is often cached, resulting in significantly + /// faster responses. Prefer this method over [getUser] which always + /// sends a request to the Auth server for each JWT. + /// + /// If the project is not using an asymmetric JWT signing key (like ECC or + /// RSA) it always sends a request to the Auth server (similar to [getUser]) to verify the JWT. + /// [jwt] An optional specific JWT you wish to verify, not the one you + /// can obtain from [currentSession]. + /// [options] Various additional options that allow you to customize the + /// behavior of this method. + /// + /// Returns a [GetClaimsResponse] containing the JWT claims, or throws an [AuthException] on error. + Future getClaims([ + String? jwt, + GetClaimsOptions? options, + ]) async { + String token = jwt ?? ''; + + if (token.isEmpty) { + final session = currentSession; + if (session == null) { + throw AuthSessionMissingException('No session found'); + } + token = session.accessToken; + } + + // Decode the JWT to get the payload + final decoded = decodeJwt(token); + + // Validate expiration unless allowExpired is true + if (!(options?.allowExpired ?? false)) { + validateExp(decoded.payload.exp); + } + + final signingKey = + (decoded.header.alg.startsWith('HS') || decoded.header.kid == null) + ? null + : await _fetchJwk(decoded.header.kid!, _jwks!); + + // If symmetric algorithm, fallback to getUser() + if (signingKey == null) { + await getUser(token); + return GetClaimsResponse( + claims: decoded.payload, + header: decoded.header, + signature: decoded.signature); + } + + try { + JWT.verify(token, signingKey.rsaPublicKey); + return GetClaimsResponse( + claims: decoded.payload, + header: decoded.header, + signature: decoded.signature); + } catch (e) { + throw AuthInvalidJwtException('Invalid JWT signature: $e'); + } + } } diff --git a/packages/gotrue/lib/src/helper.dart b/packages/gotrue/lib/src/helper.dart index 920a2b63..efdcd8b4 100644 --- a/packages/gotrue/lib/src/helper.dart +++ b/packages/gotrue/lib/src/helper.dart @@ -2,6 +2,9 @@ import 'dart:convert'; import 'dart:math'; import 'package:crypto/crypto.dart'; +import 'package:gotrue/src/base64url.dart'; +import 'package:gotrue/src/types/auth_exception.dart'; +import 'package:gotrue/src/types/jwt.dart'; /// Converts base 10 int into String representation of base 16 int and takes the last two digets. String dec2hex(int dec) { @@ -30,3 +33,60 @@ void validateUuid(String id) { throw ArgumentError('Invalid id: $id, must be a valid UUID'); } } + +/// Decodes a JWT token without performing validation +/// +/// Returns a [DecodedJwt] containing the header, payload, signature, and raw parts. +/// Throws [AuthInvalidJwtException] if the JWT structure is invalid. +DecodedJwt decodeJwt(String token) { + final parts = token.split('.'); + if (parts.length != 3) { + throw AuthInvalidJwtException('Invalid JWT structure'); + } + + final rawHeader = parts[0]; + final rawPayload = parts[1]; + final rawSignature = parts[2]; + + try { + // Decode header + final headerJson = Base64Url.decodeToString(rawHeader); + final header = JwtHeader.fromJson(json.decode(headerJson)); + + // Decode payload + final payloadJson = Base64Url.decodeToString(rawPayload); + final payload = JwtPayload.fromJson(json.decode(payloadJson)); + + // Decode signature + final signature = Base64Url.decodeToBytes(rawSignature); + + return DecodedJwt( + header: header, + payload: payload, + signature: signature, + raw: JwtRawParts( + header: rawHeader, + payload: rawPayload, + signature: rawSignature, + ), + ); + } catch (e) { + if (e is AuthInvalidJwtException) { + rethrow; + } + throw AuthInvalidJwtException('Failed to decode JWT: $e'); + } +} + +/// Validates the expiration time of a JWT +/// +/// Throws [AuthException] if the exp claim is missing or the JWT has expired. +void validateExp(int? exp) { + if (exp == null) { + throw AuthException('Missing exp claim'); + } + final timeNow = DateTime.now().millisecondsSinceEpoch / 1000; + if (exp <= timeNow) { + throw AuthException('JWT has expired'); + } +} diff --git a/packages/gotrue/lib/src/types/auth_exception.dart b/packages/gotrue/lib/src/types/auth_exception.dart index 7df1f11c..958d01c7 100644 --- a/packages/gotrue/lib/src/types/auth_exception.dart +++ b/packages/gotrue/lib/src/types/auth_exception.dart @@ -103,3 +103,15 @@ class AuthWeakPasswordException extends AuthException { String toString() => 'AuthWeakPasswordException(message: $message, statusCode: $statusCode, reasons: $reasons)'; } + +class AuthInvalidJwtException extends AuthException { + AuthInvalidJwtException(super.message) + : super( + statusCode: '400', + code: 'invalid_jwt', + ); + + @override + String toString() => + 'AuthInvalidJwtException(message: $message, statusCode: $statusCode, code: $code)'; +} diff --git a/packages/gotrue/lib/src/types/jwt.dart b/packages/gotrue/lib/src/types/jwt.dart new file mode 100644 index 00000000..17cea07d --- /dev/null +++ b/packages/gotrue/lib/src/types/jwt.dart @@ -0,0 +1,274 @@ +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') + final String alg; + + /// Key ID - identifies which key was used to sign the JWT + final String? kid; + + /// Token type - typically 'JWT' + final String? typ; + + JwtHeader({ + required this.alg, + this.kid, + this.typ, + }); + + factory JwtHeader.fromJson(Map json) { + return JwtHeader( + alg: json['alg'] as String, + kid: json['kid'] as String?, + typ: json['typ'] as String?, + ); + } + + Map toJson() { + return { + 'alg': alg, + if (kid != null) 'kid': kid, + if (typ != null) 'typ': typ, + }; + } +} + +/// JWT Payload structure with standard claims +class JwtPayload { + /// Issuer - identifies principal that issued the JWT + final String? iss; + + /// Subject - identifies the subject of the JWT + final String? sub; + + /// Audience - identifies recipients that the JWT is intended for + final dynamic aud; + + /// Expiration time - timestamp after which the JWT must not be accepted + final int? exp; + + /// Not Before - timestamp before which the JWT must not be accepted + final int? nbf; + + /// Issued At - timestamp when the JWT was issued + final int? iat; + + /// JWT ID - unique identifier for the JWT + final String? jti; + + /// Additional claims stored in the payload + final Map claims; + + JwtPayload({ + this.iss, + this.sub, + this.aud, + this.exp, + this.nbf, + this.iat, + this.jti, + Map? claims, + }) : claims = claims ?? {}; + + factory JwtPayload.fromJson(Map json) { + return JwtPayload( + iss: json['iss'] as String?, + sub: json['sub'] as String?, + aud: json['aud'], + exp: json['exp'] as int?, + nbf: json['nbf'] as int?, + iat: json['iat'] as int?, + jti: json['jti'] as String?, + claims: Map.from(json), + ); + } + + Map toJson() { + return Map.from(claims); + } +} + +/// Decoded JWT structure +class DecodedJwt { + /// JWT header + final JwtHeader header; + + /// JWT payload + final JwtPayload payload; + + /// JWT signature as raw bytes + final List signature; + + /// Raw encoded parts of the JWT + final JwtRawParts raw; + + DecodedJwt({ + required this.header, + required this.payload, + required this.signature, + required this.raw, + }); +} + +/// Raw encoded parts of a JWT +class JwtRawParts { + /// Raw base64url encoded header + final String header; + + /// Raw base64url encoded payload + final String payload; + + /// Raw base64url encoded signature + final String signature; + + JwtRawParts({ + required this.header, + required this.payload, + required this.signature, + }); +} + +/// Response from getClaims method +class GetClaimsResponse { + /// JWT claims from the payload + final JwtPayload claims; + + /// JWT header + final JwtHeader header; + + /// JWT signature + final List signature; + + GetClaimsResponse({ + required this.claims, + required this.header, + required this.signature, + }); +} + +/// Options for getClaims method +class GetClaimsOptions { + /// If set to `true`, the `exp` claim will not be validated against the current time. + /// This allows you to extract claims from expired JWTs without getting an error. + final bool allowExpired; + + const GetClaimsOptions({ + this.allowExpired = false, + }); +} + +class JWKSet { + final List keys; + + JWKSet({required this.keys}); + + factory JWKSet.fromJson(Map json) { + final keys = (json['keys'] as List?) + ?.map((e) => JWK.fromJson(e as Map)) + .toList() ?? + []; + return JWKSet(keys: keys); + } + + Map toJson() { + return { + 'keys': keys.map((e) => e.toJson()).toList(), + }; + } +} + +/// {@template jwk} +/// JSON Web Key (JWK) representation. +/// {@endtemplate} +class JWK { + /// The "kty" (key type) parameter identifies the cryptographic algorithm + /// family used with the key, such as "RSA" or "EC". + final String kty; + + /// The "key_ops" (key operations) parameter identifies the cryptographic + /// operations for which the key is intended to be used. + final List keyOps; + + /// The "alg" (algorithm) parameter identifies the algorithm intended for + /// use with the key. + final String? alg; + + /// The "kid" (key ID) parameter is used to match a specific key. + final String? kid; + + /// Additional arbitrary properties of the JWK. + final Map _additionalProperties; + + /// {@macro jwk} + JWK({ + required this.kty, + required this.keyOps, + this.alg, + this.kid, + Map? additionalProperties, + }) : _additionalProperties = additionalProperties ?? {}; + + /// Creates a [JWK] from a JSON map. + factory JWK.fromJson(Map json) { + final kty = json['kty'] as String; + final keyOps = + (json['key_ops'] as List?)?.map((e) => e as String).toList() ?? + []; + final alg = json['alg'] as String?; + final kid = json['kid'] as String?; + + final Map additionalProperties = Map.from(json); + additionalProperties.remove('kty'); + additionalProperties.remove('key_ops'); + additionalProperties.remove('alg'); + additionalProperties.remove('kid'); + + return JWK( + kty: kty, + keyOps: keyOps, + alg: alg, + kid: kid, + additionalProperties: additionalProperties, + ); + } + + /// Allows accessing additional properties using operator[]. + dynamic operator [](String key) { + switch (key) { + case 'kty': + return kty; + case 'key_ops': + return keyOps; + case 'alg': + return alg; + case 'kid': + return kid; + default: + return _additionalProperties[key]; + } + } + + /// Converts this [JWK] to a JSON map. + Map toJson() { + final Map json = { + 'kty': kty, + 'key_ops': keyOps, + ..._additionalProperties, + }; + if (alg != null) { + json['alg'] = alg; + } + if (kid != null) { + json['kid'] = kid; + } + 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 ab1535ec..49df5d29 100644 --- a/packages/gotrue/pubspec.yaml +++ b/packages/gotrue/pubspec.yaml @@ -1,26 +1,26 @@ name: gotrue description: A dart client library for the GoTrue API. version: 2.16.0 -homepage: 'https://supabase.com' -repository: 'https://github.com/supabase/supabase-flutter/tree/main/packages/gotrue' -documentation: 'https://supabase.com/docs/reference/dart/auth-signup' +homepage: "https://supabase.com" +repository: "https://github.com/supabase/supabase-flutter/tree/main/packages/gotrue" +documentation: "https://supabase.com/docs/reference/dart/auth-signup" environment: - sdk: '>=3.3.0 <4.0.0' + sdk: ">=3.3.0 <4.0.0" dependencies: collection: ^1.15.0 crypto: ^3.0.2 - http: '>=0.13.0 <2.0.0' + http: ">=0.13.0 <2.0.0" jwt_decode: ^0.3.1 retry: ^3.1.0 - rxdart: '>=0.27.7 <0.29.0' + rxdart: ">=0.27.7 <0.29.0" meta: ^1.7.0 logging: ^1.2.0 - web: '>=0.5.0 <2.0.0' + web: ">=0.5.0 <2.0.0" + dart_jsonwebtoken: ">=2.17.0 <4.0.0" dev_dependencies: - dart_jsonwebtoken: ^2.4.1 dotenv: ^4.1.0 lints: ^3.0.0 test: ^1.16.4 diff --git a/packages/gotrue/test/get_claims_test.dart b/packages/gotrue/test/get_claims_test.dart new file mode 100644 index 00000000..876bfc8e --- /dev/null +++ b/packages/gotrue/test/get_claims_test.dart @@ -0,0 +1,279 @@ +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(); + + final gotrueUrl = env['GOTRUE_URL'] ?? 'http://localhost:9998'; + final anonToken = env['GOTRUE_TOKEN'] ?? 'anonKey'; + + group('getClaims', () { + late GoTrueClient client; + late String newEmail; + + setUp(() async { + final res = await http.post( + Uri.parse('http://localhost:3000/rpc/reset_and_init_auth_data'), + headers: {'x-forwarded-for': '127.0.0.1'}); + if (res.body.isNotEmpty) throw res.body; + + newEmail = getNewEmail(); + + final asyncStorage = TestAsyncStorage(); + + client = GoTrueClient( + url: gotrueUrl, + headers: { + 'Authorization': 'Bearer $anonToken', + 'apikey': anonToken, + }, + asyncStorage: asyncStorage, + flowType: AuthFlowType.implicit, + ); + }); + + test('getClaims() with valid JWT from current session', () async { + // Sign up a user first + final response = await client.signUp( + email: newEmail, + password: password, + ); + + expect(response.session, isNotNull); + + // Get claims from current session + final claimsResponse = await client.getClaims(); + final claims = claimsResponse.claims; + + expect(claims.sub, isNotNull); + expect(claims.claims['email'], newEmail); + expect(claims.claims['role'], isNotNull); + expect(claimsResponse.claims.aud, isNotNull); + expect(claims.exp, isNotNull); + expect(claims.iat, isNotNull); + }); + + test('getClaims() with explicit JWT parameter', () async { + // Sign up a user first + final response = await client.signUp( + email: newEmail, + password: password, + ); + + expect(response.session, isNotNull); + final accessToken = response.session!.accessToken; + + // Get claims by passing JWT explicitly + final claimsResponse = await client.getClaims(accessToken); + final claims = claimsResponse.claims; + + expect(claims.sub, isNotNull); + expect(claims.claims['email'], newEmail); + }); + + test('getClaims() throws when no session exists', () async { + // Ensure no session exists + if (client.currentSession != null) { + await client.signOut(); + } + + expect( + () => client.getClaims(), + throwsA(isA()), + ); + }); + + test('getClaims() throws with invalid JWT', () async { + const invalidJwt = 'invalid.jwt.token'; + + expect( + () => client.getClaims(invalidJwt), + throwsA(isA()), + ); + }); + + test('getClaims() throws with expired JWT', () async { + // This is an expired JWT token (exp is in the past) + const expiredJwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjoxNTE2MjM5MDIyfQ.4Adcj0vVzr2Nzz_KKAKrVZsLZyTBGv9-Ey8SN0p7Kzs'; + + expect( + () => client.getClaims(expiredJwt), + throwsA(isA()), + ); + }); + + test('getClaims() with allowExpired option allows expired JWT', () async { + // This is an expired JWT token (exp is in the past) + const expiredJwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjoxNTE2MjM5MDIyfQ.4Adcj0vVzr2Nzz_KKAKrVZsLZyTBGv9-Ey8SN0p7Kzs'; + + // With allowExpired, we should be able to decode the JWT + // Note: This will still fail at getUser() because the token is invalid on the server + // but the expiration check should pass + try { + await client.getClaims( + expiredJwt, + GetClaimsOptions(allowExpired: true), + ); + // If we get here, the exp validation was skipped + } on AuthException catch (e) { + // We expect this to fail during getUser() verification, + // not during exp validation + expect(e.message, isNot(contains('expired'))); + } + }); + + test('getClaims() with options parameter (allowExpired false)', () async { + final response = await client.signUp( + email: newEmail, + password: password, + ); + + expect(response.session, isNotNull); + + // Should work normally with allowExpired: false + final claimsResponse = await client.getClaims( + null, + GetClaimsOptions(allowExpired: false), + ); + + expect(claimsResponse.claims, isNotNull); + expect(claimsResponse.claims.claims['email'], newEmail); + }); + + test('getClaims() verifies JWT with server', () async { + // Sign up a user + final response = await client.signUp( + email: newEmail, + password: password, + ); + + expect(response.session, isNotNull); + final accessToken = response.session!.accessToken; + + // Get claims - this should verify with server via getUser() + final claimsResponse = await client.getClaims(accessToken); + + // If we get here without error, verification succeeded + expect(claimsResponse.claims, isNotNull); + expect(claimsResponse.claims.claims['email'], newEmail); + }); + + test('getClaims() contains all standard JWT claims', () async { + final response = await client.signUp( + email: newEmail, + password: password, + ); + + expect(response.session, isNotNull); + + final claimsResponse = await client.getClaims(); + final claims = claimsResponse.claims; + + // Check for standard JWT claims + expect(claims.sub, isNotNull); // Subject + expect(claims.aud, isNotNull); // Audience + expect(claims.exp, isNotNull); // Expiration + expect(claims.iat, isNotNull); // Issued at + expect(claims.claims['role'], isNotNull); // Role + + // Check for Supabase-specific claims + expect(claims.claims['email'], isNotNull); + }); + + test('getClaims() with user metadata in claims', () async { + final metadata = {'custom_field': 'custom_value', 'number': 42}; + + final response = await client.signUp( + email: newEmail, + password: password, + data: metadata, + ); + + expect(response.session, isNotNull); + + final claimsResponse = await client.getClaims(); + final claims = claimsResponse.claims; + + // The user metadata should be accessible via the user object + // which is verified through getUser() call + expect(claims, isNotNull); + }); + }); + + group('JWT helper functions', () { + test('decodeJwt() successfully decodes valid JWT', () { + // A sample JWT with known values + final jwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InRlc3Qta2lkIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjk5OTk5OTk5OTl9.XyI0rWcOYLpz3R8G8qHWmg7U-tWMHJqzN_e1oDQKzgc'; + + final decoded = decodeJwt(jwt); + + expect(decoded.header.alg, 'HS256'); + expect(decoded.header.typ, 'JWT'); + expect(decoded.header.kid, 'test-kid'); + + expect(decoded.payload.sub, '1234567890'); + expect(decoded.payload.claims['name'], 'John Doe'); + expect(decoded.payload.iat, 1516239022); + expect(decoded.payload.exp, 9999999999); + }); + + test('decodeJwt() throws on invalid JWT structure', () { + const invalidJwt = 'not.a.valid'; + + expect( + () => decodeJwt(invalidJwt), + throwsA(isA()), + ); + }); + + test('decodeJwt() throws on JWT with wrong number of parts', () { + const invalidJwt = 'only.two.parts.extra'; + + expect( + () => decodeJwt(invalidJwt), + throwsA(isA()), + ); + }); + + test('decodeJwt() throws on malformed base64', () { + const invalidJwt = 'invalid!!!.invalid!!!.invalid!!!'; + + expect( + () => decodeJwt(invalidJwt), + throwsA(isA()), + ); + }); + + test('validateExp() throws on expired token', () { + final pastTime = DateTime.now().subtract(Duration(hours: 1)); + final exp = pastTime.millisecondsSinceEpoch ~/ 1000; + + expect( + () => validateExp(exp), + throwsA(isA()), + ); + }); + + test('validateExp() succeeds on valid token', () { + final futureTime = DateTime.now().add(Duration(hours: 1)); + final exp = futureTime.millisecondsSinceEpoch ~/ 1000; + + expect(() => validateExp(exp), returnsNormally); + }); + + test('validateExp() throws on null exp', () { + expect( + () => validateExp(null), + throwsA(isA()), + ); + }); + }); +} diff --git a/packages/gotrue/test/src/base64url_test.dart b/packages/gotrue/test/src/base64url_test.dart new file mode 100644 index 00000000..038647df --- /dev/null +++ b/packages/gotrue/test/src/base64url_test.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:gotrue/src/base64url.dart'; +import 'package:test/test.dart'; + +void main() { + group('Base64Url', () { + group('decode', () { + test('decodes empty string', () { + final result = Base64Url.decodeToBytes(''); + expect(result, isEmpty); + }); + + test('decodes simple data', () { + final result = Base64Url.decodeToBytes('aGVsbG8'); + expect(utf8.decode(result), 'hello'); + }); + + test('decodes data with padding', () { + final result = Base64Url.decodeToBytes('aGVsbG8='); + expect(utf8.decode(result), 'hello'); + }); + + test('decodes data with multiple padding chars', () { + final result = Base64Url.decodeToBytes('YQ=='); + expect(utf8.decode(result), 'a'); + }); + + test('decodes base64url alphabet (- and _)', () { + // "--8" in base64url decodes to [251, 239] + final result = Base64Url.decodeToBytes('--8'); + expect(result, equals(Uint8List.fromList([251, 239]))); + }); + + test('decodes with loose mode ignores padding errors', () { + // Invalid padding but should work in loose mode + final result = Base64Url.decodeToBytes('YQ'); + expect(utf8.decode(result), 'a'); + }); + + test('throws on invalid characters', () { + expect( + () => Base64Url.decodeToBytes('invalid!!!'), + throwsA(isA()), + ); + }); + }); + + group('JWT compatibility', () { + test('decodes JWT header', () { + // Standard JWT header: {"alg":"HS256","typ":"JWT"} + const jwtHeader = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'; + final decoded = Base64Url.decodeToString(jwtHeader); + final json = jsonDecode(decoded); + expect(json['alg'], 'HS256'); + expect(json['typ'], 'JWT'); + }); + + test('decodes JWT payload', () { + // Standard JWT payload with sub, name, iat + const jwtPayload = + 'eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ'; + final decoded = Base64Url.decodeToString(jwtPayload); + final json = jsonDecode(decoded); + expect(json['sub'], '1234567890'); + expect(json['name'], 'John Doe'); + expect(json['iat'], 1516239022); + }); + + test('handles JWT signature bytes', () { + // JWT signature is binary data + const jwtSignature = 'SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'; + final decoded = Base64Url.decodeToBytes(jwtSignature); + expect(decoded, isA>()); + expect(decoded.length, greaterThan(0)); + }); + }); + }); +} diff --git a/packages/supabase_flutter/pubspec.yaml b/packages/supabase_flutter/pubspec.yaml index 0be0192e..f0f9bdc1 100644 --- a/packages/supabase_flutter/pubspec.yaml +++ b/packages/supabase_flutter/pubspec.yaml @@ -25,7 +25,7 @@ dependencies: web: '>=0.5.0 <2.0.0' dev_dependencies: - dart_jsonwebtoken: ^2.4.1 + dart_jsonwebtoken: ">=2.17.0 <4.0.0" flutter_test: sdk: flutter flutter_lints: ^3.0.1