@@ -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 ? ,
0 commit comments