Implementing OAuth Browser Flows Properly
Posted: February 08, 2025

Implementing OAuth Browser Flows Properly

Table of Contents:

  • Explaining Authorization Code OAuth Flow

  • The Simple Implementation

    • Part One: Why Can’t I Use window.location.search In A Lightning Web Component?
    • Lightning Web Security Interventions
  • The Better Implementation

    • Initial Setup In Google

    • Initial Setup In Salesforce

      • External Auth Identity Provider Setup
      • External Credential Setup
      • Named Credential Setup
    • Apex Implementation

    • Apex Unit Tests

  • Actually Authenticating

  • Conclusion

Natural language is all the rage these days, and the more Generative AI that we see in our lives, the more important it is that the LLMs behind these AIs are drawing from the right sources. But documents — spreadsheets, text documents, slide shows, call transcripts, etc… — are typically the place where the “right” sources live. How can we work to combine document content and ease of access within standard and generative AI-based workflows without creating a tech debt mess, and how can we best utilize platform features while doing so?

In this post, I’ll dive into how developers can combine External Credentials, Named Credentials, and Auth Providers to take care of the heavy lifting when it comes to performing the Browser Flow-based OAuth 2.0 flow, using the common use-case of fetching document content from Google as my example.

Explaining Authorization Code OAuth Flow

Implementing OAuth 2.0 flows takes a lot of work. Google's own documentation on the subject warns the uninitiated that it’s not for the faint of heart:

Given the security implications of getting the implementation correct, we strongly encourage you to use OAuth 2.0 libraries when interacting with Google’s OAuth 2.0 endpoints. It is a best practice to use well-debugged code provided by others, and it will help you protect yourself and your users

Luckily for Salesforce developers, there are a number of tools on-platform that can ease the implementation burden (some of which are brand new), as well as the ongoing maintenance when it comes to enabling Flow and Apex-based APIs to communicate when OAuth 2.0 is part of the exchange necessary prior to accessing an API.

Let’s start by looking at a potentially naïve — but relatively simple — approach to performing OAuth if we want to access Google Drive content:

Diagram showing simple OAUth 2.0 flow

To break that down a bit further, the basics are:

  • display popup that routes to the server a user is looking to authenticate with; in the case of Google, this would be the aforementioned https://accounts.google.com/o/oauth2/auth path
  • if user proceeds with authentication, that same server redirects back to your server (using a callback URL that you provide in the call to) with a short-lived (think: valid for seconds, not minutes) authorization code
  • post back to https://accounts.google.com/o/oauth2/token, providing the authorization code (among other required parameters), receive in the response back an access_token (which, in the case of Google, is also a short-lived token) and, optionally if you supplied the extremely finicky arguments that Google expects, a long-lived refresh_token that can be used on a per-user basis to reauthenticate in the future
  • close the popup
  • proceed on with normal REST-based API calls to do things like access document content
  • the next time a document is requested, first try to use the refresh token to get a new access token, instead of having to go through the whole authorization flow again

If any of that sounds simple… it’s not. I don’t have a joke or punchline for you here. Doing it right is not easy; doing it securely is also a challenge. I’m glossing over some requirements that are Salesforce-specific in that overview (requirements that we’ll get to, shortly). But, as an example, while you can place a JavaScript debugger statement within your popup to see the values of something like:

// this is where the access_token and refresh_token values will be located
const urlParams = new URLSearchParams(window.location.search);

You won’t actually be able to access those values from a Lightning Web Component within a popup. Why is that?

The Simple Implementation

Part One: Why Can’t I Use window.location.search In A Lightning Web Component?

Well… as it turns out, answering that question requires a short side trip. But maybe it’ll be worth it to highlight a few of the potential pitfalls. Let’s start by walking through what a version of implementing OAuth would look like using the Browser Authorization Code flow:

import { LightningElement } from "lwc";

