diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 238b0921ac..beea5ef86a 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -6,7 +6,7 @@ on: pull_request_target: # zizmor: ignore[dangerous-triggers] # feature/dpopBehavior is a temporary entry: PRs in the multi-PR DPoP # rollout target this branch. Remove once DPoP is merged back to dev. - branches: [dev, master, feature/dpopBehavior] + branches: [dev, master, dpop] permissions: contents: read diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Identity/SFIdentityCoordinator.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Identity/SFIdentityCoordinator.m index c7f85a844e..cfc741bc82 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Identity/SFIdentityCoordinator.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Identity/SFIdentityCoordinator.m @@ -150,7 +150,15 @@ - (void)sendRequest cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:self.timeout]; [request setHTTPMethod:@"GET"]; - [request setValue:[NSString stringWithFormat:kHttpAuthHeaderFormatString, self.credentials.accessToken] forHTTPHeaderField:kHttpHeaderAuthorization]; + NSError *authError = nil; + BOOL ok = [SFSDKDPoPRequestDecorator applyAuthHeaders:request + scope:self.credentials.identifier + accessToken:self.credentials.accessToken + tokenType:self.credentials.tokenType + error:&authError]; + if (!ok) { + [SFSDKCoreLogger e:[self class] format:@"SFIdentityCoordinator: Failed to stamp authorization headers: %@", authError.localizedDescription]; + } [request setTimeoutInterval:self.timeout]; [request setHTTPShouldHandleCookies:NO]; [SFSDKCoreLogger d:[self class] format:@"SFIdentityCoordinator:Starting identity request at %@", self.credentials.identityUrl.absoluteString]; diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DPoP/DPoPProofBuilder.swift b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DPoP/DPoPProofBuilder.swift index 604766a8af..03c3b1b3a4 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DPoP/DPoPProofBuilder.swift +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DPoP/DPoPProofBuilder.swift @@ -43,11 +43,16 @@ public final class DPoPProofBuilder: NSObject { /// - httpMethod: HTTP verb (`"POST"` for token endpoint). /// - htu: Full request URL with no query and no fragment, per RFC 9449 §4.2. /// - nonce: Optional `nonce` claim from a prior `DPoP-Nonce` server hint. + /// - accessToken: Optional access token bound to the request. When non-nil and + /// non-empty, the payload includes the `ath` claim per RFC 9449 §4.2, + /// computed as `base64url(SHA-256(accessToken))`. Required for resource-server + /// calls; omitted for token-endpoint calls. /// - keyPair: Public key is embedded in the JWS header `jwk`; private key signs. /// - now: Injected clock for deterministic tests; defaults to `Date()`. @objc public static func buildProof(httpMethod: String, htu: URL, nonce: String?, + accessToken: String? = nil, keyPair: DPoPKeyPair, now: Date = Date()) throws -> String { let jwk: [String: String] @@ -70,6 +75,10 @@ public final class DPoPProofBuilder: NSObject { if let nonce = nonce, !nonce.isEmpty { payload["nonce"] = nonce } + if let accessToken = accessToken, !accessToken.isEmpty, + let ath = athClaim(for: accessToken) { + payload["ath"] = ath + } guard let headerSegment = encode(json: header), let payloadSegment = encode(json: payload) else { throw DPoPProofBuilderError.serializationFailed @@ -96,6 +105,18 @@ public final class DPoPProofBuilder: NSObject { return (raw as NSData).sfsdk_base64UrlString() } + /// `ath = base64url(SHA-256(access_token))` per RFC 9449 §4.2. The input is the + /// literal access-token string the SDK sends in the `Authorization: DPoP ` + /// header — confirmed by backend (Salesforce, 2026-06-10) as the exact `` + /// value, byte-for-byte, no encoding or canonicalization. + private static func athClaim(for accessToken: String) -> String? { + guard let tokenData = accessToken.data(using: .utf8), + let digest = (tokenData as NSData).sfsdk_sha256() else { + return nil + } + return (digest as NSData).sfsdk_base64UrlString() + } + private static func encode(json: [String: Any]) -> String? { // .sortedKeys keeps output stable so unit tests can snapshot byte-for-byte. guard let data = try? JSONSerialization.data(withJSONObject: json, diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DPoP/DPoPRequestDecorator.swift b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DPoP/DPoPRequestDecorator.swift index ea145f1bc4..34f1603c99 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DPoP/DPoPRequestDecorator.swift +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DPoP/DPoPRequestDecorator.swift @@ -33,12 +33,26 @@ public final class DPoPRequestDecorator: NSObject { @objc public static let dpopNonceHeaderName = "DPoP-Nonce" @objc public static let nonceErrorCode = "use_dpop_nonce" + /// Authorization scheme value used when the token endpoint returns + /// `token_type: "DPoP"` (RFC 6749 §5.1 / RFC 9449 §6.1). + @objc public static let dpopTokenType = "DPoP" + /// No-op when `SalesforceSDKManager.shared.useDPoP == NO`. Otherwise builds a fresh /// proof JWT (with cached nonce if any) and sets it on the `DPoP` header. /// `scope` is typically `SFOAuthCredentials.identifier` so the keypair and nonce cache /// are isolated per-account, even before an `SFUserAccount` exists. @objc(decorateRequest:scope:error:) public static func decorate(_ request: NSMutableURLRequest, scope: String) throws { + try decorate(request, scope: scope, accessToken: nil) + } + + /// Same as `decorate(_:scope:)` but binds the proof to the given access token via the + /// `ath` claim (RFC 9449 §4.2). Use at resource-server call sites where the SDK already + /// holds a token; pass `nil` (or use the no-token overload) at the token endpoint. + @objc(decorateRequest:scope:accessToken:error:) + public static func decorate(_ request: NSMutableURLRequest, + scope: String, + accessToken: String?) throws { guard SalesforceManager.shared.usesDPoP else { return } guard !scope.isEmpty else { SFSDKCoreLogger.i(self, message: "DPoP decorator skipped: empty scope identifier") @@ -50,12 +64,47 @@ public final class DPoPRequestDecorator: NSObject { let keyPair = try DPoPKeyStore.shared.keyPair(forScope: scope) let nonce = DPoPNonceCache.shared.nonce(htu: url, scope: scope) let proof = try DPoPProofBuilder.buildProof(httpMethod: method, - htu: url, - nonce: nonce, - keyPair: keyPair) + htu: url, + nonce: nonce, + accessToken: accessToken, + keyPair: keyPair) request.setValue(proof, forHTTPHeaderField: dpopHeaderName) } + /// Central helper for stamping the Authorization header on authenticated outbound + /// requests. Decides scheme from `tokenType`: + /// + /// - `"DPoP"` → `Authorization: DPoP ` and a fresh DPoP proof header bound + /// to `accessToken` via the `ath` claim. + /// - anything else (including `nil` / `"Bearer"`) → `Authorization: Bearer `, + /// no DPoP header. + /// + /// No-op when `accessToken` is empty — preserves the existing "no token, skip stamp" + /// behavior of the four call sites. + /// + /// - Parameters: + /// - request: the request to mutate. Existing `Authorization`/`DPoP` headers are + /// overwritten by this method (callers should guard against double-stamping + /// via their own checks). + /// - scope: per-account isolation key, typically `SFOAuthCredentials.identifier`. + /// - accessToken: the access token string sent in the Authorization header. + /// - tokenType: `SFOAuthCredentials.tokenType` (the OAuth `token_type` returned + /// by the token endpoint, RFC 6749 §5.1). Case-sensitive equality match against + /// `"DPoP"` is the only positive branch. + @objc(applyAuthHeaders:scope:accessToken:tokenType:error:) + public static func applyAuthHeaders(_ request: NSMutableURLRequest, + scope: String, + accessToken: String?, + tokenType: String?) throws { + guard let accessToken, !accessToken.isEmpty else { return } + if tokenType == dpopTokenType { + request.setValue("DPoP \(accessToken)", forHTTPHeaderField: "Authorization") + try decorate(request, scope: scope, accessToken: accessToken) + } else { + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + } + } + /// Reads `DPoP-Nonce` from a response and stores it in the cache for the next outbound /// request to the same `htu`. Per backend design doc, harvest from both 200 OK responses /// (proactive rotation) and 400/401 challenges (reactive). diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCredentials+Internal.h b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCredentials+Internal.h index 24169cf1b3..a6317588da 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCredentials+Internal.h +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCredentials+Internal.h @@ -82,6 +82,7 @@ extern NSException * _Nullable SFOAuthInvalidIdentifierException(void); @property (nonatomic, readwrite, nullable) NSString *sidCookieName; @property (nonatomic, readwrite, nullable) NSString *parentSid; @property (nonatomic, readwrite, nullable) NSString *tokenFormat; +@property (nonatomic, readwrite, nullable) NSString *tokenType; @property (nonatomic, readwrite, nullable) NSString *beaconChildConsumerKey; @property (nonatomic, readwrite, nullable) NSString *beaconChildConsumerSecret; diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCredentials.h b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCredentials.h index 668b03dfc9..487cd9a36e 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCredentials.h +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCredentials.h @@ -138,6 +138,7 @@ NS_SWIFT_NAME(OAuthCredentials) @property (nonatomic, readonly, nullable) NSString *sidCookieName; @property (nonatomic, readonly, nullable) NSString *parentSid; @property (nonatomic, readonly, nullable) NSString *tokenFormat; +@property (nonatomic, readonly, nullable) NSString *tokenType; @property (nonatomic, readonly, nullable) NSString *beaconChildConsumerKey; @property (nonatomic, readonly, nullable) NSString *beaconChildConsumerSecret; diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCredentials.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCredentials.m index c0dfadbbdb..7998459dda 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCredentials.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCredentials.m @@ -117,6 +117,7 @@ - (id)initWithCoder:(NSCoder *)coder { self.cookieSidClient = [coder decodeObjectOfClass:[NSString class] forKey:@"SFOAuthCookieSidClient"]; self.sidCookieName = [coder decodeObjectOfClass:[NSString class] forKey:@"SFOAuthSidCookieName"]; self.tokenFormat = [coder decodeObjectOfClass:[NSString class] forKey:@"SFOAuthTokenFormat"]; + self.tokenType = [coder decodeObjectOfClass:[NSString class] forKey:@"SFOAuthTokenType"]; if ([self isMemberOfClass:[SFOAuthCredentials class]]) { // Otherwise they are stored in keychain @@ -159,6 +160,7 @@ - (void)encodeWithCoder:(NSCoder *)coder { [coder encodeObject:self.cookieSidClient forKey:@"SFOAuthCookieSidClient"]; [coder encodeObject:self.sidCookieName forKey:@"SFOAuthSidCookieName"]; [coder encodeObject:self.tokenFormat forKey:@"SFOAuthTokenFormat"]; + [coder encodeObject:self.tokenType forKey:@"SFOAuthTokenType"]; [coder encodeObject:kSFOAuthArchiveVersion forKey:@"SFOAuthArchiveVersion"]; [coder encodeObject:@(self.isEncrypted) forKey:@"SFOAuthEncrypted"]; [coder encodeObject:self.additionalOAuthFields forKey:@"SFOAuthAdditionalFields"]; @@ -229,6 +231,7 @@ - (id)copyWithZone:(nullable NSZone *)zone { copyCreds.sidCookieName = self.sidCookieName; copyCreds.parentSid = self.parentSid; copyCreds.tokenFormat = self.tokenFormat; + copyCreds.tokenType = self.tokenType; copyCreds.beaconChildConsumerKey = self.beaconChildConsumerKey; copyCreds.beaconChildConsumerSecret = self.beaconChildConsumerSecret; copyCreds.additionalOAuthFields = [self.additionalOAuthFields copy]; @@ -343,6 +346,7 @@ - (void)revokeRefreshToken { self.sidCookieName = nil; self.parentSid = nil; self.tokenFormat = nil; + self.tokenType = nil; self.beaconChildConsumerKey = nil; self.beaconChildConsumerSecret = nil; } @@ -469,6 +473,9 @@ - (void)updateCredentials:(NSDictionary *) params { if (params[kSFOAuthTokenFormat]) { self.tokenFormat = params[kSFOAuthTokenFormat]; } + if (params[kSFOAuthTokenType]) { + self.tokenType = params[kSFOAuthTokenType]; + } if (params[kSFOAuthBeaconChildConsumerKey]) { self.beaconChildConsumerKey = params[kSFOAuthBeaconChildConsumerKey]; } diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestRequest.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestRequest.m index 5842d00c01..2f5fc1a348 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestRequest.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestRequest.m @@ -23,6 +23,7 @@ */ #import +#import #import "SFRestRequest+Internal.h" #import "SFRestAPI+Internal.h" #import "NSString+SFAdditions.h" @@ -200,11 +201,20 @@ - (NSURLRequest *)prepareRequestForSend:(SFUserAccount *)user { [self.request setHTTPMethod:[SFRestRequest httpMethodFromSFRestMethod:self.method]]; - // Sets OAuth Bearer token header on the request (if not already present). - // Allows Authenticated clients to make api calls that dont require access token. - if (self.requiresAuthentication && user && ![self.request.allHTTPHeaderFields.allKeys containsObject:@"Authorization"]) { - NSString *bearer = [NSString stringWithFormat:@"Bearer %@", user.credentials.accessToken]; - [self.request setValue:bearer forHTTPHeaderField:@"Authorization"]; + // Stamps the Authorization header (Bearer or DPoP, depending on credentials.tokenType), + // and on the DPoP branch attaches a per-request proof header. Skipped when the caller + // has pre-stamped Authorization themselves. + if (self.requiresAuthentication && user + && ![self.request.allHTTPHeaderFields.allKeys containsObject:@"Authorization"]) { + NSError *authError = nil; + BOOL ok = [SFSDKDPoPRequestDecorator applyAuthHeaders:self.request + scope:user.credentials.identifier + accessToken:user.credentials.accessToken + tokenType:user.credentials.tokenType + error:&authError]; + if (!ok) { + [SFSDKCoreLogger e:[self class] format:@"Failed to stamp authorization headers: %@", authError.localizedDescription]; + } } // Adds custom headers to the request if any are set. diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m index b252f9ce49..226ecfabec 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/UserAccount/SFUserAccountManager.m @@ -2128,7 +2128,15 @@ - (void)retrieveUserPhotoIfNeeded:(SFUserAccount *)account { if (account.idData.thumbnailUrl) { NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:account.idData.thumbnailUrl]; [request setHTTPMethod:@"GET"]; - [request setValue:[NSString stringWithFormat:kHttpAuthHeaderFormatString, account.credentials.accessToken] forHTTPHeaderField:kHttpHeaderAuthorization]; + NSError *authError = nil; + BOOL ok = [SFSDKDPoPRequestDecorator applyAuthHeaders:request + scope:account.credentials.identifier + accessToken:account.credentials.accessToken + tokenType:account.credentials.tokenType + error:&authError]; + if (!ok) { + [SFSDKCoreLogger e:[self class] format:@"User photo: failed to stamp authorization headers: %@", authError.localizedDescription]; + } SFNetwork *network = [SFNetwork sharedEphemeralInstance]; [network sendRequest:request dataResponseBlock:^(NSData *data, NSURLResponse *response, NSError *error){ if (error) { @@ -2385,7 +2393,15 @@ - (void)shouldBlockUser:(SFOAuthCredentials *)credentials completion:(void(^)(BO request.cachePolicy = NSURLRequestReloadIgnoringCacheData; request.HTTPMethod = @"GET"; request.HTTPShouldHandleCookies = NO; - [request setValue:[NSString stringWithFormat:kHttpAuthHeaderFormatString, credentials.accessToken] forHTTPHeaderField:kHttpHeaderAuthorization]; + NSError *authError = nil; + BOOL ok = [SFSDKDPoPRequestDecorator applyAuthHeaders:request + scope:credentials.identifier + accessToken:credentials.accessToken + tokenType:credentials.tokenType + error:&authError]; + if (!ok) { + [SFSDKCoreLogger e:[self class] format:@"shouldBlockUser: failed to stamp authorization headers: %@", authError.localizedDescription]; + } __block NSString *networkIdentifier = [SFNetwork uniqueInstanceIdentifier]; SFNetwork *network = [SFNetwork sharedEphemeralInstanceWithIdentifier:networkIdentifier]; diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKOAuth2.h b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKOAuth2.h index c01f7f3739..17a3b0f1e7 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKOAuth2.h +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKOAuth2.h @@ -134,6 +134,7 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readonly, nullable) NSString *sidCookieName; @property (nonatomic, readonly, nullable) NSString *parentSid; @property (nonatomic, readonly, nullable) NSString *tokenFormat; +@property (nonatomic, readonly, nullable) NSString *tokenType; @property (nonatomic, readonly, nullable) NSString *beaconChildConsumerKey; @property (nonatomic, readonly, nullable) NSString *beaconChildConsumerSecret; diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKOAuth2.m b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKOAuth2.m index 0574420d2a..bf524bf491 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKOAuth2.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKOAuth2.m @@ -201,6 +201,10 @@ - (NSString *)tokenFormat { return self.values[kSFOAuthTokenFormat]; } +- (NSString *)tokenType { + return self.values[kSFOAuthTokenType]; +} + - (NSString *)beaconChildConsumerKey { return self.values[kSFOAuthBeaconChildConsumerKey]; } diff --git a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKOAuthConstants.h b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKOAuthConstants.h index 3a02ee616a..34d31ace60 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKOAuthConstants.h +++ b/libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKOAuthConstants.h @@ -78,6 +78,7 @@ static NSString * const kSFOAuthCookieSidClient = @"cookie-sid_C static NSString * const kSFOAuthSidCookieName = @"sidCookieName"; static NSString * const kSFOAuthParentSid = @"parent_sid"; static NSString * const kSFOAuthTokenFormat = @"token_format"; +static NSString * const kSFOAuthTokenType = @"token_type"; static NSString * const kSFOAuthBeaconChildConsumerKey = @"beacon_child_consumer_key"; static NSString * const kSFOAuthBeaconChildConsumerSecret = @"beacon_child_consumer_secret"; diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFOAuthCredentialsTests.m b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFOAuthCredentialsTests.m index 05b9172b9b..e84a11185f 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFOAuthCredentialsTests.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFOAuthCredentialsTests.m @@ -77,6 +77,7 @@ - (void)tryUpdateCredentials:(BOOL)encrypted storageType:(SFOAuthCredentialsStor [params setObject:@"test-sid-cookie-name" forKey:@"sidCookieName"]; [params setObject:@"test-parent-sid" forKey:@"parent_sid"]; [params setObject:@"test-token-format" forKey:@"token_format"]; + [params setObject:@"test-token-type" forKey:@"token_type"]; [params setObject:@"test-beacon-child-consumer-key" forKey:@"beacon_child_consumer_key"]; [params setObject:@"test-beacon-child-consumer-secret" forKey:@"beacon_child_consumer_secret"]; [creds updateCredentials:params]; @@ -102,6 +103,7 @@ - (void)tryUpdateCredentials:(BOOL)encrypted storageType:(SFOAuthCredentialsStor XCTAssertEqualObjects(creds.sidCookieName, @"test-sid-cookie-name"); XCTAssertEqualObjects(creds.parentSid, @"test-parent-sid"); XCTAssertEqualObjects(creds.tokenFormat, @"test-token-format"); + XCTAssertEqualObjects(creds.tokenType, @"test-token-type"); XCTAssertEqualObjects(creds.beaconChildConsumerKey, @"test-beacon-child-consumer-key"); XCTAssertEqualObjects(creds.beaconChildConsumerSecret, @"test-beacon-child-consumer-secret"); } diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFSDKDPoPTests.swift b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFSDKDPoPTests.swift index 8ee6b59da7..5cbbebbfce 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFSDKDPoPTests.swift +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFSDKDPoPTests.swift @@ -94,6 +94,44 @@ class SFSDKDPoPTests: XCTestCase { XCTAssertEqual(payload["htu"] as? String, "https://login.salesforce.com/services/oauth2/token") } + func test_givenAccessTokenProvided_whenBuildProof_thenPayloadIncludesAth() throws { + let pair = try DPoPKeyStore.shared.keyPair(forScope: testScope) + let accessToken = "00DXX0000000000!ARQAQGyAccessTokenLiteralValue" + let proof = try DPoPProofBuilder.buildProof(httpMethod: "GET", + htu: URL(string: "https://example.salesforce.com/services/data/v60.0/sobjects/Account")!, + nonce: nil, + accessToken: accessToken, + keyPair: pair) + let payload = try decodeBase64UrlJSON(String(proof.split(separator: ".")[1])) + let ath = try XCTUnwrap(payload["ath"] as? String) + + // ath = base64url(SHA-256(access_token)) per RFC 9449 §4.2. + let expected = (((accessToken.data(using: .utf8)! as NSData) + .sfsdk_sha256()!) as NSData).sfsdk_base64UrlString() + XCTAssertEqual(ath, expected) + } + + func test_givenNoAccessToken_whenBuildProof_thenPayloadOmitsAth() throws { + let pair = try DPoPKeyStore.shared.keyPair(forScope: testScope) + let proof = try DPoPProofBuilder.buildProof(httpMethod: "POST", + htu: tokenURL, + nonce: nil, + keyPair: pair) + let payload = try decodeBase64UrlJSON(String(proof.split(separator: ".")[1])) + XCTAssertNil(payload["ath"], "Token-endpoint proofs must not include ath") + } + + func test_givenEmptyAccessToken_whenBuildProof_thenPayloadOmitsAth() throws { + let pair = try DPoPKeyStore.shared.keyPair(forScope: testScope) + let proof = try DPoPProofBuilder.buildProof(httpMethod: "GET", + htu: tokenURL, + nonce: nil, + accessToken: "", + keyPair: pair) + let payload = try decodeBase64UrlJSON(String(proof.split(separator: ".")[1])) + XCTAssertNil(payload["ath"]) + } + func test_givenSamePair_whenBuildProofTwice_thenJtisDiffer() throws { let pair = try DPoPKeyStore.shared.keyPair(forScope: testScope) let p1 = try DPoPProofBuilder.buildProof(httpMethod: "POST", htu: tokenURL, nonce: nil, keyPair: pair) @@ -340,6 +378,271 @@ class SFSDKDPoPTests: XCTestCase { XCTAssertEqual(DPoPNonceCache.shared.nonce(htu: tokenURL, scope: testScope), "harvested-nonce") } + // MARK: - applyAuthHeaders + + func test_givenDPoPTokenType_whenApplyAuthHeaders_thenDPoPSchemeAndProofAttached() throws { + let prior = SalesforceManager.shared.usesDPoP + SalesforceManager.shared.usesDPoP = true + defer { SalesforceManager.shared.usesDPoP = prior } + + let req = NSMutableURLRequest(url: URL(string: "https://example.salesforce.com/services/data/v60.0/sobjects/Account")!) + req.httpMethod = "GET" + let token = "00DXX0000000000!ARQAQGyAccessTokenLiteralValue" + try DPoPRequestDecorator.applyAuthHeaders(req, + scope: testScope, + accessToken: token, + tokenType: "DPoP") + XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "DPoP \(token)") + let proof = try XCTUnwrap(req.value(forHTTPHeaderField: "DPoP")) + XCTAssertEqual(proof.split(separator: ".").count, 3) + + let payload = try decodeBase64UrlJSON(String(proof.split(separator: ".")[1])) + let ath = try XCTUnwrap(payload["ath"] as? String) + let expected = (((token.data(using: .utf8)! as NSData) + .sfsdk_sha256()!) as NSData).sfsdk_base64UrlString() + XCTAssertEqual(ath, expected) + } + + func test_givenBearerTokenType_whenApplyAuthHeaders_thenBearerSchemeNoProof() throws { + let prior = SalesforceManager.shared.usesDPoP + SalesforceManager.shared.usesDPoP = true + defer { SalesforceManager.shared.usesDPoP = prior } + + let req = NSMutableURLRequest(url: tokenURL) + req.httpMethod = "GET" + try DPoPRequestDecorator.applyAuthHeaders(req, + scope: testScope, + accessToken: "tok-abc", + tokenType: "Bearer") + XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer tok-abc") + XCTAssertNil(req.value(forHTTPHeaderField: "DPoP")) + } + + func test_givenNilTokenType_whenApplyAuthHeaders_thenBearerSchemeNoProof() throws { + let req = NSMutableURLRequest(url: tokenURL) + req.httpMethod = "GET" + try DPoPRequestDecorator.applyAuthHeaders(req, + scope: testScope, + accessToken: "tok-abc", + tokenType: nil) + XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer tok-abc") + XCTAssertNil(req.value(forHTTPHeaderField: "DPoP")) + } + + func test_givenEmptyAccessToken_whenApplyAuthHeaders_thenNoHeadersStamped() throws { + let req = NSMutableURLRequest(url: tokenURL) + req.httpMethod = "GET" + try DPoPRequestDecorator.applyAuthHeaders(req, + scope: testScope, + accessToken: "", + tokenType: "DPoP") + XCTAssertNil(req.value(forHTTPHeaderField: "Authorization")) + XCTAssertNil(req.value(forHTTPHeaderField: "DPoP")) + } + + func test_givenNilAccessToken_whenApplyAuthHeaders_thenNoHeadersStamped() throws { + let req = NSMutableURLRequest(url: tokenURL) + req.httpMethod = "GET" + try DPoPRequestDecorator.applyAuthHeaders(req, + scope: testScope, + accessToken: nil, + tokenType: "DPoP") + XCTAssertNil(req.value(forHTTPHeaderField: "Authorization")) + XCTAssertNil(req.value(forHTTPHeaderField: "DPoP")) + } + + // MARK: - Identity service loop-prevention + + /// Loop regression: under DPoP-bound credentials, an identity request must go out + /// stamped `Authorization: DPoP ` with a valid `ath` claim — the same outbound + /// shape any other DPoP-aware site uses. The loop happened previously when the + /// identity site stamped `Bearer ` instead, and the server returned + /// 401, the SDK refreshed, retried with `Bearer` again, and looped. + func test_givenDPoPCredentials_whenIdentityRequestStamped_thenAuthorizationIsDPoPAndAthMatches() throws { + let prior = SalesforceManager.shared.usesDPoP + SalesforceManager.shared.usesDPoP = true + defer { SalesforceManager.shared.usesDPoP = prior } + + let scope = "sc3-\(UUID().uuidString)" + let token = "00DXX0000000000!ARQAQGyAccessTokenLiteralValue" + defer { DPoPKeyStore.shared.delete(forScope: scope) } + + let identityURL = URL(string: "https://login.salesforce.com/id/00D000000000000/005000000000000")! + let req = NSMutableURLRequest(url: identityURL) + req.httpMethod = "GET" + try DPoPRequestDecorator.applyAuthHeaders(req, + scope: scope, + accessToken: token, + tokenType: "DPoP") + + XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "DPoP \(token)", + "Identity must use DPoP scheme under a DPoP-bound token; Bearer here is what produced the original loop.") + let proof = try XCTUnwrap(req.value(forHTTPHeaderField: "DPoP")) + let payload = try decodeBase64UrlJSON(String(proof.split(separator: ".")[1])) + let ath = try XCTUnwrap(payload["ath"] as? String) + let expected = (((token.data(using: .utf8)! as NSData) + .sfsdk_sha256()!) as NSData).sfsdk_base64UrlString() + XCTAssertEqual(ath, expected) + } + + func test_givenDPoPDisabledButTokenTypeDPoP_whenApplyAuthHeaders_thenDPoPSchemeButNoProof() throws { + let prior = SalesforceManager.shared.usesDPoP + SalesforceManager.shared.usesDPoP = false + defer { SalesforceManager.shared.usesDPoP = prior } + + let req = NSMutableURLRequest(url: tokenURL) + req.httpMethod = "GET" + try DPoPRequestDecorator.applyAuthHeaders(req, + scope: testScope, + accessToken: "tok-abc", + tokenType: "DPoP") + // The Authorization scheme follows tokenType (server-driven). The DPoP proof + // header itself follows SalesforceManager.shared.usesDPoP (client opt-in). + XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "DPoP tok-abc") + XCTAssertNil(req.value(forHTTPHeaderField: "DPoP")) + } + + // MARK: - SFRestRequest end-to-end + + func test_givenDPoPCredentials_whenPrepareRestRequest_thenAuthorizationIsDPoPAndProofAttached() throws { + let prior = SalesforceManager.shared.usesDPoP + SalesforceManager.shared.usesDPoP = true + defer { SalesforceManager.shared.usesDPoP = prior } + + let scope = "rest-dpop-\(UUID().uuidString)" + let creds = OAuthCredentials(identifier: scope, + clientId: "CLIENT_ID", + encrypted: false)! + creds.accessToken = "00DXX0000000000!ARQ.dpop.access.token" + creds.tokenType = "DPoP" + creds.instanceUrl = URL(string: "https://example.salesforce.com") + creds.userId = "USERID" + creds.organizationId = "ORGID" + defer { DPoPKeyStore.shared.delete(forScope: scope) } + + let account = UserAccount(credentials: creds) + let request = RestRequest(method: .GET, + path: "/services/data/v60.0/sobjects/Account", + queryParams: nil) + let urlRequest = try XCTUnwrap(request.prepare(forSend: account)) + + XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Authorization"), + "DPoP \(creds.accessToken!)") + let proof = try XCTUnwrap(urlRequest.value(forHTTPHeaderField: "DPoP")) + XCTAssertEqual(proof.split(separator: ".").count, 3) + let payload = try decodeBase64UrlJSON(String(proof.split(separator: ".")[1])) + let ath = try XCTUnwrap(payload["ath"] as? String) + let expected = (((creds.accessToken!.data(using: .utf8)! as NSData) + .sfsdk_sha256()!) as NSData).sfsdk_base64UrlString() + XCTAssertEqual(ath, expected) + } + + func test_givenBearerCredentials_whenPrepareRestRequest_thenAuthorizationIsBearerNoProof() throws { + let creds = OAuthCredentials(identifier: "rest-bearer-\(UUID().uuidString)", + clientId: "CLIENT_ID", + encrypted: false)! + creds.accessToken = "bearer-only-access-token" + // tokenType left nil — the Bearer baseline. + creds.instanceUrl = URL(string: "https://example.salesforce.com") + creds.userId = "USERID" + creds.organizationId = "ORGID" + + let account = UserAccount(credentials: creds) + let request = RestRequest(method: .GET, + path: "/services/data/v60.0/sobjects/Account", + queryParams: nil) + let urlRequest = try XCTUnwrap(request.prepare(forSend: account)) + + XCTAssertEqual(urlRequest.value(forHTTPHeaderField: "Authorization"), + "Bearer \(creds.accessToken!)") + XCTAssertNil(urlRequest.value(forHTTPHeaderField: "DPoP"), + "Bearer credentials must not attach a DPoP header") + } + + // MARK: - Log redaction + + /// Captures every line submitted to `SFSDKCoreLogger` during the four-site DPoP flow + /// and asserts none contain the access token, the proof JWT, the embedded JWK + /// (`jwk`/`jkt`/coordinate `x`/`y`), or the `ath` thumbprint. Per CLAUDE.md the SDK must + /// never log credentials. + func test_givenDPoPBoundFlow_whenAllSitesStampHeaders_thenLoggerCapturesNoSecrets() throws { + let prior = SalesforceManager.shared.usesDPoP + SalesforceManager.shared.usesDPoP = true + defer { + SalesforceManager.shared.usesDPoP = prior + // SFLogger caches per-component loggers; swapping the factory alone won't + // re-route messages from already-cached components. Flush so the recorder + // installed by this test stops receiving messages emitted by sibling tests. + SalesforceLogger.setLogReceiverFactory(NoOpLogReceiverFactory()) + SalesforceLogger.clearAllComponents() + } + + let recorder = RecordingLogReceiver() + let factory = RecordingLogReceiverFactory(receiver: recorder) + SalesforceLogger.setLogReceiverFactory(factory) + // Force cached per-component loggers to re-bind to the recording factory above. + // Without this flush, components that logged earlier in the run would keep + // their old receiver and the recorder would silently observe nothing. + SalesforceLogger.clearAllComponents() + + let scope = "redaction-\(UUID().uuidString)" + let token = "00DXX0000000000!ARQ.redactionTestSecret.AccessTokenLiteralValue" + defer { DPoPKeyStore.shared.delete(forScope: scope) } + + // Site 1 — REST (SFRestRequest.prepare) + let creds = OAuthCredentials(identifier: scope, clientId: "CLIENT_ID", encrypted: false)! + creds.accessToken = token + creds.tokenType = "DPoP" + creds.instanceUrl = URL(string: "https://example.salesforce.com") + creds.userId = "USERID" + creds.organizationId = "ORGID" + let account = UserAccount(credentials: creds) + let restRequest = RestRequest(method: .GET, + path: "/services/data/v60.0/sobjects/Account", + queryParams: nil) + let restURLRequest = try XCTUnwrap(restRequest.prepare(forSend: account)) + let restProof = try XCTUnwrap(restURLRequest.value(forHTTPHeaderField: "DPoP")) + + // Sites 2, 3, 4 — Identity / photo / userinfo all funnel through applyAuthHeaders. + // Run the helper directly to exercise the same code path the production sites use. + for path in ["/id/00D000000000000/005000000000000", + "/profilephoto/Q3a000000000000/F", + "/services/oauth2/userinfo"] { + let req = NSMutableURLRequest(url: URL(string: "https://example.salesforce.com\(path)")!) + req.httpMethod = "GET" + try DPoPRequestDecorator.applyAuthHeaders(req, + scope: scope, + accessToken: token, + tokenType: "DPoP") + } + + let proofSegments = restProof.split(separator: ".") + XCTAssertEqual(proofSegments.count, 3) + let header = try decodeBase64UrlJSON(String(proofSegments[0])) + let payload = try decodeBase64UrlJSON(String(proofSegments[1])) + let jwk = try XCTUnwrap(header["jwk"] as? [String: String]) + let jwkX = try XCTUnwrap(jwk["x"]) + let jwkY = try XCTUnwrap(jwk["y"]) + let ath = try XCTUnwrap(payload["ath"] as? String) + + // Forbidden substrings: the access token, the full proof, and the JWK material that + // (when hashed) becomes the `jkt` thumbprint binding the token. + let forbidden: [(String, String)] = [ + ("access token", token), + ("DPoP proof JWS", restProof), + ("ath claim", ath), + ("JWK x coordinate", jwkX), + ("JWK y coordinate", jwkY), + ] + let allLines = recorder.snapshot() + for line in allLines { + for (label, secret) in forbidden { + XCTAssertFalse(line.contains(secret), + "\(label) leaked into log line: \(line)") + } + } + } + // MARK: - Helpers private func decodeBase64UrlJSON(_ segment: String) throws -> [String: Any] { @@ -396,3 +699,45 @@ class SFSDKDPoPTests: XCTestCase { return [UInt8(0x80 | bytes.count)] + bytes } } + +// MARK: - Log capture support + +/// Thread-safe accumulator for log lines emitted during a test. +private final class RecordingLogReceiver: NSObject, SalesforceLogReceiver { + private let lock = NSLock() + private var lines: [String] = [] + + func receive(level: SalesforceLogger.Level, + cls: AnyClass, + component: String, + message: String) { + lock.lock() + lines.append("[\(component)] \(NSStringFromClass(cls)): \(message)") + lock.unlock() + } + + func snapshot() -> [String] { + lock.lock() + defer { lock.unlock() } + return lines + } +} + +private final class RecordingLogReceiverFactory: NSObject, SalesforceLogReceiverFactory { + private let receiver: RecordingLogReceiver + init(receiver: RecordingLogReceiver) { self.receiver = receiver } + func create(componentName: String) -> SalesforceLogReceiver { receiver } +} + +/// Discards everything; used to detach the recorder after the test runs. +private final class NoOpLogReceiverFactory: NSObject, SalesforceLogReceiverFactory { + private let sink = NoOpLogReceiver() + func create(componentName: String) -> SalesforceLogReceiver { sink } +} + +private final class NoOpLogReceiver: NSObject, SalesforceLogReceiver { + func receive(level: SalesforceLogger.Level, + cls: AnyClass, + component: String, + message: String) {} +} diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFSDKOAuthTokenEndpointResponseTests.m b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFSDKOAuthTokenEndpointResponseTests.m index caa918054f..50c0cdc16d 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFSDKOAuthTokenEndpointResponseTests.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SFSDKOAuthTokenEndpointResponseTests.m @@ -35,7 +35,7 @@ - (instancetype)initWithDictionary:(NSDictionary *)nvPairs parseAdditionalFields @end -// Expose private helper for byte-stability regression tests (SC-4). +// Expose private helper for byte-stability regression tests. @interface SFSDKOAuth2 (TestingPrivate) - (NSMutableURLRequest *)prepareBasicRequest:(SFSDKOAuthTokenEndpointRequest *)endpointReq; @end @@ -69,6 +69,7 @@ - (void)testInitWithDictionary { [params setObject:@"test-sid-cookie-name" forKey:@"sidCookieName"]; [params setObject:@"test-parent-sid" forKey:@"parent_sid"]; [params setObject:@"test-token-format" forKey:@"token_format"]; + [params setObject:@"test-token-type" forKey:@"token_type"]; [params setObject:@"test-beacon-child-consumer-key" forKey:@"beacon_child_consumer_key"]; [params setObject:@"test-beacon-child-consumer-secret" forKey:@"beacon_child_consumer_secret"]; @@ -103,6 +104,7 @@ - (void)testInitWithDictionary { XCTAssertEqualObjects(response.sidCookieName, @"test-sid-cookie-name"); XCTAssertEqualObjects(response.parentSid, @"test-parent-sid"); XCTAssertEqualObjects(response.tokenFormat, @"test-token-format"); + XCTAssertEqualObjects(response.tokenType, @"test-token-type"); XCTAssertEqualObjects(response.beaconChildConsumerKey, @"test-beacon-child-consumer-key"); XCTAssertEqualObjects(response.beaconChildConsumerSecret, @"test-beacon-child-consumer-secret"); @@ -114,7 +116,7 @@ - (void)testInitWithDictionary { } -// SC-4: with useDPoP == NO, the prepared token-endpoint request must be byte-identical +// With useDPoP == NO, the prepared token-endpoint request must be byte-identical // to the pre-DPoP baseline — no DPoP header, same URL/method/headers, even when a // credentialsIdentifier is set on the endpoint request. - (void)test_givenUseDPoPDisabled_whenPrepareBasicRequest_thenNoDPoPHeaderAndCanonicalShape { diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceOAuthUnitTests.m b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceOAuthUnitTests.m index 90551a25f5..73e401c020 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceOAuthUnitTests.m +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceOAuthUnitTests.m @@ -145,6 +145,7 @@ - (void)testCredentialsCoding { credsIn.sidCookieName = @"sid-cookie-name"; credsIn.parentSid = @"parent-sid"; credsIn.tokenFormat = @"token-format"; + credsIn.tokenType = @"token-type"; credsIn.beaconChildConsumerKey = @"beacon-child-consumer-key"; credsIn.beaconChildConsumerSecret = @"beacon-child-consumer-secret"; @@ -189,6 +190,7 @@ - (void)testCredentialsCoding { XCTAssertEqualObjects(credsIn.sidCookieName, credsOut.sidCookieName, @"sidCookieName mismatch"); XCTAssertEqualObjects(credsIn.parentSid, credsOut.parentSid, @"parentSid mismatch"); XCTAssertEqualObjects(credsIn.tokenFormat, credsOut.tokenFormat, @"tokenFormat mismatch"); + XCTAssertEqualObjects(credsIn.tokenType, credsOut.tokenType, @"tokenType mismatch"); XCTAssertEqualObjects(credsIn.beaconChildConsumerKey, credsOut.beaconChildConsumerKey, @"beaconChildConsumerKey mismatch"); XCTAssertEqualObjects(credsIn.beaconChildConsumerSecret, credsOut.beaconChildConsumerSecret, @"beaconChildConsumerSecret mismatch"); XCTAssertEqualObjects(credsIn.additionalOAuthFields, credsOut.additionalOAuthFields, @"additionalFields mismatch"); @@ -223,6 +225,7 @@ - (void)testCredentialsCopying { NSString *sidCookieNameToCheck = @"sid-cookie-name"; NSString *parentSidToCheck = @"parent-sid"; NSString *tokenFormatToCheck = @"token-format"; + NSString *tokenTypeToCheck = @"token-type"; NSString *beaconChildConsumerKeyCheck = @"beacon-child-consumer-key"; NSString *beaconChildConsumerSecretCheck = @"beacon-child-consumer-secret"; NSDictionary *additionalFieldsToCheck = @{ @"field1": @"field1Val" }; @@ -251,6 +254,7 @@ - (void)testCredentialsCopying { origCreds.sidCookieName = sidCookieNameToCheck; origCreds.parentSid = parentSidToCheck; origCreds.tokenFormat = tokenFormatToCheck; + origCreds.tokenType = tokenTypeToCheck; origCreds.beaconChildConsumerKey = beaconChildConsumerKeyCheck; origCreds.beaconChildConsumerSecret = beaconChildConsumerSecretCheck; @@ -290,6 +294,7 @@ - (void)testCredentialsCopying { origCreds.sidCookieName = nil; origCreds.parentSid = nil; origCreds.tokenFormat = nil; + origCreds.tokenType = nil; origCreds.beaconChildConsumerKey = nil; origCreds.beaconChildConsumerSecret = nil; origCreds.additionalOAuthFields = nil; @@ -354,6 +359,8 @@ - (void)testCredentialsCopying { XCTAssertNotEqual(origCreds.sidCookieName, copiedCreds.sidCookieName); XCTAssertEqual(copiedCreds.tokenFormat, tokenFormatToCheck); XCTAssertNotEqual(origCreds.tokenFormat, copiedCreds.tokenFormat); + XCTAssertEqual(copiedCreds.tokenType, tokenTypeToCheck); + XCTAssertNotEqual(origCreds.tokenType, copiedCreds.tokenType); XCTAssertEqual(copiedCreds.additionalOAuthFields, additionalFieldsToCheck); XCTAssertNotEqual(origCreds.additionalOAuthFields, copiedCreds.additionalOAuthFields); } diff --git a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceSDKCoreTests-Bridging-Header.h b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceSDKCoreTests-Bridging-Header.h index 5ec8f70c25..f2e74f5ac4 100644 --- a/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceSDKCoreTests-Bridging-Header.h +++ b/libs/SalesforceSDKCore/SalesforceSDKCoreTests/SalesforceSDKCoreTests-Bridging-Header.h @@ -3,9 +3,17 @@ // #import #import +#import #import "SFSDKLogoutBlocker.h" #import "SFSDKAuthRequest.h" #import "SFSDKAuthSession.h" #import "SFOAuthCoordinator+Internal.h" #import "SFUserAccountManager+Internal.h" #import "SFOAuthCredentials+Internal.h" + +// Exposes the private `+clearAllComponents` selector so SFSDKDPoPTests can flush +// cached per-component loggers and force them to re-bind to a freshly installed +// SFLogReceiverFactory. See SFLogger.m. +@interface SFLogger (DPoPTestSupport) ++ (void)clearAllComponents; +@end