Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

@W-16362973: [iOS] Add QR Code Login Support in MSDK #3759

Open
wants to merge 6 commits into
base: dev
Choose a base branch
from

Conversation

JohnsonEricAtSalesforce
Copy link
Contributor

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce commented Sep 16, 2024

🎸 Ready For Review! 🥁

This adds the MSDK logic needed to support QR Code Log In to MSDK iOS, much like its Android counterpart.

This includes support for both web server flow and user agent flow in the QR code. Most of the Quip written by @wmathurin still applies, but be aware the APEX code has changed significantly for web server flow. I'll post the updated APEX here as well.

I added a really detailed code walkthrough in my self-review. I highly recommend reading it in detail this time, since this is my personal first foray into this tenured section of the authentication logic. Do share thoughts on the pattern I used for connecting the new parameters to and then using them in the existing authentication logic.

Be sure to see the follow-up pull request, W-16171422: [iOS] Update Native Login Sample App to include QR Code Login Flow.

Thanks for reading, as always.

Comment on lines +69 to +71
/** The biometric log in button */
@property (nonatomic, strong, readonly, nullable) UIButton *biometricButton;

Copy link
Contributor

Choose a reason for hiding this comment

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

Is this (going to be) used somewhere?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Unknown, as of yet. Stay tuned for a more final and review-worthy commit soon.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There's more detail coming in a few minutes when the template app pull request gets its final self-review commentary, but I would like to use this in the template app. Hopefully that'll look groovy when you get to that point.

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce force-pushed the feature/w-16171402_ios-add-qr-code-login-support-in-msdk branch from 65f19ee to 38ba3b0 Compare September 20, 2024 18:38
@codecov-commenter
Copy link

Codecov Report

Attention: Patch coverage is 0% with 68 lines in your changes missing coverage. Please review.

Project coverage is 43.32%. Comparing base (b3b783a) to head (f5ea72c).

Files with missing lines Patch % Lines
...SDKCore/Classes/UserAccount/SFUserAccountManager.m 0.00% 37 Missing ⚠️
...e/SalesforceSDKCore/Classes/Util/SFSDKAuthHelper.m 0.00% 22 Missing ⚠️
...lesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.m 0.00% 9 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev    #3759      +/-   ##
==========================================
- Coverage   43.43%   43.32%   -0.11%     
==========================================
  Files         223      223              
  Lines       20587    20637      +50     
==========================================
  Hits         8941     8941              
- Misses      11646    11696      +50     
Flag Coverage Δ
MobileSync 38.90% <0.00%> (-0.10%) ⬇️
SmartStore 23.20% <0.00%> (-0.12%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...forceSDKCore/Classes/Login/SFLoginViewController.m 0.00% <ø> (ø)
...lesforceSDKCore/Classes/OAuth/SFOAuthCoordinator.m 0.00% <0.00%> (ø)
...e/SalesforceSDKCore/Classes/Util/SFSDKAuthHelper.m 0.00% <0.00%> (ø)
...SDKCore/Classes/UserAccount/SFUserAccountManager.m 18.53% <0.00%> (-0.39%) ⬇️

@JohnsonEricAtSalesforce
Copy link
Contributor Author

Here's the APEX for user agent flow:

public class QRCodeLoginController {
    public String qrCodeOneTimeLoginHyperlink {get;set;}

    //
    // One Time Use Token Exchange
    //

    // Setup
    // 1. Create self signed cert called JWT_Bearer (see https://help.salesforce.com/s/articleView?id=sf.security_keys_creating.htm&language=en_US&type=5)
    // 2. Download the cert
    // 3. Create Connected App, capture its client id in jwtClientId
    // 4. Enable digital signatures, upload cert from step (2)
    // 4. Add web and refresh scope
    // 5. Set app to admin pre-approved
    private String jwtClientId = '3MVG9.AgwtoIvERSd8i8lePrqfs7CazRx2llbL8ubNoG6R3HsYomQFRpbayaMH4HtzH3zj0NDEmC0PIohw0Pf';
    private String selfSignedCertName = 'JWT_Bearer';
    private String instanceUrl = 'https://mobilesdkatsdb6.test1.my.pc-rnd.salesforce.com';
    private String tokenEndpoint = instanceUrl + '/services/oauth2/token';
    private String oneTimeTokenEndpoint = instanceUrl + '/services/oauth2/singleaccess';

    //
    // Mobile App Configuration
    //

    private String mobileClientId = '3MVG9.AgwtoIvERSd8i8lePrqfnKG_MM7P9KAJ4g53iaPA4EN8zUt3__o.8YA_hCeRn_kGR.Xe9I9_pnsFuAW';
    private String callbackURL = 'mobilesdk://android/pn/tester';
    private String mobileDeepLinkURL = 'mobileapp://android/login/qr';

    /**
     * Generate mobile sign in link
     *  The operation is asynchronous and the generated link is stored in qrCodeOneTimeLoginHyperlink
     */
    public PageReference generateQrCodeOneTimeLoginHyperlink() {

        String mobileStartURL = '/services/oauth2/authorize' + '?response_type=hybrid_token&client_id=' + encode(mobileClientId) + '&redirect_uri=' + encode(callbackURL);
        String bridgeUrl = this.generateBridgeUrl(mobileStartURL);
        Map<String, String> jsonMap = new Map<String, String>();
        jsonMap.put('frontdoor_bridge_url', bridgeUrl);
        String jsonString = JSON.serialize(jsonMap);
        String encodedJsonString = EncodingUtil.urlEncode(jsonString, 'UTF-8');

        this.qrCodeOneTimeLoginHyperlink = mobileDeepLinkURL + '?bridgeJson=' + encodedJsonString;
        return null;
    }

    private String generateBridgeUrl(String startURL) {
        String accessToken = getAccessToken();

        Http h = new Http();
        HttpRequest req = new HttpRequest();
        req.setMethod('POST');

        String url = oneTimeTokenEndpoint;
        req.setEndpoint(url);

        String body = 'redirect_uri=' + encode(startURL);
        req.setBody(body);

        //Add Headers
        req.setHeader('Content-Type','application/x-www-form-urlencoded');
        req.setHeader('Authorization','Bearer ' + accessToken);

        //Send Authorzation Request
        HttpResponse res = h.send(req);
        oneTimeUseResponse otur = (oneTimeUseResponse)JSON.deserialize(res.getBody(), oneTimeUseResponse.class);

        return otur.frontdoor_uri;
    }

    private String getAccessToken() {
        Auth.JWT jwt = new Auth.JWT();
        jwt.setSub(UserInfo.getUserName());
        jwt.setAud('https://login.test1.pc-rnd.salesforce.com');
        jwt.setIss(jwtClientId);

        //Additional claims to set scope
        Map<String, Object> claims = new Map<String, Object>();

        //Create the object that signs the JWT bearer token, hardcoded cert dev name for POC
        Auth.JWS jws = new Auth.JWS(jwt, selfSignedCertName);

        //POST the JWT bearer token
        Auth.JWTBearerTokenExchange bearer = new Auth.JWTBearerTokenExchange(tokenEndpoint, jws);
        String accessToken = bearer.getAccessToken();

        return accessToken;
    }

    private String encode(String value) {
        return EncodingUtil.urlEncode(value, 'UTF-8');
    }

    private class oneTimeUseResponse {
        public string frontdoor_uri;
    }
}

@JohnsonEricAtSalesforce
Copy link
Contributor Author

Here's the APEX for web server flow. Note, @wmathurin and I only have one page and controller in place. We've been toggling the APEX content as we switch between tests. We could scale that so it is more convenient.

//
// An APEX controller that prepares and generates a log in QR code using
// Salesforce Identity Single Access UI Bridge API and the hybrid web server
// flow.
//
// Setup:
// 1. Create a self-signed certificate named JWT_Bearer.  See https://help.salesforce.com/s/articleView?id=sf.security_keys_creating.htm&language=en_US&type=5
// 2. Download the certificate
// 3. Create a connected app and set `jwtClientId` to the connected app client id
// 4. Enable digital signatures and upload the certificate from step 2
// 4. Add web and refresh scopes
// 5. Set the app to admin pre-approved
//
// See https://help.salesforce.com/s/articleView?id=sf.frontdoor_singleaccess.htm&type=5
// See https://help.salesforce.com/s/articleView?id=sf.remoteaccess_oauth_hybrid_web_server_flow.htm&type=5
//
public class QRCodeLoginController {

    // The generated one-time-use hyperlink for the QR code payload.
    public String qrCodeOneTimeLoginHyperlink {get;set;}
    
    // The self-signed certificate name.
    private String selfSignedCertName = 'JWT_Bearer';

    // The connected app client id.
    private String jwtClientId = '3MVG9.AgwtoIvERSd8i8lePrqfs7CazRx2llbL8ubNoG6R3HsYomQFRpbayaMH4HtzH3zj0NDEmC0PIohw0Pf';

    // The Salesforce instance URL.
    private String instanceUrl = 'https://mobilesdkatsdb6.test1.my.pc-rnd.salesforce.com';

    // The Salesforce Identity API OAuth 2.0 token endpoint URL.
    private String oauth2TokenEndpoint = instanceUrl + '/services/oauth2/token';

    // The Salesforce Identity API OAuth 2.0 UI bridge single access endpoint URL.
    private String oauth2SingleAccessEndpoint = instanceUrl + '/services/oauth2/singleaccess';

    // The mobile app's client id.
    private String mobileClientId = '3MVG9.AgwtoIvERSd8i8lePrqfnKG_MM7P9KAJ4g53iaPA4EN8zUt3__o.8YA_hCeRn_kGR.Xe9I9_pnsFuAW';
    
    // The connected app's callback URL.
    private String callbackURL = 'mobilesdk://android/pn/tester';
    
    // The mobile app's deep link URL.
    private String mobileDeepLinkUrl = 'mobileapp://android/login/qr';

    /**
     * Generate the QR code's one-time-login hyperlink. The operation is
     * asynchronous and the generated hyperlink is stored in
     * `qrCodeOneTimeLoginHyperlink`.
     */
    public PageReference generateQrCodeOneTimeLoginHyperlink() {

        // Generate PKCE code verifier and code challenge.
        String codeVerifier = generateCodeVerifier();
        String codeChallenge = generateCodeChallenge(codeVerifier);
        
        // Generate the client start URL.
        String clientStartUrl = '/services/oauth2/authorize' + '?response_type=code&client_id=' + encode(mobileClientId) + '&redirect_uri=' + encode(callbackURL) + '&code_challenge=' + encode(codeChallenge);
        
        // Generate the UI Bridge API Front Door URL.
        String uiBridgeFrontDoorUrl = generateUiBridgeFrontDoorUrl(clientStartUrl);
        
        // Assemble the log in QR code's JSON payload.
        Map<String, String> jsonPayload = new Map<String, String>();
        jsonPayload.put('frontdoor_bridge_url', uiBridgeFrontDoorUrl);
        jsonPayload.put('pkce_code_verifier', codeVerifier);
        String jsonPayloadString = JSON.serialize(jsonPayload);
        String jsonPayloadUrlEncoded = EncodingUtil.urlEncode(jsonPayloadString, 'UTF-8');

        qrCodeOneTimeLoginHyperlink = mobileDeepLinkUrl + '?bridgeJson=' + jsonPayloadUrlEncoded;
        return null;
    }

    /*
     * Generates the UI Bridge API Front Door URL using the provided client
     * start URL.
     */
    private String generateUiBridgeFrontDoorUrl(String startUrl) {
        String accessToken = getAccessToken();

        Http http = new Http();
        HttpRequest request = new HttpRequest();
        request.setMethod('POST');

        String url = oauth2SingleAccessEndpoint;
        request.setEndpoint(url);

        String body = 'redirect_uri=' + encode(startUrl);
        request.setBody(body);

        request.setHeader('Content-Type','application/x-www-form-urlencoded');
        request.setHeader('Authorization','Bearer ' + accessToken);

        HttpResponse response = http.send(request);
        SingleAccessResponse singleAccessResponse = (SingleAccessResponse)JSON.deserialize(response.getBody(), SingleAccessResponse.class);

        return singleAccessResponse.frontdoor_uri;
    }

    /*
     * Gets the access token.
     */
    private String getAccessToken() {
        Auth.JWT jwt = new Auth.JWT();
        jwt.setSub(UserInfo.getUserName());
        jwt.setAud('https://login.test1.pc-rnd.salesforce.com');
        jwt.setIss(jwtClientId);

        // Additional claims to set scope
        Map<String, Object> claims = new Map<String, Object>();

        // Create the object that signs the JWT bearer token with a hardcoded certificate developer name for POC.
        Auth.JWS jws = new Auth.JWS(jwt, selfSignedCertName);

        // POST the JWT bearer token.
        Auth.JWTBearerTokenExchange bearer = new Auth.JWTBearerTokenExchange(oauth2TokenEndpoint, jws);
        String accessToken = bearer.getAccessToken();

        return accessToken;
    }

    /*
     * Generates a PKCE code verifier.
     */
    private String generateCodeVerifier() {
        // Code verifier set up.
        String codeVerifierCharacterSet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
        Integer codeVerifierLength = 128;

        // Generate code verifier string.
        String codeVerifier = '';
        for (Integer i = 0; i < codeVerifierLength; i++) {
            Integer index = Math.mod(Math.abs(Crypto.getRandomInteger()), codeVerifierCharacterSet.length());
            codeVerifier += codeVerifierCharacterSet.substring(index, index + 1);
        }

        // Encode code verifier string to Base64 spec.
        codeVerifier = EncodingUtil.base64Encode(Blob.valueOf(codeVerifier));

        // Encode code verifier Base64 to Base64 URL-safe spec.
        codeVerifier = codeVerifier.replace('+', '-').replace('/', '_').replace('=', '');

        return codeVerifier;
    }

    /*
     * Generates a PKCE code challenge from the provided code verifier.
     */
    private String generateCodeChallenge(String codeVerifier) {
        // Generate code challenge string from code verifier.
        Blob codeVerifierBlob = Blob.valueOf(codeVerifier);
        Blob codeChallenge256Blob = Crypto.generateDigest('SHA-256', codeVerifierBlob);

        // Encode code challenge string to Base64 spec.
        String codeChallengeBase64Encoded = EncodingUtil.base64Encode(codeChallenge256Blob);
        // Encode code challenge Base64 to Base64 URL-safe spec.
        String codeChallengeBase64UrlSafeEncoded = codeChallengeBase64Encoded.replace('+', '-').replace('/', '_').replace('=', '');

        return codeChallengeBase64UrlSafeEncoded;
    }

    /*
     * URL encodes a string. 
     */
    private String encode(String value) {
        return EncodingUtil.urlEncode(value, 'UTF-8');
    }

    /*
     * Encodes a given Base64 string to the Base64 URL-safe spec.
     */
    private String base64ToBase64UrlSafe(String base64Value) {
      return base64Value.replace('+', '-').replace('/', '_').replace('=', '');
    }

    /*
     * A class to model responses from the Salesforce Identity OAuth 2.0 UI bridge single access endpoint.
     */
    private class SingleAccessResponse {
        public string frontdoor_uri;
    }
}

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce marked this pull request as ready for review September 26, 2024 21:46
Copy link
Contributor Author

@JohnsonEricAtSalesforce JohnsonEricAtSalesforce left a comment

Choose a reason for hiding this comment

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

Here's my self-review notes and code walk-though.

@@ -225,6 +225,9 @@ NS_SWIFT_NAME(SalesforceManager)
*/
@property (nonatomic, assign) BOOL isLoginWebviewInspectable;

/*** Indicates if login via QR Code and UI bridge API is enabled */
@property (nonatomic, assign) BOOL isQrCodeLoginEnabled;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This matches a new property also added on the Android side. It's use, however, is subtly different on iOS due to differences in the logic flow of finishing authorization after the allow page. I'll add a comment below on that.


import Foundation

public extension SalesforceLoginViewController {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This Swift extension of the Objective-C SFLoginViewController translates almost the exact same logic for QR Code Log In methods from Android Kotlin here to Swift. As is often the case, I was hoping to keep the logical implementation as similar as possible between the two platforms.

print("Login With Frontdoor Bridge URL: '\(frontdoorBridgeUrlString)'/'\(String(describing: pkceCodeVerifier))'.")

guard let frontdoorBridgeUrl = URL(string: frontdoorBridgeUrlString) else { return }
guard let webView = oauthView as? WKWebView else { return }
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Is this a safe use of oauthView in MSDK iOS? Is there any more type-safe way to access the web view being used for log in? Note, QR Code Log In is web login specific.

@@ -791,7 +791,7 @@ - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigati
NSURL *url = navigationAction.request.URL;
NSString *requestUrl = [url absoluteString];
if ([self isRedirectURL:requestUrl]) {
if ([[SalesforceSDKManager sharedManager] useWebServerAuthentication]) {
if ([[SalesforceSDKManager sharedManager] useWebServerAuthentication] && ![[SalesforceSDKManager sharedManager] isQrCodeLoginEnabled]) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

As promised above, here's the only current use of isQrCodeLoginEnabled on the iOS side. This is different than the Android logic which uses it for checking the handling of deep-linked intents from external QR code readers. Here, we need a way to send the received callback/redirect URL to handleUserAgentResponse since that's the iOS logic to extracting the URL fragment with the authorization parameters.

The logic in handling the callback URL with the authorization parameters fragment after the allow and deny prompt is very different between Android and iOS. I was hesitant to tamper with useWebServerAuthentication, but perhaps we could simply set that to false automatically when isQrCodeLoginEnabled is true? That might work is the two are implicitly coupled. @wmathurin?

Comment on lines +69 to +71
/** The biometric log in button */
@property (nonatomic, strong, readonly, nullable) UIButton *biometricButton;

Copy link
Contributor Author

Choose a reason for hiding this comment

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

There's more detail coming in a few minutes when the template app pull request gets its final self-review commentary, but I would like to use this in the template app. Hopefully that'll look groovy when you get to that point.

request.codeVerifier = self.codeVerifier;

// Choose either the default generated code verifier or the code verifier matching the overriding Salesforce Identity API UI Bridge front door bridge.
request.codeVerifier = self.overrideWithCodeVerifier ? self.overrideWithCodeVerifier : self.codeVerifier;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here's the selection between the existing code verifier that would be used for the default login URL or the code verifier passed from QR code login.

@@ -750,8 +756,8 @@ - (NSString *)approvalURLForEndpoint:(NSString *)authorizeEndpoint

if (!codeChallenge) {
// Code verifier challenge:
// - self.codeVerifier is a base64url-encoded random data string
// - The code challenge sent here is an SHA-256 hash of self.codeVerifier, also base64url-encoded
// - self.codeVerifier is a Base64 URL-Safe encoded (Note, not URL encoded) random data string
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I updated this comment since it is quite a significant difference. These values are specifically being Base64 URL/Filename Safe Encoded, which is actually what the iOS code already does. This caused some confusion when I was comparing it with the APEX code where many users are erroneously posting that the code challenge should be URL encoded when it actually must be Base 64 then Base 64 URL-safe encoded (which isn't the same). I hope this being more specific will be less misleading in the future.

[self handleWebServerResponse:url];
// Determine if presence of override parameters requiring the user agent flow.
BOOL overrideWithUserAgentFlow = self.overrideWithfrontDoorBridgeUrl && !self.overrideWithCodeVerifier;
if ( [[SalesforceSDKManager sharedManager] useWebServerAuthentication] && !overrideWithUserAgentFlow) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@bbirman, @brandonpage and @wmathurin - This works, but I wonder if it's the best way to "override" to the flow needed by the QR code Vs. the app's preference. I tested both QR code types plus logging in as usual via the web view. It gives me the control flow I expected, but I would love more eyes on this spot.

If it helps to paraphrase the logic, the two "override" parameters are only present when calling through the auth helper with them from a QR code log in. A front door URL by itself needs user agent flow and with a code verifier requires web server flow.

Perhaps it's coincidental, but I noticed the user agent flow uses a fragment string # and the web server flow uses a query string ?. I tried it out, just switching on that, and it actually works perfectly since these two paths are hard coded to those URL formats. Would it be simpler to switch on the URL format?

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't know if we should count on the URL format staying the same. I would rather having it based on something we truly control (like the presence or absence of the code verifier in the QR url).

Alternatively, we could have another parameter encoded in the QR url to indicate explicitly the flow to use: useUserAgentFlow = true | false.

Or like you said, we could just fall back on the the app configuration itself in which case a QR url without code verifier in an app configured to use web server flow, would end up using web server flow without pkce.

What am I saying? I think we can start with the approach you implemented above (driven by the QR url but without a dedicated param).

@@ -192,14 +192,24 @@ Set this block to handle presentation of the Authentication View Controller.

- (BOOL)authenticateUsingIDP:(SFSDKAuthRequest *)request completion:(SFUserAccountManagerSuccessCallbackBlock)completionBlock failure:(SFUserAccountManagerFailureCallbackBlock)failureBlock;

- (BOOL)authenticateWithRequest:(SFSDKAuthRequest *)request completion:(SFUserAccountManagerSuccessCallbackBlock)completionBlock failure:(SFUserAccountManagerFailureCallbackBlock)failureBlock;
- (BOOL)authenticateWithRequest:(SFSDKAuthRequest *)request
Copy link
Contributor Author

Choose a reason for hiding this comment

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

These overloads will collapse significantly in Swift - someday.

@param codeVerifier Optionally and only with the front door bridge URL parameter, a code verifier to use
when the front door bridge URL is using web server authentication
*/
+ (void)loginIfRequired:(nullable UIScene *)scene
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the key public API for QR code login from the app.

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.

4 participants