From 844d0b0127b9d27b0604e9e5efb1b332742908f2 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:14:01 -0800 Subject: [PATCH 1/2] Update consent UI to handle PKCE + access token exchange --- .../web/src/pages/oauth-login-page/hooks.ts | 83 ++++++++++++++++++- .../src/pages/oauth-login-page/messages.ts | 4 + .../web/src/pages/oauth-login-page/utils.ts | 45 ++++++++++ 3 files changed, 130 insertions(+), 2 deletions(-) diff --git a/packages/web/src/pages/oauth-login-page/hooks.ts b/packages/web/src/pages/oauth-login-page/hooks.ts index 6d8d3781b7f..065cb969763 100644 --- a/packages/web/src/pages/oauth-login-page/hooks.ts +++ b/packages/web/src/pages/oauth-login-page/hooks.ts @@ -22,6 +22,7 @@ import { messages } from './messages' import { Display } from './types' import { authWrite, + exchangeForAuthorizationCode, formOAuthResponse, getDeveloperApp, getIsAppAuthorized, @@ -44,13 +45,24 @@ const useParsedQueryParams = () => { redirect_uri: redirectUri, app_name: appName, response_mode: responseMode, - api_key: apiKey, + api_key, + client_id, origin, tx, display: displayQueryParam, + response_type: responseType, + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod, ...rest } = queryString.parse(search) + const apiKey = + typeof api_key === 'string' + ? api_key + : typeof client_id === 'string' + ? client_id + : undefined + const parsedRedirectUri = useMemo<'postmessage' | URL | null>(() => { if (redirectUri && typeof redirectUri === 'string') { if (redirectUri.toLowerCase() === 'postmessage') { @@ -112,6 +124,14 @@ const useParsedQueryParams = () => { } else if (!isValidApiKey(apiKey)) { error = messages.invalidApiKeyError } + // PKCE-specific validations when response_type=code + if (!error && responseType === 'code') { + if (!codeChallenge || typeof codeChallenge !== 'string') { + error = messages.missingCodeChallengeError + } else if (codeChallengeMethod !== 'S256') { + error = messages.invalidCodeChallengeMethodError + } + } } else if (scope === 'write_once') { // Write-once scope-specific validations: const { error: writeOnceParamsError, txParams: txParamsRes } = @@ -148,7 +168,10 @@ const useParsedQueryParams = () => { error, tx, txParams, - display + display, + responseType, + codeChallenge, + codeChallengeMethod } } @@ -186,6 +209,9 @@ export const useOAuthSetup = ({ txParams, tx, display, + responseType, + codeChallenge, + codeChallengeMethod, error: initError } = useParsedQueryParams() const { data: accountStatus } = useAccountStatus() @@ -503,6 +529,59 @@ export const useOAuthSetup = ({ } } + // PKCE flow: exchange for authorization code and redirect with code + if (responseType === 'code') { + const code = await exchangeForAuthorizationCode({ + account, + userEmail, + apiKey: apiKey as string, + redirectUri: (redirectUri as string) ?? 'postMessage', + codeChallenge: codeChallenge as string, + codeChallengeMethod: (codeChallengeMethod as string) ?? 'S256', + scope: scope as string, + onError: () => { + onError({ + isUserError: false, + errorMessage: messages.miscError + }) + } + }) + if (!code) return + + record( + make(Name.AUDIUS_OAUTH_COMPLETE, { + appId: (apiKey || appName)!, + scope: scope!, + alreadyAuthorized: !shouldCreateWriteGrant + }) + ) + + if (parsedRedirectUri === 'postmessage') { + if (parsedOrigin && window.opener) { + window.opener.postMessage({ state, code }, parsedOrigin.origin) + } else { + onError({ + isUserError: false, + errorMessage: messages.noWindowError + }) + } + } else if (parsedRedirectUri) { + if (responseMode === 'query') { + if (state != null) { + parsedRedirectUri.searchParams.append('state', state as string) + } + parsedRedirectUri.searchParams.append('code', code) + window.location.href = parsedRedirectUri.toString() + } else { + const statePart = state != null ? `state=${state}&` : '' + parsedRedirectUri.hash = `#${statePart}code=${code}` + window.location.href = parsedRedirectUri.toString() + } + } + return + } + + // Implicit flow: form JWT and redirect await formResponseAndRedirect({ account, grantCreated: shouldCreateWriteGrant diff --git a/packages/web/src/pages/oauth-login-page/messages.ts b/packages/web/src/pages/oauth-login-page/messages.ts index e0d95b1d715..d442af90c38 100644 --- a/packages/web/src/pages/oauth-login-page/messages.ts +++ b/packages/web/src/pages/oauth-login-page/messages.ts @@ -51,6 +51,10 @@ export const messages = { signedInAs: `You’re signed in as`, missingApiKeyError: 'Whoops, this is an invalid link (app API Key missing)', invalidApiKeyError: 'Whoops, this is an invalid link (app API Key invalid)', + missingCodeChallengeError: + 'Whoops, this is an invalid link (code_challenge is required for PKCE flow).', + invalidCodeChallengeMethodError: + 'Whoops, this is an invalid link (code_challenge_method must be S256).', approveTxToConnectProfile: 'Approve the pending transaction in your wallet to finish connecting your Audius profile.', back: 'Back', diff --git a/packages/web/src/pages/oauth-login-page/utils.ts b/packages/web/src/pages/oauth-login-page/utils.ts index f63372c8bf2..ac0f1de8987 100644 --- a/packages/web/src/pages/oauth-login-page/utils.ts +++ b/packages/web/src/pages/oauth-login-page/utils.ts @@ -6,6 +6,7 @@ import base64url from 'base64url' import { audiusBackendInstance } from 'services/audius-backend/audius-backend-instance' import { audiusSdk } from 'services/audius-sdk' import { identityService } from 'services/audius-sdk/identity' +import { env } from 'services/env' import { messages } from './messages' @@ -158,6 +159,50 @@ export const formOAuthResponse = async ({ return `${header}.${payload}.${base64url.encode(signature)}` } +export const exchangeForAuthorizationCode = async ({ + account, + userEmail, + apiKey, + redirectUri, + codeChallenge, + codeChallengeMethod, + scope, + onError +}: { + account: UserMetadata + userEmail: string | null + apiKey: string + redirectUri: string + codeChallenge: string + codeChallengeMethod: string + scope: string + onError: () => void +}): Promise => { + // 1. Build JWT (same as implicit flow — proves user identity to API) + const jwt = await formOAuthResponse({ account, userEmail, apiKey, onError }) + if (!jwt) return null + + // 2. Exchange JWT + PKCE params for authorization code + const res = await fetch(`${env.API_URL}/v1/oauth/authorize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: jwt, + client_id: apiKey, + redirect_uri: redirectUri, + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod, + scope + }) + }) + if (!res.ok) { + onError() + return null + } + const { code } = await res.json() + return code +} + export const authWrite = async ({ userId, appApiKey }: CreateGrantRequest) => { const sdk = await audiusSdk() await sdk.grants.createGrant({ From 809c217b793895b4bba35f033c04d6cde737c130 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Mon, 9 Mar 2026 10:48:08 -0700 Subject: [PATCH 2/2] try/catch fetch Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../web/src/pages/oauth-login-page/utils.ts | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/packages/web/src/pages/oauth-login-page/utils.ts b/packages/web/src/pages/oauth-login-page/utils.ts index ac0f1de8987..e734d24fe40 100644 --- a/packages/web/src/pages/oauth-login-page/utils.ts +++ b/packages/web/src/pages/oauth-login-page/utils.ts @@ -183,24 +183,29 @@ export const exchangeForAuthorizationCode = async ({ if (!jwt) return null // 2. Exchange JWT + PKCE params for authorization code - const res = await fetch(`${env.API_URL}/v1/oauth/authorize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - token: jwt, - client_id: apiKey, - redirect_uri: redirectUri, - code_challenge: codeChallenge, - code_challenge_method: codeChallengeMethod, - scope + try { + const res = await fetch(`${env.API_URL}/v1/oauth/authorize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: jwt, + client_id: apiKey, + redirect_uri: redirectUri, + code_challenge: codeChallenge, + code_challenge_method: codeChallengeMethod, + scope + }) }) - }) - if (!res.ok) { + if (!res.ok) { + onError() + return null + } + const { code } = await res.json() + return code + } catch { onError() return null } - const { code } = await res.json() - return code } export const authWrite = async ({ userId, appApiKey }: CreateGrantRequest) => {