import getAuthCodeURL from "@salesforce/apex/OAuthDocumentController.getAuthCodeURL";
import getAccessToken from "@salesforce/apex/OAuthDocumentController.getAccessToken";
import getDocument from "@salesforce/apex/OAuthDocumentController.getDocument";
import getDocumentViaRefreshToken from "@salesforce/apex/OAuthDocumentController.getDocumentViaRefreshToken";

const DEFAULT_ERROR_MESSAGE =
  "There was an error retrieving your document, please paste the contents of your document into the textbox";
// postMessage ONLY listens to messages with this name
const POPUP_EVENT_LISTENER_NAME = "message";
const REFRESH_TOKEN_SESSION_STORAGE_NAME = "document-refresh-token";

export default class DocumentRetriever extends LightningElement {
  _documentLink;
  _returnLink = "/lightning/cmp/c__documentRetriever";

  errorMessage = DEFAULT_ERROR_MESSAGE;
  errorStyle = "";
  isLoadingDocumentContent = false;
  showContent = false;

  get _currentLocalStorageKey() {
    // unshown - return some combination of REFRESH_TOKEN_SESSION_STORAGE_NAME
    // and the document provider you're using
  }

  connectedCallback() {
    const popupContext = window.opener;

    if (popupContext) {
      const params = new URLSearchParams(window.location.search);
      const code = params.get("code");
      const state = params.get("state");
      popupContext.postMessage({ code, state });
      window.close();
    } else {
      this.showContent = true;
      window.addEventListener(POPUP_EVENT_LISTENER_NAME, this._popupListener);
    }
  }

  disconnectedCallback() {
    window.removeEventListener(POPUP_EVENT_LISTENER_NAME, this._popupListener);
  }

  handleDocumentLink(event) {
    this.errorStyle = "";
    this.errorMessage = DEFAULT_ERROR_MESSAGE;
    this.isDocumentSuccessfullyRetrieved = false;
    this._documentLink = event.detail.value;
    if (!this._documentLink) {
      return;
    }

    // possible security concern here!
    // an alternative is using an encrypted field stored securely
    // on the backend; that requires a record per user, though
    // for some companies, storing a long-lived refresh token
    // in localStorage will be fine; for others, this won't fly
    const refreshToken = window.localStorage.getItem(
      this._currentLocalStorageKey
    );
    this.isLoadingDocumentContent = true;
    const documentFunction = refreshToken
      ? () =>
          this._documentPromiseGuardian(
            getDocumentViaRefreshToken({
              refreshToken,
              documentLink: this._documentLink,
            })
          )
      : this._openOAuthPopup;
    documentFunction();
  }

  _openOAuthPopup = () => {
    const features = {
      popup: "yes",
      width: 500,
      height: 500,
      toolbar: "no",
      menubar: "no",
    };
    // you could do some fun stuff with getBoundingRect() here
    // but let's be honest - it's a popup. It's not going to look good,
    // and the size of the popup itself will have little to do with that
    features.top = Math.round(window.innerHeight / 2 - features.height / 2);
    features.left = Math.round(window.innerWidth / 2 - features.width / 2);

    // for some reason, MDN thought this was a good idea - the properties for a popup
    // are comma-separated key-value pairs in a string
    // https://developer.mozilla.org/en-US/docs/Web/API/Window/open#windowfeatures
    const windowFeatures = Object.entries(features)
      .reduce((str, [key, value]) => (str += `${key}=${value},`), "")
      .slice(0, -1); // remove last ',' (comma)

    getAuthCodeURL({
      documentLink: this._documentLink,
      relativeRedirectLink: this._returnLink,
    })
      .then((authCodeUrl) => {
        // _blank means it's a popup
        window.open(authCodeUrl, "_blank", windowFeatures);
      })
      .catch(this._handleError);
  };

