I’ve been wrestling with implementing the Authorization Code flow with PKCE for a new internal SPA tool we’re building. The initial auth request seems fine, I get redirected back with the code and state, but the token exchange is bombing out.
Here’s the setup. I’m generating the code verifier and challenge client-side using the Web Crypto API. I’m hashing the verifier with SHA-256 and base64url encoding it for the code_challenge.
const encoder = new TextEncoder();
const data = encoder.encode(codeVerifier);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashBase64 = hashArray.map(b => String.fromCharCode(b)).join('');
const codeChallenge = btoa(hashBase64).replace(/\+/g, '-').replace(/\=/g, '_');
The redirect URL looks correct:
https://api.mypurecloud.com/oauth/authorize?response_type=code&client_id=MY_CLIENT_ID&redirect_uri=http://localhost:3000/callback&code_challenge=...&code_challenge_method=S256
When I exchange the code for a token, I’m hitting /oauth/token with a POST request. The body is application/x-www-form-urlencoded.
POST /oauth/token HTTP/1.1
Host: api.mypurecloud.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=AUTH_CODE_FROM_REDIRECT&redirect_uri=http://localhost:3000/callback&client_id=MY_CLIENT_ID&code_verifier=ORIGINAL_VERIFIER
The response is a 401 Unauthorized with this JSON payload:
{
"error": "invalid_grant",
"error_description": "The authorization code has expired or is invalid."
}
I’ve double-checked the code_verifier in the exchange request matches the one used to generate the challenge. It’s the exact same string. I’m using the same client_id and redirect_uri. The code is only valid for a few minutes, so I’m swapping it immediately.
Is there something specific about how Genesys Cloud expects the code_verifier to be formatted? Or maybe I’m missing a parameter in the token request? The docs are a bit sparse on the exact error reasons for invalid_grant in this context.
Any ideas what I’m missing here?