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..d3f7fc5a --- /dev/null +++ b/packages/gotrue/lib/src/base64url.dart @@ -0,0 +1,91 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +/// Base64URL encoding and decoding utilities for JWT operations. +/// Uses dart:convert for the core base64 operations and converts to/from base64url format. +class Base64Url { + /// Decodes a base64url encoded string to bytes + /// + /// [input] The base64url encoded string to decode + /// [loose] If true, allows lenient parsing that doesn't strictly validate padding + static Uint8List decode(String input, {bool loose = false}) { + // Convert base64url to base64 by replacing characters and adding padding + String base64 = _base64urlToBase64(input); + + try { + return base64Decode(base64); + } catch (e) { + if (loose) { + // Try to decode with minimal padding adjustments + return _decodeLoose(input); + } + rethrow; + } + } + + /// Encodes bytes to a base64url encoded string + /// + /// [data] The bytes to encode + /// [pad] If true, adds padding characters to the output + static String encode(List data, {bool pad = false}) { + // Use dart:convert base64 encoding + String base64 = base64Encode(data); + + // Convert base64 to base64url + String base64url = _base64ToBase64url(base64); + + // Remove padding if not requested + if (!pad) { + base64url = base64url.replaceAll('=', ''); + } + + return base64url; + } + + /// Decodes a base64url string to a UTF-8 string + static String decodeToString(String input, {bool loose = false}) { + final bytes = decode(input, loose: loose); + return utf8.decode(bytes); + } + + /// Encodes a UTF-8 string to base64url + static String encodeFromString(String input, {bool pad = false}) { + final bytes = utf8.encode(input); + return encode(bytes, pad: pad); + } + + /// Converts base64url to base64 format + static String _base64urlToBase64(String base64url) { + // Replace base64url characters with base64 characters + String base64 = base64url.replaceAll('-', '+').replaceAll('_', '/'); + + // Add padding if needed + int paddingLength = (4 - (base64.length % 4)) % 4; + return base64 + '=' * paddingLength; + } + + /// Converts base64 to base64url format + static String _base64ToBase64url(String base64) { + // Replace characters (keep padding as-is) + return base64.replaceAll('+', '-').replaceAll('/', '_'); + } + + /// Loose decoding for malformed base64url strings + static Uint8List _decodeLoose(String input) { + // Try to fix common issues and decode + String fixed = input; + + // Add minimal padding if needed + if (fixed.length % 4 != 0) { + fixed += '=' * (4 - (fixed.length % 4)); + } + + String base64 = _base64urlToBase64(fixed); + + try { + return base64Decode(base64); + } catch (e) { + throw FormatException('Invalid base64url string: $input'); + } + } +} 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..2e66ecee 100644 --- a/packages/gotrue/lib/src/gotrue_client.dart +++ b/packages/gotrue/lib/src/gotrue_client.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math'; +import 'dart:typed_data'; import 'package:collection/collection.dart'; import 'package:gotrue/gotrue.dart'; @@ -13,6 +14,7 @@ import 'package:http/http.dart'; import 'package:jwt_decode/jwt_decode.dart'; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; +import 'package:pointycastle/export.dart'; import 'package:retry/retry.dart'; import 'package:rxdart/subjects.dart'; @@ -58,6 +60,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 +1341,123 @@ 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); + } + + // For symmetric algorithms (HS256, HS384, HS512) or missing kid, use server verification + if (decoded.header.kid == null || decoded.header.alg.startsWith('HS')) { + await getUser(token); + return GetClaimsResponse( + claims: decoded.payload, + header: decoded.header, + signature: decoded.signature); + } + + final signingKey = + (decoded.header.kid == null || decoded.header.alg.startsWith('HS')) + ? 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); + } + + final publicKey = RSAPublicKey(signingKey['n'], signingKey['e']); + final signer = RSASigner(SHA256Digest(), '0609608648016503040201'); // PKCS1 + signer.init(false, PublicKeyParameter(publicKey)); + + final signature = RSASignature(Uint8List.fromList(decoded.signature)); + final isValidSignature = signer.verifySignature( + Uint8List.fromList( + utf8.encode('${decoded.raw.header}.${decoded.raw.payload}')), + signature, + ); + + if (!isValidSignature) { + throw AuthException('Invalid JWT signature'); + } + + return GetClaimsResponse( + claims: decoded.payload, + header: decoded.header, + signature: decoded.signature); + } } diff --git a/packages/gotrue/lib/src/helper.dart b/packages/gotrue/lib/src/helper.dart index 920a2b63..9373bb95 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, loose: true); + final header = JwtHeader.fromJson(json.decode(headerJson)); + + // Decode payload + final payloadJson = Base64Url.decodeToString(rawPayload, loose: true); + final payload = JwtPayload.fromJson(json.decode(payloadJson)); + + // Decode signature + final signature = Base64Url.decode(rawSignature, loose: true); + + 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..d3e6ebc1 --- /dev/null +++ b/packages/gotrue/lib/src/types/jwt.dart @@ -0,0 +1,265 @@ +/// 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; + } +} diff --git a/packages/gotrue/pubspec.yaml b/packages/gotrue/pubspec.yaml index 2558dc43..22371984 100644 --- a/packages/gotrue/pubspec.yaml +++ b/packages/gotrue/pubspec.yaml @@ -18,6 +18,7 @@ dependencies: meta: ^1.7.0 logging: ^1.2.0 web: '>=0.5.0 <2.0.0' + pointycastle: ^3.7.3 dev_dependencies: dart_jsonwebtoken: ^2.4.1 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..46af70ee --- /dev/null +++ b/packages/gotrue/test/src/base64url_test.dart @@ -0,0 +1,175 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:gotrue/src/base64url.dart'; +import 'package:test/test.dart'; + +void main() { + group('Base64Url', () { + group('encode', () { + test('encodes empty data', () { + final result = Base64Url.encode([]); + expect(result, ''); + }); + + test('encodes simple data without padding', () { + final data = utf8.encode('hello'); + final result = Base64Url.encode(data, pad: false); + expect(result, 'aGVsbG8'); + }); + + test('encodes simple data with padding', () { + final data = utf8.encode('hello'); + final result = Base64Url.encode(data, pad: true); + expect(result, 'aGVsbG8='); + }); + + test('encodes data that requires multiple padding chars', () { + final data = utf8.encode('a'); + final result = Base64Url.encode(data, pad: true); + expect(result, 'YQ=='); + }); + + test('uses base64url alphabet (- and _ instead of + and /)', () { + // This byte sequence produces characters that differ between base64 and base64url + final data = Uint8List.fromList([251, 239]); + final result = Base64Url.encode(data, pad: false); + // In base64 this would be "++" + // In base64url this should be "--" + expect(result, '--8'); + }); + + test('encodes binary data correctly', () { + final data = Uint8List.fromList([0, 1, 2, 3, 4, 5]); + final result = Base64Url.encode(data, pad: false); + expect(result.length, greaterThan(0)); + // Verify we can decode it back + final decoded = Base64Url.decode(result); + expect(decoded, equals(data)); + }); + }); + + group('decode', () { + test('decodes empty string', () { + final result = Base64Url.decode(''); + expect(result, isEmpty); + }); + + test('decodes simple data', () { + final result = Base64Url.decode('aGVsbG8'); + expect(utf8.decode(result), 'hello'); + }); + + test('decodes data with padding', () { + final result = Base64Url.decode('aGVsbG8='); + expect(utf8.decode(result), 'hello'); + }); + + test('decodes data with multiple padding chars', () { + final result = Base64Url.decode('YQ=='); + expect(utf8.decode(result), 'a'); + }); + + test('decodes base64url alphabet (- and _)', () { + // "--8" in base64url decodes to [251, 239] + final result = Base64Url.decode('--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.decode('YQ', loose: true); + expect(utf8.decode(result), 'a'); + }); + + test('throws on invalid characters', () { + expect( + () => Base64Url.decode('invalid!!!'), + throwsA(isA()), + ); + }); + + test('decodes data with implicit padding in strict mode', () { + // 'YQ' is 'a' without padding, should work in strict mode + // because the remainder is valid (2 or 4) + final result = Base64Url.decode('YQ', loose: false); + expect(utf8.decode(result), 'a'); + }); + }); + + group('round-trip encoding', () { + test('encodes and decodes simple string', () { + const original = 'The quick brown fox jumps over the lazy dog'; + final encoded = Base64Url.encodeFromString(original); + final decoded = Base64Url.decodeToString(encoded); + expect(decoded, original); + }); + + test('encodes and decodes empty string', () { + const original = ''; + final encoded = Base64Url.encodeFromString(original); + final decoded = Base64Url.decodeToString(encoded); + expect(decoded, original); + }); + + test('encodes and decodes unicode string', () { + const original = 'Hello δΈ–η•Œ 🌍'; + final encoded = Base64Url.encodeFromString(original); + final decoded = Base64Url.decodeToString(encoded); + expect(decoded, original); + }); + + test('encodes and decodes binary data', () { + final original = Uint8List.fromList( + List.generate(256, (i) => i % 256)); // All byte values + final encoded = Base64Url.encode(original); + final decoded = Base64Url.decode(encoded); + expect(decoded, equals(original)); + }); + + test('encodes and decodes with padding', () { + final original = utf8.encode('test'); + final encoded = Base64Url.encode(original, pad: true); + final decoded = Base64Url.decode(encoded); + expect(decoded, equals(original)); + }); + + test('encodes and decodes without padding', () { + final original = utf8.encode('test'); + final encoded = Base64Url.encode(original, pad: false); + final decoded = Base64Url.decode(encoded, loose: true); + expect(decoded, equals(original)); + }); + }); + + group('JWT compatibility', () { + test('decodes JWT header', () { + // Standard JWT header: {"alg":"HS256","typ":"JWT"} + const jwtHeader = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'; + final decoded = Base64Url.decodeToString(jwtHeader, loose: true); + 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, loose: true); + 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.decode(jwtSignature, loose: true); + expect(decoded, isA()); + expect(decoded.length, greaterThan(0)); + }); + }); + }); +}