Generating the code challenge for PKCE in OAuth 2
What is the code challenge
For authenticating single-page applications against an OAuth 2 server, the current RFC recommends an authentication code grant with PKCE (Proof Key for Code Exchange). Here's how it works.
When the user initiates an authentication flow, the client should compute a code_verifier
. This must be a random, high entropy string between 43 and 128 characters.
Next up, the client computes a code_challenge
starting from the code_verifier
. This is the result of the following pseudo-code:
code_challenge = base64urlEncode(SHA256(ASCII(code_verifier)))
See also rfc7636, section 4.
The code_challenge
must be sent in the first step of the authorization flow.
The code_verifier
instead must be sent along the POST
request to the authorization server for requesting the final access token.
To generate code_verifier
and code_challenge
we can employ two different methods, depending on the platform.
Generating code challenge in Node.js
If the single-page application entry point is served by Node.js we can use this platform with the following modules:
randomstring
crypto
(part of the standard library)base64url
The following example illustrates the process. I borrowed this snippet from OAuth 2 in action by Justin Richer:
const randomstring = require("randomstring");
const crypto = require("crypto");
const base64url = require("base64url");
const code_verifier = randomstring.generate(128);
const base64Digest = crypto
.createHash("sha256")
.update(code_verifier)
.digest("base64");
console.log(base64Digest); // +PCBxoCJMdDloUVl1ctjvA6VNbY6fTg1P7PNhymbydM=
const code_challenge = base64url.fromBase64(base64Digest);
console.log(code_challenge); // -PCBxoCJMdDloUVl1ctjvA6VNbY6fTg1P7PNhymbydM
Here we first create a base64 digest of the code_verifier
hash. Then we create a base64 encoded url string.
Generating code challenge in the frontend
If the single-page is just a single html with chunks and bundles, we can't rely on Node.js crypto
to generate the hash, nor on randomstring, which is 306kB alone.
We can certainly use a dynamic import to load the module on demand, but it still feels a waste. For now, let's pretend we found a random string generator secure and lightweight enough. We call it fictional-random-string-generator
.
For the crypto part in the frontend instead, we can use window.crypto.subtle
. Let's see the code step by step.
First, we generate the SHA-256 digest (this snippet instead came from my mind):
import randomstring from "fictional-random-string-generator";
import { encode as base64encode } from "base64-arraybuffer";
const code_verifier = randomstring.generate(128);
async function generateCodeChallenge(codeVerifier) {
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await window.crypto.subtle.digest("SHA-256", data);
//
}
Here we call window.crypto.subtle.digest()
which takes the desired hashing algorithm, and an ArrayBuffer
. We compute the latter with TextEncoder
.
Important: window.crypto.subtle
is available only over HTTPS, or on http://localhost
.
Be aware that these are quite new Web APIs, older browsers don't support them.
Next up, we convert the return value of window.crypto.subtle.digest()
, a Promise
which resolves to an ArrayBuffer
, to its base64 representation with the base64-arraybuffer
package:
import randomstring from "fictional-random-string-generator";
import { encode as base64encode } from "base64-arraybuffer";
const code_verifier = randomstring.generate(128);
async function generateCodeChallenge(codeVerifier) {
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const digest = await window.crypto.subtle.digest("SHA-256", data);
const base64Digest = base64encode(digest);
// you can extract this replacing code to a function
return base64Digest
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
Finally, we escape the base64 to a base64url string. We can finally consume this function to obtain a code challenge:
generateCodeChallenge(code_verifier).then(challenge => {
console.log(challenge);
// example:
// -PCBxoCJMdDloUVl1ctjvA6VNbY6fTg1P7PNhymbydM
});
We then send the challenge in the first step of the authorization flow.