Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public enum DPoPProofBuilderError: Int, Error {
case jwkExportFailed = 1
case serializationFailed = 2
case signingFailed = 3
case thumbprintFailed = 4
}

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

/// RFC 7638 JWK thumbprint of a P-256 EC public key.
///
/// Computes: `base64url(SHA-256(canonical_json({crv:"P-256", kty:"EC", x:<...>, y:<...>})))`
/// where `canonical_json` is UTF-8, no whitespace, keys in lexicographic order.
///
/// Used at `/authorize` time to bind the authorization code to the same DPoP
/// key pair that will prove possession at `/token` (RFC 9449 §10 authorization
/// code binding via `dpop_jkt`).
///
/// - Parameter publicKey: The P-256 `SecKey` whose thumbprint to compute.
/// Same key that will later populate the DPoP proof header's `jwk` claim.
/// - Returns: 43-character base64url string (SHA-256 hash, base64url-encoded, no padding).
/// - Throws: `DPoPProofBuilderError.jwkExportFailed` if `Encryptor.jwkP256` fails;
/// `DPoPProofBuilderError.thumbprintFailed` if canonicalization or hashing fails.
@objc public static func jwkThumbprint(publicKey: SecKey) throws -> String {
let jwk: [String: String]
do {
jwk = try Encryptor.jwkP256(from: publicKey)
} 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.

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.

throw DPoPProofBuilderError.thumbprintFailed
}
return (digest as NSData).sfsdk_base64UrlString()
}

// MARK: - Helpers

/// 96 bits (12 bytes) of random entropy, per backend design doc.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -866,9 +866,46 @@ - (NSString *)approvalURLForEndpoint:(NSString *)authorizeEndpoint
[approvalUrlString appendFormat:@"&%@=%@", @"login_hint", self.loginHint];
}

[self appendDPoPJktIfNeededTo:approvalUrlString domain:domain credentials:credentials];

return approvalUrlString;
}

// Appends `&dpop_jkt=<base64url-sha256>` to the approval URL when DPoP is enabled
// and the login server is a my-domain server (RFC 9449 §10 authorization code
// binding). Soft-fails on key-material / crypto errors: logs a warning and leaves
// the URL untouched so login proceeds — the server will surface an RFC-shaped
// `invalid_request` error if the ECA requires code binding.
- (void)appendDPoPJktIfNeededTo:(NSMutableString *)approvalUrlString
domain:(NSString *)domain
credentials:(SFOAuthCredentials *)credentials {
if (![[SalesforceSDKManager sharedManager] useDPoP]) {
return;
}
if (domain == nil || [SFSDKAuthConfigUtil isPoolLoginHost:domain]) {
return;
}
if (credentials.identifier.length == 0) {
[SFSDKCoreLogger w:[self class] format:@"DPoP dpop_jkt skipped: missing credentials.identifier"];
return;
}

NSError *err = nil;
SFSDKDPoPKeyPair *pair = [SFSDKDPoPKeyStore.shared keyPairForCredentials:credentials error:&err];
if (!pair || err) {
[SFSDKCoreLogger w:[self class] format:@"DPoP dpop_jkt skipped: key pair load failed (%@)", err.localizedDescription];
return;
}

NSString *thumbprint = [SFSDKDPoPProofBuilder jwkThumbprintWithPublicKey:pair.publicKey error:&err];
if (thumbprint.length == 0 || err) {
[SFSDKCoreLogger w:[self class] format:@"DPoP dpop_jkt skipped: thumbprint failed (%@)", err.localizedDescription];
return;
}

[approvalUrlString appendFormat:@"&%@=%@", kSFOAuthDPoPJktParamName, thumbprint];
}

/**
* Resets all state related to Salesforce Identity API UI Bridge front door bridge URL log in to its default
* inactive state.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,22 @@
#import <SalesforceSDKCore/SFOAuthCredentials.h>
#import <SalesforceSDKCore/SalesforceSDKConstants.h>

/// Salesforce pool-server host strings. Exact-match constants used by
/// `SFSDKAuthConfigUtil` and by DPoP `dpop_jkt` gating to distinguish
/// pool hosts from my-domain hosts.
FOUNDATION_EXTERN NSString * _Nonnull const kSFSDKSandboxLoginURL; // test.salesforce.com
FOUNDATION_EXTERN NSString * _Nonnull const kSFSDKProductionLoginURL; // login.salesforce.com
FOUNDATION_EXTERN NSString * _Nonnull const kSFSDKWelcomeLoginURL; // welcome.salesforce.com/discovery

@interface SFSDKAuthConfigUtil : NSObject

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

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

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

@end
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,22 @@
#import <SalesforceSDKCommon/SFJsonUtils.h>

static NSString * const kSFOAuthEndPointAuthConfiguration = @"/.well-known/auth-configuration";
static NSString * const kSandboxLoginURL = @"test.salesforce.com";
static NSString * const kProductionLoginURL = @"login.salesforce.com";
static NSString * const kWelcomeLoginURL = @"welcome.salesforce.com/discovery";

NSString * const kSFSDKSandboxLoginURL = @"test.salesforce.com";
NSString * const kSFSDKProductionLoginURL = @"login.salesforce.com";
NSString * const kSFSDKWelcomeLoginURL = @"welcome.salesforce.com/discovery";

@implementation SFSDKAuthConfigUtil

+ (BOOL)isPoolLoginHost:(NSString *)host {
return [host isEqualToString:kSFSDKSandboxLoginURL]
|| [host isEqualToString:kSFSDKProductionLoginURL]
|| [host isEqualToString:kSFSDKWelcomeLoginURL];
}

+ (void)getMyDomainAuthConfig:(MyDomainAuthConfigBlock)authConfigBlock loginDomain:(NSString *)loginDomain {
NSString *orgConfigUrl = [NSString stringWithFormat:@"https://%@%@", loginDomain, kSFOAuthEndPointAuthConfiguration];
if ([loginDomain isEqualToString:kSandboxLoginURL] || [loginDomain isEqualToString:kProductionLoginURL] || [loginDomain isEqualToString:kWelcomeLoginURL]) {
if ([SFSDKAuthConfigUtil isPoolLoginHost:loginDomain]) {
[SFSDKCoreLogger d:[self class] format:@"%@ Skipping auth config retrieval for login pool URL", NSStringFromSelector(_cmd)];
authConfigBlock(nil, nil);
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ static NSString * const kSFRevokePath = @"/services/oa
static NSUInteger const kSFOAuthCodeVerifierByteLength = 128;
static NSString * const kSFOAuthCodeVerifierParamName = @"code_verifier";
static NSString * const kSFOAuthCodeChallengeParamName = @"code_challenge";
static NSString * const kSFOAuthDPoPJktParamName = @"dpop_jkt";
static NSString * const kSFOAuthResponseTypeCode = @"code";
static NSString * const kSFOAuthAccessToken = @"access_token";
static NSString * const kSFOAuthClientId = @"client_id";
Expand Down
Loading
Loading