Skip to content

Commit 8d8241f

Browse files
grdsdevclaude
andcommitted
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]>
1 parent 4f80744 commit 8d8241f

File tree

2 files changed

+97
-28
lines changed

2 files changed

+97
-28
lines changed

Sources/Auth/AuthClient.swift

Lines changed: 83 additions & 28 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
@@ -53,9 +81,6 @@ public actor AuthClient {
5381
nonisolated private var sessionStorage: SessionStorage { Dependencies[clientID].sessionStorage }
5482
nonisolated private var pkce: PKCE { Dependencies[clientID].pkce }
5583

56-
/// Cache for JWKS (JSON Web Key Set)
57-
private var jwksCache: JWKS?
58-
5984
/// Returns the session, refreshing it if necessary.
6085
///
6186
/// If no session can be found, a ``AuthError/sessionMissing`` error is thrown.
@@ -1452,16 +1477,24 @@ public actor AuthClient {
14521477
return url
14531478
}
14541479

1455-
/// Fetches a JWK from the JWKS endpoint
1480+
/// Fetches a JWK from the JWKS endpoint with caching
14561481
private func fetchJWK(kid: String, jwks: JWKS? = nil) async throws -> JWK {
14571482
// Try fetching from the supplied jwks
14581483
if let jwk = jwks?.keys.first(where: { $0.kid == kid }) {
14591484
return jwk
14601485
}
14611486

1462-
// Try fetching from cache
1463-
if let jwk = jwksCache?.keys.first(where: { $0.kid == kid }) {
1464-
return jwk
1487+
let now = date()
1488+
let storageKey = configuration.storageKey ?? defaultStorageKey
1489+
1490+
// Try fetching from global cache
1491+
if let cached = await globalJWKSCache.get(for: storageKey),
1492+
let jwk = cached.jwks.keys.first(where: { $0.kid == kid })
1493+
{
1494+
// Check if cache is still valid (not stale)
1495+
if cached.cachedAt.addingTimeInterval(JWKS_TTL) > now {
1496+
return jwk
1497+
}
14651498
}
14661499

14671500
// Fetch from well-known endpoint
@@ -1478,8 +1511,11 @@ public actor AuthClient {
14781511
throw AuthError.jwtVerificationFailed(message: "JWKS is empty")
14791512
}
14801513

1481-
// Cache the JWKS
1482-
jwksCache = fetchedJWKS
1514+
// Cache the JWKS globally
1515+
await globalJWKSCache.set(
1516+
CachedJWKS(jwks: fetchedJWKS, cachedAt: now),
1517+
for: storageKey
1518+
)
14831519

14841520
// Find the signing key
14851521
guard let jwk = fetchedJWKS.keys.first(where: { $0.kid == kid }) else {
@@ -1489,22 +1525,27 @@ public actor AuthClient {
14891525
return jwk
14901526
}
14911527

1492-
/// Verifies and extracts claims from a JWT.
1528+
/// Extracts the JWT claims present in the access token by first verifying the
1529+
/// JWT against the server's JSON Web Key Set endpoint `/.well-known/jwks.json`
1530+
/// which is often cached, resulting in significantly faster responses. Prefer
1531+
/// this method over ``user(jwt:)`` which always sends a request to the Auth
1532+
/// server for each JWT.
14931533
///
1494-
/// This method verifies the JWT signature and returns the claims if valid. For symmetric JWTs (HS256),
1495-
/// it validates against the server using the `getUser` method. For asymmetric JWTs (RS256, ES256),
1496-
/// it verifies the signature using the JWKS (JSON Web Key Set) from the well-known endpoint.
1534+
/// If the project is not using an asymmetric JWT signing key (like ECC or RSA)
1535+
/// it always sends a request to the Auth server (similar to ``user(jwt:)``) to
1536+
/// verify the JWT.
14971537
///
14981538
/// - Parameters:
1499-
/// - jwt: The JWT to verify. If nil, uses the access token from the current session.
1500-
/// - jwks: Optional JWKS to use for verification. If nil, fetches from the server.
1539+
/// - jwt: An optional specific JWT you wish to verify, not the one you can obtain from ``session``.
1540+
/// - options: Various additional options that allow you to customize the behavior of this method.
15011541
///
15021542
/// - Returns: A `JWTClaimsResponse` containing the verified claims, header, and signature.
15031543
///
15041544
/// - Throws: `AuthError.jwtVerificationFailed` if verification fails, or `AuthError.sessionMissing` if no session exists.
1505-
///
1506-
/// - Note: This is an experimental method and may change in future versions.
1507-
public func getClaims(jwt: String? = nil, jwks: JWKS? = nil) async throws -> JWTClaimsResponse {
1545+
public func getClaims(
1546+
jwt: String? = nil,
1547+
options: GetClaimsOptions = GetClaimsOptions()
1548+
) async throws -> JWTClaimsResponse {
15081549
let token: String
15091550
if let jwt {
15101551
token = jwt
@@ -1519,11 +1560,13 @@ public actor AuthClient {
15191560
throw AuthError.jwtVerificationFailed(message: "Invalid JWT structure")
15201561
}
15211562

1522-
// Validate expiration
1523-
if let exp = decodedJWT.payload["exp"] as? TimeInterval {
1524-
let now = Date().timeIntervalSince1970
1525-
if exp <= now {
1526-
throw AuthError.jwtVerificationFailed(message: "JWT has expired")
1563+
// Validate expiration unless allowExpired is true
1564+
if !options.allowExpired {
1565+
if let exp = decodedJWT.payload["exp"] as? TimeInterval {
1566+
let now = date().timeIntervalSince1970
1567+
if exp <= now {
1568+
throw AuthError.jwtVerificationFailed(message: "JWT has expired")
1569+
}
15271570
}
15281571
}
15291572

@@ -1535,8 +1578,14 @@ public actor AuthClient {
15351578
if alg == "HS256" || alg == "RS256" || kid == nil {
15361579
_ = try await user(jwt: token)
15371580
// getUser succeeds, so claims can be trusted
1538-
let claims = try configuration.decoder.decode(JWTClaims.self, from: JSONSerialization.data(withJSONObject: decodedJWT.payload))
1539-
let header = try configuration.decoder.decode(JWTHeader.self, from: JSONSerialization.data(withJSONObject: decodedJWT.header))
1581+
let claims = try configuration.decoder.decode(
1582+
JWTClaims.self,
1583+
from: JSONSerialization.data(withJSONObject: decodedJWT.payload)
1584+
)
1585+
let header = try configuration.decoder.decode(
1586+
JWTHeader.self,
1587+
from: JSONSerialization.data(withJSONObject: decodedJWT.header)
1588+
)
15401589
return JWTClaimsResponse(claims: claims, header: header, signature: decodedJWT.signature)
15411590
}
15421591

@@ -1545,7 +1594,7 @@ public actor AuthClient {
15451594
throw AuthError.jwtVerificationFailed(message: "Missing kid in JWT header")
15461595
}
15471596

1548-
let signingKey = try await fetchJWK(kid: kid, jwks: jwks)
1597+
let signingKey = try await fetchJWK(kid: kid, jwks: options.jwks)
15491598

15501599
let isValid = try JWTVerifier.verify(jwt: decodedJWT, jwk: signingKey)
15511600

@@ -1554,8 +1603,14 @@ public actor AuthClient {
15541603
}
15551604

15561605
// Decode claims and header
1557-
let claims = try configuration.decoder.decode(JWTClaims.self, from: JSONSerialization.data(withJSONObject: decodedJWT.payload))
1558-
let header = try configuration.decoder.decode(JWTHeader.self, from: JSONSerialization.data(withJSONObject: decodedJWT.header))
1606+
let claims = try configuration.decoder.decode(
1607+
JWTClaims.self,
1608+
from: JSONSerialization.data(withJSONObject: decodedJWT.payload)
1609+
)
1610+
let header = try configuration.decoder.decode(
1611+
JWTHeader.self,
1612+
from: JSONSerialization.data(withJSONObject: decodedJWT.header)
1613+
)
15591614

15601615
return JWTClaimsResponse(claims: claims, header: header, signature: decodedJWT.signature)
15611616
}

Sources/Auth/Types.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,3 +1326,17 @@ public struct JWTClaimsResponse: Sendable {
13261326
public let header: JWTHeader
13271327
public let signature: Data
13281328
}
1329+
1330+
/// Options for the getClaims method
1331+
public struct GetClaimsOptions: Sendable {
1332+
/// If set to `true` the `exp` claim will not be validated against the current time.
1333+
public let allowExpired: Bool
1334+
1335+
/// If set, this JSON Web Key Set is going to have precedence over the cached value available on the server.
1336+
public let jwks: JWKS?
1337+
1338+
public init(allowExpired: Bool = false, jwks: JWKS? = nil) {
1339+
self.allowExpired = allowExpired
1340+
self.jwks = jwks
1341+
}
1342+
}

0 commit comments

Comments
 (0)