Skip to content
Merged
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',
Comment thread
rickyrombo marked this conversation as resolved.
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
50 changes: 50 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,55 @@ 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
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
}
}

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