Skip to content

Commit 4f80744

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

File tree

6 files changed

+449
-12
lines changed

6 files changed

+449
-12
lines changed

Sources/Auth/AuthClient.swift

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ public actor AuthClient {
5353
nonisolated private var sessionStorage: SessionStorage { Dependencies[clientID].sessionStorage }
5454
nonisolated private var pkce: PKCE { Dependencies[clientID].pkce }
5555

56+
/// Cache for JWKS (JSON Web Key Set)
57+
private var jwksCache: JWKS?
58+
5659
/// Returns the session, refreshing it if necessary.
5760
///
5861
/// If no session can be found, a ``AuthError/sessionMissing`` error is thrown.
@@ -1448,6 +1451,114 @@ public actor AuthClient {
14481451

14491452
return url
14501453
}
1454+
1455+
/// Fetches a JWK from the JWKS endpoint
1456+
private func fetchJWK(kid: String, jwks: JWKS? = nil) async throws -> JWK {
1457+
// Try fetching from the supplied jwks
1458+
if let jwk = jwks?.keys.first(where: { $0.kid == kid }) {
1459+
return jwk
1460+
}
1461+
1462+
// Try fetching from cache
1463+
if let jwk = jwksCache?.keys.first(where: { $0.kid == kid }) {
1464+
return jwk
1465+
}
1466+
1467+
// Fetch from well-known endpoint
1468+
let response = try await api.execute(
1469+
HTTPRequest(
1470+
url: configuration.url.appendingPathComponent(".well-known/jwks.json"),
1471+
method: .get
1472+
)
1473+
)
1474+
1475+
let fetchedJWKS = try response.decoded(as: JWKS.self, decoder: configuration.decoder)
1476+
1477+
guard !fetchedJWKS.keys.isEmpty else {
1478+
throw AuthError.jwtVerificationFailed(message: "JWKS is empty")
1479+
}
1480+
1481+
// Cache the JWKS
1482+
jwksCache = fetchedJWKS
1483+
1484+
// Find the signing key
1485+
guard let jwk = fetchedJWKS.keys.first(where: { $0.kid == kid }) else {
1486+
throw AuthError.jwtVerificationFailed(message: "No matching signing key found in JWKS")
1487+
}
1488+
1489+
return jwk
1490+
}
1491+
1492+
/// Verifies and extracts claims from a JWT.
1493+
///
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.
1497+
///
1498+
/// - 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.
1501+
///
1502+
/// - Returns: A `JWTClaimsResponse` containing the verified claims, header, and signature.
1503+
///
1504+
/// - 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 {
1508+
let token: String
1509+
if let jwt {
1510+
token = jwt
1511+
} else {
1512+
guard let session = try? await session else {
1513+
throw AuthError.sessionMissing
1514+
}
1515+
token = session.accessToken
1516+
}
1517+
1518+
guard let decodedJWT = JWT.decode(token) else {
1519+
throw AuthError.jwtVerificationFailed(message: "Invalid JWT structure")
1520+
}
1521+
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")
1527+
}
1528+
}
1529+
1530+
let alg = decodedJWT.header["alg"] as? String
1531+
let kid = decodedJWT.header["kid"] as? String
1532+
1533+
// If symmetric algorithm (HS256), RS256 (not yet fully supported), or no kid, fallback to getUser()
1534+
// RS256 will be fully supported client-side once swift-crypto's RSA API is public
1535+
if alg == "HS256" || alg == "RS256" || kid == nil {
1536+
_ = try await user(jwt: token)
1537+
// 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))
1540+
return JWTClaimsResponse(claims: claims, header: header, signature: decodedJWT.signature)
1541+
}
1542+
1543+
// Asymmetric JWT verification using CryptoKit (currently only ES256)
1544+
guard let kid else {
1545+
throw AuthError.jwtVerificationFailed(message: "Missing kid in JWT header")
1546+
}
1547+
1548+
let signingKey = try await fetchJWK(kid: kid, jwks: jwks)
1549+
1550+
let isValid = try JWTVerifier.verify(jwt: decodedJWT, jwk: signingKey)
1551+
1552+
guard isValid else {
1553+
throw AuthError.jwtVerificationFailed(message: "Invalid JWT signature")
1554+
}
1555+
1556+
// 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))
1559+
1560+
return JWTClaimsResponse(claims: claims, header: header, signature: decodedJWT.signature)
1561+
}
14511562
}
14521563

14531564
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
}

Sources/Auth/JWTVerifier.swift

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
//
2+
// JWTVerifier.swift
3+
// Supabase
4+
//
5+
// Created by Claude on 06/10/25.
6+
//
7+
8+
import CryptoKit
9+
import Foundation
10+
11+
enum JWTVerifier {
12+
/// Verifies an asymmetric JWT signature using CryptoKit
13+
static func verify(
14+
jwt: DecodedJWT,
15+
jwk: JWK
16+
) throws -> Bool {
17+
guard let alg = jwt.header["alg"] as? String else {
18+
throw AuthError.jwtVerificationFailed(message: "Missing alg in JWT header")
19+
}
20+
21+
let message = "\(jwt.raw.header).\(jwt.raw.payload)".data(using: .utf8)!
22+
23+
switch alg {
24+
case "RS256":
25+
// RS256 (RSA) verification requires swift-crypto's _RSA which is not yet public API
26+
// For now, we fall back to server-side verification via getUser()
27+
throw AuthError.jwtVerificationFailed(
28+
message: "RS256 JWTs are currently verified server-side via getUser()"
29+
)
30+
case "ES256":
31+
return try verifyES256(message: message, signature: jwt.signature, jwk: jwk)
32+
case "HS256":
33+
// Symmetric keys should be verified server-side via getUser
34+
throw AuthError.jwtVerificationFailed(
35+
message: "HS256 JWTs must be verified server-side"
36+
)
37+
default:
38+
throw AuthError.jwtVerificationFailed(message: "Unsupported algorithm: \(alg)")
39+
}
40+
}
41+
42+
private static func verifyES256(message: Data, signature: Data, jwk: JWK) throws -> Bool {
43+
guard
44+
let xString = jwk.x,
45+
let yString = jwk.y,
46+
let xData = Base64URL.decode(xString),
47+
let yData = Base64URL.decode(yString)
48+
else {
49+
throw AuthError.jwtVerificationFailed(message: "Invalid EC JWK")
50+
}
51+
52+
// For P256, we need to construct the X9.63 representation
53+
// X9.63 format: 0x04 + x + y for uncompressed point
54+
var x963Data = Data([0x04])
55+
x963Data.append(xData)
56+
x963Data.append(yData)
57+
58+
// Create EC public key from JWK
59+
let publicKey = try P256.Signing.PublicKey(
60+
x963Representation: x963Data
61+
)
62+
63+
let isValid = publicKey.isValidSignature(
64+
try P256.Signing.ECDSASignature(rawRepresentation: signature),
65+
for: SHA256.hash(data: message)
66+
)
67+
68+
return isValid
69+
}
70+
}

0 commit comments

Comments
 (0)