Skip to content

Commit ce4bacc

Browse files
authored
Merge pull request #4060 from sfdctaka/feature/dpopApiCalls
DPoP proof JWT on API calls (with nonce retry per RFC 9449 §8/§9)
2 parents 190fa7f + 97a6241 commit ce4bacc

19 files changed

Lines changed: 751 additions & 48 deletions

.github/workflows/pr.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ on:
66
pull_request_target: # zizmor: ignore[dangerous-triggers]
77
# feature/dpopBehavior is a temporary entry: PRs in the multi-PR DPoP
88
# rollout target this branch. Remove once DPoP is merged back to dev.
9-
branches: [dev, master, feature/dpopBehavior]
9+
branches: [dev, master, dpop]
1010

1111
permissions:
1212
contents: read

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Identity/SFIdentityCoordinator.m

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,21 @@ - (void)sendRequest
150150
cachePolicy:NSURLRequestReloadIgnoringCacheData
151151
timeoutInterval:self.timeout];
152152
[request setHTTPMethod:@"GET"];
153-
[request setValue:[NSString stringWithFormat:kHttpAuthHeaderFormatString, self.credentials.accessToken] forHTTPHeaderField:kHttpHeaderAuthorization];
153+
NSError *authError = nil;
154+
BOOL ok = [SFSDKDPoPRequestDecorator applyAuthHeaders:request
155+
scope:self.credentials.identifier
156+
accessToken:self.credentials.accessToken
157+
tokenType:self.credentials.tokenType
158+
error:&authError];
159+
if (!ok) {
160+
[SFSDKCoreLogger e:[self class] format:@"SFIdentityCoordinator: Failed to stamp authorization headers: %@", authError.localizedDescription];
161+
[self notifyDelegateOfFailure:authError];
162+
return;
163+
}
154164
[request setTimeoutInterval:self.timeout];
155165
[request setHTTPShouldHandleCookies:NO];
156166
[SFSDKCoreLogger d:[self class] format:@"SFIdentityCoordinator:Starting identity request at %@", self.credentials.identityUrl.absoluteString];
167+
157168
__weak __typeof(self) weakSelf = self;
158169
self.networkIdentifier = [SFNetwork uniqueInstanceIdentifier];
159170
SFNetwork *network = [SFNetwork sharedEphemeralInstanceWithIdentifier:self.networkIdentifier];

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DPoP/DPoPNonceCache.swift

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -28,26 +28,26 @@ import Foundation
2828