  _popupListener = (event) => {
    if (event.data.code && event.data.state) {
      getAccessToken({
        authorizationCode: event.data.code,
        documentLink: event.data.state,
        // note that this ends up becoming a redirect_uri query parameter
        // and it needs to EXACTLY match what was submitted when navigating
        // the user to the auth code URL, or the whole OAuth flow will fail
        relativeRedirectLink: this._returnLink,
      })
        .then(({ access_token, refresh_token }) => {
          window.localStorage.setItem(
            this._currentLocalStorageKey,
            refresh_token
          );
          this._getDoc(access_token);
          this.errorStyle = "";
        })
        .catch(this._handleError);
    }
  };

  _getDoc(accessToken) {
    this._documentPromiseGuardian(
      getDocument({
        accessToken,
        documentLink: this._documentLink,
      })
    );
  }

  _documentPromiseGuardian = (func) =>
    func
      .then((result) => {
        // 401 on a call to get actual document content
        // really just means that the refresh token has
        // been invalidated or the access token has expired -
        // in either case, that requires re-doing the authorization code
        // flow
        if (result.isAccessTokenValid) {
          this.isDocumentSuccessfullyRetrieved =
            result.isDocumentSuccessfullyRetrieved;
          this.errorMessage = result.errorMessage ?? this.errorMessage;
          this.dispatchEvent(
            new CustomEvent("documentreceived", { detail: result })
          );
          if (!this.isDocumentSuccessfullyRetrieved) {
            throw new Error("Show error styling");
          }
        } else {
          this._openOAuthPopup();
        }
      })
      .catch(this._handleError)
      .finally(() => {
        this.isLoadingDocumentContent = false;
      });

  _handleError = () => {
    this.errorStyle = "slds-theme_error";
  };
}

I’m going to skim through the Apex side of things simply because it’s even more ceremony with uninteresting but important details like: ensuring access_type=offline and scope=https://www.googleapis.com/auth/drive.readonly are part of the query parameters appended to the two /auth URLs you end up hitting. Google is extremely particular about this, unlike some other providers; you have to get the parameters right the very first time a user authenticates, or they’ll never get a refresh token supplied (without having to go into their Google user profile, navigating to Data & Privacy, Third Party Apps & Services, and unlinking the application you just went through all the work of having authenticated to).

We’re also going to skip the markup side of things, though that’s relatively simple (displaying a spinner when the popup loads, showing success when a document is fetched, etc…), hopefully it’s already clear — this “simple” example (again, without even showing the markup or the Apex side of things) already has to handle a lot of complexity. At a bare minimum, in addition to the Apex side of things, we would also have needed to set up Auth Providers — and the means for differentiating between providers based on the link supplied — for Google and any other document content providers we’re looking for users to be able to authenticate to.

All of that is to say: we’re finally about to be able to answer the question of “why can’t I use window.location.search in my component?” If you expose a way to run that handleDocumentLink function (like using it as the onchange handler for a lightning-input, for example), you should see the fun little popup come up, and you’ll get to go through the actual browser-based part of the authorization. But here’s the thing — the popup will close, and nothing else will happen. Your document content won’t get retrieved, in other words. So what’s happening?

If you comment out the window.close() line in the popup part of the connectedCallback() and insert a few lines like such:

connectedCallback() {
  const popupContext = window.opener;

  if (popupContext) {
    debugger;
    const params = new URLSearchParams(window.location.search);
    const code = params.get("code");
    const state = params.get("state");
    console.log(code);
    console.log(state);
    popupContext.postMessage({ code, state });
  }
  // ...etc
}

You’ll have to be very quick when the popup opens in order to get that debugger statement to pause execution, but the console.log invocations will tell the story as well as any. You’ll see two nulls printed out in your JavaScript developer console. After pulling some of your hair out, you might think to check the Sources tab in said developer console, where you’ll immediately be taunted by two things:

  • your lightning web component’s source will show the code and state parameters in the sidebar under the lightning/cmp folder BUT:
  • your connectedCallback() code in the unminified version of your component should display something like this:
 const e = window.opener;
