diff --git a/Auth0.xcodeproj/project.pbxproj b/Auth0.xcodeproj/project.pbxproj index c0922c453..706d1f622 100644 --- a/Auth0.xcodeproj/project.pbxproj +++ b/Auth0.xcodeproj/project.pbxproj @@ -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 */; }; @@ -960,6 +965,7 @@ 5C4F553423C9124200C89615 /* JWKSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JWKSpec.swift; sourceTree = ""; }; 5C4F553923C9125600C89615 /* JWTAlgorithmSpec.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JWTAlgorithmSpec.swift; sourceTree = ""; }; 5C505FAB2E216672005D0757 /* DPoPError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DPoPError.swift; sourceTree = ""; }; + AA000001000000000000AAA0 /* DPoPCredentialValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DPoPCredentialValidator.swift; sourceTree = ""; }; 5C505FB12E216699005D0757 /* SenderConstraining.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SenderConstraining.swift; sourceTree = ""; }; 5C505FB72E2166FB005D0757 /* DPoPProofGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DPoPProofGenerator.swift; sourceTree = ""; }; 5C505FBD2E216746005D0757 /* DPoPChallenge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DPoPChallenge.swift; sourceTree = ""; }; @@ -1380,6 +1386,7 @@ children = ( 5C79AE072E040CA600CD0D41 /* DPoP.swift */, 5C505FAB2E216672005D0757 /* DPoPError.swift */, + AA000001000000000000AAA0 /* DPoPCredentialValidator.swift */, 5C505FB12E216699005D0757 /* SenderConstraining.swift */, 5C505FC32E216783005D0757 /* ECPublicKey.swift */, 5C505FB72E2166FB005D0757 /* DPoPProofGenerator.swift */, @@ -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 */, @@ -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 */, @@ -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 */, @@ -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 */, @@ -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 */, diff --git a/Auth0/CredentialsManager.swift b/Auth0/CredentialsManager.swift index a8209a60c..3828bd594 100644 --- a/Auth0/CredentialsManager.swift +++ b/Auth0/CredentialsManager.swift @@ -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 @@ -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. @@ -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. @@ -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. @@ -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) @@ -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) @@ -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) @@ -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) diff --git a/Auth0/DPoP/DPoPCredentialValidator.swift b/Auth0/DPoP/DPoPCredentialValidator.swift new file mode 100644 index 000000000..11dbcaa88 --- /dev/null +++ b/Auth0/DPoP/DPoPCredentialValidator.swift @@ -0,0 +1,88 @@ +import Foundation +import SimpleKeychain + +struct DPoPCredentialValidator: Sendable { + + private let authentication: Authentication + private let sendableStorage: SendableBox + 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) } + } +} diff --git a/Auth0Tests/CredentialsManagerDPoPSpec.swift b/Auth0Tests/CredentialsManagerDPoPSpec.swift index 0dbb9b754..4669e9843 100644 --- a/Auth0Tests/CredentialsManagerDPoPSpec.swift +++ b/Auth0Tests/CredentialsManagerDPoPSpec.swift @@ -295,6 +295,140 @@ class CredentialsManagerDPoPSpec: QuickSpec { } + // MARK: - ssoCredentials DPoP validation + + describe("ssoCredentials DPoP validation") { + + it("should return dpopNotConfigured when DPoP credentials exist but authentication has no DPoP") { + let auth = Auth0.authentication(clientId: ClientId, domain: Domain) + let manager = CredentialsManager(authentication: auth, storage: spyStorage) + let dpopCredentials = Credentials(accessToken: AccessToken, + tokenType: "DPoP", + idToken: IdToken, + refreshToken: RefreshToken, + expiresAt: Date(timeIntervalSinceNow: -ExpiresIn), + scope: Scope) + try manager.store(credentials: dpopCredentials) + waitUntil(timeout: Timeout) { done in + manager.ssoCredentials { result in + expect(result).to(haveCredentialsManagerError(.dpopNotConfigured)) + done() + } + } + } + + it("should return dpopKeyMissing and clear credentials when key pair is gone") { + let keyStore = MockDPoPKeyStore(failHasPrivateKey: true) + var auth = Auth0.authentication(clientId: ClientId, domain: Domain) + auth.dpop = DPoP(keyStore: keyStore) + let manager = CredentialsManager(authentication: auth, storage: spyStorage) + let dpopCredentials = Credentials(accessToken: AccessToken, + tokenType: "DPoP", + idToken: IdToken, + refreshToken: RefreshToken, + expiresAt: Date(timeIntervalSinceNow: -ExpiresIn), + scope: Scope) + try manager.store(credentials: dpopCredentials) + waitUntil(timeout: Timeout) { done in + manager.ssoCredentials { result in + expect(result).to(haveCredentialsManagerError(.dpopKeyMissing)) + expect(spyStorage.store["credentials"]).to(beNil()) + done() + } + } + } + + it("should return dpopKeyMismatch and clear credentials when thumbprint does not match") { + let keyStore = MockDPoPKeyStore() + var auth = Auth0.authentication(clientId: ClientId, domain: Domain) + auth.dpop = DPoP(keyStore: keyStore) + let manager = CredentialsManager(authentication: auth, storage: spyStorage) + let dpopCredentials = Credentials(accessToken: AccessToken, + tokenType: "DPoP", + idToken: IdToken, + refreshToken: RefreshToken, + expiresAt: Date(timeIntervalSinceNow: -ExpiresIn), + scope: Scope) + try manager.store(credentials: dpopCredentials) + spyStorage.store[dpopThumbprintKey] = Data("stale_thumbprint_from_old_key".utf8) + waitUntil(timeout: Timeout) { done in + manager.ssoCredentials { result in + expect(result).to(haveCredentialsManagerError(.dpopKeyMismatch)) + expect(spyStorage.store["credentials"]).to(beNil()) + done() + } + } + } + + } + + // MARK: - apiCredentials DPoP validation + + describe("apiCredentials DPoP validation") { + + it("should return dpopNotConfigured when DPoP credentials exist but authentication has no DPoP") { + let auth = Auth0.authentication(clientId: ClientId, domain: Domain) + let manager = CredentialsManager(authentication: auth, storage: spyStorage) + let dpopCredentials = Credentials(accessToken: AccessToken, + tokenType: "DPoP", + idToken: IdToken, + refreshToken: RefreshToken, + expiresAt: Date(timeIntervalSinceNow: -ExpiresIn), + scope: Scope) + try manager.store(credentials: dpopCredentials) + waitUntil(timeout: Timeout) { done in + manager.apiCredentials(forAudience: "https://api.example.com") { result in + expect(result).to(haveCredentialsManagerError(.dpopNotConfigured)) + done() + } + } + } + + it("should return dpopKeyMissing and clear credentials when key pair is gone") { + let keyStore = MockDPoPKeyStore(failHasPrivateKey: true) + var auth = Auth0.authentication(clientId: ClientId, domain: Domain) + auth.dpop = DPoP(keyStore: keyStore) + let manager = CredentialsManager(authentication: auth, storage: spyStorage) + let dpopCredentials = Credentials(accessToken: AccessToken, + tokenType: "DPoP", + idToken: IdToken, + refreshToken: RefreshToken, + expiresAt: Date(timeIntervalSinceNow: -ExpiresIn), + scope: Scope) + try manager.store(credentials: dpopCredentials) + waitUntil(timeout: Timeout) { done in + manager.apiCredentials(forAudience: "https://api.example.com") { result in + expect(result).to(haveCredentialsManagerError(.dpopKeyMissing)) + expect(spyStorage.store["credentials"]).to(beNil()) + done() + } + } + } + + it("should return dpopKeyMismatch and clear credentials when thumbprint does not match") { + let keyStore = MockDPoPKeyStore() + var auth = Auth0.authentication(clientId: ClientId, domain: Domain) + auth.dpop = DPoP(keyStore: keyStore) + let manager = CredentialsManager(authentication: auth, storage: spyStorage) + let dpopCredentials = Credentials(accessToken: AccessToken, + tokenType: "DPoP", + idToken: IdToken, + refreshToken: RefreshToken, + expiresAt: Date(timeIntervalSinceNow: -ExpiresIn), + scope: Scope) + try manager.store(credentials: dpopCredentials) + spyStorage.store[dpopThumbprintKey] = Data("stale_thumbprint_from_old_key".utf8) + waitUntil(timeout: Timeout) { done in + manager.apiCredentials(forAudience: "https://api.example.com") { result in + expect(result).to(haveCredentialsManagerError(.dpopKeyMismatch)) + expect(spyStorage.store["credentials"]).to(beNil()) + done() + } + } + } + + } + // MARK: - clear() describe("clear") {