Skip to content

Commit fda262b

Browse files
grdsdevclaude
andauthored
feat(auth): introduce getClaims method to verify and extract JWT claims (#812)
* feat(auth): introduce getClaims method to verify and extract JWT claims 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 <[email protected]> * feat(auth): make getClaims non-experimental, add global JWKS cache 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 <[email protected]> * feat(auth): add graceful fallback for JWK not found in JWKS 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 <[email protected]> * fix jwt verify * test: add tests for getClaims method * fallback to getUser if Security isn't available * fix encoding implementation of JWTClaims type --------- Co-authored-by: Claude <[email protected]>
1 parent a2320ec commit fda262b

File tree

8 files changed

+927
-20
lines changed

8 files changed

+927
-20
lines changed

Sources/Auth/AuthClient.swift

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,34 @@ struct AuthClientLoggerDecorator: SupabaseLogger {
3030
}
3131
}
3232

33+
/// JWKS cache TTL (Time To Live) - 10 minutes
34+
private let JWKS_TTL: TimeInterval = 10 * 60
35+
36+
/// Cached JWKS value with timestamp
37+
private struct CachedJWKS {
38+
let jwks: JWKS
39+
let cachedAt: Date
40+
}
41+
42+
/// Global JWKS cache shared across all clients with the same storage key.
43+
/// This is especially useful for shared-memory execution environments such as
44+
/// AWS Lambda or serverless functions. Regardless of how many clients are created,
45+
/// if they share the same storage key they will use the same JWKS cache,
46+
/// significantly speeding up getClaims() with asymmetric JWTs.
47+
private actor GlobalJWKSCache {
48+
private var cache: [String: CachedJWKS] = [:]
49+
50+
func get(for key: String) -> CachedJWKS? {
51+
cache[key]
52+
}
53+
54+
func set(_ value: CachedJWKS, for key: String) {
55+
cache[key] = value
56+
}
57+
}
58+
59+
private let globalJWKSCache = GlobalJWKSCache()
60+
3361
public actor AuthClient {
3462
static var globalClientID = 0
3563
nonisolated let clientID: AuthClientID
@@ -1448,6 +1476,150 @@ public actor AuthClient {
14481476

14491477
return url
14501478
}
1479+
1480+
/// Fetches a JWK from the JWKS endpoint with caching
1481+
/// Returns nil if the key is not found, allowing graceful fallback to server-side verification
1482+
private func fetchJWK(kid: String, jwks: JWKS? = nil) async throws -> JWK? {
1483+
// Try fetching from the supplied jwks
1484+
if let jwk = jwks?.keys.first(where: { $0.kid == kid }) {
1485+
return jwk
1486+
}
1487+
1488+
let now = date()
1489+
let storageKey = configuration.storageKey ?? defaultStorageKey
1490+
1491+
// Try fetching from global cache
1492+
if let cached = await globalJWKSCache.get(for: storageKey),
1493+
let jwk = cached.jwks.keys.first(where: { $0.kid == kid })
1494+
{
1495+
// Check if cache is still valid (not stale)
1496+
if cached.cachedAt.addingTimeInterval(JWKS_TTL) > now {
1497+
return jwk
1498+
}
1499+
}
1500+
1501+
// Fetch from well-known endpoint
1502+
let response = try await api.execute(
1503+
HTTPRequest(
1504+
url: configuration.url.appendingPathComponent(".well-known/jwks.json"),
1505+
method: .get
1506+
)
1507+
)
1508+
1509+
let fetchedJWKS = try response.decoded(as: JWKS.self, decoder: configuration.decoder)
1510+
1511+
// Return nil if JWKS is empty (will fallback to getUser)
1512+
guard !fetchedJWKS.keys.isEmpty else {
1513+
return nil
1514+
}
1515+
1516+
// Cache the JWKS globally
1517+
await globalJWKSCache.set(
1518+
CachedJWKS(jwks: fetchedJWKS, cachedAt: now),
1519+
for: storageKey
1520+
)
1521+
1522+
// Find the signing key - return nil if not found (will fallback to getUser)
1523+
// This handles key rotation scenarios where the JWT is signed with a key not yet in the cache
1524+
return fetchedJWKS.keys.first(where: { $0.kid == kid })
1525+
}
1526+
1527+
/// Extracts the JWT claims present in the access token by first verifying the
1528+
/// JWT against the server's JSON Web Key Set endpoint `/.well-known/jwks.json`
1529+
/// which is often cached, resulting in significantly faster responses. Prefer
1530+
/// this method over ``user(jwt:)`` which always sends a request to the Auth
1531+
/// server for each JWT.
1532+
///
1533+
/// If the project is not using an asymmetric JWT signing key (like ECC or RSA)
1534+
/// it always sends a request to the Auth server (similar to ``user(jwt:)``) to
1535+
/// verify the JWT.
1536+
///
1537+
/// - Parameters:
1538+
/// - jwt: An optional specific JWT you wish to verify, not the one you can obtain from ``session``.
1539+
/// - options: Various additional options that allow you to customize the behavior of this method.
1540+
///
1541+
/// - Returns: A `JWTClaimsResponse` containing the verified claims, header, and signature.
1542+
///
1543+
/// - Throws: `AuthError.jwtVerificationFailed` if verification fails, or `AuthError.sessionMissing` if no session exists.
1544+
public func getClaims(
1545+
jwt: String? = nil,
1546+
options: GetClaimsOptions = GetClaimsOptions()
1547+
) async throws -> JWTClaimsResponse {
1548+
let token: String
1549+
if let jwt {
1550+
token = jwt
1551+
} else {
1552+
guard let session = try? await session else {
1553+
throw AuthError.sessionMissing
1554+
}
1555+
token = session.accessToken
1556+
}
1557+
1558+
guard let decodedJWT = JWT.decode(token) else {
1559+
throw AuthError.jwtVerificationFailed(message: "Invalid JWT structure")
1560+
}
1561+
1562+
// Validate expiration unless allowExpired is true
1563+
if !options.allowExpired {
1564+
if let exp = decodedJWT.payload["exp"] as? TimeInterval {
1565+
let now = date().timeIntervalSince1970
1566+
if exp <= now {
1567+
throw AuthError.jwtVerificationFailed(message: "JWT has expired")
1568+
}
1569+
}
1570+
}
1571+
1572+
let alg = decodedJWT.header["alg"] as? String
1573+
let kid = decodedJWT.header["kid"] as? String
1574+
1575+
// Try to fetch the signing key for asymmetric JWTs
1576+
// Returns nil if: no alg, symmetric algorithm (HS256/HS512), no kid, or key not found in JWKS
1577+
let signingKey: JWK?
1578+
if let alg, !alg.hasPrefix("HS"), let kid {
1579+
// Only attempt to fetch JWK for asymmetric algorithms with a kid
1580+
signingKey = try await fetchJWK(kid: kid, jwks: options.jwks)
1581+
} else {
1582+
signingKey = nil
1583+
}
1584+
1585+
// If no signing key available (symmetric algorithm, RS256, no kid, or key not found),
1586+
// fallback to server-side verification via getUser()
1587+
guard
1588+
let signingKey,
1589+
let alg = signingKey.alg,
1590+
let algorithm = JWTAlgorithm(rawValue: alg)
1591+
else {
1592+
_ = try await user(jwt: token)
1593+
// getUser succeeds, so claims can be trusted
1594+
let claims = try configuration.decoder.decode(
1595+
JWTClaims.self,
1596+
from: JSONSerialization.data(withJSONObject: decodedJWT.payload)
1597+
)
1598+
let header = try configuration.decoder.decode(
1599+
JWTHeader.self,
1600+
from: JSONSerialization.data(withJSONObject: decodedJWT.header)
1601+
)
1602+
return JWTClaimsResponse(claims: claims, header: header, signature: decodedJWT.signature)
1603+
}
1604+
1605+
let isValid = algorithm.verify(jwt: decodedJWT, jwk: signingKey)
1606+
1607+
guard isValid else {
1608+
throw AuthError.jwtVerificationFailed(message: "Invalid JWT signature")
1609+
}
1610+
1611+
// Decode claims and header
1612+
let claims = try configuration.decoder.decode(
1613+
JWTClaims.self,
1614+
from: JSONSerialization.data(withJSONObject: decodedJWT.payload)
1615+
)
1616+
let header = try configuration.decoder.decode(
1617+
JWTHeader.self,
1618+
from: JSONSerialization.data(withJSONObject: decodedJWT.header)
1619+
)
1620+
1621+
return JWTClaimsResponse(claims: claims, header: header, signature: decodedJWT.signature)
1622+
}
14511623
}
14521624

