Skip to content

Commit 995ce8a

Browse files
authored
Merge pull request #4106 from sfdctaka/feature/dpop-jkt-authorize
DPoP: send dpop_jkt on /authorize (RFC 9449 §10 code binding)
2 parents ce4bacc + f60327e commit 995ce8a

7 files changed

Lines changed: 530 additions & 8 deletions

File tree

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public enum DPoPProofBuilderError: Int, Error {
3232
case jwkExportFailed = 1
3333
case serializationFailed = 2
3434
case signingFailed = 3
35+
case thumbprintFailed = 4
3536
}
3637

3738
/// Builds an RFC 9449 §4 DPoP proof JWS for a single token-endpoint request.
@@ -97,6 +98,42 @@ public final class DPoPProofBuilder: NSObject {
9798
return "\(signingInput).\(signatureSegment)"
9899
}
99100

101+
/// RFC 7638 JWK thumbprint of a P-256 EC public key.
102+
///
103+
/// Computes: `base64url(SHA-256(canonical_json({crv:"P-256", kty:"EC", x:<...>, y:<...>})))`
104+
/// where `canonical_json` is UTF-8, no whitespace, keys in lexicographic order.
105+
///
106+
/// Used at `/authorize` time to bind the authorization code to the same DPoP
107+
/// key pair that will prove possession at `/token` (RFC 9449 §10 authorization
108+
/// code binding via `dpop_jkt`).
109+
///
110+
/// - Parameter publicKey: The P-256 `SecKey` whose thumbprint to compute.
111+
/// Same key that will later populate the DPoP proof header's `jwk` claim.
112+
/// - Returns: 43-character base64url string (SHA-256 hash, base64url-encoded, no padding).
113+
/// - Throws: `DPoPProofBuilderError.jwkExportFailed` if `Encryptor.jwkP256` fails;
114+
/// `DPoPProofBuilderError.thumbprintFailed` if canonicalization or hashing fails.
115+
@objc public static func jwkThumbprint(publicKey: SecKey) throws -> String {
116+
let jwk: [String: String]
117+
do {
118+
jwk = try Encryptor.jwkP256(from: publicKey)
119+
} catch {
120+
throw DPoPProofBuilderError.jwkExportFailed
121+
}
122+
// RFC 7638 §3.2: canonical JSON must contain ONLY the required members
123+
// for the key type — for P-256 that is exactly {crv, kty, x, y}. If
124+
// Encryptor.jwkP256 ever grows optional fields (kid, use, key_ops...)
125+
// the thumbprint computed here will silently diverge from what the
126+
// server derives off the DPoP proof's `jwk` claim, breaking the
127+
// authorize↔token binding. Keep jwkP256's output minimal.
128+
guard let canonicalData = try? JSONSerialization.data(withJSONObject: jwk,
129+
options: [.sortedKeys, .withoutEscapingSlashes]),
130+
let digest = (canonicalData as NSData).sfsdk_sha256() else {
131+
SFSDKCoreLogger.w(Self.self, message: "DPoP jwkThumbprint: JWK canonicalization or SHA-256 hash failed")
132+
throw DPoPProofBuilderError.thumbprintFailed
133+
}
134+
return (digest as NSData).sfsdk_base64UrlString()
135+
}
136+
100137
// MARK: - Helpers
101138

102139
/// 96 bits (12 bytes) of random entropy, per backend design doc.

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.m

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -866,9 +866,46 @@ - (NSString *)approvalURLForEndpoint:(NSString *)authorizeEndpoint
866866
[approvalUrlString appendFormat:@"&%@=%@", @"login_hint", self.loginHint];
867867
}
868868

869+
[self appendDPoPJktIfNeededTo:approvalUrlString domain:domain credentials:credentials];
870+
869871
return approvalUrlString;
870872
}
871873

