Skip to content

DPoP: send dpop_jkt on /authorize (RFC 9449 §10 code binding) - #4106

Merged
sfdctaka merged 2 commits into
forcedotcom:dpopfrom
sfdctaka:feature/dpop-jkt-authorize
Jul 16, 2026
Merged

DPoP: send dpop_jkt on /authorize (RFC 9449 §10 code binding)#4106
sfdctaka merged 2 commits into
forcedotcom:dpopfrom
sfdctaka:feature/dpop-jkt-authorize

Conversation

@sfdctaka

@sfdctaka sfdctaka commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Bind the authorization code returned by /authorize to the DPoP key pair that will later prove possession at /token, per RFC 9449 §10. Mirrors the Android implementation.

When SalesforceSDKManager.usesDPoP is YES, the login server is a my-domain host, and credentials.identifier is set, the approval URL now carries dpop_jkt=<RFC 7638 thumbprint>. Pool hosts (login/test/welcome.salesforce.com) are excluded — Salesforce blocks DPoP at the pool servers.

Changes

  • DPoPProofBuilder.jwkThumbprint(publicKey:) — new @objc static that returns the 43-char base64url RFC 7638 thumbprint. Composed from existing primitives (Encryptor.jwkP256, JSONSerialization(.sortedKeys), sfsdk_sha256, sfsdk_base64UrlString). New .thumbprintFailed error case.
  • SFSDKAuthConfigUtil.isPoolLoginHost: — new predicate. The three pool-host string constants are lifted from .m file-locals into the header as FOUNDATION_EXTERN so both the auth-config prefetch check and the dpop_jkt gate share one source of truth.
  • SFOAuthCoordinator.approvalURLForEndpoint:… — after the existing scope/login-hint appends, invokes a new private helper -appendDPoPJktIfNeededTo:domain:credentials: that gates on usesDPoP + isPoolLoginHost + non-empty identifier, loads the key pair via DPoPKeyStore.keyPair(forCredentials:), and appends &dpop_jkt=…. Crypto/keychain errors soft-fail (warn-log, URL untouched, login proceeds).
  • kSFOAuthDPoPJktParamName — new constant next to kSFOAuthCodeChallengeParamName.

Tests

  • DPoPProofBuilder.jwkThumbprint — RFC 7638 fixture against RFC 6979 §A.2.5 P-256 test key, uniqueness across pairs, 43-char base64url shape.
  • SFOAuthCoordinator.generateApprovalUrlString:
    • dpop_jkt present + shape correct under DPoP + my-domain + identifier
    • dpop_jkt value equals jwkThumbprint(publicKey:) of the pair DPoPKeyStore returns at /token time (authorize↔token binding invariant)
    • All three pool hosts (login, test, welcome.../discovery) — no dpop_jkt
    • usesDPoP=NO — byte-identical baseline; no dpop_jkt
    • Empty credentials.identifier — soft-fail path, no dpop_jkt, no exception
    • migrateRefreshToken: construction path carries dpop_jkt
    • Both entry points: user-agent (response_type=token/hybrid_token) and web-server (response_type=code + code_challenge)

Test plan

  • Manual: log into a Require DPoP ECA on a my-domain org via RestAPIExplorer with usesDPoP=YES; confirm dpop_jkt present on the /authorize request and matches the token-endpoint JWK thumbprint.
  • Verify pool-host login still works (login.salesforce.com) — no dpop_jkt sent, flow unchanged.

Binds the authorization code returned by /authorize to the DPoP key pair
that will later prove possession at /token. Mirrors the Android implementation.

- Add DPoPProofBuilder.jwkThumbprint(publicKey:) — RFC 7638 JWK thumbprint
  (43-char base64url of SHA-256 over canonical JSON with sorted keys).
- Add SFSDKAuthConfigUtil.isPoolLoginHost: predicate and lift the three
  pool-host constants to the header for reuse. Refactor the existing pool
  check to consume the new predicate — single source of truth.
- Append &dpop_jkt=<thumbprint> to the approval URL when SalesforceSDKManager.usesDPoP
  is YES, the login server is a my-domain host, and credentials.identifier
  is set. Pool hosts are excluded (Salesforce blocks DPoP at pool servers).
- Soft-fail on crypto/keychain error: log a warning and leave the URL
  untouched so login proceeds; the server will surface the RFC error.

Tests cover: URL shape (43-char base64url), authorize↔token key binding,
pool-host exclusion (three hosts), byte-identical baseline when usesDPoP=NO,
soft-fail on missing key material, migrateRefreshToken path, and both the
user-agent and web-server flow entry points. Also adds jwkThumbprint unit
tests against an RFC 6979 fixed test key with a precomputed RFC 7638
fixture and shape/uniqueness checks.
@github-actions

Copy link
Copy Markdown
1 Warning
⚠️ Big PR, try to keep changes smaller if you can.

Generated by 🚫 Danger

@github-actions

Copy link
Copy Markdown
1 Warning
⚠️ Static Analysis found an issue with one or more files you modified. Please fix the issue(s).

Clang Static Analysis Issues

