Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 81 additions & 2 deletions packages/web/src/pages/oauth-login-page/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { messages } from './messages'
import { Display } from './types'
import {
authWrite,
exchangeForAuthorizationCode,
formOAuthResponse,
getDeveloperApp,
getIsAppAuthorized,
Expand All @@ -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') {
Expand Down Expand Up @@ -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 } =
Expand Down Expand Up @@ -148,7 +168,10 @@ const useParsedQueryParams = () => {
error,
tx,
txParams,
display
display,
responseType,
codeChallenge,
codeChallengeMethod
}
}

Expand Down Expand Up @@ -186,6 +209,9 @@ export const useOAuthSetup = ({
txParams,
tx,
display,
responseType,
codeChallenge,
codeChallengeMethod,
error: initError
} = useParsedQueryParams()
const { data: accountStatus } = useAccountStatus()
Expand Down Expand Up @@ -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',
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

redirectUri is defaulted to 'postMessage' here, but the rest of this module treats the special value as lowercase 'postmessage' (see parsing and redirect validation). This mismatch can cause the backend authorize call to receive an unexpected redirect_uri value if redirect_uri is ever missing/undefined. Prefer using 'postmessage' consistently (or better: derive the value from parsedRedirectUri and avoid a fallback that can bypass earlier validation).

Suggested change
redirectUri: (redirectUri as string) ?? 'postMessage',
redirectUri: (redirectUri as string) ?? 'postmessage',

Copilot uses AI. Check for mistakes.
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
Expand Down
4 changes: 4 additions & 0 deletions packages/web/src/pages/oauth-login-page/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
45 changes: 45 additions & 0 deletions packages/web/src/pages/oauth-login-page/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<string | null> => {
// 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
Comment on lines +186 to +203
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetch() can throw (network error, CORS failure), which will currently reject the promise and skip the !res.ok handling and onError() callback. Wrap the request + JSON parsing in a try/catch and call onError() on exceptions to keep error handling consistent with the rest of this file.

Suggested change
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
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) {
onError()
return null
}
const { code } = await res.json()
return code
} catch {
onError()
return null
}

Copilot uses AI. Check for mistakes.
}

export const authWrite = async ({ userId, appApiKey }: CreateGrantRequest) => {
const sdk = await audiusSdk()
await sdk.grants.createGrant({
Expand Down
Loading