Skip to content

Commit fd60e57

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

File tree

1 file changed

+22
-18
lines changed

1 file changed

+22
-18
lines changed

Sources/Auth/AuthClient.swift

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1478,7 +1478,8 @@ public actor AuthClient {
14781478
}
14791479

14801480
/// Fetches a JWK from the JWKS endpoint with caching
1481-
private func fetchJWK(kid: String, jwks: JWKS? = nil) async throws -> JWK {
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? {
14821483
// Try fetching from the supplied jwks
14831484
if let jwk = jwks?.keys.first(where: { $0.kid == kid }) {
14841485
return jwk
@@ -1507,8 +1508,9 @@ public actor AuthClient {
15071508

15081509
let fetchedJWKS = try response.decoded(as: JWKS.self, decoder: configuration.decoder)
15091510

1511+
// Return nil if JWKS is empty (will fallback to getUser)
15101512
guard !fetchedJWKS.keys.isEmpty else {
1511-
throw AuthError.jwtVerificationFailed(message: "JWKS is empty")
1513+
return nil
15121514
}
15131515

15141516
// Cache the JWKS globally
@@ -1517,12 +1519,9 @@ public actor AuthClient {
15171519
for: storageKey
15181520
)
15191521

1520-
// Find the signing key
1521-
guard let jwk = fetchedJWKS.keys.first(where: { $0.kid == kid }) else {
1522-
throw AuthError.jwtVerificationFailed(message: "No matching signing key found in JWKS")
1523-
}
1524-
1525-
return jwk
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 })
15261525
}
15271526

15281527
/// Extracts the JWT claims present in the access token by first verifying the
@@ -1573,9 +1572,20 @@ public actor AuthClient {
15731572
let alg = decodedJWT.header["alg"] as? String
15741573
let kid = decodedJWT.header["kid"] as? String
15751574

1576-
// If symmetric algorithm (HS256), RS256 (not yet fully supported), or no kid, fallback to getUser()
1577-
// RS256 will be fully supported client-side once swift-crypto's RSA API is public
1578-
if alg == "HS256" || alg == "RS256" || kid == nil {
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+
// RS256 is currently not fully supported client-side (falls back to server-side)
1581+
signingKey = alg == "RS256" ? nil : try await fetchJWK(kid: kid, jwks: options.jwks)
1582+
} else {
1583+
signingKey = nil
1584+
}
1585+
1586+
// If no signing key available (symmetric algorithm, RS256, no kid, or key not found),
1587+
// fallback to server-side verification via getUser()
1588+
if signingKey == nil {
15791589
_ = try await user(jwt: token)
15801590
// getUser succeeds, so claims can be trusted
15811591
let claims = try configuration.decoder.decode(
@@ -1590,13 +1600,7 @@ public actor AuthClient {
15901600
}
15911601

15921602
// Asymmetric JWT verification using CryptoKit (currently only ES256)
1593-
guard let kid else {
1594-
throw AuthError.jwtVerificationFailed(message: "Missing kid in JWT header")
1595-
}
1596-
1597-
let signingKey = try await fetchJWK(kid: kid, jwks: options.jwks)
1598-
1599-
let isValid = try JWTVerifier.verify(jwt: decodedJWT, jwk: signingKey)
1603+
let isValid = try JWTVerifier.verify(jwt: decodedJWT, jwk: signingKey!)
16001604

16011605
guard isValid else {
16021606
throw AuthError.jwtVerificationFailed(message: "Invalid JWT signature")

0 commit comments

Comments
 (0)