if (e) {
    const t = new URLSearchParams(
      // 👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇👇 uh oh!
      (window === globalThis || window === document ?
        location :
        window.location)
      .search
    )
    , n = t.get("code")
    , i = t.get("state");
    console.log(n),
    console.log(i),
    // etc..
}

Lightning Web Security Interventions

Well, well, well. If it isn’t my old friend, Lightning Web Security! As it turns out, LWS does a hot-swap of URL search parameter values when they’re provided as part of a callback URL. You could access those parameters via CurrentPageReference in LWC … but they’re not prefixed with the c__ underscores that make them accessible there (nor will you have any way to do this).

That means, in the year 2025, that there’s really only one viable frontend solution: bypassing Lightning Web Security. In case this isn’t obvious, that’s not a good idea. But it can be done, using an even older friend — namely, Visualforce.

I don’t even want to go into the details. Essentially, you can make a Visualforce page, the reference for which (/apex/ExampleOAuthRedirect, perhaps) is used as the relativeRedirectUrl parameter. In your Visualforce page, you can then do something like this:

<script>
  const urlParams = new URLSearchParams(window.location.search);
  const namespacedParams = [
    ...urlParams.keys().map((key) => {
      const val = urlParams.get(key);
      // transform each value into a CurrentPageReference-compatible value
      return "c__" + key + "=" + val;
    }),
  ];

  window.open(
    `/lightning/cmp/c__documentRetriever?${namespacedParams.join("&")}`,
    "_top"
  );
</script>

Which then allows you to wire up and access those properties via CurrentPageReference. But… this is atrocious, in addition to being insecure. So let’s not do it!

The Better Implementation

But wait, you might be saying! We just looked at all that sweet, sweet code and none of it came to anything!

Don’t worry — we’re about to make everything right. As it turns out, Spring ‘25 introduces a new section in the Named Credentials part of setup: that of External Auth Identity Providers. But before we start talking about them, I’m going to back up and properly document everything you need to implement this properly.

Initial Setup In Google

If you aren’t the Google Console admin, you’ll need to work with your admin on the Google side to:

  1. Get a Console project created
  2. Have the Google Drive API enabled within that project
  3. Create an OAuth client on that side. Save the Client Id and Client Secret that are made as part of this step
  4. Add https://www.googleapis.com/auth/drive.readonly as an OAuth scope to the client that’s been created
  5. Add “https://www.salesforce.com” to the “Authorized JavaScript origins” section:

Adding the correct Authorized JavaScript origin to your Google application

  1. Add the Redirect URIs (the setup for which we’ll discuss in a moment on the Salesforce side), which are now generic and not org-specific (this was one of the major drawbacks of the now-legacy Auth Providers within Salesforce):

Adding the correct Authorized redirect URIs to your Google application

Not too bad, right? Let’s move on to the Salesforce side of things.

Initial Setup In Salesforce

External Auth Identity Provider Setup

Prior to delving into the External Credential side of Setup, we’ll first need to create an Authorization Provider (Auth Provider for short) to house the Client Id and Client Secret that were created in the initial setup within Google. Prior to Spring ‘25, the Auth. Provider section in Salesforce Setup was the only way to go about mapping this kind of information to an External Credential, but legacy Auth Providers suffered from two issues that have historically made them non-preferred for this kind of integration:

  1. The client secret, while omitted from the metadata when retrieving it via the CLI, is a required part of the metadata while deploying, which is potentially insecure (and requires the use of something like string replacement prior to packaging or deploying)
  2. The client secret is stored in plain text, which is potentially insecure (particularly for ISVs or anybody looking to package this kind of metadata to other orgs)
  3. No overrides were configurable for things like additional query parameters passed as part of the authorization, token, and refresh requests
  4. The callback URL was org-specific