File Type Category Description Line Col
SFOAuthCoordinator Nullability Memory error nil assigned to a pointer which is expected to have non-null value 119 19
SFOAuthCoordinator Nullability Memory error nil assigned to a pointer which is expected to have non-null value 243 15
SFOAuthCredentials Dereference of null pointer Logic error Access to instance variable '_credentialsChangeSet' results in a dereference of a null pointer (loaded from variable 'self') 196 27
SFOAuthCredentials Nullability Memory error nil passed to a callee that requires a non-null 1st parameter 300 20
SFRestAPI Nullability Memory error nil assigned to a pointer which is expected to have non-null value 98 5
SFUserAccountManager Nullability Memory error Null passed to a callee that requires a non-null 2nd parameter 1584 15
SFUserAccountManager Nullability Memory error Null passed to a callee that requires a non-null 2nd parameter 1599 15
SFUserAccountManager Nullability Memory error nil passed to a callee that requires a non-null 2nd parameter 2257 13
SalesforceSDKManager Nil value used as mutex for @synchronized() (no synchronization will occur) Logic error Nil value used as mutex for @synchronized() (no synchronization will occur) 145 5
SalesforceSDKManager Nil value used as mutex for @synchronized() (no synchronization will occur) Logic error Nil value used as mutex for @synchronized() (no synchronization will occur) 157 5

Generated by 🚫 Danger

@sfdctaka
sfdctaka changed the base branch from dev to dpop July 15, 2026 23:13
@github-actions

Copy link
Copy Markdown
TestsPassed ☑️SkippedFailed ❌️
SalesforceSDKCore iOS ^18 Test Results702 ran700 ✅2 ❌
TestResult
SalesforceSDKCore iOS ^18 Test Results
LoginOptionsViewControllerTests.testBootConfigPickerViewRendered()❌ failure
testMalformedCallbackURL()❌ failure

@codecov

codecov Bot commented Jul 15, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.33333% with 6 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (dpop@ce4bacc). Learn more about missing BASE report.

Files with missing lines Patch % Lines
...lesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.m 78.94% 4 Missing ⚠️
...eSDKCore/Classes/OAuth/DPoP/DPoPProofBuilder.swift 83.33% 2 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             dpop    #4106   +/-   ##
=======================================
  Coverage        ?   65.95%           
=======================================
  Files           ?      251           
  Lines           ?    21822           
  Branches        ?        0           
=======================================
  Hits            ?    14392           
  Misses          ?     7430           
  Partials        ?        0           
Components Coverage Δ
Analytics 70.78% <0.00%> (?)
Common 71.06% <0.00%> (?)
Core 58.25% <0.00%> (?)
SmartStore 73.60% <0.00%> (?)
MobileSync 89.06% <0.00%> (?)
Files with missing lines Coverage Δ
...lesforceSDKCore/Classes/Util/SFSDKAuthConfigUtil.m 76.92% <100.00%> (ø)
...eSDKCore/Classes/OAuth/DPoP/DPoPProofBuilder.swift 86.88% <83.33%> (ø)
...lesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.m 44.63% <78.94%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

} catch {
throw DPoPProofBuilderError.jwkExportFailed
}
// RFC 7638: canonical JSON with lexicographic key ordering, UTF-8, no whitespace.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: RFC 7638 §3.2 requires the canonical JSON to contain only the required members for the key type — for P-256 that's exactly {crv, kty, x, y}, no optional fields like kid, use, or key_ops. jwk is passed directly to JSONSerialization here, so if Encryptor.jwkP256 ever grows extra fields the thumbprint will silently diverge from what the server computes off the DPoP proof's jwk claim, breaking the authorize↔token binding. The fixture test would catch it, but worth a comment stating the invariant in-context so the dependency is visible to a future jwkP256 author.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in f60327e — added the RFC 7638 §3.2 minimality note tying the thumbprint's correctness to jwkP256's output staying at exactly {crv, kty, x, y}, and a SFSDKCoreLogger.w line before the thumbprintFailed throw so canonicalization failures are diagnosable.

// RFC 7638: canonical JSON with lexicographic key ordering, UTF-8, no whitespace.
guard let canonicalData = try? JSONSerialization.data(withJSONObject: jwk,
options: [.sortedKeys, .withoutEscapingSlashes]),
let digest = (canonicalData as NSData).sfsdk_sha256() else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: try? on the JSONSerialization call above swallows any error before this throw, so if canonicalization ever fails the failure is silent. In practice a [String: String] dict won't cause a serialization error, but a log here (matching the style of appendDPoPJktIfNeededTo:) would make failures diagnosable: SFSDKCoreLogger.w(DPoPProofBuilder.self, format: "DPoP jwkThumbprint: canonicalization or hash failed")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in f60327e — added the RFC 7638 §3.2 minimality note tying the thumbprint's correctness to jwkP256's output staying at exactly {crv, kty, x, y}, and a SFSDKCoreLogger.w line before the thumbprintFailed throw so canonicalization failures are diagnosable.

@github-actions

Copy link
Copy Markdown
TestsPassedSkippedFailed ❌️
AuthFlowTester UI Test Results all1 ran1 ❌
TestResult
AuthFlowTester UI Test Results all
AuthFlowTesterUITests.xctest
LegacyLoginTests.testCAOpaque_DefaultScopes_WebServerFlow()❌ failure

…canonicalization failure

- Add in-context comment tying jwkThumbprint's correctness to jwkP256's
  output being minimal ({crv, kty, x, y} only). Optional JWK fields would
  silently break the authorize↔token binding; this makes the dependency
  visible to a future jwkP256 author.
- Log a warn line when JSON canonicalization or SHA-256 hashing fails
  before throwing thumbprintFailed, matching the diagnosability style of
  the coordinator's appendDPoPJktIfNeededTo: soft-fail path.
@sfdctaka
sfdctaka merged commit 995ce8a into forcedotcom:dpop Jul 16, 2026
4 checks passed
@sfdctaka
sfdctaka deleted the feature/dpop-jkt-authorize branch July 16, 2026 00:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants