Skip to content

Commit 20d0ff6

Browse files
refactor: extract DPoPCredentialValidator from CredentialsManager
1 parent 97a93ed commit 20d0ff6

2 files changed

Lines changed: 93 additions & 54 deletions

File tree

Auth0/CredentialsManager.swift

Lines changed: 9 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public struct CredentialsManager: Sendable {
3737
private let dpopThumbprintKey: String
3838
private let authentication: Authentication
3939
private let maxRetries: Int
40+
private let dpopValidator: DPoPCredentialValidator
4041
#if WEB_AUTH_PLATFORM
4142
var bioAuth: BioAuthentication?
4243
// Biometric session management - using a class to allow mutation in non-mutating methods
@@ -71,6 +72,9 @@ public struct CredentialsManager: Sendable {
7172
self.authentication = authentication
7273
self.sendableStorage = SendableBox(value: storage)
7374
self.maxRetries = max(0, maxRetries)
75+
self.dpopValidator = DPoPCredentialValidator(authentication: authentication,
76+
storage: storage,
77+
thumbprintKey: dpopThumbprintKey)
7478
}
7579

7680
/// Retrieves the user information from the Keychain synchronously, without checking if the credentials are expired.
@@ -153,7 +157,7 @@ public struct CredentialsManager: Sendable {
153157
let data = try NSKeyedArchiver.archivedData(withRootObject: credentials,
154158
requiringSecureCoding: true)
155159
try self.storage.setEntry(data, forKey: self.storeKey)
156-
try? saveDPoPThumbprint(for: credentials)
160+
dpopValidator.saveThumbprint(for: credentials)
157161
}
158162

159163
/// Clears credentials stored in the Keychain.
@@ -172,7 +176,7 @@ public struct CredentialsManager: Sendable {
172176
self.biometricSession.lock.unlock()
173177
#endif
174178
try self.storage.deleteEntry(forKey: self.storeKey)
175-
try? self.storage.deleteEntry(forKey: self.dpopThumbprintKey)
179+
dpopValidator.clearThumbprint()
176180
}
177181

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

775-
private func validateDPoPState(for credentials: Credentials) throws {
776-
let storedThumbprint = try? self.storage.getEntry(forKey: self.dpopThumbprintKey)
777-
let storedThumbPrintValue = storedThumbprint.flatMap { String(data: $0, encoding: .utf8) }
778-
779-
let isDPoPBound = credentials.tokenType.caseInsensitiveCompare("DPoP") == .orderedSame
780-
|| storedThumbPrintValue != nil
781-
782-
guard isDPoPBound else { return }
783-
784-
guard let dpop = self.authentication.dpop else {
785-
throw CredentialsManagerError.dpopNotConfigured
786-
}
787-
788-
let hasKeyPair = try? dpop.hasKeypair()
789-
790-
guard hasKeyPair == true else {
791-
try self.clear()
792-
throw CredentialsManagerError.dpopKeyMissing
793-
}
794-
795-
// Hash the current thumbprint to compare against the stored hash
796-
let currentThumbprint = try dpop.jkt()
797-
if let stored = storedThumbPrintValue {
798-
if stored != currentThumbprint {
799-
try self.clear()
800-
throw CredentialsManagerError.dpopKeyMismatch
801-
}
802-
} else {
803-
try self.storage.setEntry(Data(currentThumbprint.utf8), forKey: dpopThumbprintKey)
804-
}
805-
}
806-
807-
private func saveDPoPThumbprint(for credentials: Credentials) throws {
808-
// token type must be DPoP and authentication must have non nil dpop property
809-
guard credentials.tokenType.caseInsensitiveCompare("DPoP") == .orderedSame ||
810-
self.authentication.dpop != nil else {
811-
try self.storage.deleteEntry(forKey: self.dpopThumbprintKey)
812-
return
813-
}
814-
815-
if let dpop = authentication.dpop,
816-
let thumbprint = try? dpop.jkt() {
817-
// Store a SHA-256 hash of the thumbprint to avoid persisting the raw key thumbprint in device storage
818-
try self.storage.setEntry(Data(thumbprint.utf8), forKey: self.dpopThumbprintKey)
819-
} else {
820-
try self.storage.deleteEntry(forKey: self.dpopThumbprintKey)
821-
}
822-
}
823-
824779
private func retrieveAPICredentials(audience: String, scope: String?) throws -> APICredentials? {
825780
let key = getAPICredentialsStorageKey(audience: audience, scope: scope)
826781
let data = try self.storage.getEntry(forKey: key)
@@ -883,7 +838,7 @@ public struct CredentialsManager: Sendable {
883838
return callback(.failure(.noRefreshToken))
884839
}
885840

886-
try self.validateDPoPState(for: credentials)
841+
try self.dpopValidator.validate(for: credentials)
887842

888843
self.authentication
889844
.renew(withRefreshToken: refreshToken, scope: scope)
@@ -958,7 +913,7 @@ public struct CredentialsManager: Sendable {
958913
complete()
959914
return callback(.failure(.noRefreshToken))
960915
}
961-
try self.validateDPoPState(for: credentials)
916+
try self.dpopValidator.validate(for: credentials)
962917

963918
self.authentication
964919
.ssoExchange(withRefreshToken: refreshToken)
@@ -1018,7 +973,7 @@ public struct CredentialsManager: Sendable {
1018973
complete()
1019974
return callback(.failure(.noRefreshToken))
1020975
}
1021-
try self.validateDPoPState(for: currentCredentials)
976+
try self.dpopValidator.validate(for: currentCredentials)
1022977

1023978
self.authentication
1024979
.renew(withRefreshToken: refreshToken, audience: audience, scope: scope)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import Foundation
2+
import SimpleKeychain
3+
4+
/// Validates DPoP state before credential renewal and manages thumbprint persistence.
5+
///
6+
/// Encapsulates two concerns that were previously split across `validateDPoPState(for:)` and
7+
/// `saveDPoPThumbprint(for:)` in `CredentialsManager`:
8+
/// - Pre-renewal validation: confirms the DPoP key pair is present and matches the stored thumbprint.
9+
/// - Post-store bookkeeping: persists (or clears) the thumbprint alongside new credentials.
10+
struct DPoPCredentialValidator: Sendable {
11+
12+
private let authentication: Authentication
13+
private let sendableStorage: SendableBox<CredentialsStorage>
14+
private let thumbprintKey: String
15+
16+
private var storage: CredentialsStorage { sendableStorage.value }
17+
18+
init(authentication: Authentication, storage: CredentialsStorage, thumbprintKey: String) {
19+
self.authentication = authentication
20+
self.sendableStorage = SendableBox(value: storage)
21+
self.thumbprintKey = thumbprintKey
22+
}
23+
24+
/// Validates DPoP state for the given credentials before attempting renewal.
25+
///
26+
/// - If the credentials are not DPoP-bound (no DPoP token type and no stored thumbprint), returns immediately.
27+
/// - If DPoP-bound but the `Authentication` client has no DPoP configuration, throws `.dpopNotConfigured`.
28+
/// - If the key pair is missing, clears credentials and throws `.dpopKeyMissing`.
29+
/// - If the stored thumbprint doesn't match the current key, clears credentials and throws `.dpopKeyMismatch`.
30+
/// - If no thumbprint is stored yet, persists the current one for future validation.
31+
func validate(for credentials: Credentials) throws {
32+
let storedThumbprint = storedThumbprintValue()
33+
let isDPoPBound = credentials.tokenType.caseInsensitiveCompare("DPoP") == .orderedSame
34+
|| storedThumbprint != nil
35+
36+
guard isDPoPBound else { return }
37+
38+
guard let dpop = authentication.dpop else {
39+
throw CredentialsManagerError.dpopNotConfigured
40+
}
41+
42+
let hasKeyPair = try? dpop.hasKeypair()
43+
guard hasKeyPair == true else {
44+
try storage.deleteEntry(forKey: thumbprintKey)
45+
throw CredentialsManagerError.dpopKeyMissing
46+
}
47+
48+
let currentThumbprint = try dpop.jkt()
49+
if let stored = storedThumbprint {
50+
guard stored == currentThumbprint else {
51+
try storage.deleteEntry(forKey: thumbprintKey)
52+
throw CredentialsManagerError.dpopKeyMismatch
53+
}
54+
} else {
55+
try storage.setEntry(Data(currentThumbprint.utf8), forKey: thumbprintKey)
56+
}
57+
}
58+
59+
/// Saves the DPoP thumbprint alongside newly stored credentials.
60+
///
61+
/// Called by `CredentialsManager.store(credentials:)`. Clears the thumbprint entry when
62+
/// the new credentials are not DPoP-bound and no DPoP client is configured.
63+
func saveThumbprint(for credentials: Credentials) {
64+
let isDPoP = credentials.tokenType.caseInsensitiveCompare("DPoP") == .orderedSame
65+
|| authentication.dpop != nil
66+
guard isDPoP, let dpop = authentication.dpop, let thumbprint = try? dpop.jkt() else {
67+
try? storage.deleteEntry(forKey: thumbprintKey)
68+
return
69+
}
70+
try? storage.setEntry(Data(thumbprint.utf8), forKey: thumbprintKey)
71+
}
72+
73+
/// Clears the stored DPoP thumbprint.
74+
func clearThumbprint() {
75+
try? storage.deleteEntry(forKey: thumbprintKey)
76+
}
77+
78+
// MARK: - Private
79+
80+
private func storedThumbprintValue() -> String? {
81+
let data = try? storage.getEntry(forKey: thumbprintKey)
82+
return data.flatMap { String(data: $0, encoding: .utf8) }
83+
}
84+
}

0 commit comments

Comments
 (0)