Luckily for us, Spring ‘25 will see the addition of External Auth Identity Providers, and you can already access this feature in pre-release orgs! External Auth Identity Providers solve all of the above mentioned issues, and pave the way for easily adding Authorization Code-based OAuth 2.0 browser flows:

  1. Head to the Named Credentials section of Setup
  2. Navigate to the tab labeled “External Auth Identity Providers”
  3. Click “New”
  4. Fill in your desired label and API name
  5. Note that currently the only Authentication options are “OAuth 2.0” for protocol and “Authorization Code (Browser Flow)” for Authorization Flow Type:

Showing the only options currently available for Authentication in External Auth Identity Providers as of Spring 25 release

👆 these are the only options

  1. Fill in the Client Id and Client Secret that were set up within Google. You do not need to enable the “Pass Client Credentials in Request Body” when using Google as an authorization provider, but you can toggle this checkbox if you’d like; it will work toggled or untoggled
  2. Fill in the Authorize Endpoint URL with “https://accounts.google.com/o/oauth2/auth”
  3. Fill in the Token Endpoint URL with “https://accounts.google.com/o/oauth2/token”
  4. Save the External Auth Identity Provider, then scroll down to the Custom Request Parameters section and create two new parameters:
NAME Value Request Type Parameter Location
access_type offline Authorize Request Query Parameter
prompt consent Authorize Request Query Parameter

When you’re done, the Custom Request Parameter section should look like this:

Custom Request Parameter set up

While that involved a certain amount of clicking, this External Auth Identity Provider is going to make performing authenticated callouts to Google a breeze. Without those two parameters, Google won’t issue a refresh token when a user initially authenticates – and without a refresh token, users will have to re-authenticate every few hours when their access token expires, which isn’t a great user experience.

If you’re an ISV or package author, note that when retrieving the metadata, the Client Id and Client Secret are not included in the XML returned. These values can be configured using the Connect API as part of your post-install script, or as a manual post-install step. This is a security feature, not a bug, in other words.

External Credential Setup

Now, we’ll need to create an External Credential to act as the “container” for all of the callouts associated with Google:

  1. Fill out the label, like “Google Drive”
  2. Fill out the developer name for your External Credential, like “Google_Drive”
  3. Add the proper scopes (with a space in between each one) to the Scope field: “openid https://www.googleapis.com/auth/drive.readonly”
  4. Under the Identity Provider section, click the dropdown to select “External Auth Identity Provider” and search for the External Auth Identity Provider you set up in the previous step using the text input
  5. Save the External Credential
  6. Scroll to the Principals section and create a new Principal with Per User Principal selected as the Identity Type
  7. Retrieve your newly created External Credential using the sf cli (or whatever your preferred mechanism is for retrieving this metadata)
  8. Users will need to have the permissions for this Principal assigned to them under the External Credential Principal Access section within Permission Sets
  9. Users will also need to have Create, Read, Edit, and Delete permissions for the User External Credentials object via Permission Sets

Perfect. Let’s click the “Named Credential” breadcrumb at the top of our newly created External Credential to return to the Named Credential home within Setup. Now all we need to do is wire up a Named Credential!

Named Credential Setup

We’re in the home stretch as far as Setup is concerned:

  1. Within the Named Credential tab, click New
  2. Add your desired Label and API Name: I used “Google Drive” and “Google_Drive”
  3. Use “https://www.googleapis.com” for the URL
  4. Ensure the “Enabled for Callouts” checkbox is toggled
  5. Select the External Credential you set up in the previous step within the Authorization section
  6. Save the Named Credential
  7. Retrieve your newly created Named Credential using the sf cli (or whatever your preferred mechanism is for retrieving this metadata)

Congratulations! You’ve made it to the end of the Setup steps required. Let’s dive into the code.

Apex Implementation

I’ll be referring to the Apex class we’ll create as a controller because it will enable the fetching of document content within a Lightning Web Component like the one shown previously, but if you only ever need to communicate with the API via Apex (or Flow), you can eliminate the @AuraEnabled annotations from the methods shown.

If you are not planning to use Lightning Web Components and a frontend entry point for interacting with the component, you’ll have to think about how to direct users to authenticate, which I’ll cover briefly in the section following this one.

