
Implementing OAuth Browser Flows Properly
Table of Contents:
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:
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 anaccess_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-livedrefresh_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 null
s 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
andstate
parameters in the sidebar under thelightning/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:
- Get a Console project created
- Have the Google Drive API enabled within that project
- Create an OAuth client on that side. Save the Client Id and Client Secret that are made as part of this step
- Add https://www.googleapis.com/auth/drive.readonly as an OAuth scope to the client that’s been created
- Add “https://www.salesforce.com” to the “Authorized JavaScript origins” section:
- 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):
- https://test.salesforce.com/services/extidp/callback
- https://login.salesforce.com/services/extidp/callback
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:
- 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)
- 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)
- No overrides were configurable for things like additional query parameters passed as part of the authorization, token, and refresh requests
- 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:
- Head to the Named Credentials section of Setup
- Navigate to the tab labeled “External Auth Identity Providers”
- Click “New”
- Fill in your desired label and API name
- Note that currently the only Authentication options are “OAuth 2.0” for protocol and “Authorization Code (Browser Flow)” for Authorization Flow Type:
👆 these are the only options
- 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
- Fill in the Authorize Endpoint URL with “https://accounts.google.com/o/oauth2/auth”
- Fill in the Token Endpoint URL with “https://accounts.google.com/o/oauth2/token”
- 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:
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:
- Fill out the label, like “Google Drive”
- Fill out the developer name for your External Credential, like “Google_Drive”
- Add the proper scopes (with a space in between each one) to the Scope field: “openid https://www.googleapis.com/auth/drive.readonly”
- 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
- Save the External Credential
- Scroll to the Principals section and create a new Principal with Per User Principal selected as the Identity Type
- Retrieve your newly created External Credential using the sf cli (or whatever your preferred mechanism is for retrieving this metadata)
- Users will need to have the permissions for this Principal assigned to them under the External Credential Principal Access section within Permission Sets
- 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:
- Within the Named Credential tab, click New
- Add your desired Label and API Name: I used “Google Drive” and “Google_Drive”
- Use “https://www.googleapis.com” for the URL
- Ensure the “Enabled for Callouts” checkbox is toggled
- Select the External Credential you set up in the previous step within the Authorization section
- Save the Named Credential
- 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:
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: