@@ -3,6 +3,8 @@ import { Logger, type LoggerService } from '../services/Logger'
33import { isOAuthScopeValid , isWriteOnceParams } from '../utils/oauthScope'
44import { parseParams } from '../utils/parseParams'
55
6+ import { generateCodeVerifier , generateCodeChallenge } from './pkce'
7+ import type { OAuthTokenStore } from './tokenStore'
68import {
79 OAuthScope ,
810 IsWriteAccessGrantedSchema ,
@@ -114,12 +116,16 @@ const generateAudiusLogoSvg = (size: 'small' | 'medium' | 'large') => {
114116}
115117
116118const CSRF_TOKEN_KEY = 'audiusOauthState'
119+ const PKCE_VERIFIER_KEY = 'audiusPkceCodeVerifier'
120+ const PKCE_REDIRECT_URI_KEY = 'audiusPkceRedirectUri'
117121
118122type OAuthConfig = {
119123 appName ?: string
120124 apiKey ?: string
121125 usersApi : UsersApi
122126 logger ?: LoggerService
127+ tokenStore ?: OAuthTokenStore
128+ basePath ?: string
123129}
124130
125131export 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