There are a few things to note before looking at the code sample:

  • We don’t want to perform a callout if a user hasn’t authenticated yet to the system in question. This involves the usage of the Connect API, which carries with it implications for testing, because typically only tests decorated with the @SeeAllData annotation can use the Connect API, but using that annotation is an anti-pattern and should be strictly avoided. For that reason, I’ve created a virtual class that can be overridden within tests so that the Connect API is not used there
  • I’ve created a generic interface that can fetch document content from Google Drive or any other provider; though I’ve only shown the Google based setup, the same steps above will work with pretty much any other document content provider. You may need to tweak or omit certain parameters while creating the External Auth Identity Provider, but most content providers (in my experiece) tend to implement OAuth 2.0 very close to spec, and as Google is the most exacting provider I’ve worked with, wiring up additional providers should be a walk in the park compared to combing through Google’s OAuth docs trying to understand why the consent parameter is necessary! 😅`

And here’s the code:

public with sharing class OAuthDocumentController {
  // alternatively, be a good citizen and use DI to inject this via interface
  @TestVisible
  private virtual class CredentialAuthenticationStatusHelper {
    public virtual Boolean isNotActive(String credentialApiName) {
      return ConnectApi.NamedCredentials.getCredential(
        credentialApiName,
        'Per User',
        ConnectApi.CredentialPrincipalType.PerUserPrincipal
      )
      .authenticationStatus == ConnectApi.CredentialAuthenticationStatus.NOTCONFIGURED;
    }
  }

  @TestVisible
  private static CredentialAuthenticationStatusHelper credentialAuthenticationHelper = new CredentialAuthenticationStatusHelper();


  public class DocumentResponse {
    @AuraEnabled
    public String html;
  }


  @AuraEnabled
  public static DocumentResponse getDocument(String documentLink) {
    return getDocumentProvider(documentLink).getDocument();
  }


  private static DocumentProvider getDocumentProvider(String documentLink) {
    // unshown - this is a factory method. If you had OTHER document providers
    // you'd have to parse the link here in order to decide which instance to create
    return new GoogleDocumentProvider(documentLink);
  }

  private abstract class DocumentProvider {
    protected final String documentLink;
    protected final String apiName;


    public DocumentProvider(String documentLink, String apiName) {
      this.apiName = apiName;
      this.documentLink = documentLink;
    }


    public DocumentResponse getDocument() {
      if (credentialAuthenticationHelper.isNotActive(this.apiName)) {
        this.throwNoAccessException('There was a problem authenticating to ' + this.getHumanReadableProviderName());
      }


      HttpRequest req = new HttpRequest();
      req.setEndpoint(this.getRemoteDocumentLink());
      req.setMethod('GET');


      HttpResponse res = new Http().send(req);


      DocumentResponse documentResponse = new DocumentResponse();
      String possibleErrorMessage;
      if (res.getStatusCode() < 300) {
        documentResponse.html = this.getDocumentContent(res.getBody());
      } else if (res.getStatusCode() == 401) {
        this.throwNoAccessException('Your session has expired. Please re-authenticate and try again.');
      } else if (res.getStatusCode() == 403) {
        possibleErrorMessage = 'You don\'t seem to have access to this document. Request access then please retry.';
      } else if (res.getStatusCode() == 404) {
        possibleErrorMessage = 'It looks like you provided an invalid link. Try re-pasting it, and we\'ll attempt to fetch your document again';
      } else {
        possibleErrorMessage = 'An unexpected error occurred';
      }
      if (possibleErrorMessage != null) {
        throw new CalloutException(possibleErrorMessage);
      }
      return documentResponse;
    }


    protected abstract String getHumanReadableProviderName();
    protected abstract String getRemoteDocumentLink();


    protected virtual String getDocumentContent(String json) {
      return json;
    }


    private void throwNoAccessException(String message) {
      // NoAccessException does not have a String-based constructor
      Exception noAccessException = new NoAccessException();
      noAccessException.setMessage(message);
      throw noAccessException;
    }
  }


  private class GoogleDocumentProvider extends DocumentProvider {
    public GoogleDocumentProvider(String documentLink) {
      super(documentLink, 'Google_Drive');
    }


    protected override String getRemoteDocumentLink() {
      return String.format(
        'callout:' + this.apiName + '/drive/v3/files/{0}/export?mimeType=text/plain',
        new List<String>{ this.documentLink.substringAfter('d/').substringBefore('/').trim() }
      );
    }


    protected override String getHumanReadableProviderName() {
      return 'Google Drive';
    }
  }
}

By using an interface for the DocumentProvider, we can easily add different providers as shown – including customizing how to parse the returned response.

By referencing the API name for the Named Credential(s) that we’ve set up, any authenticated user’s callout will automatically get the correct Authorization header added to the HttpRequest being used.

Note the error handling, which takes care of the common HTTP status codes and what message to return based off of them.

Apex Unit Tests

Here’s the corresponding test class. Note, again, that the Connect API is stubbed within these tests to avoid getting an error related to needing the SeeAllData=true property to be supplied to the @IsTest annotation:

@IsTest
private class OAuthDocumentControllerTest {
  static Boolean isCredentialNotActive = false;
  static {
    OAuthDocumentController.credentialAuthenticationHelper = new CredentialAuthenticationStatusHelperMock();
  }


  private class CredentialAuthenticationStatusHelperMock extends OAuthDocumentController.CredentialAuthenticationStatusHelper {
    public override Boolean isNotActive(String credentialApiName) {
      return isCredentialNotActive;
    }
  }

  @IsTest
  static void getsGoogleDocumentSuccessfully() {
    HttpMock mock = new HttpMock();
    mock.response.setStatusCode(200);
    mock.response.setBody('someValue');
    Test.setMock(HttpCalloutMock.class, mock);


    OAuthDocumentController.DocumentResponse docResponse = ACCENG_OAuthDocumentController.getDocument(
      'https://docs.google.com/document/d/fakeId/someOtherStuffThatIsNotImportant'
    );


    Assert.areEqual('someValue', docResponse.html);
  }

  @IsTest
  static void handlesInvalidAccessTokenWhenGettingDocument() {
    HttpMock mock = new HttpMock();
    mock.response.setStatusCode(401);
    Test.setMock(HttpCalloutMock.class, mock);


    try {
      OAuthDocumentController.getDocument('https://docs.google.com/document/d/fakeId');
      Assert.fail('Should not make it here');
    } catch (System.NoAccessException ex) {
      Assert.areEqual('Your session has expired. Please re-authenticate and try again.', ex.getMessage());
    }
  }

  @IsTest
  static void handlesRestrictedAccessWhenGettingDocument() {
    HttpMock mock = new HttpMock();
    mock.response.setStatusCode(403);
    Test.setMock(HttpCalloutMock.class, mock);


    try {
      OAuthDocumentController.getDocument('https://docs.google.com/document/d/fakeId');
      Assert.fail('Should not make it here');
    } catch (CalloutException ex) {
      Assert.areEqual(
        'You don\'t seem to have access to this document. Request access then please retry.',
        ex.getMessage()
      );
    }
  }

  @IsTest
  static void handlesInvalidLinkWhenGettingDocument() {
    HttpMock mock = new HttpMock();
    mock.response.setStatusCode(404);
    Test.setMock(HttpCalloutMock.class, mock);

    try {
      OAuthDocumentController.getDocument('https://docs.google.com/document/d/fakeId');
      Assert.fail('Should not make it here');
    } catch (CalloutException ex) {
      Assert.areEqual(
        'It looks like you provided an invalid link. Try re-pasting it, and we\'ll attempt to fetch your document again',
        ex.getMessage()
      );
    }
  }


  @IsTest
  static void handlesUnexpectedErrorWhenGettingDocument() {
    HttpMock mock = new HttpMock();
    mock.response.setStatusCode(500);
    Test.setMock(HttpCalloutMock.class, mock);

    try {
      OAuthDocumentController.getDocument('https://docs.google.com/document/d/fakeId');
      Assert.fail('Should not make it here');
    } catch (CalloutException ex) {
      Assert.areEqual('An unexpected error occurred', ex.getMessage());
    }
  }


  @IsTest
  static void throwsNoAccessExceptionWhenNotAuthenticated() {
    isCredentialNotActive = true;

    try {
      OAuthDocumentController.getDocument('https://docs.google.com/d/someFakeGoogleId/');
      Assert.fail('Should not make it here');
    } catch (NoAccessException ex) {
      Assert.areEqual('There was a problem authenticating to Google Drive', ex.getMessage());
    }
  }


  private class HttpMock implements System.HttpCalloutMock {
    public HttpRequest req;
    public HttpResponse response = new HttpResponse();

    public HttpResponse respond(HttpRequest req) {
      this.req = req;
      return this.response;
    }
  }
}

Whew. This test class’s code is self-contained, but if you have a preferred HttpCalloutMock within your codebase, you may prefer to take advantage of that in order to further validate the requests being sent.

Actually Authenticating

Whether you’re choosing to perform callouts from Apex, Flow, LWC or wherever — you’ll have to direct users to authenticate the first time they try to fetch a document. The easiest way to do so is by directing them to the External Credentials part of Settings:

External Credential part of user settings

You can use the relative URL path of /lightning/settings/personal/ExternalCredentials/home to direct users to this page; after all of the setup and wiring you’ve done, the reward is getting to perform the authentication flow with no other ceremony from this page by clicking the “Allow Access” button. Once an External Credential has been configured, Salesforce will automatically refresh the access token necessary to authorize each callout for you, which is quite nice.

Alternatively, you can dynamically generate the URL needed to start the authorization flow using the code in the Named Credentials developer guide, which I’ll copy here for posterity:

ConnectApi.OAuthCredentialAuthUrlInput input = new ConnectApi.OAuthCredentialAuthUrlInput();
input.externalCredential = 'This is the API name of your Exteranl Cred';
input.principalType = ConnectApi.CredentialPrincipalType.NamedPrincipal;
input.principalName = 'The API name for the principal';
ConnectApi.OAuthCredentialAuthUrl output = ConnectApi.NamedCredentials.getOAuthCredentialAuthUrl(input);

// Users must open this link and authorize in the browser
System.debug(output.authenticationUrl);

Note that you can essentially combo the above code with the credentialAuthenticationHelper.isNotActive() check that I documented in the OAuthDocumentController to choose whether or not to dynamically render an authorization button without having to fail at calling out first, which is a nice plus.

Conclusion

In the end, this is really a “standing on the shoulders of giants” moment. Popup based authentication that’s managed by you isn’t a great idea; it has the potential to be insecure, carries much more in the way of security and implementation-level concerns (to say nothing of needing Visualforce page access granted to users in the year 2025!), and in general is a tech debt burden you’ll likely be carrying forward for a long time. I was lucky enough to have had the exact feature I needed be implemented at the exact moment I needed it; while that doesn’t always happen, I’m excited to see where External Auth Identity Providers will go from here.

Thanks, as always, to my subscribers on Patreon — thanks, especially, to Henry Vu and Arc, both of whom in addition to being great friends have continued to support me and my writing. Remember that you can always bookmark headings (the links for which are available in the table of contents at the top of each post); for longer posts, hopefully that helps to break up the reading into more approachable segments.

Lastly, here are a few further resources for you when it comes to OAuth and credentialing in general on-platform:

In the past three years, hundreds of thousands of you have come to read & enjoy the Joys Of Apex. Over that time period, I've remained staunchly opposed to advertising on the site, but I've made a Patreon account in the event that you'd like to show your support there. Know that the content here will always remain free. Thanks again for reading — see you next time!