Skip to content

Commit da6ebf4

Browse files
committed
update oauth service to support pkce
1 parent c3faa8f commit da6ebf4

2 files changed

Lines changed: 203 additions & 2 deletions

File tree

packages/sdk/src/sdk/createSdkWithoutServices.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,9 @@ export const createSdkWithoutServices = (config: SdkConfig) => {
112112
typeof window !== 'undefined'
113113
? new OAuth({
114114
apiKey,
115-
usersApi
115+
usersApi,
116+
tokenStore,
117+
basePath
116118
})
117119
: undefined
118120

packages/sdk/src/sdk/oauth/OAuth.ts

Lines changed: 200 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { Logger, type LoggerService } from '../services/Logger'
33
import { isOAuthScopeValid, isWriteOnceParams } from '../utils/oauthScope'
44
import { parseParams } from '../utils/parseParams'
55

6+
import { generateCodeVerifier, generateCodeChallenge } from './pkce'
7+
import type { OAuthTokenStore } from './tokenStore'
68
import {
79
OAuthScope,
810
IsWriteAccessGrantedSchema,
@@ -114,12 +116,16 @@ const generateAudiusLogoSvg = (size: 'small' | 'medium' | 'large') => {
114116
}
115117

116118
const CSRF_TOKEN_KEY = 'audiusOauthState'
119+
const PKCE_VERIFIER_KEY = 'audiusPkceCodeVerifier'
120+
const PKCE_REDIRECT_URI_KEY = 'audiusPkceRedirectUri'
117121

118122
type OAuthConfig = {
119123
appName?: string
120124
apiKey?: string
121125
usersApi: UsersApi
122126
logger?: LoggerService
127+
tokenStore?: OAuthTokenStore
128+
basePath?: string
123129
}
124130

125131
export class OAuth {
@@ -202,6 +208,29 @@ export class OAuth {
202208
redirectUri?: string
203209
display?: 'popup' | 'fullScreen'
204210
responseMode?: 'fragment' | 'query'
211+
}) {
212+
// Delegate to async implementation
213+
this._loginAsync({
214+
scope,
215+
params,
216+
redirectUri,
217+
display,
218+
responseMode
219+
})
220+
}
221+
222+
private async _loginAsync({
223+
scope = 'read',
224+
params,
225+
redirectUri = 'postMessage',
226+
display = 'popup',
227+
responseMode = 'fragment'
228+
}: {
229+
scope?: OAuthScope
230+
params?: WriteOnceParams
231+
redirectUri?: string
232+
display?: 'popup' | 'fullScreen'
233+
responseMode?: 'fragment' | 'query'
205234
}) {
206235
const scopeFormatted = typeof scope === 'string' ? [scope] : scope
207236
if (!this.config.appName && !this.apiKey) {
@@ -234,8 +263,25 @@ export class OAuth {
234263
return
235264
}
236265

266+
// Determine whether to use PKCE (auto-detect: write scope + apiKey + no apiSecret)
267+
const usePkce =
268+
effectiveScope === 'write' &&
269+
this.apiKey != null &&
270+
this.config.tokenStore != null &&
271+
this.config.basePath != null
272+
237273
const csrfToken = generateId()
238274
window.localStorage.setItem(CSRF_TOKEN_KEY, csrfToken)
275+
276+
let pkceParams = ''
277+
if (usePkce) {
278+
const codeVerifier = generateCodeVerifier()
279+
window.sessionStorage.setItem(PKCE_VERIFIER_KEY, codeVerifier)
280+
window.sessionStorage.setItem(PKCE_REDIRECT_URI_KEY, redirectUri)
281+
const codeChallenge = await generateCodeChallenge(codeVerifier)
282+
pkceParams = `&response_type=code&code_challenge=${encodeURIComponent(codeChallenge)}&code_challenge_method=S256`
283+
}
284+
239285
const windowOptions =
240286
'toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no, width=375, height=785, top=100, left=100'
241287
const originURISafe = encodeURIComponent(window.location.origin)
@@ -256,7 +302,7 @@ export class OAuth {
256302

257303
const fullOauthUrl = `${
258304
OAUTH_URL[this.env]
259-
}?scope=${effectiveScope}&state=${csrfToken}&redirect_uri=${redirectUri}&origin=${originURISafe}&${responseModeParam}&${appIdURIParam}${writeOnceParams}&display=${display}`
305+
}?scope=${effectiveScope}&state=${csrfToken}&redirect_uri=${redirectUri}&origin=${originURISafe}&${responseModeParam}&${appIdURIParam}${writeOnceParams}${pkceParams}&display=${display}`
260306

261307
if (redirectUri === 'postMessage') {
262308
this.activePopupWindow = window.open(fullOauthUrl, '', windowOptions)
@@ -348,6 +394,90 @@ export class OAuth {
348394

349395
async _receiveMessage(event: MessageEvent) {
350396
const oauthOrigin = new URL(OAUTH_URL[this.env]).origin
397+
398+
// PKCE flow — consent screen posts { state, code }
399+
if (
400+
event.origin === oauthOrigin &&
401+
event.source === this.activePopupWindow &&
402+
event.data.state &&
403+
event.data.code &&
404+
this.config.tokenStore &&
405+
this.config.basePath
406+
) {
407+
this._clearPopupCheckInterval()
408+
if (this.activePopupWindow) {
409+
if (!this.activePopupWindow.closed) {
410+
this.activePopupWindow.close()
411+
}
412+
this.activePopupWindow = null
413+
}
414+
415+
if (this.getCsrfToken() !== event.data.state) {
416+
this._surfaceError('State mismatch.')
417+
return
418+
}
419+
420+
const codeVerifier = window.sessionStorage.getItem(PKCE_VERIFIER_KEY)
421+
const storedRedirectUri = window.sessionStorage.getItem(
422+
PKCE_REDIRECT_URI_KEY
423+
)
424+
// Clean up PKCE session state
425+
window.sessionStorage.removeItem(PKCE_VERIFIER_KEY)
426+
window.sessionStorage.removeItem(PKCE_REDIRECT_URI_KEY)
427+
428+
if (!codeVerifier) {
429+
this._surfaceError('PKCE code verifier not found in session storage.')
430+
return
431+
}
432+
433+
// Exchange authorization code for tokens
434+
try {
435+
const tokenRes = await fetch(`${this.config.basePath}/oauth/token`, {
436+
method: 'POST',
437+
headers: { 'Content-Type': 'application/json' },
438+
body: JSON.stringify({
439+
grant_type: 'authorization_code',
440+
code: event.data.code,
441+
code_verifier: codeVerifier,
442+
client_id: this.apiKey,
443+
redirect_uri: storedRedirectUri ?? 'postMessage'
444+
})
445+
})
446+
if (!tokenRes.ok) {
447+
const err = await tokenRes.json().catch(() => ({}))
448+
this._surfaceError(err.error_description ?? 'Token exchange failed.')
449+
return
450+
}
451+
const tokens = await tokenRes.json()
452+
this.config.tokenStore.setTokens(
453+
tokens.access_token,
454+
tokens.refresh_token
455+
)
456+
457+
// Fetch user profile via /oauth/me
458+
const meRes = await fetch(`${this.config.basePath}/oauth/me`, {
459+
headers: {
460+
Authorization: `Bearer ${tokens.access_token}`
461+
}
462+
})
463+
if (!meRes.ok) {
464+
this._surfaceError('Failed to fetch user profile.')
465+
return
466+
}
467+
const profile = (await meRes.json()) as DecodedUserToken
468+
469+
if (this.loginSuccessCallback) {
470+
this.loginSuccessCallback(profile, tokens.access_token)
471+
}
472+
} catch (e) {
473+
this._surfaceError(
474+
e instanceof Error ? e.message : 'Token exchange failed.'
475+
)
476+
}
477+
return
478+
}
479+
480+
// Implicit flow — consent screen posts { state, token }
351481
if (
352482
event.origin !== oauthOrigin ||
353483
event.source !== this.activePopupWindow ||
@@ -376,4 +506,73 @@ export class OAuth {
376506
this._surfaceError('The token was invalid.')
377507
}
378508
}
509+
510+
/**
511+
* Refresh the access token using the stored refresh token.
512+
* Updates the token store on success. Returns `true` if refresh succeeded.
513+
*/
514+
async refreshAccessToken(): Promise<boolean> {
515+
if (!this.config.tokenStore || !this.config.basePath) {
516+
this._surfaceError(
517+
'Token store and basePath are required for token refresh.'
518+
)
519+
return false
520+
}
521+
const refreshToken = this.config.tokenStore.refreshToken
522+
if (!refreshToken) {
523+
this._surfaceError('No refresh token available.')
524+
return false
525+
}
526+
try {
527+
const res = await fetch(`${this.config.basePath}/oauth/token`, {
528+
method: 'POST',
529+
headers: { 'Content-Type': 'application/json' },
530+
body: JSON.stringify({
531+
grant_type: 'refresh_token',
532+
refresh_token: refreshToken,
533+
client_id: this.apiKey
534+
})
535+
})
536+
if (!res.ok) {
537+
return false
538+
}
539+
const tokens = await res.json()
540+
if (tokens.access_token && tokens.refresh_token) {
541+
this.config.tokenStore.setTokens(
542+
tokens.access_token,
543+
tokens.refresh_token
544+
)
545+
return true
546+
}
547+
return false
548+
} catch {
549+
return false
550+
}
551+
}
552+
553+
/**
554+
* Revoke the current refresh token server-side, clear all stored tokens
555+
* and PKCE session state. After this call, all API instances revert to
556+
* unauthenticated.
557+
*/
558+
async logout(): Promise<void> {
559+
if (this.config.tokenStore?.refreshToken && this.config.basePath) {
560+
try {
561+
await fetch(`${this.config.basePath}/oauth/revoke`, {
562+
method: 'POST',
563+
headers: { 'Content-Type': 'application/json' },
564+
body: JSON.stringify({
565+
token: this.config.tokenStore.refreshToken,
566+
client_id: this.apiKey
567+
})
568+
})
569+
} catch {
570+
// Per RFC 7009, revocation errors are non-fatal
571+
}
572+
}
573+
this.config.tokenStore?.clear()
574+
window.sessionStorage.removeItem(PKCE_VERIFIER_KEY)
575+
window.sessionStorage.removeItem(PKCE_REDIRECT_URI_KEY)
576+
window.localStorage.removeItem(CSRF_TOKEN_KEY)
577+
}
379578
}

0 commit comments

Comments
 (0)