874+
// Appends `&dpop_jkt=<base64url-sha256>` to the approval URL when DPoP is enabled
875+
// and the login server is a my-domain server (RFC 9449 §10 authorization code
876+
// binding). Soft-fails on key-material / crypto errors: logs a warning and leaves
877+
// the URL untouched so login proceeds — the server will surface an RFC-shaped
878+
// `invalid_request` error if the ECA requires code binding.
879+
- (void)appendDPoPJktIfNeededTo:(NSMutableString *)approvalUrlString
880+
domain:(NSString *)domain
881+
credentials:(SFOAuthCredentials *)credentials {
882+
if (![[SalesforceSDKManager sharedManager] useDPoP]) {
883+
return;
884+
}
885+
if (domain == nil || [SFSDKAuthConfigUtil isPoolLoginHost:domain]) {
886+
return;
887+
}
888+
if (credentials.identifier.length == 0) {
889+
[SFSDKCoreLogger w:[self class] format:@"DPoP dpop_jkt skipped: missing credentials.identifier"];
890+
return;
891+
}
892+
893+
NSError *err = nil;
894+
SFSDKDPoPKeyPair *pair = [SFSDKDPoPKeyStore.shared keyPairForCredentials:credentials error:&err];
895+
if (!pair || err) {
896+
[SFSDKCoreLogger w:[self class] format:@"DPoP dpop_jkt skipped: key pair load failed (%@)", err.localizedDescription];
897+
return;
898+
}
899+
900+
NSString *thumbprint = [SFSDKDPoPProofBuilder jwkThumbprintWithPublicKey:pair.publicKey error:&err];
901+
if (thumbprint.length == 0 || err) {
902+
[SFSDKCoreLogger w:[self class] format:@"DPoP dpop_jkt skipped: thumbprint failed (%@)", err.localizedDescription];
903+
return;
904+
}
905+
906+
[approvalUrlString appendFormat:@"&%@=%@", kSFOAuthDPoPJktParamName, thumbprint];
907+
}
908+
872909
/**
873910
* Resets all state related to Salesforce Identity API UI Bridge front door bridge URL log in to its default
874911
* inactive state.

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKAuthConfigUtil.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,22 @@
3030
#import <SalesforceSDKCore/SFOAuthCredentials.h>
3131
#import <SalesforceSDKCore/SalesforceSDKConstants.h>
3232

33+
/// Salesforce pool-server host strings. Exact-match constants used by
34+
/// `SFSDKAuthConfigUtil` and by DPoP `dpop_jkt` gating to distinguish
35+
/// pool hosts from my-domain hosts.
36+
FOUNDATION_EXTERN NSString * _Nonnull const kSFSDKSandboxLoginURL; // test.salesforce.com
37+
FOUNDATION_EXTERN NSString * _Nonnull const kSFSDKProductionLoginURL; // login.salesforce.com
38+
FOUNDATION_EXTERN NSString * _Nonnull const kSFSDKWelcomeLoginURL; // welcome.salesforce.com/discovery
39+
3340
@interface SFSDKAuthConfigUtil : NSObject
3441

3542
typedef void (^ _Nonnull MyDomainAuthConfigBlock)(SFOAuthOrgAuthConfiguration * _Nullable authConfig, NSError * _Nullable error);
3643

3744
+ (void)getMyDomainAuthConfig:(nonnull MyDomainAuthConfigBlock)authConfigBlock loginDomain:(nonnull NSString *)loginDomain;
3845

46+
/// YES when `host` is one of the three Salesforce pool servers
47+
/// (login.salesforce.com, test.salesforce.com, welcome.salesforce.com/discovery).
48+
/// NO for my-domain servers. Exact string match; no normalization.
49+
+ (BOOL)isPoolLoginHost:(nonnull NSString *)host;
50+
3951
@end

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKAuthConfigUtil.m

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,22 @@
3131
#import <SalesforceSDKCommon/SFJsonUtils.h>
3232

3333
static NSString * const kSFOAuthEndPointAuthConfiguration = @"/.well-known/auth-configuration";
34-
static NSString * const kSandboxLoginURL = @"test.salesforce.com";
35-
static NSString * const kProductionLoginURL = @"login.salesforce.com";
36-
static NSString * const kWelcomeLoginURL = @"welcome.salesforce.com/discovery";
34+
35+
NSString * const kSFSDKSandboxLoginURL = @"test.salesforce.com";
36+
NSString * const kSFSDKProductionLoginURL = @"login.salesforce.com";
37+
NSString * const kSFSDKWelcomeLoginURL = @"welcome.salesforce.com/discovery";
3738

3839
@implementation SFSDKAuthConfigUtil
3940

41+
+ (BOOL)isPoolLoginHost:(NSString *)host {
42+
return [host isEqualToString:kSFSDKSandboxLoginURL]
43+
|| [host isEqualToString:kSFSDKProductionLoginURL]
44+
|| [host isEqualToString:kSFSDKWelcomeLoginURL];
45+
}
46+
4047
+ (void)getMyDomainAuthConfig:(MyDomainAuthConfigBlock)authConfigBlock loginDomain:(NSString *)loginDomain {
4148
NSString *orgConfigUrl = [NSString stringWithFormat:@"https://%@%@", loginDomain, kSFOAuthEndPointAuthConfiguration];
42-
if ([loginDomain isEqualToString:kSandboxLoginURL] || [loginDomain isEqualToString:kProductionLoginURL] || [loginDomain isEqualToString:kWelcomeLoginURL]) {
49+
if ([SFSDKAuthConfigUtil isPoolLoginHost:loginDomain]) {
4350
[SFSDKCoreLogger d:[self class] format:@"%@ Skipping auth config retrieval for login pool URL", NSStringFromSelector(_cmd)];
4451
authConfigBlock(nil, nil);
4552
return;

libs/SalesforceSDKCore/SalesforceSDKCore/Classes/Util/SFSDKOAuthConstants.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ static NSString * const kSFRevokePath = @"/services/oa
3838
static NSUInteger const kSFOAuthCodeVerifierByteLength = 128;
3939
static NSString * const kSFOAuthCodeVerifierParamName = @"code_verifier";
4040
static NSString * const kSFOAuthCodeChallengeParamName = @"code_challenge";
41+
static NSString * const kSFOAuthDPoPJktParamName = @"dpop_jkt";
4142
static NSString * const kSFOAuthResponseTypeCode = @"code";
4243
static NSString * const kSFOAuthAccessToken = @"access_token";
4344
static NSString * const kSFOAuthClientId = @"client_id";

0 commit comments

Comments
 (0)