From 4f807443f71d60ddc862040118aa37d95f360de1 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 6 Oct 2025 17:36:17 -0300 Subject: [PATCH 1/7] feat(auth): introduce getClaims method to verify and extract JWT claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds JWT claims verification and extraction functionality to the Auth client, porting the feature from auth-js PR #1030. Key changes: - Add Base64URL encoding/decoding utilities - Extend JWT helper to decode full JWT (header, payload, signature) - Add JWK types (JWK, JWKS, JWTHeader, JWTClaims, etc.) - Add JWTVerifier for asymmetric JWT signature verification (ES256) - Implement getClaims method in AuthClient - Add jwtVerificationFailed error to AuthError The getClaims method verifies JWT signatures and returns claims: - For HS256 (symmetric) and RS256 JWTs: validates server-side via getUser - For ES256 JWTs: verifies signature client-side using CryptoKit - Supports custom JWKS or fetches from /.well-known/jwks.json - Caches JWKS to minimize network requests Note: RS256 client-side verification will be added once swift-crypto's RSA API becomes public. Currently falls back to server-side verification. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Sources/Auth/AuthClient.swift | 111 ++++++++++++++++++ Sources/Auth/AuthError.swift | 8 +- Sources/Auth/JWTVerifier.swift | 70 ++++++++++++ Sources/Auth/Types.swift | 196 ++++++++++++++++++++++++++++++++ Sources/Helpers/Base64URL.swift | 32 ++++++ Sources/Helpers/JWT.swift | 44 +++++-- 6 files changed, 449 insertions(+), 12 deletions(-) create mode 100644 Sources/Auth/JWTVerifier.swift create mode 100644 Sources/Helpers/Base64URL.swift diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 5a36766f1..451d98ed1 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -53,6 +53,9 @@ public actor AuthClient { nonisolated private var sessionStorage: SessionStorage { Dependencies[clientID].sessionStorage } nonisolated private var pkce: PKCE { Dependencies[clientID].pkce } + /// Cache for JWKS (JSON Web Key Set) + private var jwksCache: JWKS? + /// Returns the session, refreshing it if necessary. /// /// If no session can be found, a ``AuthError/sessionMissing`` error is thrown. @@ -1448,6 +1451,114 @@ public actor AuthClient { return url } + + /// Fetches a JWK from the JWKS endpoint + private func fetchJWK(kid: String, jwks: JWKS? = nil) async throws -> JWK { + // Try fetching from the supplied jwks + if let jwk = jwks?.keys.first(where: { $0.kid == kid }) { + return jwk + } + + // Try fetching from cache + if let jwk = jwksCache?.keys.first(where: { $0.kid == kid }) { + return jwk + } + + // Fetch from well-known endpoint + let response = try await api.execute( + HTTPRequest( + url: configuration.url.appendingPathComponent(".well-known/jwks.json"), + method: .get + ) + ) + + let fetchedJWKS = try response.decoded(as: JWKS.self, decoder: configuration.decoder) + + guard !fetchedJWKS.keys.isEmpty else { + throw AuthError.jwtVerificationFailed(message: "JWKS is empty") + } + + // Cache the JWKS + jwksCache = fetchedJWKS + + // Find the signing key + guard let jwk = fetchedJWKS.keys.first(where: { $0.kid == kid }) else { + throw AuthError.jwtVerificationFailed(message: "No matching signing key found in JWKS") + } + + return jwk + } + + /// Verifies and extracts claims from a JWT. + /// + /// This method verifies the JWT signature and returns the claims if valid. For symmetric JWTs (HS256), + /// it validates against the server using the `getUser` method. For asymmetric JWTs (RS256, ES256), + /// it verifies the signature using the JWKS (JSON Web Key Set) from the well-known endpoint. + /// + /// - Parameters: + /// - jwt: The JWT to verify. If nil, uses the access token from the current session. + /// - jwks: Optional JWKS to use for verification. If nil, fetches from the server. + /// + /// - Returns: A `JWTClaimsResponse` containing the verified claims, header, and signature. + /// + /// - Throws: `AuthError.jwtVerificationFailed` if verification fails, or `AuthError.sessionMissing` if no session exists. + /// + /// - Note: This is an experimental method and may change in future versions. + public func getClaims(jwt: String? = nil, jwks: JWKS? = nil) async throws -> JWTClaimsResponse { + let token: String + if let jwt { + token = jwt + } else { + guard let session = try? await session else { + throw AuthError.sessionMissing + } + token = session.accessToken + } + + guard let decodedJWT = JWT.decode(token) else { + throw AuthError.jwtVerificationFailed(message: "Invalid JWT structure") + } + + // Validate expiration + if let exp = decodedJWT.payload["exp"] as? TimeInterval { + let now = Date().timeIntervalSince1970 + if exp <= now { + throw AuthError.jwtVerificationFailed(message: "JWT has expired") + } + } + + let alg = decodedJWT.header["alg"] as? String + let kid = decodedJWT.header["kid"] as? String + + // If symmetric algorithm (HS256), RS256 (not yet fully supported), or no kid, fallback to getUser() + // RS256 will be fully supported client-side once swift-crypto's RSA API is public + if alg == "HS256" || alg == "RS256" || kid == nil { + _ = try await user(jwt: token) + // getUser succeeds, so claims can be trusted + let claims = try configuration.decoder.decode(JWTClaims.self, from: JSONSerialization.data(withJSONObject: decodedJWT.payload)) + let header = try configuration.decoder.decode(JWTHeader.self, from: JSONSerialization.data(withJSONObject: decodedJWT.header)) + return JWTClaimsResponse(claims: claims, header: header, signature: decodedJWT.signature) + } + + // Asymmetric JWT verification using CryptoKit (currently only ES256) + guard let kid else { + throw AuthError.jwtVerificationFailed(message: "Missing kid in JWT header") + } + + let signingKey = try await fetchJWK(kid: kid, jwks: jwks) + + let isValid = try JWTVerifier.verify(jwt: decodedJWT, jwk: signingKey) + + guard isValid else { + throw AuthError.jwtVerificationFailed(message: "Invalid JWT signature") + } + + // Decode claims and header + let claims = try configuration.decoder.decode(JWTClaims.self, from: JSONSerialization.data(withJSONObject: decodedJWT.payload)) + let header = try configuration.decoder.decode(JWTHeader.self, from: JSONSerialization.data(withJSONObject: decodedJWT.header)) + + return JWTClaimsResponse(claims: claims, header: header, signature: decodedJWT.signature) + } } extension AuthClient { diff --git a/Sources/Auth/AuthError.swift b/Sources/Auth/AuthError.swift index 5349d36f7..991100bf5 100644 --- a/Sources/Auth/AuthError.swift +++ b/Sources/Auth/AuthError.swift @@ -114,6 +114,7 @@ extension ErrorCode { //#nosec G101 -- Not a secret value. public static let invalidCredentials = ErrorCode("invalid_credentials") public static let emailAddressNotAuthorized = ErrorCode("email_address_not_authorized") + public static let invalidJWT = ErrorCode("invalid_jwt") } public enum AuthError: LocalizedError, Equatable { @@ -261,13 +262,17 @@ public enum AuthError: LocalizedError, Equatable { /// Error thrown when an error happens during implicit grant flow. case implicitGrantRedirect(message: String) + /// Error thrown when JWT verification fails. + case jwtVerificationFailed(message: String) + public var message: String { switch self { case .sessionMissing: "Auth session missing." case let .weakPassword(message, _), let .api(message, _, _, _), let .pkceGrantCodeExchange(message, _, _), - let .implicitGrantRedirect(message): + let .implicitGrantRedirect(message), + let .jwtVerificationFailed(message): message // Deprecated cases case .missingExpClaim: "Missing expiration claim in the access token." @@ -283,6 +288,7 @@ public enum AuthError: LocalizedError, Equatable { case .weakPassword: .weakPassword case let .api(_, errorCode, _, _): errorCode case .pkceGrantCodeExchange, .implicitGrantRedirect: .unknown + case .jwtVerificationFailed: .invalidJWT // Deprecated cases case .missingExpClaim, .malformedJWT, .invalidRedirectScheme, .missingURL: .unknown } diff --git a/Sources/Auth/JWTVerifier.swift b/Sources/Auth/JWTVerifier.swift new file mode 100644 index 000000000..581a082ee --- /dev/null +++ b/Sources/Auth/JWTVerifier.swift @@ -0,0 +1,70 @@ +// +// JWTVerifier.swift +// Supabase +// +// Created by Claude on 06/10/25. +// + +import CryptoKit +import Foundation + +enum JWTVerifier { + /// Verifies an asymmetric JWT signature using CryptoKit + static func verify( + jwt: DecodedJWT, + jwk: JWK + ) throws -> Bool { + guard let alg = jwt.header["alg"] as? String else { + throw AuthError.jwtVerificationFailed(message: "Missing alg in JWT header") + } + + let message = "\(jwt.raw.header).\(jwt.raw.payload)".data(using: .utf8)! + + switch alg { + case "RS256": + // RS256 (RSA) verification requires swift-crypto's _RSA which is not yet public API + // For now, we fall back to server-side verification via getUser() + throw AuthError.jwtVerificationFailed( + message: "RS256 JWTs are currently verified server-side via getUser()" + ) + case "ES256": + return try verifyES256(message: message, signature: jwt.signature, jwk: jwk) + case "HS256": + // Symmetric keys should be verified server-side via getUser + throw AuthError.jwtVerificationFailed( + message: "HS256 JWTs must be verified server-side" + ) + default: + throw AuthError.jwtVerificationFailed(message: "Unsupported algorithm: \(alg)") + } + } + + private static func verifyES256(message: Data, signature: Data, jwk: JWK) throws -> Bool { + guard + let xString = jwk.x, + let yString = jwk.y, + let xData = Base64URL.decode(xString), + let yData = Base64URL.decode(yString) + else { + throw AuthError.jwtVerificationFailed(message: "Invalid EC JWK") + } + + // For P256, we need to construct the X9.63 representation + // X9.63 format: 0x04 + x + y for uncompressed point + var x963Data = Data([0x04]) + x963Data.append(xData) + x963Data.append(yData) + + // Create EC public key from JWK + let publicKey = try P256.Signing.PublicKey( + x963Representation: x963Data + ) + + let isValid = publicKey.isValidSignature( + try P256.Signing.ECDSASignature(rawRepresentation: signature), + for: SHA256.hash(data: message) + ) + + return isValid + } +} diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index 2cf82812d..a12c39402 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -1130,3 +1130,199 @@ public struct ListOAuthClientsPaginatedResponse: Hashable, Sendable { public var lastPage: Int public var total: Int } + +// MARK: - JWT Claims + +/// JSON Web Key (JWK) representation +public struct JWK: Codable, Hashable, Sendable { + /// Key type (e.g., "RSA", "EC", "oct") + public let kty: String + /// Key operations (e.g., ["sign", "verify"]) + public let keyOps: [String]? + /// Algorithm (e.g., "RS256", "ES256", "HS256") + public let alg: String? + /// Key ID + public let kid: String? + + // RSA-specific fields + /// RSA modulus (base64url-encoded) + public let n: String? + /// RSA exponent (base64url-encoded) + public let e: String? + + // EC-specific fields + /// EC curve name (e.g., "P-256") + public let crv: String? + /// EC x coordinate (base64url-encoded) + public let x: String? + /// EC y coordinate (base64url-encoded) + public let y: String? + + // Symmetric key field + /// Symmetric key value (base64url-encoded) + public let k: String? + + enum CodingKeys: String, CodingKey { + case kty + case keyOps = "key_ops" + case alg + case kid + case n + case e + case crv + case x + case y + case k + } +} + +/// JSON Web Key Set (JWKS) +public struct JWKS: Codable, Hashable, Sendable { + public let keys: [JWK] +} + +/// JWT Header +public struct JWTHeader: Codable, Hashable, Sendable { + /// Algorithm (e.g., "RS256", "ES256", "HS256") + public let alg: String + /// Key ID + public let kid: String? + /// Type (typically "JWT") + public let typ: String? +} + +/// JWT Claims +public struct JWTClaims: Codable, Hashable, Sendable { + /// Issuer + public let iss: String? + /// Subject + public let sub: String? + /// Audience + public let aud: AudienceClaim? + /// Expiration time + public let exp: TimeInterval? + /// Issued at + public let iat: TimeInterval? + /// Not before + public let nbf: TimeInterval? + /// JWT ID + public let jti: String? + /// Role + public let role: String? + /// Authenticator Assurance Level + public let aal: String? + /// Session ID + public let sessionId: String? + /// Email + public let email: String? + /// Phone + public let phone: String? + /// App metadata + public let appMetadata: [String: AnyJSON]? + /// User metadata + public let userMetadata: [String: AnyJSON]? + /// Additional claims + public var additionalClaims: [String: AnyJSON] = [:] + + enum CodingKeys: String, CodingKey { + case iss + case sub + case aud + case exp + case iat + case nbf + case jti + case role + case aal + case sessionId = "session_id" + case email + case phone + case appMetadata = "app_metadata" + case userMetadata = "user_metadata" + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + iss = try container.decodeIfPresent(String.self, forKey: .iss) + sub = try container.decodeIfPresent(String.self, forKey: .sub) + aud = try container.decodeIfPresent(AudienceClaim.self, forKey: .aud) + exp = try container.decodeIfPresent(TimeInterval.self, forKey: .exp) + iat = try container.decodeIfPresent(TimeInterval.self, forKey: .iat) + nbf = try container.decodeIfPresent(TimeInterval.self, forKey: .nbf) + jti = try container.decodeIfPresent(String.self, forKey: .jti) + role = try container.decodeIfPresent(String.self, forKey: .role) + aal = try container.decodeIfPresent(String.self, forKey: .aal) + sessionId = try container.decodeIfPresent(String.self, forKey: .sessionId) + email = try container.decodeIfPresent(String.self, forKey: .email) + phone = try container.decodeIfPresent(String.self, forKey: .phone) + appMetadata = try container.decodeIfPresent([String: AnyJSON].self, forKey: .appMetadata) + userMetadata = try container.decodeIfPresent([String: AnyJSON].self, forKey: .userMetadata) + + // Decode additional claims + let allKeys = try decoder.container(keyedBy: AnyCodingKey.self) + var additional: [String: AnyJSON] = [:] + for key in allKeys.allKeys { + if CodingKeys(stringValue: key.stringValue) == nil { + if let value = try? allKeys.decode(AnyJSON.self, forKey: key) { + additional[key.stringValue] = value + } + } + } + additionalClaims = additional + } +} + +/// Audience claim can be either a string or an array of strings +public enum AudienceClaim: Codable, Hashable, Sendable { + case string(String) + case array([String]) + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if let string = try? container.decode(String.self) { + self = .string(string) + } else if let array = try? container.decode([String].self) { + self = .array(array) + } else { + throw DecodingError.typeMismatch( + AudienceClaim.self, + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Expected String or [String] for audience claim" + ) + ) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case let .string(value): + try container.encode(value) + case let .array(value): + try container.encode(value) + } + } +} + +private struct AnyCodingKey: CodingKey { + var stringValue: String + var intValue: Int? + + init?(stringValue: String) { + self.stringValue = stringValue + intValue = nil + } + + init?(intValue: Int) { + stringValue = "\(intValue)" + self.intValue = intValue + } +} + +/// Response from getClaims method +public struct JWTClaimsResponse: Sendable { + public let claims: JWTClaims + public let header: JWTHeader + public let signature: Data +} diff --git a/Sources/Helpers/Base64URL.swift b/Sources/Helpers/Base64URL.swift new file mode 100644 index 000000000..66926eedf --- /dev/null +++ b/Sources/Helpers/Base64URL.swift @@ -0,0 +1,32 @@ +// +// Base64URL.swift +// Supabase +// +// Created by Claude on 06/10/25. +// + +import Foundation + +package enum Base64URL { + /// Decodes a base64url-encoded string to Data + package static func decode(_ value: String) -> Data? { + var base64 = value.replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + let length = Double(base64.lengthOfBytes(using: .utf8)) + let requiredLength = 4 * ceil(length / 4.0) + let paddingLength = requiredLength - length + if paddingLength > 0 { + let padding = "".padding(toLength: Int(paddingLength), withPad: "=", startingAt: 0) + base64 = base64 + padding + } + return Data(base64Encoded: base64, options: .ignoreUnknownCharacters) + } + + /// Encodes Data to a base64url-encoded string + package static func encode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/Sources/Helpers/JWT.swift b/Sources/Helpers/JWT.swift index 86dfb5d0c..983e231a9 100644 --- a/Sources/Helpers/JWT.swift +++ b/Sources/Helpers/JWT.swift @@ -7,6 +7,13 @@ import Foundation +package struct DecodedJWT { + package let header: [String: Any] + package let payload: [String: Any] + package let signature: Data + package let raw: (header: String, payload: String) +} + package enum JWT { package static func decodePayload(_ jwt: String) -> [String: Any]? { let parts = jwt.split(separator: ".") @@ -15,7 +22,7 @@ package enum JWT { } let payload = String(parts[1]) - guard let data = base64URLDecode(payload) else { + guard let data = Base64URL.decode(payload) else { return nil } let json = try? JSONSerialization.jsonObject(with: data, options: []) @@ -25,16 +32,31 @@ package enum JWT { return decodedPayload } - private static func base64URLDecode(_ value: String) -> Data? { - var base64 = value.replacingOccurrences(of: "-", with: "+") - .replacingOccurrences(of: "_", with: "/") - let length = Double(base64.lengthOfBytes(using: .utf8)) - let requiredLength = 4 * ceil(length / 4.0) - let paddingLength = requiredLength - length - if paddingLength > 0 { - let padding = "".padding(toLength: Int(paddingLength), withPad: "=", startingAt: 0) - base64 = base64 + padding + package static func decode(_ jwt: String) -> DecodedJWT? { + let parts = jwt.split(separator: ".") + guard parts.count == 3 else { + return nil + } + + let headerString = String(parts[0]) + let payloadString = String(parts[1]) + let signatureString = String(parts[2]) + + guard + let headerData = Base64URL.decode(headerString), + let payloadData = Base64URL.decode(payloadString), + let signatureData = Base64URL.decode(signatureString), + let headerJSON = try? JSONSerialization.jsonObject(with: headerData, options: []) as? [String: Any], + let payloadJSON = try? JSONSerialization.jsonObject(with: payloadData, options: []) as? [String: Any] + else { + return nil } - return Data(base64Encoded: base64, options: .ignoreUnknownCharacters) + + return DecodedJWT( + header: headerJSON, + payload: payloadJSON, + signature: signatureData, + raw: (header: headerString, payload: payloadString) + ) } } From 8d8241fa5547e145e466130756b3f72fc207eb34 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 6 Oct 2025 17:48:20 -0300 Subject: [PATCH 2/7] feat(auth): make getClaims non-experimental, add global JWKS cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit applies changes from auth-js PR #1078 to improve getClaims performance and remove its experimental status. Key changes: - Add global JWKS cache shared across all clients with the same storage key - Implement JWKS cache expiry with TTL (10 minutes) - Add GetClaimsOptions struct with allowExpired and custom jwks options - Remove experimental warning from getClaims documentation - Update getClaims to accept options parameter instead of separate jwks param - Add CachedJWKS struct to track cache timestamps - Implement GlobalJWKSCache actor for thread-safe global caching Performance improvements: - Global cache significantly reduces JWKS fetches in serverless environments - Cache TTL prevents stale keys while minimizing network requests - Especially beneficial for AWS Lambda, Cloud Functions, etc. Breaking change: - getClaims now accepts GetClaimsOptions instead of JWKS parameter - Old: getClaims(jwt:jwks:) - New: getClaims(jwt:options:) Migration: ```swift // Before let response = try await client.auth.getClaims(jwks: customJWKS) // After let response = try await client.auth.getClaims( options: GetClaimsOptions(jwks: customJWKS) ) // With allowExpired let response = try await client.auth.getClaims( options: GetClaimsOptions(allowExpired: true) ) ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Sources/Auth/AuthClient.swift | 111 +++++++++++++++++++++++++--------- Sources/Auth/Types.swift | 14 +++++ 2 files changed, 97 insertions(+), 28 deletions(-) diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 451d98ed1..6f2f875f6 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -30,6 +30,34 @@ struct AuthClientLoggerDecorator: SupabaseLogger { } } +/// JWKS cache TTL (Time To Live) - 10 minutes +private let JWKS_TTL: TimeInterval = 10 * 60 + +/// Cached JWKS value with timestamp +private struct CachedJWKS { + let jwks: JWKS + let cachedAt: Date +} + +/// Global JWKS cache shared across all clients with the same storage key. +/// This is especially useful for shared-memory execution environments such as +/// AWS Lambda or serverless functions. Regardless of how many clients are created, +/// if they share the same storage key they will use the same JWKS cache, +/// significantly speeding up getClaims() with asymmetric JWTs. +private actor GlobalJWKSCache { + private var cache: [String: CachedJWKS] = [:] + + func get(for key: String) -> CachedJWKS? { + cache[key] + } + + func set(_ value: CachedJWKS, for key: String) { + cache[key] = value + } +} + +private let globalJWKSCache = GlobalJWKSCache() + public actor AuthClient { static var globalClientID = 0 nonisolated let clientID: AuthClientID @@ -53,9 +81,6 @@ public actor AuthClient { nonisolated private var sessionStorage: SessionStorage { Dependencies[clientID].sessionStorage } nonisolated private var pkce: PKCE { Dependencies[clientID].pkce } - /// Cache for JWKS (JSON Web Key Set) - private var jwksCache: JWKS? - /// Returns the session, refreshing it if necessary. /// /// If no session can be found, a ``AuthError/sessionMissing`` error is thrown. @@ -1452,16 +1477,24 @@ public actor AuthClient { return url } - /// Fetches a JWK from the JWKS endpoint + /// Fetches a JWK from the JWKS endpoint with caching private func fetchJWK(kid: String, jwks: JWKS? = nil) async throws -> JWK { // Try fetching from the supplied jwks if let jwk = jwks?.keys.first(where: { $0.kid == kid }) { return jwk } - // Try fetching from cache - if let jwk = jwksCache?.keys.first(where: { $0.kid == kid }) { - return jwk + let now = date() + let storageKey = configuration.storageKey ?? defaultStorageKey + + // Try fetching from global cache + if let cached = await globalJWKSCache.get(for: storageKey), + let jwk = cached.jwks.keys.first(where: { $0.kid == kid }) + { + // Check if cache is still valid (not stale) + if cached.cachedAt.addingTimeInterval(JWKS_TTL) > now { + return jwk + } } // Fetch from well-known endpoint @@ -1478,8 +1511,11 @@ public actor AuthClient { throw AuthError.jwtVerificationFailed(message: "JWKS is empty") } - // Cache the JWKS - jwksCache = fetchedJWKS + // Cache the JWKS globally + await globalJWKSCache.set( + CachedJWKS(jwks: fetchedJWKS, cachedAt: now), + for: storageKey + ) // Find the signing key guard let jwk = fetchedJWKS.keys.first(where: { $0.kid == kid }) else { @@ -1489,22 +1525,27 @@ public actor AuthClient { return jwk } - /// Verifies and extracts claims from a JWT. + /// 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 ``user(jwt:)`` which always sends a request to the Auth + /// server for each JWT. /// - /// This method verifies the JWT signature and returns the claims if valid. For symmetric JWTs (HS256), - /// it validates against the server using the `getUser` method. For asymmetric JWTs (RS256, ES256), - /// it verifies the signature using the JWKS (JSON Web Key Set) from the well-known endpoint. + /// 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 ``user(jwt:)``) to + /// verify the JWT. /// /// - Parameters: - /// - jwt: The JWT to verify. If nil, uses the access token from the current session. - /// - jwks: Optional JWKS to use for verification. If nil, fetches from the server. + /// - jwt: An optional specific JWT you wish to verify, not the one you can obtain from ``session``. + /// - options: Various additional options that allow you to customize the behavior of this method. /// /// - Returns: A `JWTClaimsResponse` containing the verified claims, header, and signature. /// /// - Throws: `AuthError.jwtVerificationFailed` if verification fails, or `AuthError.sessionMissing` if no session exists. - /// - /// - Note: This is an experimental method and may change in future versions. - public func getClaims(jwt: String? = nil, jwks: JWKS? = nil) async throws -> JWTClaimsResponse { + public func getClaims( + jwt: String? = nil, + options: GetClaimsOptions = GetClaimsOptions() + ) async throws -> JWTClaimsResponse { let token: String if let jwt { token = jwt @@ -1519,11 +1560,13 @@ public actor AuthClient { throw AuthError.jwtVerificationFailed(message: "Invalid JWT structure") } - // Validate expiration - if let exp = decodedJWT.payload["exp"] as? TimeInterval { - let now = Date().timeIntervalSince1970 - if exp <= now { - throw AuthError.jwtVerificationFailed(message: "JWT has expired") + // Validate expiration unless allowExpired is true + if !options.allowExpired { + if let exp = decodedJWT.payload["exp"] as? TimeInterval { + let now = date().timeIntervalSince1970 + if exp <= now { + throw AuthError.jwtVerificationFailed(message: "JWT has expired") + } } } @@ -1535,8 +1578,14 @@ public actor AuthClient { if alg == "HS256" || alg == "RS256" || kid == nil { _ = try await user(jwt: token) // getUser succeeds, so claims can be trusted - let claims = try configuration.decoder.decode(JWTClaims.self, from: JSONSerialization.data(withJSONObject: decodedJWT.payload)) - let header = try configuration.decoder.decode(JWTHeader.self, from: JSONSerialization.data(withJSONObject: decodedJWT.header)) + let claims = try configuration.decoder.decode( + JWTClaims.self, + from: JSONSerialization.data(withJSONObject: decodedJWT.payload) + ) + let header = try configuration.decoder.decode( + JWTHeader.self, + from: JSONSerialization.data(withJSONObject: decodedJWT.header) + ) return JWTClaimsResponse(claims: claims, header: header, signature: decodedJWT.signature) } @@ -1545,7 +1594,7 @@ public actor AuthClient { throw AuthError.jwtVerificationFailed(message: "Missing kid in JWT header") } - let signingKey = try await fetchJWK(kid: kid, jwks: jwks) + let signingKey = try await fetchJWK(kid: kid, jwks: options.jwks) let isValid = try JWTVerifier.verify(jwt: decodedJWT, jwk: signingKey) @@ -1554,8 +1603,14 @@ public actor AuthClient { } // Decode claims and header - let claims = try configuration.decoder.decode(JWTClaims.self, from: JSONSerialization.data(withJSONObject: decodedJWT.payload)) - let header = try configuration.decoder.decode(JWTHeader.self, from: JSONSerialization.data(withJSONObject: decodedJWT.header)) + let claims = try configuration.decoder.decode( + JWTClaims.self, + from: JSONSerialization.data(withJSONObject: decodedJWT.payload) + ) + let header = try configuration.decoder.decode( + JWTHeader.self, + from: JSONSerialization.data(withJSONObject: decodedJWT.header) + ) return JWTClaimsResponse(claims: claims, header: header, signature: decodedJWT.signature) } diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index a12c39402..fab7e2f8d 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -1326,3 +1326,17 @@ public struct JWTClaimsResponse: Sendable { public let header: JWTHeader public let signature: Data } + +/// Options for the getClaims method +public struct GetClaimsOptions: Sendable { + /// If set to `true` the `exp` claim will not be validated against the current time. + public let allowExpired: Bool + + /// If set, this JSON Web Key Set is going to have precedence over the cached value available on the server. + public let jwks: JWKS? + + public init(allowExpired: Bool = false, jwks: JWKS? = nil) { + self.allowExpired = allowExpired + self.jwks = jwks + } +} From fd60e572b2542d52629f7f6092679bacf19da5e0 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Mon, 6 Oct 2025 17:52:53 -0300 Subject: [PATCH 3/7] feat(auth): add graceful fallback for JWK not found in JWKS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit applies changes from auth-js PR #1080 to handle key rotation scenarios more gracefully. Key changes: - Change fetchJWK to return optional JWK? instead of throwing errors - Return nil when JWKS is empty or key not found in JWKS - Restructure getClaims logic to try fetching JWK first - Fallback to server-side verification (getUser) if key not found - Handle symmetric algorithms (HS256) and RS256 with nil check Why this matters: When developers rotate keys faster than cache TTL (10 minutes), a JWT may be signed with a key ID that's not yet in the cached JWKS. Instead of failing with an error, the method now gracefully falls back to server-side verification via getUser(). This ensures: - Zero downtime during key rotation - Better resilience against cache staleness - Transparent fallback for users Example scenario: 1. JWKS is cached with key ID "abc123" 2. Admin rotates standby key to active (new key ID "xyz789") 3. User receives JWT signed with "xyz789" 4. fetchJWK returns nil (key not in cache) 5. getClaims automatically falls back to getUser() 6. Verification succeeds server-side 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Sources/Auth/AuthClient.swift | 40 +++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 6f2f875f6..3e3baeb88 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -1478,7 +1478,8 @@ public actor AuthClient { } /// Fetches a JWK from the JWKS endpoint with caching - private func fetchJWK(kid: String, jwks: JWKS? = nil) async throws -> JWK { + /// Returns nil if the key is not found, allowing graceful fallback to server-side verification + private func fetchJWK(kid: String, jwks: JWKS? = nil) async throws -> JWK? { // Try fetching from the supplied jwks if let jwk = jwks?.keys.first(where: { $0.kid == kid }) { return jwk @@ -1507,8 +1508,9 @@ public actor AuthClient { let fetchedJWKS = try response.decoded(as: JWKS.self, decoder: configuration.decoder) + // Return nil if JWKS is empty (will fallback to getUser) guard !fetchedJWKS.keys.isEmpty else { - throw AuthError.jwtVerificationFailed(message: "JWKS is empty") + return nil } // Cache the JWKS globally @@ -1517,12 +1519,9 @@ public actor AuthClient { for: storageKey ) - // Find the signing key - guard let jwk = fetchedJWKS.keys.first(where: { $0.kid == kid }) else { - throw AuthError.jwtVerificationFailed(message: "No matching signing key found in JWKS") - } - - return jwk + // Find the signing key - return nil if not found (will fallback to getUser) + // This handles key rotation scenarios where the JWT is signed with a key not yet in the cache + return fetchedJWKS.keys.first(where: { $0.kid == kid }) } /// Extracts the JWT claims present in the access token by first verifying the @@ -1573,9 +1572,20 @@ public actor AuthClient { let alg = decodedJWT.header["alg"] as? String let kid = decodedJWT.header["kid"] as? String - // If symmetric algorithm (HS256), RS256 (not yet fully supported), or no kid, fallback to getUser() - // RS256 will be fully supported client-side once swift-crypto's RSA API is public - if alg == "HS256" || alg == "RS256" || kid == nil { + // Try to fetch the signing key for asymmetric JWTs + // Returns nil if: no alg, symmetric algorithm (HS256/HS512), no kid, or key not found in JWKS + let signingKey: JWK? + if let alg, !alg.hasPrefix("HS"), let kid { + // Only attempt to fetch JWK for asymmetric algorithms with a kid + // RS256 is currently not fully supported client-side (falls back to server-side) + signingKey = alg == "RS256" ? nil : try await fetchJWK(kid: kid, jwks: options.jwks) + } else { + signingKey = nil + } + + // If no signing key available (symmetric algorithm, RS256, no kid, or key not found), + // fallback to server-side verification via getUser() + if signingKey == nil { _ = try await user(jwt: token) // getUser succeeds, so claims can be trusted let claims = try configuration.decoder.decode( @@ -1590,13 +1600,7 @@ public actor AuthClient { } // Asymmetric JWT verification using CryptoKit (currently only ES256) - guard let kid else { - throw AuthError.jwtVerificationFailed(message: "Missing kid in JWT header") - } - - let signingKey = try await fetchJWK(kid: kid, jwks: options.jwks) - - let isValid = try JWTVerifier.verify(jwt: decodedJWT, jwk: signingKey) + let isValid = try JWTVerifier.verify(jwt: decodedJWT, jwk: signingKey!) guard isValid else { throw AuthError.jwtVerificationFailed(message: "Invalid JWT signature") From 6c2d4cf0149c2c4f6b9cf9a4719b21a58f720362 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 7 Oct 2025 07:34:10 -0300 Subject: [PATCH 4/7] fix jwt verify --- Sources/Auth/AuthClient.swift | 11 ++-- Sources/Auth/Internal/JWK+RSA.swift | 65 ++++++++++++++++++++++ Sources/Auth/Internal/JWTAlgorithm.swift | 29 ++++++++++ Sources/Auth/JWTVerifier.swift | 70 ------------------------ Sources/Auth/Types.swift | 20 +++---- 5 files changed, 111 insertions(+), 84 deletions(-) create mode 100644 Sources/Auth/Internal/JWK+RSA.swift create mode 100644 Sources/Auth/Internal/JWTAlgorithm.swift delete mode 100644 Sources/Auth/JWTVerifier.swift diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 3e3baeb88..68e8a7c6e 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -1490,7 +1490,7 @@ public actor AuthClient { // Try fetching from global cache if let cached = await globalJWKSCache.get(for: storageKey), - let jwk = cached.jwks.keys.first(where: { $0.kid == kid }) + let jwk = cached.jwks.keys.first(where: { $0.kid == kid }) { // Check if cache is still valid (not stale) if cached.cachedAt.addingTimeInterval(JWKS_TTL) > now { @@ -1585,7 +1585,11 @@ public actor AuthClient { // If no signing key available (symmetric algorithm, RS256, no kid, or key not found), // fallback to server-side verification via getUser() - if signingKey == nil { + guard + let signingKey, + let alg = signingKey.alg, + let algorithm = JWTAlgorithm(rawValue: alg) + else { _ = try await user(jwt: token) // getUser succeeds, so claims can be trusted let claims = try configuration.decoder.decode( @@ -1599,8 +1603,7 @@ public actor AuthClient { return JWTClaimsResponse(claims: claims, header: header, signature: decodedJWT.signature) } - // Asymmetric JWT verification using CryptoKit (currently only ES256) - let isValid = try JWTVerifier.verify(jwt: decodedJWT, jwk: signingKey!) + let isValid = algorithm.verify(jwt: decodedJWT, jwk: signingKey) guard isValid else { throw AuthError.jwtVerificationFailed(message: "Invalid JWT signature") diff --git a/Sources/Auth/Internal/JWK+RSA.swift b/Sources/Auth/Internal/JWK+RSA.swift new file mode 100644 index 000000000..0b3ae49a2 --- /dev/null +++ b/Sources/Auth/Internal/JWK+RSA.swift @@ -0,0 +1,65 @@ +// +// JWK+RSA.swift +// Supabase +// +// Created by Guilherme Souza on 07/10/25. +// + +import Foundation + +extension JWK { + var rsaPublishKey: SecKey? { + guard kty == "RSA", + alg == "RS256", + let n, + let modulus = Base64URL.decode(n), + let e, + let exponent = Base64URL.decode(e) + else { + return nil + } + + let encodedKey = encodeRSAPublishKey(modulus: [UInt8](modulus), exponent: [UInt8](exponent)) + return generateRSAPublicKey(from: encodedKey) + } +} + +extension JWK { + fileprivate func encodeRSAPublishKey(modulus: [UInt8], exponent: [UInt8]) -> Data { + var prefixedModulus: [UInt8] = [0x00] // To indicate that the number is not negative + prefixedModulus.append(contentsOf: modulus) + let encodedModulus = prefixedModulus.derEncode(as: 2) // Integer + let encodedExponent = exponent.derEncode(as: 2) // Integer + let encodedSequence = (encodedModulus + encodedExponent).derEncode(as: 48) // Sequence + return Data(encodedSequence) + } + + fileprivate func generateRSAPublicKey(from derEncodedData: Data) -> SecKey? { + let sizeInBits = derEncodedData.count * MemoryLayout.size + let attributes: [CFString: Any] = [ + kSecAttrKeyType: kSecAttrKeyTypeRSA, + kSecAttrKeyClass: kSecAttrKeyClassPublic, + kSecAttrKeySizeInBits: NSNumber(value: sizeInBits), + kSecAttrIsPermanent: false, + ] + return SecKeyCreateWithData(derEncodedData as CFData, attributes as CFDictionary, nil) + } +} + +extension [UInt8] { + fileprivate func derEncode(as dataType: UInt8) -> [UInt8] { + var encodedBytes: [UInt8] = [dataType] + var numberOfBytes = count + if numberOfBytes < 128 { + encodedBytes.append(UInt8(numberOfBytes)) + } else { + let lengthData = Data(bytes: &numberOfBytes, count: MemoryLayout.size(ofValue: numberOfBytes)) + let lengthBytes = [UInt8](lengthData).filter({ $0 != 0 }).reversed() + encodedBytes.append(UInt8(truncatingIfNeeded: lengthBytes.count) | 0b10000000) + encodedBytes.append(contentsOf: lengthBytes) + } + encodedBytes.append(contentsOf: self) + return encodedBytes + } + +} diff --git a/Sources/Auth/Internal/JWTAlgorithm.swift b/Sources/Auth/Internal/JWTAlgorithm.swift new file mode 100644 index 000000000..079b91dd4 --- /dev/null +++ b/Sources/Auth/Internal/JWTAlgorithm.swift @@ -0,0 +1,29 @@ +// +// JWTVerifier.swift +// Supabase +// +// Created by Claude on 06/10/25. +// + +import Foundation + +enum JWTAlgorithm: String { + case rs256 = "RS256" + + func verify( + jwt: DecodedJWT, + jwk: JWK + ) -> Bool { + let message = "\(jwt.raw.header).\(jwt.raw.payload)".data(using: .utf8)! + switch self { + case .rs256: + return SecKeyVerifySignature( + jwk.rsaPublishKey!, + .rsaSignatureMessagePKCS1v15SHA256, + message as CFData, + jwt.signature as CFData, + nil + ) + } + } +} diff --git a/Sources/Auth/JWTVerifier.swift b/Sources/Auth/JWTVerifier.swift deleted file mode 100644 index 581a082ee..000000000 --- a/Sources/Auth/JWTVerifier.swift +++ /dev/null @@ -1,70 +0,0 @@ -// -// JWTVerifier.swift -// Supabase -// -// Created by Claude on 06/10/25. -// - -import CryptoKit -import Foundation - -enum JWTVerifier { - /// Verifies an asymmetric JWT signature using CryptoKit - static func verify( - jwt: DecodedJWT, - jwk: JWK - ) throws -> Bool { - guard let alg = jwt.header["alg"] as? String else { - throw AuthError.jwtVerificationFailed(message: "Missing alg in JWT header") - } - - let message = "\(jwt.raw.header).\(jwt.raw.payload)".data(using: .utf8)! - - switch alg { - case "RS256": - // RS256 (RSA) verification requires swift-crypto's _RSA which is not yet public API - // For now, we fall back to server-side verification via getUser() - throw AuthError.jwtVerificationFailed( - message: "RS256 JWTs are currently verified server-side via getUser()" - ) - case "ES256": - return try verifyES256(message: message, signature: jwt.signature, jwk: jwk) - case "HS256": - // Symmetric keys should be verified server-side via getUser - throw AuthError.jwtVerificationFailed( - message: "HS256 JWTs must be verified server-side" - ) - default: - throw AuthError.jwtVerificationFailed(message: "Unsupported algorithm: \(alg)") - } - } - - private static func verifyES256(message: Data, signature: Data, jwk: JWK) throws -> Bool { - guard - let xString = jwk.x, - let yString = jwk.y, - let xData = Base64URL.decode(xString), - let yData = Base64URL.decode(yString) - else { - throw AuthError.jwtVerificationFailed(message: "Invalid EC JWK") - } - - // For P256, we need to construct the X9.63 representation - // X9.63 format: 0x04 + x + y for uncompressed point - var x963Data = Data([0x04]) - x963Data.append(xData) - x963Data.append(yData) - - // Create EC public key from JWK - let publicKey = try P256.Signing.PublicKey( - x963Representation: x963Data - ) - - let isValid = publicKey.isValidSignature( - try P256.Signing.ECDSASignature(rawRepresentation: signature), - for: SHA256.hash(data: message) - ) - - return isValid - } -} diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index fab7e2f8d..fa10a7e6e 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -384,11 +384,11 @@ enum VerifyOTPParams: Encodable { func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { - case let .email(value): + case .email(let value): try container.encode(value) - case let .mobile(value): + case .mobile(let value): try container.encode(value) - case let .tokenHash(value): + case .tokenHash(let value): try container.encode(value) } } @@ -448,20 +448,20 @@ public enum AuthResponse: Codable, Hashable, Sendable { public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { - case let .session(value): try container.encode(value) - case let .user(value): try container.encode(value) + case .session(let value): try container.encode(value) + case .user(let value): try container.encode(value) } } public var user: User { switch self { - case let .session(session): session.user - case let .user(user): user + case .session(let session): session.user + case .user(let user): user } } public var session: Session? { - if case let .session(session) = self { return session } + if case .session(let session) = self { return session } return nil } } @@ -1297,9 +1297,9 @@ public enum AudienceClaim: Codable, Hashable, Sendable { public func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { - case let .string(value): + case .string(let value): try container.encode(value) - case let .array(value): + case .array(let value): try container.encode(value) } } From 11c6c859f704b43215c5f349c40b7ad451562df0 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 7 Oct 2025 07:51:44 -0300 Subject: [PATCH 5/7] test: add tests for getClaims method --- Sources/Auth/AuthClient.swift | 3 +- Tests/AuthTests/AuthClientTests.swift | 339 ++++++++++++++++++++++++++ 2 files changed, 340 insertions(+), 2 deletions(-) diff --git a/Sources/Auth/AuthClient.swift b/Sources/Auth/AuthClient.swift index 68e8a7c6e..0d605063b 100644 --- a/Sources/Auth/AuthClient.swift +++ b/Sources/Auth/AuthClient.swift @@ -1577,8 +1577,7 @@ public actor AuthClient { let signingKey: JWK? if let alg, !alg.hasPrefix("HS"), let kid { // Only attempt to fetch JWK for asymmetric algorithms with a kid - // RS256 is currently not fully supported client-side (falls back to server-side) - signingKey = alg == "RS256" ? nil : try await fetchJWK(kid: kid, jwks: options.jwks) + signingKey = try await fetchJWK(kid: kid, jwks: options.jwks) } else { signingKey = nil } diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index 19f58bbbb..4bb919ac3 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -2227,6 +2227,345 @@ final class AuthClientTests: XCTestCase { XCTAssertNil(Dependencies[sut.clientID].sessionStorage.get()) } + // MARK: - getClaims Tests + + func testGetClaims_withHS256JWT_shouldFallbackAndReturnClaims() async throws { + // HS256 JWT (symmetric algorithm) - will use server-side verification + let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.4Adcj0vZKqXRB_mPpDVkWvB3xw7yHYjpzGJLKFQjKEc" + + let user = User(fromMockNamed: "user") + + Mock( + url: clientURL.appendingPathComponent("user"), + ignoreQuery: true, + contentType: .json, + statusCode: 200, + data: [.get: try! AuthClient.Configuration.jsonEncoder.encode(user)] + ).register() + + let sut = makeSUT() + + let result = try await sut.getClaims(jwt: jwt) + + XCTAssertEqual(result.claims.sub, "1234567890") + XCTAssertEqual(result.claims.iss, "http://localhost:54321/auth/v1") + if case let .string(aud) = result.claims.aud { + XCTAssertEqual(aud, "authenticated") + } else { + XCTFail("Expected string audience") + } + XCTAssertEqual(result.claims.role, "authenticated") + XCTAssertEqual(result.header.alg, "HS256") + XCTAssertNil(result.header.kid) + } + + func testGetClaims_withoutJWT_shouldUseSessionAccessToken() async throws { + // HS256 JWT from session + let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.4Adcj0vZKqXRB_mPpDVkWvB3xw7yHYjpzGJLKFQjKEc" + + var session = Session.validSession + session.accessToken = jwt + + let user = User(fromMockNamed: "user") + + Mock( + url: clientURL.appendingPathComponent("user"), + ignoreQuery: true, + contentType: .json, + statusCode: 200, + data: [.get: try! AuthClient.Configuration.jsonEncoder.encode(user)] + ).register() + + let sut = makeSUT() + Dependencies[sut.clientID].sessionStorage.store(session) + + let result = try await sut.getClaims() + + XCTAssertEqual(result.claims.sub, "1234567890") + XCTAssertEqual(result.claims.role, "authenticated") + } + + func testGetClaims_withProvidedJWKS_shouldStillFallbackForES256() async throws { + // ES256 is not yet supported client-side, so it will fallback to server even with JWKS + let jwt = "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2lkIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.dummysignature" + + // JWK is Codable, no custom init needed + let jwkDict: [String: Any] = [ + "kty": "EC", + "kid": "test-kid", + "alg": "ES256", + "crv": "P-256", + "x": "MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4", + "y": "4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM" + ] + + let jwkData = try JSONSerialization.data(withJSONObject: jwkDict) + let jwk = try AuthClient.Configuration.jsonDecoder.decode(JWK.self, from: jwkData) + let jwks = JWKS(keys: [jwk]) + + let user = User(fromMockNamed: "user") + + Mock( + url: clientURL.appendingPathComponent("user"), + ignoreQuery: true, + contentType: .json, + statusCode: 200, + data: [.get: try! AuthClient.Configuration.jsonEncoder.encode(user)] + ).register() + + let sut = makeSUT() + + let result = try await sut.getClaims(jwt: jwt, options: GetClaimsOptions(jwks: jwks)) + + XCTAssertEqual(result.claims.sub, "1234567890") + XCTAssertEqual(result.claims.role, "authenticated") + } + + func testGetClaims_withES256JWT_shouldFallbackToServerVerification() async throws { + // ES256 JWT without kid - will fallback to server + let jwt = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.dummysignature" + + let user = User(fromMockNamed: "user") + + Mock( + url: clientURL.appendingPathComponent("user"), + ignoreQuery: true, + contentType: .json, + statusCode: 200, + data: [.get: try! AuthClient.Configuration.jsonEncoder.encode(user)] + ).register() + + let sut = makeSUT() + + let result = try await sut.getClaims(jwt: jwt) + + XCTAssertEqual(result.claims.sub, "1234567890") + XCTAssertEqual(result.claims.role, "authenticated") + } + + func testGetClaims_withRS256JWT_whenJWKNotFound_shouldFallbackToServerVerification() async throws { + // RS256 JWT with kid but key not in JWKS - will try to fetch JWKS, not find it, then fallback to server + let jwt = "eyJhbGciOiJSUzI1NiIsImtpZCI6InRlc3Qta2lkIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.dummysignature" + + // Mock JWKS endpoint with different kid + let jwkDict: [String: Any] = [ + "kty": "RSA", + "kid": "different-kid", + "alg": "RS256", + "n": "modulus", + "e": "AQAB" + ] + let jwkData = try JSONSerialization.data(withJSONObject: jwkDict) + let jwk = try AuthClient.Configuration.jsonDecoder.decode(JWK.self, from: jwkData) + let jwks = JWKS(keys: [jwk]) + + Mock( + url: clientURL.appendingPathComponent(".well-known/jwks.json"), + ignoreQuery: true, + contentType: .json, + statusCode: 200, + data: [.get: try! AuthClient.Configuration.jsonEncoder.encode(jwks)] + ).register() + + let user = User(fromMockNamed: "user") + + Mock( + url: clientURL.appendingPathComponent("user"), + ignoreQuery: true, + contentType: .json, + statusCode: 200, + data: [.get: try! AuthClient.Configuration.jsonEncoder.encode(user)] + ).register() + + let sut = makeSUT() + + let result = try await sut.getClaims(jwt: jwt) + + XCTAssertEqual(result.claims.sub, "1234567890") + XCTAssertEqual(result.claims.role, "authenticated") + } + + func testGetClaims_withNoKidInHeader_shouldFallbackToServerVerification() async throws { + // JWT without kid - cannot look up in JWKS + let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5ODc2NTQzMjEiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjU0MzIxL2F1dGgvdjEiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjE1MTYyMzkwMjIsInJvbGUiOiJhdXRoZW50aWNhdGVkIn0.YT0NvH-jYKCiN-wrAVcMmTIxZkQ3OtqTVFjJAqGcRuw" + + let user = User(fromMockNamed: "user") + + Mock( + url: clientURL.appendingPathComponent("user"), + ignoreQuery: true, + contentType: .json, + statusCode: 200, + data: [.get: try! AuthClient.Configuration.jsonEncoder.encode(user)] + ).register() + + let sut = makeSUT() + + let result = try await sut.getClaims(jwt: jwt) + + XCTAssertEqual(result.claims.sub, "987654321") + XCTAssertEqual(result.claims.role, "authenticated") + } + + func testGetClaims_withoutJWTAndNoSession_shouldThrowSessionMissing() async throws { + let sut = makeSUT() + + do { + _ = try await sut.getClaims() + XCTFail("Expected sessionMissing error") + } catch let error as AuthError { + guard case .sessionMissing = error else { + XCTFail("Expected sessionMissing error, got \(error)") + return + } + } catch { + XCTFail("Expected AuthError, got \(error)") + } + } + + func testGetClaims_withInvalidJWTStructure_shouldThrowJWTVerificationFailed() async throws { + let invalidJWT = "invalid.jwt.token" + + let sut = makeSUT() + + do { + _ = try await sut.getClaims(jwt: invalidJWT) + XCTFail("Expected jwtVerificationFailed error") + } catch let error as AuthError { + guard case .jwtVerificationFailed(let message) = error else { + XCTFail("Expected jwtVerificationFailed error, got \(error)") + return + } + XCTAssertEqual(message, "Invalid JWT structure") + } catch { + XCTFail("Expected AuthError, got \(error)") + } + } + + func testGetClaims_withExpiredJWT_shouldThrowJWTVerificationFailed() async throws { + // JWT with exp in the past + let expiredJWT = "eyJhbGciOiJFUzI1NiIsImtpZCI6InRlc3Qta2lkIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6MTUxNjIzOTAyMiwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.MEYCIQDmtLy0PF_lR7rJQHyKLmJKp1xFKECfVvGTBcXiVnz0jAIhAOoXZJ3kHSA2MqL1XhcUy8dWOZCr6zWCN_FXsP8qKfPR" + + let sut = makeSUT() + + do { + _ = try await sut.getClaims(jwt: expiredJWT) + XCTFail("Expected jwtVerificationFailed error") + } catch let error as AuthError { + guard case .jwtVerificationFailed(let message) = error else { + XCTFail("Expected jwtVerificationFailed error, got \(error)") + return + } + XCTAssertEqual(message, "JWT has expired") + } catch { + XCTFail("Expected AuthError, got \(error)") + } + } + + func testGetClaims_withExpiredJWTAndAllowExpired_shouldReturnClaims() async throws { + // JWT with exp in the past but allowExpired option - falls back to server + let expiredJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6MTUxNjIzOTAyMiwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.aN0HLYHkp7nKZp4xWvBaDqSrCFBxk2tq0KZc4BXGqYs" + + let user = User(fromMockNamed: "user") + + Mock( + url: clientURL.appendingPathComponent("user"), + ignoreQuery: true, + contentType: .json, + statusCode: 200, + data: [.get: try! AuthClient.Configuration.jsonEncoder.encode(user)] + ).register() + + let sut = makeSUT() + + let result = try await sut.getClaims(jwt: expiredJWT, options: GetClaimsOptions(allowExpired: true)) + + XCTAssertEqual(result.claims.sub, "1234567890") + XCTAssertEqual(result.claims.exp, 1516239022) + } + + func testGetClaims_whenServerRejectsJWT_shouldThrowError() async throws { + // HS256 JWT that will be verified server-side + let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.4Adcj0vZKqXRB_mPpDVkWvB3xw7yHYjpzGJLKFQjKEc" + + Mock( + url: clientURL.appendingPathComponent("user"), + ignoreQuery: true, + contentType: .json, + statusCode: 401, + data: [.get: try! AuthClient.Configuration.jsonEncoder.encode([ + "error": "invalid_token", + "error_description": "Invalid JWT" + ])] + ).register() + + let sut = makeSUT() + + do { + _ = try await sut.getClaims(jwt: jwt) + XCTFail("Expected error from server") + } catch { + // Expected to fail + } + } + + func testGetClaims_withComplexClaims_shouldDecodeAllFields() async throws { + // JWT with multiple claim fields + // HS256 so it falls back to server verification + let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjoiYXV0aGVudGljYXRlZCIsImV4cCI6OTk5OTk5OTk5OSwiaWF0IjoxNTE2MjM5MDIyLCJuYmYiOjE1MTYyMzkwMjIsImp0aSI6InRlc3QtanRpIiwicm9sZSI6ImF1dGhlbnRpY2F0ZWQiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJwaG9uZSI6IisxMjM0NTY3ODkwIn0.dBYm1Y-TfRjPsxw_gXqHB5zGHSH9hXS0OeFN_wL8HbA" + + let user = User(fromMockNamed: "user") + + Mock( + url: clientURL.appendingPathComponent("user"), + ignoreQuery: true, + contentType: .json, + statusCode: 200, + data: [.get: try! AuthClient.Configuration.jsonEncoder.encode(user)] + ).register() + + let sut = makeSUT() + + let result = try await sut.getClaims(jwt: jwt) + + XCTAssertEqual(result.claims.sub, "1234567890") + XCTAssertEqual(result.claims.iss, "http://localhost:54321/auth/v1") + if case let .string(aud) = result.claims.aud { + XCTAssertEqual(aud, "authenticated") + } else { + XCTFail("Expected string audience") + } + XCTAssertEqual(result.claims.exp, 9999999999) + XCTAssertEqual(result.claims.iat, 1516239022) + XCTAssertEqual(result.claims.nbf, 1516239022) + XCTAssertEqual(result.claims.jti, "test-jti") + XCTAssertEqual(result.claims.role, "authenticated") + XCTAssertEqual(result.claims.email, "test@example.com") + XCTAssertEqual(result.claims.phone, "+1234567890") + } + + func testGetClaims_withArrayAudience_shouldDecodeCorrectly() async throws { + // JWT with audience as array + let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdDo1NDMyMS9hdXRoL3YxIiwiYXVkIjpbImF1dGhlbnRpY2F0ZWQiLCJzZXJ2aWNlLXJvbGUiXSwiZXhwIjo5OTk5OTk5OTk5LCJpYXQiOjE1MTYyMzkwMjIsInJvbGUiOiJhdXRoZW50aWNhdGVkIn0.Jz-lHQoR2VsQ_vX8wKyN7mPxT4aU9cF1bYsHqGdWlIk" + + let user = User(fromMockNamed: "user") + + Mock( + url: clientURL.appendingPathComponent("user"), + ignoreQuery: true, + contentType: .json, + statusCode: 200, + data: [.get: try! AuthClient.Configuration.jsonEncoder.encode(user)] + ).register() + + let sut = makeSUT() + + let result = try await sut.getClaims(jwt: jwt) + + XCTAssertEqual(result.claims.sub, "1234567890") + XCTAssertNotNil(result.claims.aud) + } + private func makeSUT(flowType: AuthFlowType = .pkce) -> AuthClient { let sessionConfiguration = URLSessionConfiguration.default sessionConfiguration.protocolClasses = [MockingURLProtocol.self] From bf5989a621c897089c4286d049dddac0b1b2d89c Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 7 Oct 2025 08:14:56 -0300 Subject: [PATCH 6/7] fallback to getUser if Security isn't available --- Sources/Auth/Internal/JWK+RSA.swift | 101 ++++++++++++----------- Sources/Auth/Internal/JWTAlgorithm.swift | 18 ++-- 2 files changed, 65 insertions(+), 54 deletions(-) diff --git a/Sources/Auth/Internal/JWK+RSA.swift b/Sources/Auth/Internal/JWK+RSA.swift index 0b3ae49a2..10233a678 100644 --- a/Sources/Auth/Internal/JWK+RSA.swift +++ b/Sources/Auth/Internal/JWK+RSA.swift @@ -7,59 +7,66 @@ import Foundation -extension JWK { - var rsaPublishKey: SecKey? { - guard kty == "RSA", - alg == "RS256", - let n, - let modulus = Base64URL.decode(n), - let e, - let exponent = Base64URL.decode(e) - else { - return nil - } +#if canImport(Security) - let encodedKey = encodeRSAPublishKey(modulus: [UInt8](modulus), exponent: [UInt8](exponent)) - return generateRSAPublicKey(from: encodedKey) - } -} + extension JWK { + var rsaPublishKey: SecKey? { + guard kty == "RSA", + alg == "RS256", + let n, + let modulus = Base64URL.decode(n), + let e, + let exponent = Base64URL.decode(e) + else { + return nil + } -extension JWK { - fileprivate func encodeRSAPublishKey(modulus: [UInt8], exponent: [UInt8]) -> Data { - var prefixedModulus: [UInt8] = [0x00] // To indicate that the number is not negative - prefixedModulus.append(contentsOf: modulus) - let encodedModulus = prefixedModulus.derEncode(as: 2) // Integer - let encodedExponent = exponent.derEncode(as: 2) // Integer - let encodedSequence = (encodedModulus + encodedExponent).derEncode(as: 48) // Sequence - return Data(encodedSequence) + let encodedKey = encodeRSAPublishKey(modulus: [UInt8](modulus), exponent: [UInt8](exponent)) + return generateRSAPublicKey(from: encodedKey) + } } - fileprivate func generateRSAPublicKey(from derEncodedData: Data) -> SecKey? { - let sizeInBits = derEncodedData.count * MemoryLayout.size - let attributes: [CFString: Any] = [ - kSecAttrKeyType: kSecAttrKeyTypeRSA, - kSecAttrKeyClass: kSecAttrKeyClassPublic, - kSecAttrKeySizeInBits: NSNumber(value: sizeInBits), - kSecAttrIsPermanent: false, - ] - return SecKeyCreateWithData(derEncodedData as CFData, attributes as CFDictionary, nil) + extension JWK { + fileprivate func encodeRSAPublishKey(modulus: [UInt8], exponent: [UInt8]) -> Data { + var prefixedModulus: [UInt8] = [0x00] // To indicate that the number is not negative + prefixedModulus.append(contentsOf: modulus) + let encodedModulus = prefixedModulus.derEncode(as: 2) // Integer + let encodedExponent = exponent.derEncode(as: 2) // Integer + let encodedSequence = (encodedModulus + encodedExponent).derEncode(as: 48) // Sequence + return Data(encodedSequence) + } + + fileprivate func generateRSAPublicKey(from derEncodedData: Data) -> SecKey? { + let sizeInBits = derEncodedData.count * MemoryLayout.size + let attributes: [CFString: Any] = [ + kSecAttrKeyType: kSecAttrKeyTypeRSA, + kSecAttrKeyClass: kSecAttrKeyClassPublic, + kSecAttrKeySizeInBits: NSNumber(value: sizeInBits), + kSecAttrIsPermanent: false, + ] + return SecKeyCreateWithData(derEncodedData as CFData, attributes as CFDictionary, nil) + } } -} -extension [UInt8] { - fileprivate func derEncode(as dataType: UInt8) -> [UInt8] { - var encodedBytes: [UInt8] = [dataType] - var numberOfBytes = count - if numberOfBytes < 128 { - encodedBytes.append(UInt8(numberOfBytes)) - } else { - let lengthData = Data(bytes: &numberOfBytes, count: MemoryLayout.size(ofValue: numberOfBytes)) - let lengthBytes = [UInt8](lengthData).filter({ $0 != 0 }).reversed() - encodedBytes.append(UInt8(truncatingIfNeeded: lengthBytes.count) | 0b10000000) - encodedBytes.append(contentsOf: lengthBytes) + extension [UInt8] { + fileprivate func derEncode(as dataType: UInt8) -> [UInt8] { + var encodedBytes: [UInt8] = [dataType] + var numberOfBytes = count + if numberOfBytes < 128 { + encodedBytes.append(UInt8(numberOfBytes)) + } else { + let lengthData = Data( + bytes: &numberOfBytes, + count: MemoryLayout.size(ofValue: numberOfBytes) + ) + let lengthBytes = [UInt8](lengthData).filter({ $0 != 0 }).reversed() + encodedBytes.append(UInt8(truncatingIfNeeded: lengthBytes.count) | 0b10000000) + encodedBytes.append(contentsOf: lengthBytes) + } + encodedBytes.append(contentsOf: self) + return encodedBytes } - encodedBytes.append(contentsOf: self) - return encodedBytes + } -} +#endif diff --git a/Sources/Auth/Internal/JWTAlgorithm.swift b/Sources/Auth/Internal/JWTAlgorithm.swift index 079b91dd4..4d43f370f 100644 --- a/Sources/Auth/Internal/JWTAlgorithm.swift +++ b/Sources/Auth/Internal/JWTAlgorithm.swift @@ -17,13 +17,17 @@ enum JWTAlgorithm: String { let message = "\(jwt.raw.header).\(jwt.raw.payload)".data(using: .utf8)! switch self { case .rs256: - return SecKeyVerifySignature( - jwk.rsaPublishKey!, - .rsaSignatureMessagePKCS1v15SHA256, - message as CFData, - jwt.signature as CFData, - nil - ) + #if canImport(Security) + return SecKeyVerifySignature( + jwk.rsaPublishKey!, + .rsaSignatureMessagePKCS1v15SHA256, + message as CFData, + jwt.signature as CFData, + nil + ) + #else + return false + #endif } } } From 46111f5802f9b643ce29f9eb4c85cd2f40be8f23 Mon Sep 17 00:00:00 2001 From: Guilherme Souza Date: Tue, 7 Oct 2025 11:56:31 -0300 Subject: [PATCH 7/7] fix encoding implementation of JWTClaims type --- Sources/Auth/Types.swift | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/Sources/Auth/Types.swift b/Sources/Auth/Types.swift index fa10a7e6e..f0400d4e8 100644 --- a/Sources/Auth/Types.swift +++ b/Sources/Auth/Types.swift @@ -1261,15 +1261,36 @@ public struct JWTClaims: Codable, Hashable, Sendable { // Decode additional claims let allKeys = try decoder.container(keyedBy: AnyCodingKey.self) var additional: [String: AnyJSON] = [:] - for key in allKeys.allKeys { - if CodingKeys(stringValue: key.stringValue) == nil { - if let value = try? allKeys.decode(AnyJSON.self, forKey: key) { - additional[key.stringValue] = value - } + for key in allKeys.allKeys where CodingKeys(stringValue: key.stringValue) == nil { + if let value = try? allKeys.decode(AnyJSON.self, forKey: key) { + additional[key.stringValue] = value } } additionalClaims = additional } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(self.iss, forKey: .iss) + try container.encodeIfPresent(self.sub, forKey: .sub) + try container.encodeIfPresent(self.aud, forKey: .aud) + try container.encodeIfPresent(self.exp, forKey: .exp) + try container.encodeIfPresent(self.iat, forKey: .iat) + try container.encodeIfPresent(self.nbf, forKey: .nbf) + try container.encodeIfPresent(self.jti, forKey: .jti) + try container.encodeIfPresent(self.role, forKey: .role) + try container.encodeIfPresent(self.aal, forKey: .aal) + try container.encodeIfPresent(self.sessionId, forKey: .sessionId) + try container.encodeIfPresent(self.email, forKey: .email) + try container.encodeIfPresent(self.phone, forKey: .phone) + try container.encodeIfPresent(self.appMetadata, forKey: .appMetadata) + try container.encodeIfPresent(self.userMetadata, forKey: .userMetadata) + + var additionalClaimsContainer = encoder.container(keyedBy: AnyCodingKey.self) + for (key, value) in additionalClaims { + try additionalClaimsContainer.encode(value, forKey: AnyCodingKey(stringValue: key)!) + } + } } /// Audience claim can be either a string or an array of strings