How to Set Up JavaScript OAuth Authorization Code Grant with PKCE
The most secure OAuth method for single-page applications is Authorization Code Grant with PKCE (Public Key with Code Exchange). In this post I’ll show you how to implement the OAuth flow within your application’s web page.
In this post I’ll show how to implement the OAuth Authorization Code grant flow with PKCE for a single-page web application. This is a generic example; it is not specific to Docusign.
Introduction
Modern web apps such as Google’s GMail app are written as single-page applications (SPA). The software for the application runs in the browser itself. Backend services are used via API calls.
The applications often need the user to authenticate via OAuth to obtain an access token. The access token is then used with the API requests.
The OAuth standards consider a single page application to be a public OAuth client. This means the app has no way to securely store a secret. For these types of apps, OAuth v2 uses the Implicit Grant flow. Docusign supports the Implicit Grant flow.
OAuth v2 is from 2012. OAuth v2.1 does not include the Implicit Grant flow. For public clients, v2.1 requires the Authorization Code Grant to not use a secret and to require the PKCE (Proof Key for Code Exchange by OAuth Public Clients) code_challenge and code_verifier parameters.
While Docusign continues to support the Implicit Grant flow for public clients, we agree with the OAuth v2.1 standard and recommend that all new public clients use Authorization Code Grant. Existing public clients should be upgraded too.
Configuring the Client ID for a Public Client
Each OAuth service uses its own configuration system. This section describes how to configure the Docusign Account Service for a Public Client SPA Client ID. Docusign uses the term “Integration Key” instead of “Client ID.” They’re synonyms. Other OAuth services will have similar configuration options.
Open the Demo eSignature Account Admin tool to administer your account settings, including its Client IDs. Click the Integrations / Apps and Keys link in the lefthand navigation bar.
Click Add App and Integration Key to create a new Client ID. In the Authentication section of the form, use the settings shown in the screenshot below:
In the Additional settings section, use the Redirect URIs field to add the URL for your application’s URL. For example, if your application’s url is https://myapp.example.com/myapp/, that will be your Redirect URL. (In this case, as is common, while your app starts with index.html, the directory-level URL is preferred because your webserver responds with the directory’s index.html file by default.) Note that you must include the trailing slash, since it will be added by the browser and the configured redirect URL must exactly match the OAuth initial request.
In the Origin URLs, add the origin for your app. In this case, the origin would be https://myapp.example.com. Note: no trailing slash. See my post Focused View, Safari, iOS Oh My! for more about origins.
Check the Allow CORS for OAuth calls checkbox.
Check the appropriate Allowed HTTP Methods checkboxes needed for your application. At a minimum, check Get and Post.
Application OAuth User Experience
Your application can integrate the OAuth flow in different ways:
To enable the user to login, the application can open a new browser tab or browser pop-up window with the OAuth login screen. The URL of the OAuth browser tab or window should be visible to the user. To combat clickjacking attacks, Docusign and most other OAuth services do not allow iframes to be used. The disadvantage of this pattern is the new browser window or tab. The advantage is that the SPA application itself continues to operate during the OAuth flow, so its state is maintained with no additional effort.
Instead, my recommendation is to use the application’s browser window for the OAuth flow. This example demonstrates how to do this. This pattern requires the application to store its state and to restart smoothly when the OAuth service redirects the browser back to the application (via the redirect URL of the Client ID’s configuration.)
Implementing the OAuth Authorization Code grant flow
Pseudocode
This pseudocode includes links to an open-source code example, the Template Edit example. You can download the example’s source from the Docusign GitHub repository.
Step 1. The application starts.
// File: script.js
// Mainline
// ...
$("#modalLogin button").click(login); // Line 242 Register for button click
Step 2. The authCodePkce library class is initialized (line 264) with the application’s main URL. The oAuthReturnUrl property uses the Window:location property and the path will include the trailing slash.
// File: script.js
data.authCodePkce = new AuthCodePkce({
oAuthReturnUrl: `${location.origin}${location.pathname}`,
clientId: platform,
showMsg: toast
});
// Look at the hash data to see if we're
// now receiving the OAuth response
await data.authCodePkce.oauthResponse();
// Are we logged in? -- See step 4, below
if (data.authCodePkce.checkToken()) {
// logged in
Step 3. The authCodePkce.oauthResponse() method checks the current URL for the presence of a search string (query parameters) in the current URL. For example, the URL could be https://myapp.example.com/myapp/?state=abcd123&code=4567 or the URL may have no query parameters.
If no query parameters are found (line 196) then return to the main app startup (line 272). If QPs are found then continue with Step 5. OAuth Response, shown below.
// File: authCodePkce.js
async oauthResponse() {
const search = location.search.substring(1); // remove the #
if (!search.length) {return} // Line 196. EARLY RETURN
Step 4. Are we logged in? Call the authCodePkce.checkToken() method. It checks its instance variables and returns true if a current access token is found.
// File: script.js
// Are we logged in?
if (data.authCodePkce.checkToken()) {
// logged in
False response steps: we’re not logged in
At this point, your app could store its state for when it is later restarted with an access token. The example app does not need to store any startup state.
Step Fa: Show (line 295) the Login UX modal dialog to the user.
// File: script.js
} else {
// not logged in, show Login modal
loginModal.show(); // Line 295
}
Later, the user clicks the Login button on the Login modal dialog: Step Fb: Start (line 120) the login process by calling the authCodePkce.login() method.
// File: script.js
const login = function loginF() {
data.authCodePkce.login();
}.bind(this);
// File: authCodePkce.js
async login() {
....
await this.createCodeVerifierChallenge();
// Make a random nonce to use with OAuth call
this._nonce = this.generateCodeVerifier()
this.storeOAuthData();
Step Fc: Create a codeVerifier, a secret (line 305), and a matching codeChallenge (a SHA256 hash of the codeVerifier that is then Base64-encoded). The Window.crypto API is used. Also, create a random nonce (stored in _nounce) that will be used for the OAuth state parameter:
// File: authCodePkce.js
/**
* Create
* this.codeVerifier -- a secret
* this.codeChallenge -- SHA256(this.CodeVerifier)
* See https://stackoverflow.com/a/63336562/64904
*/
async createCodeVerifierChallenge() {
this.codeVerifier = this.generateCodeVerifier();
async function sha256(plain) {
// returns promise ArrayBuffer
const encoder = new TextEncoder();
const data = encoder.encode(plain);
return window.crypto.subtle.digest("SHA-256", data);
}
function base64urlencode(a) {
let str = "";
let bytes = new Uint8Array(a);
let len = bytes.byteLength;
for (let i = 0; i < len; i++) {
str += String.fromCharCode(bytes[i]);
}
return btoa(str)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
const hashed = await sha256(this.codeVerifier);
this.codeChallenge = base64urlencode(hashed);
// Check the code verifier and challenge here:
// https://example-app.com/pkce
}
/**
* return codeVerifier -- a secret
* 128 characters
* Characters English letters A-Z or a-z, Numbers 0-9,
* Symbols “-”, “.”, “_” or “~”.
* See https://crypto.stackexchange.com/q/109579/8680
*/
generateCodeVerifier() {
function dec2hex(dec) {
return ("0" + dec.toString(16)).substr(-2);
}
const len = 128;
var array = new Uint32Array( len / 2);
window.crypto.getRandomValues(array);
return Array.from(array, dec2hex).join("");
}
Step Fd: Line 134: Store the nonce and codeVerifier in the browser’s window storage:
// File: authCodePkce.js
storeOAuthData() {
try {
localStorage.setItem(OAUTH_DATA,
JSON.stringify({nonce: this._nonce,
codeVerifier: this.codeVerifier}))
} catch {};
}
Step Fe: Line 136: Create the URL to obtain the authorization code. Path: /oauth/auth. The state, codeChallenge, and scope parameters are included in the URL.
Line 154: Change the browser tab’s URL to be the OAuth authorization URL. At this point the application is unloaded and the OAuth service provider is opened. It will authenticate (possibly via an SSO system) and authorize the request for an authorization code. It will then redirect the browser to the initial URL of the application. The application is restarted at step 1 above, but with query parameters.
// File: authCodePkce.js
const url = // Line 136
`${this.oAuthServiceProvider}${this.authPath}` +
`?response_type=code` +
`&scope=${this.oAuthScopes}` +
`&client_id=${this.oAuthClientID}` +
`&code_challenge=${this.codeChallenge}` +
`&code_challenge_method=S256` +
`&redirect_uri=${this.oAuthReturnUrl}` +
`&state=${this._nonce}`;
...
// Line 154
location.href = url; // In the current tab, goto the OAuth URL
true response steps: we’re logged in
Step Ta. Get the app’s configuration values (line 274) from localStorage. This step could also get additional state that had been stored previously (see step Fa above).
Complete the login. At this point, the app has an access token for making API calls. The app’s startup then makes a UserInfo API call to obtain the user’s name, email, account information, etc.
The app is now ready for use.
// File: script.js
// Line 271
// Are we logged in?
if (data.authCodePkce.checkToken()) {
// logged in
settingsGet(configuration); // Line 274
...
data.loader.show("Completing Login Process")
Step 5. OAuth Response
Line 270: In the main script.js startup, the authCodePkce.oauthResponse() method (line 197) found that there were query parameters in the URL.
See the added comments in the code snippet:
Lines 198 - 204: The query parameters are extracted from the URL
Line 206: The previously stored OAuth parameters (see Step Fd above) are retrieved.
Line 207: The OAuth state query parameter is checked with the _nounce value that was previously stored.
Lines 217 and 220: Exchange the OAuth authorization code query parameter for the access token. The previously stored codeVerifier is used.
Line 237: The token request is created and sent.
Line 250: The response, which includes the access token, is received and processed.
// File: authCodePkce.js
async oauthResponse() {
const search = location.search.substring(1); // remove the #
if (!search.length) {return} // EARLY RETURN
// Line 197
window.history.pushState("", "", `${location.origin}${location.pathname}`);
// Lines 198 - 204, The query parameters are extracted from the URL
const items = search.split(/\=|\&/);
let i = 0;
let response = {};
while (i + 1 < items.length) {
response[items[i]] = items[i + 1];
i += 2;
}
const state = response.state;
// Line 206 The previously stored OAuth parameters
// (see Step Fd above) are retrieved.
this.getOAuthData();
// Line 207 The OAuth state query parameter is checked
// with the previously stored value.
if (state !== this._nonce) {
console.error({incoming_nonce: state, stored_nonce: this._nonce});
this.showMsg("OAuth problem: Bad state response. Possible attacker!?!");
}
this.code = response.code;
if (!this.code) {
this.err ("Bad OAuth response");
this.showMsg("Bad OAuth response. Missing code.");
return ("error");
}
// Line 217 Exchange the OAuth authorization code query
// parameter for the access token.
// The previously stored codeVerifier is used.
await this.authCodeExchange()
}
// Line 222 Exchange the OAuth authorization code query
// parameter for the access token.
// The previously stored codeVerifier is used.
async authCodeExchange() {
// exchange the authorization code for the access token
// https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
// Token exchange example response:
/**
* {
"access_token":"eyJ0eX...6-tOlc8jCM9OSY_r...I9H0-ZYYag",
"token_type":"Bearer",
"refresh_token":"eyJ0eX...AiOiJNVCIsImFsZy...4WI0JRwWI5Rw",
"expires_in":28800,
"scope":"signature cors"
}
*/
let returnStatus;
try {
// Line 237 The token request is created and sent.
const formData = new FormData();
formData.append('grant_type', 'authorization_code');
formData.append('code', this.code);
formData.append('client_id', this.oAuthClientID);
formData.append('code_verifier', this.codeVerifier);
const url = `${this.oAuthServiceProvider}${this.tokenPath}`;
const rawResponse = await fetch(url,
{mode: 'cors',
method: 'POST',
body: formData
});
const response = rawResponse && rawResponse.ok &&
await rawResponse.json();
// Line 250 The response, which includes the access token,
// is received and processed.
if (response) {
this.accessToken = response.access_token;
this.refreshToken = response.refresh_token;
this.accessTokenExpires = new Date(
Date.now() + response.expires_in * 1000
);
console.log (`\n\n#### Access Token expiration: ${response.expires_in / 60 / 60} hours`);
console.log (`\n\n#### Access Token expiration datetime: ${this.accessTokenExpires}`);
returnStatus = "ok";
Summary
The OAuth Authorization Code Grant flow, with PKCE and without a secret, can be used to obtain an access token for a single-page web application written in basic Javascript, React, or any other browser framework.
The essential idea is that when the application starts up, it must check the URL to see if it includes OAuth-related query parameters. If it does not, then the application is truly starting up and it then asks the user to login. If the query parameters are present, then the app is actually in the middle of the OAuth grant flow. It obtains an access token by completing the OAuth flow. The application is then fully ready for use.
Additional resources
Larry Kluger has over 40 years of tech industry experience as a software developer, developer advocate, entrepreneur, and product manager. An award-winning speaker prominent StackOverflow contributor, he enjoys giving talks and helping the ISV and developer communities.
Twitter: @larrykluger
LinkedIn: https://www.linkedin.com/in/larrykluger/
Related posts