Skip to content
Draft
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
2 changes: 1 addition & 1 deletion .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand All @@ -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 <token>`
/// header — confirmed by backend (Salesforce, 2026-06-10) as the exact `<token>`
/// 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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 <token>` and a fresh DPoP proof header bound
/// to `accessToken` via the `ath` claim.
/// - anything else (including `nil` / `"Bearer"`) → `Authorization: Bearer <token>`,
/// 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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"];
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -343,6 +346,7 @@ - (void)revokeRefreshToken {
self.sidCookieName = nil;
self.parentSid = nil;
self.tokenFormat = nil;
self.tokenType = nil;
self.beaconChildConsumerKey = nil;
self.beaconChildConsumerSecret = nil;
}
Expand Down Expand Up @@ -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];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
*/

#import <SalesforceSDKCommon/SFJsonUtils.h>
#import <SalesforceSDKCore/SalesforceSDKCore-Swift.h>
#import "SFRestRequest+Internal.h"
#import "SFRestAPI+Internal.h"
#import "NSString+SFAdditions.h"
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,10 @@ - (NSString *)tokenFormat {
return self.values[kSFOAuthTokenFormat];
}

- (NSString *)tokenType {
return self.values[kSFOAuthTokenType];
}

- (NSString *)beaconChildConsumerKey {
return self.values[kSFOAuthBeaconChildConsumerKey];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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");
}
Expand Down
Loading