14531625
extension AuthClient {

Sources/Auth/AuthError.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ extension ErrorCode {
114114
//#nosec G101 -- Not a secret value.
115115
public static let invalidCredentials = ErrorCode("invalid_credentials")
116116
public static let emailAddressNotAuthorized = ErrorCode("email_address_not_authorized")
117+
public static let invalidJWT = ErrorCode("invalid_jwt")
117118
}
118119

119120
public enum AuthError: LocalizedError, Equatable {
@@ -261,13 +262,17 @@ public enum AuthError: LocalizedError, Equatable {
261262
/// Error thrown when an error happens during implicit grant flow.
262263
case implicitGrantRedirect(message: String)
263264

265+
/// Error thrown when JWT verification fails.
266+
case jwtVerificationFailed(message: String)
267+
264268
public var message: String {
265269
switch self {
266270
case .sessionMissing: "Auth session missing."
267271
case let .weakPassword(message, _),
268272
let .api(message, _, _, _),
269273
let .pkceGrantCodeExchange(message, _, _),
270-
let .implicitGrantRedirect(message):
274+
let .implicitGrantRedirect(message),
275+
let .jwtVerificationFailed(message):
271276
message
272277
// Deprecated cases
273278
case .missingExpClaim: "Missing expiration claim in the access token."
@@ -283,6 +288,7 @@ public enum AuthError: LocalizedError, Equatable {
283288
case .weakPassword: .weakPassword
284289
case let .api(_, errorCode, _, _): errorCode
285290
case .pkceGrantCodeExchange, .implicitGrantRedirect: .unknown
291+
case .jwtVerificationFailed: .invalidJWT
286292
// Deprecated cases
287293
case .missingExpClaim, .malformedJWT, .invalidRedirectScheme, .missingURL: .unknown
288294
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
//
2+
// JWK+RSA.swift
3+
// Supabase
4+
//
5+
// Created by Guilherme Souza on 07/10/25.
6+
//
7+
8+
import Foundation
9+
10+
#if canImport(Security)
11+
12+
extension JWK {
13+
var rsaPublishKey: SecKey? {
14+
guard kty == "RSA",
15+
alg == "RS256",
16+
let n,
17+
let modulus = Base64URL.decode(n),
18+
let e,
19+
let exponent = Base64URL.decode(e)
20+
else {
21+
return nil
22+
}
23+
24+
let encodedKey = encodeRSAPublishKey(modulus: [UInt8](modulus), exponent: [UInt8](exponent))
25+
return generateRSAPublicKey(from: encodedKey)
26+
}
27+
}
28+
29+
extension JWK {
30+
fileprivate func encodeRSAPublishKey(modulus: [UInt8], exponent: [UInt8]) -> Data {
31+
var prefixedModulus: [UInt8] = [0x00] // To indicate that the number is not negative
32+
prefixedModulus.append(contentsOf: modulus)
33+
let encodedModulus = prefixedModulus.derEncode(as: 2) // Integer
34+
let encodedExponent = exponent.derEncode(as: 2) // Integer
35+
let encodedSequence = (encodedModulus + encodedExponent).derEncode(as: 48) // Sequence
36+
return Data(encodedSequence)
37+
}
38+
39+
fileprivate func generateRSAPublicKey(from derEncodedData: Data) -> SecKey? {
40+
let sizeInBits = derEncodedData.count * MemoryLayout<UInt8>.size
41+
let attributes: [CFString: Any] = [
42+
kSecAttrKeyType: kSecAttrKeyTypeRSA,
43+
kSecAttrKeyClass: kSecAttrKeyClassPublic,
44+
kSecAttrKeySizeInBits: NSNumber(value: sizeInBits),
45+
kSecAttrIsPermanent: false,
46+
]
47+
return SecKeyCreateWithData(derEncodedData as CFData, attributes as CFDictionary, nil)
48+
}
49+
}
50+
51+
extension [UInt8] {
52+
fileprivate func derEncode(as dataType: UInt8) -> [UInt8] {
53+
var encodedBytes: [UInt8] = [dataType]
54+
var numberOfBytes = count
55+
if numberOfBytes < 128 {
56+
encodedBytes.append(UInt8(numberOfBytes))
57+
} else {
58+
let lengthData = Data(
59+
bytes: &numberOfBytes,
60+
count: MemoryLayout.size(ofValue: numberOfBytes)
61+
)
62+
let lengthBytes = [UInt8](lengthData).filter({ $0 != 0 }).reversed()
63+
encodedBytes.append(UInt8(truncatingIfNeeded: lengthBytes.count) | 0b10000000)
64+
encodedBytes.append(contentsOf: lengthBytes)
65+
}
66+
encodedBytes.append(contentsOf: self)
67+
return encodedBytes
68+
}
69+
70+
}
71+
72+
#endif
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//
2+
// JWTVerifier.swift
3+
// Supabase
4+
//
5+
// Created by Claude on 06/10/25.
6+
//
7+
8+
import Foundation
9+
10+
enum JWTAlgorithm: String {
11+
case rs256 = "RS256"
12+
13+
func verify(
14+
jwt: DecodedJWT,
15+
jwk: JWK
16+
) -> Bool {
17+
let message = "\(jwt.raw.header).\(jwt.raw.payload)".data(using: .utf8)!
18+
switch self {
19+
case .rs256:
20+
#if canImport(Security)
21+
return SecKeyVerifySignature(
22+
jwk.rsaPublishKey!,
23+
.rsaSignatureMessagePKCS1v15SHA256,
24+
message as CFData,
25+
jwt.signature as CFData,
26+
nil
27+
)
28+
#else
29+
return false
30+
#endif
31+
}
32+
}
33+
}

0 commit comments

Comments
 (0)