Skip to content
Closed
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
12 changes: 12 additions & 0 deletions Auth0.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@
5C505FAE2E216677005D0757 /* DPoPError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C505FAB2E216672005D0757 /* DPoPError.swift */; };
5C505FAF2E216677005D0757 /* DPoPError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C505FAB2E216672005D0757 /* DPoPError.swift */; };
5C505FB02E216677005D0757 /* DPoPError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C505FAB2E216672005D0757 /* DPoPError.swift */; };
AA000001000000000000AAA1 /* DPoPCredentialValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000001000000000000AAA0 /* DPoPCredentialValidator.swift */; };
AA000001000000000000AAA2 /* DPoPCredentialValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000001000000000000AAA0 /* DPoPCredentialValidator.swift */; };
AA000001000000000000AAA3 /* DPoPCredentialValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000001000000000000AAA0 /* DPoPCredentialValidator.swift */; };
AA000001000000000000AAA4 /* DPoPCredentialValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000001000000000000AAA0 /* DPoPCredentialValidator.swift */; };
AA000001000000000000AAA5 /* DPoPCredentialValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA000001000000000000AAA0 /* DPoPCredentialValidator.swift */; };
5C505FB22E21669F005D0757 /* SenderConstraining.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C505FB12E216699005D0757 /* SenderConstraining.swift */; };
5C505FB32E21669F005D0757 /* SenderConstraining.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C505FB12E216699005D0757 /* SenderConstraining.swift */; };
5C505FB42E21669F005D0757 /* SenderConstraining.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C505FB12E216699005D0757 /* SenderConstraining.swift */; };
Expand Down Expand Up @@ -960,6 +965,7 @@
5C4F553423C9124200C89615 /* JWKSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JWKSpec.swift; sourceTree = "<group>"; };
5C4F553923C9125600C89615 /* JWTAlgorithmSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JWTAlgorithmSpec.swift; sourceTree = "<group>"; };
5C505FAB2E216672005D0757 /* DPoPError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DPoPError.swift; sourceTree = "<group>"; };
AA000001000000000000AAA0 /* DPoPCredentialValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DPoPCredentialValidator.swift; sourceTree = "<group>"; };
5C505FB12E216699005D0757 /* SenderConstraining.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SenderConstraining.swift; sourceTree = "<group>"; };
5C505FB72E2166FB005D0757 /* DPoPProofGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DPoPProofGenerator.swift; sourceTree = "<group>"; };
5C505FBD2E216746005D0757 /* DPoPChallenge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DPoPChallenge.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1380,6 +1386,7 @@
children = (
5C79AE072E040CA600CD0D41 /* DPoP.swift */,
5C505FAB2E216672005D0757 /* DPoPError.swift */,
AA000001000000000000AAA0 /* DPoPCredentialValidator.swift */,
5C505FB12E216699005D0757 /* SenderConstraining.swift */,
5C505FC32E216783005D0757 /* ECPublicKey.swift */,
5C505FB72E2166FB005D0757 /* DPoPProofGenerator.swift */,
Expand Down Expand Up @@ -2535,6 +2542,7 @@
5CDF67392DD3A8D600A9B513 /* Auth0APIError.swift in Sources */,
5C0AF09D2833420200162044 /* WebAuthentication.swift in Sources */,
5C505FB02E216677005D0757 /* DPoPError.swift in Sources */,
AA000001000000000000AAA1 /* DPoPCredentialValidator.swift in Sources */,
5CB41D4823D0BA2C00074024 /* IDTokenValidator.swift in Sources */,
D499DB1A2E6EB1FD000E7E2F /* PasskeyEnrollmentChallenge.swift in Sources */,
D499DB1B2E6EB1FD000E7E2F /* PhoneEnrollmentChallenge.swift in Sources */,
Expand Down Expand Up @@ -2718,6 +2726,7 @@
5C505FB32E21669F005D0757 /* SenderConstraining.swift in Sources */,
5C505FBE2E21674A005D0757 /* DPoPChallenge.swift in Sources */,
5C505FAF2E216677005D0757 /* DPoPError.swift in Sources */,
AA000001000000000000AAA2 /* DPoPCredentialValidator.swift in Sources */,
5F74CB411CEFD5E600226823 /* JSONObjectPayload.swift in Sources */,
5C41F6CB244F96E300252548 /* BioAuthentication.swift in Sources */,
V17KI34D4PWP799FLD81GPVP /* BiometricPolicy.swift in Sources */,
Expand Down Expand Up @@ -2956,6 +2965,7 @@
5C6513AA2791CDDE004EBC22 /* Version.swift in Sources */,
5C505FD32E21688E005D0757 /* SecureEnclaveKeyStore.swift in Sources */,
5C505FAD2E216677005D0757 /* DPoPError.swift in Sources */,
AA000001000000000000AAA3 /* DPoPCredentialValidator.swift in Sources */,
5B1748761EF2D3A70060E653 /* Shared.swift in Sources */,
5C80980E275A7B8600DC0A76 /* CredentialsStorage.swift in Sources */,
F11977F4EFCC4656965E3131 /* JWK+RSA.swift in Sources */,
Expand Down Expand Up @@ -3045,6 +3055,7 @@
5C6513A92791CDDE004EBC22 /* Version.swift in Sources */,
5C505FD22E21688E005D0757 /* SecureEnclaveKeyStore.swift in Sources */,
5C505FAC2E216677005D0757 /* DPoPError.swift in Sources */,
AA000001000000000000AAA4 /* DPoPCredentialValidator.swift in Sources */,
5B1748771EF2D3A90060E653 /* Shared.swift in Sources */,
5C80980D275A7B8600DC0A76 /* CredentialsStorage.swift in Sources */,
8D1D6205C538412A9B9A21C7 /* JWK+RSA.swift in Sources */,
Expand Down Expand Up @@ -3157,6 +3168,7 @@
C1B3B9FD2C24B6D4004A32A4 /* BioAuthentication.swift in Sources */,
2Z7FPPVSUUJRZC06V5RA8B2B /* BiometricPolicy.swift in Sources */,
5C505FAE2E216677005D0757 /* DPoPError.swift in Sources */,
AA000001000000000000AAA5 /* DPoPCredentialValidator.swift in Sources */,
C1B3B9FE2C24B6D4004A32A4 /* CredentialsManager.swift in Sources */,
C1B3B9FF2C24B6D4004A32A4 /* CredentialsStorage.swift in Sources */,
C1B3BA002C24B6D4004A32A4 /* CredentialsManagerError.swift in Sources */,
Expand Down
64 changes: 10 additions & 54 deletions Auth0/CredentialsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public struct CredentialsManager: Sendable {
private let dpopThumbprintKey: String
private let authentication: Authentication
private let maxRetries: Int
private let dpopValidator: DPoPCredentialValidator
#if WEB_AUTH_PLATFORM
var bioAuth: BioAuthentication?
// Biometric session management - using a class to allow mutation in non-mutating methods
Expand Down Expand Up @@ -71,6 +72,10 @@ public struct CredentialsManager: Sendable {
self.authentication = authentication
self.sendableStorage = SendableBox(value: storage)
self.maxRetries = max(0, maxRetries)
self.dpopValidator = DPoPCredentialValidator(authentication: authentication,
storage: storage,
credentialsKey: storeKey,
thumbprintKey: dpopThumbprintKey)
}

/// Retrieves the user information from the Keychain synchronously, without checking if the credentials are expired.
Expand Down Expand Up @@ -153,7 +158,7 @@ public struct CredentialsManager: Sendable {
let data = try NSKeyedArchiver.archivedData(withRootObject: credentials,
requiringSecureCoding: true)
try self.storage.setEntry(data, forKey: self.storeKey)
try? saveDPoPThumbprint(for: credentials)
dpopValidator.saveThumbprint(for: credentials)
}

/// Clears credentials stored in the Keychain.
Expand All @@ -172,7 +177,7 @@ public struct CredentialsManager: Sendable {
self.biometricSession.lock.unlock()
#endif
try self.storage.deleteEntry(forKey: self.storeKey)
try? self.storage.deleteEntry(forKey: self.dpopThumbprintKey)
dpopValidator.clearThumbprint()
}

/// Clears API credentials stored in the Keychain for a given audience value.
Expand Down Expand Up @@ -772,55 +777,6 @@ public struct CredentialsManager: Sendable {
return try NSKeyedUnarchiver.unarchivedObject(ofClass: Credentials.self, from: data)
}

private func validateDPoPState(for credentials: Credentials) throws {
let storedThumbprint = try? self.storage.getEntry(forKey: self.dpopThumbprintKey)
let storedThumbPrintValue = storedThumbprint.flatMap { String(data: $0, encoding: .utf8) }

let isDPoPBound = credentials.tokenType.caseInsensitiveCompare("DPoP") == .orderedSame
|| storedThumbPrintValue != nil

guard isDPoPBound else { return }

guard let dpop = self.authentication.dpop else {
throw CredentialsManagerError.dpopNotConfigured
}

let hasKeyPair = try? dpop.hasKeypair()

guard hasKeyPair == true else {
try self.clear()
throw CredentialsManagerError.dpopKeyMissing
}

// Hash the current thumbprint to compare against the stored hash
let currentThumbprint = try dpop.jkt()
if let stored = storedThumbPrintValue {
if stored != currentThumbprint {
try self.clear()
throw CredentialsManagerError.dpopKeyMismatch
}
} else {
try self.storage.setEntry(Data(currentThumbprint.utf8), forKey: dpopThumbprintKey)
}
}

private func saveDPoPThumbprint(for credentials: Credentials) throws {
// token type must be DPoP and authentication must have non nil dpop property
guard credentials.tokenType.caseInsensitiveCompare("DPoP") == .orderedSame ||
self.authentication.dpop != nil else {
try self.storage.deleteEntry(forKey: self.dpopThumbprintKey)
return
}

if let dpop = authentication.dpop,
let thumbprint = try? dpop.jkt() {
// Store a SHA-256 hash of the thumbprint to avoid persisting the raw key thumbprint in device storage
try self.storage.setEntry(Data(thumbprint.utf8), forKey: self.dpopThumbprintKey)
} else {
try self.storage.deleteEntry(forKey: self.dpopThumbprintKey)
}
}

private func retrieveAPICredentials(audience: String, scope: String?) throws -> APICredentials? {
let key = getAPICredentialsStorageKey(audience: audience, scope: scope)
let data = try self.storage.getEntry(forKey: key)
Expand Down Expand Up @@ -883,7 +839,7 @@ public struct CredentialsManager: Sendable {
return callback(.failure(.noRefreshToken))
}

try self.validateDPoPState(for: credentials)
try self.dpopValidator.validate(for: credentials)

self.authentication
.renew(withRefreshToken: refreshToken, scope: scope)
Expand Down Expand Up @@ -958,7 +914,7 @@ public struct CredentialsManager: Sendable {
complete()
return callback(.failure(.noRefreshToken))
}
try self.validateDPoPState(for: credentials)
try self.dpopValidator.validate(for: credentials)

self.authentication
.ssoExchange(withRefreshToken: refreshToken)
Expand Down Expand Up @@ -1018,7 +974,7 @@ public struct CredentialsManager: Sendable {
complete()
return callback(.failure(.noRefreshToken))
}
try self.validateDPoPState(for: currentCredentials)
try self.dpopValidator.validate(for: currentCredentials)

self.authentication
.renew(withRefreshToken: refreshToken, audience: audience, scope: scope)
Expand Down
88 changes: 88 additions & 0 deletions Auth0/DPoP/DPoPCredentialValidator.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import Foundation
import SimpleKeychain

struct DPoPCredentialValidator: Sendable {

private let authentication: Authentication
private let sendableStorage: SendableBox<CredentialsStorage>
private let thumbprintKey: String
private let credentialsKey: String

private var storage: CredentialsStorage { sendableStorage.value }

init(authentication: Authentication,
storage: CredentialsStorage,
credentialsKey: String,
thumbprintKey: String) {
self.authentication = authentication
self.sendableStorage = SendableBox(value: storage)
self.credentialsKey = credentialsKey
self.thumbprintKey = thumbprintKey
}

/// Validates DPoP state for the given credentials before attempting renewal.
///
/// - If the credentials are not DPoP-bound (no DPoP token type and no stored thumbprint), returns immediately.
/// - If DPoP-bound but the `Authentication` client has no DPoP configuration, throws `.dpopNotConfigured`.
/// - If the key pair is missing, clears credentials and throws `.dpopKeyMissing`.
/// - If the stored thumbprint doesn't match the current key, clears credentials and throws `.dpopKeyMismatch`.
/// - If no thumbprint is stored yet, persists the current one for future validation.
func validate(for credentials: Credentials) throws {
let storedThumbprint = storedThumbprintValue()
let isDPoPBound = credentials.tokenType.caseInsensitiveCompare("DPoP") == .orderedSame
|| storedThumbprint != nil

guard isDPoPBound else { return }

guard let dpop = authentication.dpop else {
throw CredentialsManagerError.dpopNotConfigured
}

let hasKeyPair = try? dpop.hasKeypair()
guard hasKeyPair == true else {
try clearAll()
throw CredentialsManagerError.dpopKeyMissing
}

let currentThumbprint = try dpop.jkt()
if let stored = storedThumbprint {
guard stored == currentThumbprint else {
try clearAll()
throw CredentialsManagerError.dpopKeyMismatch
}
} else {
try storage.setEntry(Data(currentThumbprint.utf8), forKey: thumbprintKey)
}
}

/// Saves the DPoP thumbprint alongside newly stored credentials.
///
/// Called by `CredentialsManager.store(credentials:)`. Clears the thumbprint entry when
/// the new credentials are not DPoP-bound and no DPoP client is configured.
func saveThumbprint(for credentials: Credentials) {
let isDPoP = credentials.tokenType.caseInsensitiveCompare("DPoP") == .orderedSame
|| authentication.dpop != nil
guard isDPoP, let dpop = authentication.dpop, let thumbprint = try? dpop.jkt() else {
try? storage.deleteEntry(forKey: thumbprintKey)
return
}
try? storage.setEntry(Data(thumbprint.utf8), forKey: thumbprintKey)
}

/// Clears the stored DPoP thumbprint.
func clearThumbprint() {
try? storage.deleteEntry(forKey: thumbprintKey)
}

// MARK: - Private

private func clearAll() throws {
try storage.deleteEntry(forKey: credentialsKey)
try? storage.deleteEntry(forKey: thumbprintKey)
}

private func storedThumbprintValue() -> String? {
let data = try? storage.getEntry(forKey: thumbprintKey)
return data.flatMap { String(data: $0, encoding: .utf8) }
}
}
Loading
Loading