2929
/// Process-lifetime cache of `DPoP-Nonce` values, keyed by `(htu, scope)`.
3030
/// `scope` is typically `SFOAuthCredentials.identifier`. Fed by:
31-
/// - reactive 400 / 401 challenges (RFC 9449 §8)
32-
/// - proactive `DPoP-Nonce` response headers on 200 OK (Salesforce backend rotation).
31+
/// - proactive `DPoP-Nonce` harvest from token-endpoint responses (Salesforce only
32+
/// emits nonces from the token endpoint — resource-server responses don't carry
33+
/// `DPoP-Nonce` on 2xx or on challenges).
34+
/// - reactive 400 / 401 challenges at the token endpoint (RFC 9449 §8).
3335
///
34-
/// Read semantics (`nonce(htu:scope:)`) are intentionally non-destructive: the cache
35-
/// returns the most recently observed nonce for a given `(htu, scope)` and does NOT
36+
/// Read semantics (`nonce(htu:scope:)`, `latest(forScope:)`) are intentionally
37+
/// non-destructive: the cache returns the most recently observed nonce and does NOT
3638
/// invalidate it on read. Rationale:
37-
/// - The Salesforce backend rotates the nonce on every response, so a successful
38-
/// request's response brings the next nonce via `harvestNonce(...)` — staleness
39-
/// is bounded to "exactly one outbound request" in the steady state.
40-
/// - The server is the authority on freshness: a stale nonce produces a
41-
/// `use_dpop_nonce` challenge that the caller already retries once.
42-
/// - Read-and-remove would force concurrent callers to race for a single nonce,
43-
/// causing all-but-one to fall back to the unauthenticated path and incur an
44-
/// extra round-trip per concurrent call.
45-
///
46-
/// This PR uses the cache only at the token endpoint, where requests are serial,
47-
/// so the read-doesn't-remove choice has no observable effect today. The model
48-
/// will be revisited when DPoP is extended to API calls in a later phase: with
49-
/// concurrent REST calls, the right policy might be "one-use-per-write" or
50-
/// "serialize on rotation."
39+
/// - Per RFC 9449 §9, a server-issued nonce is reusable until the server rotates it.
40+
/// Read-and-remove would force concurrent callers to race for a single nonce.
41+
/// The non-destructive read lets every concurrent caller use the most recently
42+
/// harvested value.
43+
/// - Resource-server calls reuse the latest token-endpoint nonce via
44+
/// `latest(forScope:)`. When the access token's nonce ages out, the next refresh
45+
/// (driven by the existing 401-on-resource → refresh-on-token → retry-resource
46+
/// path) hits the token endpoint and harvests a fresh nonce on the way back, so
47+
/// the retried resource-server call picks up the new value.
48+
/// - When the server rotates the nonce mid-flight, harvest from the in-flight
49+
/// response wins over harvest from a stale response that lost the race;
50+
/// last-writer-wins is acceptable because both values are server-issued.
5151
@objc(SFSDKDPoPNonceCache)
5252
public final class DPoPNonceCache: NSObject {
5353

@@ -62,14 +62,38 @@ public final class DPoPNonceCache: NSObject {
6262

6363
/// Returns the most recently observed nonce for `(htu, scope)`, or `nil` if none.
6464
/// Non-destructive — see class doc comment for rationale.
65-
// TODO: Revisit read semantics when DPoP extends to API calls. Concurrent REST callers may
66-
// need one-use-per-write or serialize-on-rotation to avoid all-but-one hitting use_dpop_nonce.
6765
@objc(nonceForHtu:scope:)
6866
public func nonce(htu: URL, scope: String?) -> String? {
6967
let key = Self.cacheKey(htu: htu, scope: scope)
7068
return queue.sync { storage[key] }
7169
}
7270

71+
/// Returns the most recently observed nonce for `scope`, regardless of `htu`.
72+
///
73+
/// RFC 9449 §8/§9 leaves it to the authorization server to decide whether resource
74+
/// servers also emit `DPoP-Nonce`. Salesforce's deployment seeds the nonce only on
75+
/// token-endpoint responses; resource-server responses do not refresh it. Clients
76+
/// are expected to reuse that token-endpoint nonce on every DPoP-protected call for
77+
/// the lifetime of the DPoP session, and re-authenticate (which mints a fresh nonce)
78+
/// when the session expires or the server replies with `use_dpop_nonce`.
79+
///
80+
/// `nonce(htu:scope:)` is the spec-correct per-resource lookup. This method is the
81+
/// fall-through used by `DPoPRequestDecorator` when the per-`htu` slot is empty —
82+
/// in practice the only populated slot for a given scope is the token endpoint, and
83+
/// returning it lets the proof carry the server-issued nonce on resource-server
84+
/// calls without an unnecessary `use_dpop_nonce` round-trip.
85+
@objc(latestForScope:)
86+
public func latest(forScope scope: String?) -> String? {
87+
let scopeKey = (scope?.isEmpty == false) ? scope! : "anonymous"
88+
let suffix = "|" + scopeKey
89+
return queue.sync {
90+
for (key, value) in storage where key.hasSuffix(suffix) {
91+
return value
92+
}
93+
return nil
94+
}
95+
}
96+
7397
@objc(setNonce:htu:scope:)
7498
public func setNonce(_ nonce: String, htu: URL, scope: String?) {
7599
let key = Self.cacheKey(htu: htu, scope: scope)

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DPoP/DPoPProofBuilder.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,16 @@ public final class DPoPProofBuilder: NSObject {
4343
/// - httpMethod: HTTP verb (`"POST"` for token endpoint).
4444
/// - htu: Full request URL with no query and no fragment, per RFC 9449 §4.2.
4545
/// - nonce: Optional `nonce` claim from a prior `DPoP-Nonce` server hint.
46+
/// - accessToken: Optional access token bound to the request. When non-nil and
47+
/// non-empty, the payload includes the `ath` claim per RFC 9449 §4.2,
48+
/// computed as `base64url(SHA-256(accessToken))`. Required for resource-server
49+
/// calls; omitted for token-endpoint calls.
4650
/// - keyPair: Public key is embedded in the JWS header `jwk`; private key signs.
4751
/// - now: Injected clock for deterministic tests; defaults to `Date()`.
4852
@objc public static func buildProof(httpMethod: String,
4953
htu: URL,
5054
nonce: String?,
55+
accessToken: String? = nil,
5156
keyPair: DPoPKeyPair,
5257
now: Date = Date()) throws -> String {
5358
let jwk: [String: String]
@@ -70,6 +75,10 @@ public final class DPoPProofBuilder: NSObject {
7075
if let nonce = nonce, !nonce.isEmpty {
7176
payload["nonce"] = nonce
7277
}
78+
if let accessToken = accessToken, !accessToken.isEmpty,
79+
let ath = athClaim(for: accessToken) {
80+
payload["ath"] = ath
81+
}
7382
guard let headerSegment = encode(json: header),
7483
let payloadSegment = encode(json: payload) else {
7584
throw DPoPProofBuilderError.serializationFailed
@@ -96,6 +105,18 @@ public final class DPoPProofBuilder: NSObject {
96105
return (raw as NSData).sfsdk_base64UrlString()
97106
}
98107

108+
/// `ath = base64url(SHA-256(access_token))` per RFC 9449 §4.2. The input is the
109+
/// literal access-token string the SDK sends in the `Authorization: DPoP <token>`
110+
/// header — confirmed by backend (Salesforce, 2026-06-10) as the exact `<token>`
111+
/// value, byte-for-byte, no encoding or canonicalization.
112+
private static func athClaim(for accessToken: String) -> String? {
113+
guard let tokenData = accessToken.data(using: .utf8),
114+
let digest = (tokenData as NSData).sfsdk_sha256() else {
115+
return nil
116+
}
117+
return (digest as NSData).sfsdk_base64UrlString()
118+
}
119+
99120
private static func encode(json: [String: Any]) -> String? {
100121
// .sortedKeys keeps output stable so unit tests can snapshot byte-for-byte.
101122
guard let data = try? JSONSerialization.data(withJSONObject: json,

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/DPoP/DPoPRequestDecorator.swift

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,26 @@ public final class DPoPRequestDecorator: NSObject {
3333
@objc public static let dpopNonceHeaderName = "DPoP-Nonce"
3434
@objc public static let nonceErrorCode = "use_dpop_nonce"
3535

36+
/// Authorization scheme value used when the token endpoint returns
37+
/// `token_type: "DPoP"` (RFC 6749 §5.1 / RFC 9449 §6.1).
38+
@objc public static let dpopTokenType = "DPoP"
39+
3640
/// No-op when `SalesforceSDKManager.shared.useDPoP == NO`. Otherwise builds a fresh
3741
/// proof JWT (with cached nonce if any) and sets it on the `DPoP` header.
3842
/// `scope` is typically `SFOAuthCredentials.identifier` so the keypair and nonce cache
3943
/// are isolated per-account, even before an `SFUserAccount` exists.
4044
@objc(decorateRequest:scope:error:)
4145
public static func decorate(_ request: NSMutableURLRequest, scope: String) throws {
46+
try decorate(request, scope: scope, accessToken: nil)
47+
}
48+
49+
/// Same as `decorate(_:scope:)` but binds the proof to the given access token via the
50+
/// `ath` claim (RFC 9449 §4.2). Use at resource-server call sites where the SDK already
51+
/// holds a token; pass `nil` (or use the no-token overload) at the token endpoint.
52+
@objc(decorateRequest:scope:accessToken:error:)
53+
public static func decorate(_ request: NSMutableURLRequest,
54+
scope: String,
55+
accessToken: String?) throws {
4256
guard SalesforceManager.shared.usesDPoP else { return }
4357
guard !scope.isEmpty else {
4458
SFSDKCoreLogger.i(self, message: "DPoP decorator skipped: empty scope identifier")
@@ -48,28 +62,59 @@ public final class DPoPRequestDecorator: NSObject {
4862
let method = request.httpMethod
4963

5064
let keyPair = try DPoPKeyStore.shared.keyPair(forScope: scope)
65+
// Salesforce seeds DPoP-Nonce only on token-endpoint responses; resource-server
66+
// responses don't refresh it. RFC 9449 §8/§9 permits this. Look up the per-htu
67+
// entry first (spec-correct), then fall back to the latest nonce for the same
68+
// scope so resource-server calls reuse the token-endpoint nonce instead of
69+
// paying a `use_dpop_nonce` round-trip.
5170
let nonce = DPoPNonceCache.shared.nonce(htu: url, scope: scope)
71+
?? DPoPNonceCache.shared.latest(forScope: scope)
5272
let proof = try DPoPProofBuilder.buildProof(httpMethod: method,
53-
htu: url,
54-
nonce: nonce,
55-
keyPair: keyPair)
73+
htu: url,
74+
nonce: nonce,
75+
accessToken: accessToken,
76+
keyPair: keyPair)
5677
request.setValue(proof, forHTTPHeaderField: dpopHeaderName)
5778
}
5879

59-
/// Reads `DPoP-Nonce` from a response and stores it in the cache for the next outbound
60-
/// request to the same `htu`. Per backend design doc, harvest from both 200 OK responses
61-
/// (proactive rotation) and 400/401 challenges (reactive).
80+
/// Central helper for stamping the Authorization header on authenticated outbound
81+
/// requests. Decides scheme from `tokenType`:
82+
///
83+
/// - `"DPoP"` → `Authorization: DPoP <token>` and a fresh DPoP proof header bound
84+
/// to `accessToken` via the `ath` claim.
85+
/// - anything else (including `nil` / `"Bearer"`) → `Authorization: Bearer <token>`,
86+
/// no DPoP header.
6287
///
63-
/// Concurrency note: the token endpoint is called serially, so this PR's caller pattern
64-
/// is "request → harvest → next request" with no overlap. When DPoP is extended to REST
65-
/// API calls in a later phase, in-flight concurrent calls will all carry the same nonce
66-
/// and only one will rotate it cleanly; the others will see a `use_dpop_nonce` challenge
67-
/// and retry. At that point, this site needs to decide between accepting the extra
68-
/// round-trip, serializing requests through a per-`htu` lock, or pre-fetching a nonce.
69-
/// Out of scope for the token-endpoint PR.
70-
// TODO: Handle concurrent REST callers when DPoP extends to API calls. Today's serial
71-
// token-endpoint caller pattern means harvest-then-next-request never overlaps; with
72-
// concurrent REST, decide between extra-round-trip, per-htu serialization, or pre-fetch.
88+
/// No-op when `accessToken` is empty — preserves the existing "no token, skip stamp"
89+
/// behavior of the four call sites.
90+
///
91+
/// - Parameters:
92+
/// - request: the request to mutate. Existing `Authorization`/`DPoP` headers are
93+
/// overwritten by this method (callers should guard against double-stamping
94+
/// via their own checks).
95+
/// - scope: per-account isolation key, typically `SFOAuthCredentials.identifier`.
96+
/// - accessToken: the access token string sent in the Authorization header.
97+
/// - tokenType: `SFOAuthCredentials.tokenType` (the OAuth `token_type` returned
98+
/// by the token endpoint, RFC 6749 §5.1). Case-sensitive equality match against
99+
/// `"DPoP"` is the only positive branch.
100+
@objc(applyAuthHeaders:scope:accessToken:tokenType:error:)
101+
public static func applyAuthHeaders(_ request: NSMutableURLRequest,
102+
scope: String,
103+
accessToken: String?,
104+
tokenType: String?) throws {
105+
guard let accessToken, !accessToken.isEmpty else { return }
106+
if tokenType == dpopTokenType {
107+
request.setValue("DPoP \(accessToken)", forHTTPHeaderField: "Authorization")
108+
try decorate(request, scope: scope, accessToken: accessToken)
109+
} else {
110+
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
111+
}
112+
}
113+
114+
/// Reads `DPoP-Nonce` from a response and stores it in the cache for the next outbound
115+
/// request to the same `htu`. Per RFC 9449 §8/§9, harvest from both 2xx responses
116+
/// (proactive rotation) and 400/401 nonce challenges (reactive). Safe to call on every
117+
/// response — a missing or empty header is a no-op.
73118
@objc(harvestNonceFromResponse:requestURL:scope:)
74119
public static func harvestNonce(from response: URLResponse?,
75120
requestURL: URL?,

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCredentials+Internal.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ extern NSException * _Nullable SFOAuthInvalidIdentifierException(void);
8282
@property (nonatomic, readwrite, nullable) NSString *sidCookieName;
8383
@property (nonatomic, readwrite, nullable) NSString *parentSid;
8484
@property (nonatomic, readwrite, nullable) NSString *tokenFormat;
85+
@property (nonatomic, readwrite, nullable) NSString *tokenType;
8586
@property (nonatomic, readwrite, nullable) NSString *beaconChildConsumerKey;
8687
@property (nonatomic, readwrite, nullable) NSString *beaconChildConsumerSecret;
8788

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCredentials.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ NS_SWIFT_NAME(OAuthCredentials)
138138
@property (nonatomic, readonly, nullable) NSString *sidCookieName;
139139
@property (nonatomic, readonly, nullable) NSString *parentSid;
140140
@property (nonatomic, readonly, nullable) NSString *tokenFormat;
141+
@property (nonatomic, readonly, nullable) NSString *tokenType;
141142
@property (nonatomic, readonly, nullable) NSString *beaconChildConsumerKey;
142143
@property (nonatomic, readonly, nullable) NSString *beaconChildConsumerSecret;
143144

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCredentials.m

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ - (id)initWithCoder:(NSCoder *)coder {
117117
self.cookieSidClient = [coder decodeObjectOfClass:[NSString class] forKey:@"SFOAuthCookieSidClient"];
118118
self.sidCookieName = [coder decodeObjectOfClass:[NSString class] forKey:@"SFOAuthSidCookieName"];
119119
self.tokenFormat = [coder decodeObjectOfClass:[NSString class] forKey:@"SFOAuthTokenFormat"];
120+
self.tokenType = [coder decodeObjectOfClass:[NSString class] forKey:@"SFOAuthTokenType"];
120121

121122
if ([self isMemberOfClass:[SFOAuthCredentials class]]) {
122123
// Otherwise they are stored in keychain
@@ -159,6 +160,7 @@ - (void)encodeWithCoder:(NSCoder *)coder {
159160
[coder encodeObject:self.cookieSidClient forKey:@"SFOAuthCookieSidClient"];
160161
[coder encodeObject:self.sidCookieName forKey:@"SFOAuthSidCookieName"];
161162
[coder encodeObject:self.tokenFormat forKey:@"SFOAuthTokenFormat"];
163+
[coder encodeObject:self.tokenType forKey:@"SFOAuthTokenType"];
162164
[coder encodeObject:kSFOAuthArchiveVersion forKey:@"SFOAuthArchiveVersion"];
163165
[coder encodeObject:@(self.isEncrypted) forKey:@"SFOAuthEncrypted"];
164166
[coder encodeObject:self.additionalOAuthFields forKey:@"SFOAuthAdditionalFields"];
@@ -229,6 +231,7 @@ - (id)copyWithZone:(nullable NSZone *)zone {
229231
copyCreds.sidCookieName = self.sidCookieName;
230232
copyCreds.parentSid = self.parentSid;
231233
copyCreds.tokenFormat = self.tokenFormat;
234+
copyCreds.tokenType = self.tokenType;
232235
copyCreds.beaconChildConsumerKey = self.beaconChildConsumerKey;
233236
copyCreds.beaconChildConsumerSecret = self.beaconChildConsumerSecret;
234237
copyCreds.additionalOAuthFields = [self.additionalOAuthFields copy];
@@ -343,6 +346,7 @@ - (void)revokeRefreshToken {
343346
self.sidCookieName = nil;
344347
self.parentSid = nil;
345348
self.tokenFormat = nil;
349+
self.tokenType = nil;
346350
self.beaconChildConsumerKey = nil;
347351
self.beaconChildConsumerSecret = nil;
348352
}
@@ -469,6 +473,9 @@ - (void)updateCredentials:(NSDictionary *) params {
469473
if (params[kSFOAuthTokenFormat]) {
470474
self.tokenFormat = params[kSFOAuthTokenFormat];
471475
}
476+
if (params[kSFOAuthTokenType]) {
477+
self.tokenType = params[kSFOAuthTokenType];
478+
}
472479
if (params[kSFOAuthBeaconChildConsumerKey]) {
473480
self.beaconChildConsumerKey = params[kSFOAuthBeaconChildConsumerKey];
474481
}

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/RestAPI/SFRestAPI.m

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ - (void)enqueueRequest:(SFRestRequest *)request shouldRetry:(BOOL)shouldRetry {
324324
} else {
325325
network = [self networkForRequest:request];
326326
}
327+
327328
__block NSURLSessionDataTask *dataTask = [network sendRequest:finalRequest dataResponseBlock:^(NSData *data, NSURLResponse *response, NSError *error) {
328329
__strong typeof(weakSelf) strongSelf = weakSelf;
329330
[SFNetwork removeSharedInstanceForIdentifier:instanceIdentifier];

0 commit comments

Comments
 (0)