Auth0, Expo, and React Native: Authorization Code Grant Flow with PKCE

One of the most devious software problems I’ve encountered in some time.

Image for post
Image for post

Motivation: Never Log In Again!

I was trying to implement the PKCE flow on my react native application so that I could renew access tokens on the user’s behalf. With this type of flow, a user only has to authenticate once [1]. After this initial authentication, we securely store each user’s refresh token, and can use it later to get another access token / refresh token pair exactly at the moment their current access token expires. (My application has an extra security step in that I have chosen to rotate the refresh tokens — with Auth0 this is an optional setting, but recommended).

Introducing PKCE: Proof Key for Code Exchange

The aforementioned flow of ‘renewing an access token on behalf of a user’ is possible with a refresh token, and to get a refresh token via Auth0, we can use Proof Key for Code Exchange, or PKCE. With Auth0, the PKCE flow can be achieved by implementing a call to a pair of endpoints:

  1. a POST request on /oauth/token
  • On the POST request, you provide the code_verifier which was used to produce the code_challenge along with the code you just received, to get the access_token, refresh_token, and id_token.
import { AuthSession } from 'expo';
import * as Crypto from 'expo-crypto';
import * as Random from 'expo-random';
function URLEncode(str) {
return str.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
async function sha256(buffer) {
return await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, buffer, { encoding: Crypto.CryptoEncoding.BASE64 });
}
const randomBytes = await Random.getRandomBytesAsync(32);
let verifier = URLEncode(btoa(randomBytes.toString()));
let challenge = URLEncode(await sha256(verifier));
const redirectUrl = AuthSession.getRedirectUrl();
let authUrl = `${auth0Domain}/authorize?` + this.toQueryString({
audience: `${auth0Audience}`,
client_id: `${auth0ClientID}`,
connection: 'facebook',
scope: 'openid profile email offline_access',
redirect_uri: redirectUrl,
response_type: 'code',
code_challenge: challenge,
code_challenge_method: "S256"
nonce: 'test',
});
const result = await AuthSession.startAsync({
authUrl: authUrl
});
console.log(JSON.stringify(result));
mkdir crypto-test
cd crypto-test
npm init -y
const crypto = require("crypto");function base64URLEncode(str) {
return str
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
function sha256(buffer) {
return crypto.createHash("sha256").update(buffer).digest();
}
const code_verifier = base64URLEncode(crypto.randomBytes(32));
const code_challenge = base64URLEncode(sha256(code_verifier));
console.log("code_verifier: " + code_verifier);
console.log("code_challenge: " + code_challenge);
code_verifier: XtXt_0n2w4k5mpty1noXyBN-M7DkK6FuCBXfgIa3TrQ
code_challenge: j564Gfuqd2DhQy4l_N9X-fHxxcUejgms6fKvTWBUEA8

The Problem: Always Be Sure About the Types you are Working with!

I saw in the vanilla node version that the code_verifier variable is of type Buffer before it runs through the base64URLEncode function. However, we can see that Expo’s expo-random package getRandomBytesAsync returns a Uint8Array (TypeScript and IntelliSense helped a lot here where I could see the types directly in the code, and didn’t have to hunt down the documentation as I would if it were plain JavaScript).

const base64String = Buffer.from(yourUint8Array).toString('base64');
import { Buffer } from 'buffer';
npm install --save buffer
const randomBytes = await Random.getRandomBytesAsync(32);
const verifier = URLEncode(btoa(randomBytes.toString()));
const randomBytes = await Random.getRandomBytesAsync(32);
const base64String = Buffer.from(randomBytes).toString('base64');
const code_verifier = URLEncode(base64String);
import * as AuthSession from 'expo-auth-session';
import * as Crypto from 'expo-crypto';
import * as Random from 'expo-random';
import { AuthSessionResult } from 'expo-auth-session';
import { Buffer } from 'buffer';
import { generateShortUUID } from '../helpers/utilHelpers';
import AsyncStorageService from './AsyncStorageService';
import { getServer } from '../helpers/serverHelpers';
interface StringMap {
[key: string]: string;
}
function toQueryString(params: StringMap): string {
return '?' +
Object.entries(params)
.map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`
)
.join('&');
}
function URLEncode(str): string {
return str
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
async function sha256(buffer): Promise<string> {
return await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
buffer,
{ encoding: Crypto.CryptoEncoding.BASE64 }
);
}
export async function loginPKCEFlow(): Promise<void> {
const state = generateShortUUID();
const randomBytes = await Random.getRandomBytesAsync(32);
const base64String = Buffer.from(randomBytes).toString('base64');
const code_verifier = URLEncode(base64String);
const code_challenge = URLEncode(await sha256(code_verifier));
const redirectUrl = AuthSession.getRedirectUrl();
const authenticationOptions = {
response_type: 'code',
code_challenge: code_challenge,
code_challenge_method: 'S256',
client_id: process.env.AUTH0_CLIENT_ID,
redirect_uri: redirectUrl,
scope: 'openid profile email offline_access',
audience: process.env.ROOT_API_URL,
state,
};
const authUrl =
`${process.env.AUTH0_DOMAIN}authorize` +
toQueryString(authenticationOptions);
const result = await AuthSession.startAsync({
authUrl: authUrl,
});
if (
result.type === 'success' &&
result.params &&
result.params.code &&
result.params.state === state
) {
const code = result.params.code;
const authorizationCodeResponse = await getServer(
'authorization-code',
{
code,
codeVerifier: code_verifier,
redirectUrl,
}
);
if (authorizationCodeResponse.data.accessToken) {
await AsyncStorageService.setAccessToken(
authorizationCodeResponse.data.accessToken
);
}
}
}
export function generateShortUUID(): string {
return Math.random()
.toString(36)
.substring(2, 15);
}

Footnotes

  1. There are, of course, technically a few edge cases to this. One case, of course, is if the refresh token itself expires. Then we have no choice but to ask the user to authenticate with an OAuth method again.
  2. Which now has a link to this blog post in hopes to help those in the future! 😄

https://wheelscreener.com https://chrisfrew.in 👨‍💻Full Stack Software Engineer 🏠Austria/USA 🍺Homebrewer ⛷🏃‍Outdoorsman

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store