Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions Sources/Auth/AuthClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,34 @@
}
}

/// 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
Expand Down Expand Up @@ -93,8 +121,8 @@
/// - Parameters:
/// - configuration: The client configuration.
public init(configuration: Configuration) {
AuthClient.globalClientID += 1

Check warning on line 124 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (test, MACOS, 15.4)

reference to static property 'globalClientID' is not concurrency-safe because it involves shared mutable state; this is an error in Swift 6

Check warning on line 124 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (test, MACOS, 15.4)

reference to static property 'globalClientID' is not concurrency-safe because it involves shared mutable state; this is an error in Swift 6

Check warning on line 124 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (MACOS, 15.4)

reference to static property 'globalClientID' is not concurrency-safe because it involves shared mutable state; this is an error in Swift 6

Check warning on line 124 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (test, IOS, 15.4)

reference to static property 'globalClientID' is not concurrency-safe because it involves shared mutable state; this is an error in Swift 6

Check warning on line 124 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (test, IOS, 15.4)

reference to static property 'globalClientID' is not concurrency-safe because it involves shared mutable state; this is an error in Swift 6

Check warning on line 124 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (test, MAC_CATALYST, 15.4)

reference to static property 'globalClientID' is not concurrency-safe because it involves shared mutable state; this is an error in Swift 6

Check warning on line 124 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (test, MAC_CATALYST, 15.4)

reference to static property 'globalClientID' is not concurrency-safe because it involves shared mutable state; this is an error in Swift 6
clientID = AuthClient.globalClientID

Check warning on line 125 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (test, MACOS, 15.4)

reference to static property 'globalClientID' is not concurrency-safe because it involves shared mutable state; this is an error in Swift 6

Check warning on line 125 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (test, MACOS, 15.4)

reference to static property 'globalClientID' is not concurrency-safe because it involves shared mutable state; this is an error in Swift 6

Check warning on line 125 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (MACOS, 15.4)

reference to static property 'globalClientID' is not concurrency-safe because it involves shared mutable state; this is an error in Swift 6

Check warning on line 125 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (test, IOS, 15.4)

reference to static property 'globalClientID' is not concurrency-safe because it involves shared mutable state; this is an error in Swift 6

Check warning on line 125 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (test, MAC_CATALYST, 15.4)

reference to static property 'globalClientID' is not concurrency-safe because it involves shared mutable state; this is an error in Swift 6

Dependencies[clientID] = Dependencies(
configuration: configuration,
Expand All @@ -104,7 +132,7 @@
sessionStorage: .live(clientID: clientID),
sessionManager: .live(clientID: clientID),
logger: configuration.logger.map {
AuthClientLoggerDecorator(clientID: clientID, decoratee: $0)

Check warning on line 135 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (test, MACOS, 15.4)

nonisolated property 'clientID' can not be referenced from a non-isolated context; this is an error in Swift 6

Check warning on line 135 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (test, MACOS, 15.4)

nonisolated property 'clientID' can not be referenced from a non-isolated context; this is an error in Swift 6

Check warning on line 135 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (MACOS, 15.4)

nonisolated property 'clientID' can not be referenced from a non-isolated context; this is an error in Swift 6

Check warning on line 135 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (test, IOS, 15.4)

nonisolated property 'clientID' can not be referenced from a non-isolated context; this is an error in Swift 6

Check warning on line 135 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (test, MAC_CATALYST, 15.4)

nonisolated property 'clientID' can not be referenced from a non-isolated context; this is an error in Swift 6
}
)

Expand Down Expand Up @@ -339,7 +367,7 @@
method: .post,
query: [URLQueryItem(name: "grant_type", value: "password")],
body: configuration.encoder.encode(
UserCredentials(

Check warning on line 370 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (MACOS, 15.4)

'UserCredentials' is deprecated: Access to UserCredentials will be removed on the next major release.
email: email,
password: password,
gotrueMetaSecurity: captchaToken.map(AuthMetaSecurity.init(captchaToken:))
Expand All @@ -366,7 +394,7 @@
method: .post,
query: [URLQueryItem(name: "grant_type", value: "password")],
body: configuration.encoder.encode(
UserCredentials(

Check warning on line 397 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (MACOS, 15.4)

'UserCredentials' is deprecated: Access to UserCredentials will be removed on the next major release.
password: password,
phone: phone,
gotrueMetaSecurity: captchaToken.map(AuthMetaSecurity.init(captchaToken:))
Expand Down Expand Up @@ -1448,6 +1476,150 @@

return url
}

/// Fetches a JWK from the JWKS endpoint with caching
/// 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
}

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
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)

// Return nil if JWKS is empty (will fallback to getUser)
guard !fetchedJWKS.keys.isEmpty else {
return nil
}

// Cache the JWKS globally
await globalJWKSCache.set(
CachedJWKS(jwks: fetchedJWKS, cachedAt: now),
for: storageKey
)

// 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
/// 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.
///
/// 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: 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.
public func getClaims(
jwt: String? = nil,
options: GetClaimsOptions = GetClaimsOptions()
) 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 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")
}
}
}

let alg = decodedJWT.header["alg"] as? String
let kid = decodedJWT.header["kid"] as? String

// 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
signingKey = 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()
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(
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)
}

let isValid = algorithm.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 {
Expand All @@ -1470,7 +1642,7 @@
final class DefaultPresentationContextProvider: NSObject,
ASWebAuthenticationPresentationContextProviding
{
func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor {

Check warning on line 1645 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (test, MACOS, 15.4)

main actor-isolated instance method 'presentationAnchor(for:)' cannot be used to satisfy nonisolated protocol requirement

Check warning on line 1645 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (test, MACOS, 15.4)

main actor-isolated instance method 'presentationAnchor(for:)' cannot be used to satisfy nonisolated protocol requirement

Check warning on line 1645 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (MACOS, 15.4)

main actor-isolated instance method 'presentationAnchor(for:)' cannot be used to satisfy nonisolated protocol requirement

Check warning on line 1645 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (test, IOS, 15.4)

main actor-isolated instance method 'presentationAnchor(for:)' cannot be used to satisfy nonisolated protocol requirement

Check warning on line 1645 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (test, IOS, 15.4)

main actor-isolated instance method 'presentationAnchor(for:)' cannot be used to satisfy nonisolated protocol requirement

Check warning on line 1645 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (test, MAC_CATALYST, 15.4)

main actor-isolated instance method 'presentationAnchor(for:)' cannot be used to satisfy nonisolated protocol requirement

Check warning on line 1645 in Sources/Auth/AuthClient.swift

View workflow job for this annotation

GitHub Actions / xcodebuild (macOS legacy) (test, MAC_CATALYST, 15.4)

main actor-isolated instance method 'presentationAnchor(for:)' cannot be used to satisfy nonisolated protocol requirement
ASPresentationAnchor()
}
}
Expand Down
8 changes: 7 additions & 1 deletion Sources/Auth/AuthError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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."
Expand All @@ -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
}
Expand Down
72 changes: 72 additions & 0 deletions Sources/Auth/Internal/JWK+RSA.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
//
// JWK+RSA.swift
// Supabase
//
// Created by Guilherme Souza on 07/10/25.
//

import Foundation

#if canImport(Security)

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<UInt8>.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
}

}

#endif
33 changes: 33 additions & 0 deletions Sources/Auth/Internal/JWTAlgorithm.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
//
// 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:
#if canImport(Security)
return SecKeyVerifySignature(
jwk.rsaPublishKey!,
.rsaSignatureMessagePKCS1v15SHA256,
message as CFData,
jwt.signature as CFData,
nil
)
#else
return false
#endif
}
}
}
Loading
Loading