Skip to content

Commit 9535fc8

Browse files
rickyromboCopilot
andauthored
Update OAuth consent UI to handle OAuth 2.0 Auth Code + PKCE (#13819)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 031fe31 commit 9535fc8

3 files changed

Lines changed: 135 additions & 2 deletions

File tree

packages/web/src/pages/oauth-login-page/hooks.ts

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { messages } from './messages'
2222
import { Display } from './types'
2323
import {
2424
authWrite,
25+
exchangeForAuthorizationCode,
2526
formOAuthResponse,
2627
getDeveloperApp,
2728
getIsAppAuthorized,
@@ -44,13 +45,24 @@ const useParsedQueryParams = () => {
4445
redirect_uri: redirectUri,
4546
app_name: appName,
4647
response_mode: responseMode,
47-
api_key: apiKey,
48+
api_key,
49+
client_id,
4850
origin,
4951
tx,
5052
display: displayQueryParam,
53+
response_type: responseType,
54+
code_challenge: codeChallenge,
55+
code_challenge_method: codeChallengeMethod,
5156
...rest
5257
} = queryString.parse(search)
5358

59+
const apiKey =
60+
typeof api_key === 'string'
61+
? api_key
62+
: typeof client_id === 'string'
63+
? client_id
64+
: undefined
65+
5466
const parsedRedirectUri = useMemo<'postmessage' | URL | null>(() => {
5567
if (redirectUri && typeof redirectUri === 'string') {
5668
if (redirectUri.toLowerCase() === 'postmessage') {
@@ -112,6 +124,14 @@ const useParsedQueryParams = () => {
112124
} else if (!isValidApiKey(apiKey)) {
113125
error = messages.invalidApiKeyError
114126
}
127+
// PKCE-specific validations when response_type=code
128+
if (!error && responseType === 'code') {
129+
if (!codeChallenge || typeof codeChallenge !== 'string') {
130+
error = messages.missingCodeChallengeError
131+
} else if (codeChallengeMethod !== 'S256') {
132+
error = messages.invalidCodeChallengeMethodError
133+
}
134+
}
115135
} else if (scope === 'write_once') {
116136
// Write-once scope-specific validations:
117137
const { error: writeOnceParamsError, txParams: txParamsRes } =
@@ -148,7 +168,10 @@ const useParsedQueryParams = () => {
148168
error,
149169
tx,
150170
txParams,
151-
display
171+
display,
172+
responseType,
173+
codeChallenge,
174+
codeChallengeMethod
152175
}
153176
}
154177

@@ -186,6 +209,9 @@ export const useOAuthSetup = ({
186209
txParams,
187210
tx,
188211
display,
212+
responseType,
213+
codeChallenge,
214+
codeChallengeMethod,
189215
error: initError
190216
} = useParsedQueryParams()
191217
const { data: accountStatus } = useAccountStatus()
@@ -503,6 +529,59 @@ export const useOAuthSetup = ({
503529
}
504530
}
505531

532+
// PKCE flow: exchange for authorization code and redirect with code
533+
if (responseType === 'code') {
534+
const code = await exchangeForAuthorizationCode({
535+
account,
536+
userEmail,
537+
apiKey: apiKey as string,
538+
redirectUri: (redirectUri as string) ?? 'postMessage',
539+
codeChallenge: codeChallenge as string,
540+
codeChallengeMethod: (codeChallengeMethod as string) ?? 'S256',
541+
scope: scope as string,
542+
onError: () => {
543+
onError({
544+
isUserError: false,
545+
errorMessage: messages.miscError
546+
})
547+
}
548+
})
549+
if (!code) return
550+
551+
record(
552+
make(Name.AUDIUS_OAUTH_COMPLETE, {
553+
appId: (apiKey || appName)!,
554+
scope: scope!,
555+
alreadyAuthorized: !shouldCreateWriteGrant
556+
})
557+
)
558+
559+
if (parsedRedirectUri === 'postmessage') {
560+
if (parsedOrigin && window.opener) {
561+
window.opener.postMessage({ state, code }, parsedOrigin.origin)
562+
} else {
563+
onError({
564+
isUserError: false,
565+
errorMessage: messages.noWindowError
566+
})
567+
}
568+
} else if (parsedRedirectUri) {
569+
if (responseMode === 'query') {
570+
if (state != null) {
571+
parsedRedirectUri.searchParams.append('state', state as string)
572+
}
573+
parsedRedirectUri.searchParams.append('code', code)
574+
window.location.href = parsedRedirectUri.toString()
575+
} else {
576+
const statePart = state != null ? `state=${state}&` : ''
577+
parsedRedirectUri.hash = `#${statePart}code=${code}`
578+
window.location.href = parsedRedirectUri.toString()
579+
}
580+
}
581+
return
582+
}
583+
584+
// Implicit flow: form JWT and redirect
506585
await formResponseAndRedirect({
507586
account,
508587
grantCreated: shouldCreateWriteGrant

packages/web/src/pages/oauth-login-page/messages.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ export const messages = {
5151
signedInAs: `You’re signed in as`,
5252
missingApiKeyError: 'Whoops, this is an invalid link (app API Key missing)',
5353
invalidApiKeyError: 'Whoops, this is an invalid link (app API Key invalid)',
54+
missingCodeChallengeError:
55+
'Whoops, this is an invalid link (code_challenge is required for PKCE flow).',
56+
invalidCodeChallengeMethodError:
57+
'Whoops, this is an invalid link (code_challenge_method must be S256).',
5458
approveTxToConnectProfile:
5559
'Approve the pending transaction in your wallet to finish connecting your Audius profile.',
5660
back: 'Back',

packages/web/src/pages/oauth-login-page/utils.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import base64url from 'base64url'
66
import { audiusBackendInstance } from 'services/audius-backend/audius-backend-instance'
77
import { audiusSdk } from 'services/audius-sdk'
88
import { identityService } from 'services/audius-sdk/identity'
9+
import { env } from 'services/env'
910

1011
import { messages } from './messages'
1112

@@ -158,6 +159,55 @@ export const formOAuthResponse = async ({
158159
return `${header}.${payload}.${base64url.encode(signature)}`
159160
}
160161

162+
export const exchangeForAuthorizationCode = async ({
163+
account,
164+
userEmail,
165+
apiKey,
166+
redirectUri,
167+
codeChallenge,
168+
codeChallengeMethod,
169+
scope,
170+
onError
171+
}: {
172+
account: UserMetadata
173+
userEmail: string | null
174+
apiKey: string
175+
redirectUri: string
176+
codeChallenge: string
177+
codeChallengeMethod: string
178+
scope: string
179+
onError: () => void
180+
}): Promise<string | null> => {
181+
// 1. Build JWT (same as implicit flow — proves user identity to API)
182+
const jwt = await formOAuthResponse({ account, userEmail, apiKey, onError })
183+
if (!jwt) return null
184+
185+
// 2. Exchange JWT + PKCE params for authorization code
186+
try {
187+
const res = await fetch(`${env.API_URL}/v1/oauth/authorize`, {
188+
method: 'POST',
189+
headers: { 'Content-Type': 'application/json' },
190+
body: JSON.stringify({
191+
token: jwt,
192+
client_id: apiKey,
193+
redirect_uri: redirectUri,
194+
code_challenge: codeChallenge,
195+
code_challenge_method: codeChallengeMethod,
196+
scope
197+
})
198+
})
199+
if (!res.ok) {
200+
onError()
201+
return null
202+
}
203+
const { code } = await res.json()
204+
return code
205+
} catch {
206+
onError()
207+
return null
208+
}
209+
}
210+
161211
export const authWrite = async ({ userId, appApiKey }: CreateGrantRequest) => {
162212
const sdk = await audiusSdk()
163213
await sdk.grants.createGrant({

0 commit comments

Comments
 (0)