diff --git a/.changeset/tricky-zoos-film.md b/.changeset/tricky-zoos-film.md new file mode 100644 index 00000000000..c7bf4e58435 --- /dev/null +++ b/.changeset/tricky-zoos-film.md @@ -0,0 +1,9 @@ +--- +'@audius/sdk': patch +--- + +Update OAuth service to allow for loginAsync to not require init() + +- Promisifies OAuth logins +- Uses API URL rather than hardcoding the production URL (respects environment) +- Fixes minor error handling diff --git a/packages/sdk/src/sdk/oauth/OAuth.test.ts b/packages/sdk/src/sdk/oauth/OAuth.test.ts index 28f132b2158..36f1e5e73b5 100644 --- a/packages/sdk/src/sdk/oauth/OAuth.test.ts +++ b/packages/sdk/src/sdk/oauth/OAuth.test.ts @@ -8,7 +8,9 @@ import { OAuthTokenStore } from './tokenStore' vi.stubGlobal('window', { localStorage: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }, sessionStorage: { getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() }, + crypto: { getRandomValues: vi.fn((arr: Uint8Array) => arr.fill(0)) }, addEventListener: vi.fn(), + removeEventListener: vi.fn(), location: { href: '', origin: 'https://example.com' }, open: vi.fn() }) @@ -206,3 +208,91 @@ describe('OAuth.refreshAccessToken', () => { expect(result).toBeNull() }) }) + +describe('OAuth message listener lifecycle', () => { + beforeEach(() => { + vi.mocked(window.addEventListener).mockClear() + vi.mocked(window.removeEventListener).mockClear() + vi.mocked(window.open).mockReturnValue({ + closed: false, + close: vi.fn() + } as unknown as Window) + vi.mocked(window.localStorage.setItem).mockClear() + vi.mocked(window.localStorage.getItem).mockReturnValue('csrf-token') + vi.mocked(window.sessionStorage.setItem).mockClear() + vi.mocked(window.sessionStorage.getItem).mockReturnValue(null) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('does not attach a message listener in the constructor', () => { + makeOAuth({ basePath: 'https://api.example.com' }) + expect(window.addEventListener).not.toHaveBeenCalled() + }) + + it('attaches a message listener when loginAsync starts (postMessage flow)', async () => { + const oauth = makeOAuth({ basePath: 'https://api.example.com' }) + // Kick off a login — don't await so we can inspect immediately + oauth.loginAsync({ redirectUri: 'postMessage' }) + // Flush microtasks + await Promise.resolve() + expect(window.addEventListener).toHaveBeenCalledWith( + 'message', + expect.any(Function), + false + ) + }) + + it('does not attach a duplicate listener on repeated loginAsync calls', async () => { + const oauth = makeOAuth({ basePath: 'https://api.example.com' }) + oauth.loginAsync({ redirectUri: 'postMessage' }) + await Promise.resolve() + oauth.loginAsync({ redirectUri: 'postMessage' }) + await Promise.resolve() + // Should still only be registered once + const messageAddCalls = vi + .mocked(window.addEventListener) + .mock.calls.filter(([event]) => event === 'message') + expect(messageAddCalls).toHaveLength(1) + }) + + it('removes the message listener when the login settles', async () => { + const oauth = makeOAuth({ basePath: 'https://api.example.com' }) + const loginPromise = oauth.loginAsync({ redirectUri: 'postMessage' }) + await Promise.resolve() + + // Retrieve the registered handler + const addCall = vi + .mocked(window.addEventListener) + .mock.calls.find(([event]) => event === 'message') + expect(addCall).toBeDefined() + const registeredHandler = addCall![1] + + // Settle the login via an error path + ;(oauth as any)._settleLogin(new Error('test settle')) + + // Await so rejection is handled + await loginPromise.catch(() => {}) + + expect(window.removeEventListener).toHaveBeenCalledWith( + 'message', + registeredHandler, + false + ) + }) + + it('does not attach a listener when redirectUri is not postMessage', async () => { + const oauth = makeOAuth({ basePath: 'https://api.example.com' }) + // When redirectUri is a real URL the code does window.location.href = … + // and never enters the postMessage branch — don't await the never-settling promise + oauth.loginAsync({ redirectUri: 'https://myapp.example.com/callback' }) + await Promise.resolve() + expect(window.addEventListener).not.toHaveBeenCalledWith( + 'message', + expect.any(Function), + false + ) + }) +}) diff --git a/packages/sdk/src/sdk/oauth/OAuth.ts b/packages/sdk/src/sdk/oauth/OAuth.ts index 75ecbd57a7a..264e1ecef15 100644 --- a/packages/sdk/src/sdk/oauth/OAuth.ts +++ b/packages/sdk/src/sdk/oauth/OAuth.ts @@ -4,10 +4,10 @@ import { isOAuthScopeValid, isWriteOnceParams } from '../utils/oauthScope' import { generateCodeVerifier, generateCodeChallenge } from './pkce' import type { OAuthTokenStore } from './tokenStore' -import { OAuthScope, WriteOnceParams, OAuthEnv, OAUTH_URL } from './types' +import { OAuthScope, WriteOnceParams, LoginResult } from './types' export type LoginSuccessCallback = ( - profile: DecodedUserToken, + profile: LoginResult['profile'], encodedJwt: string ) => void export type LoginErrorCallback = (errorMessage: string) => void @@ -125,8 +125,12 @@ export class OAuth { loginSuccessCallback: LoginSuccessCallback | null loginErrorCallback: LoginErrorCallback | null apiKey: string | null - env: OAuthEnv = 'production' logger: LoggerService + private _currentLoginResolve: ((result: LoginResult) => void) | null = null + + private _currentLoginReject: ((error: Error) => void) | null = null + + private _boundMessageHandler: ((e: MessageEvent) => void) | null = null constructor(private readonly config: OAuthConfig) { if (typeof window === 'undefined') { @@ -144,25 +148,18 @@ export class OAuth { ) } + /** + * @deprecated No longer necessary to call init() before login(). Use loginAsync() which returns a promise, or pass an onSuccess/onError callbacks to login(). + */ init({ successCallback, - errorCallback, - env = 'production' + errorCallback }: { successCallback: LoginSuccessCallback errorCallback?: LoginErrorCallback - env?: OAuthEnv }) { this.loginSuccessCallback = successCallback this.loginErrorCallback = errorCallback ?? null - this.env = env - window.addEventListener( - 'message', - (e: MessageEvent) => { - this._receiveMessage(e) - }, - false - ) } login({ @@ -170,25 +167,40 @@ export class OAuth { params, redirectUri = 'postMessage', display = 'popup', - responseMode = 'fragment' + responseMode = 'fragment', + onSuccess, + onError }: { scope?: OAuthScope params?: WriteOnceParams redirectUri?: string display?: 'popup' | 'fullScreen' responseMode?: 'fragment' | 'query' + onSuccess?: LoginSuccessCallback + onError?: LoginErrorCallback }) { - // Delegate to async implementation - this._loginAsync({ - scope, - params, - redirectUri, - display, - responseMode - }) + this.loginAsync({ scope, params, redirectUri, display, responseMode }) + .then(({ profile, encodedJwt }) => { + if (onSuccess) { + onSuccess(profile, encodedJwt) + } else { + this.loginSuccessCallback?.(profile, encodedJwt) + } + }) + .catch((err: Error) => { + const errorMessage = + err instanceof Error ? err.message : 'An unknown error occurred.' + if (onError) { + onError(errorMessage) + } else if (this.loginErrorCallback) { + this.loginErrorCallback(errorMessage) + } else { + this.logger.error(errorMessage) + } + }) } - private async _loginAsync({ + async loginAsync({ scope = 'read', params, redirectUri = 'postMessage', @@ -200,26 +212,32 @@ export class OAuth { redirectUri?: string display?: 'popup' | 'fullScreen' responseMode?: 'fragment' | 'query' - }) { + }): Promise { + if (this._currentLoginResolve != null) { + return Promise.reject(new Error('A login is already in progress.')) + } + + const promise = new Promise((resolve, reject) => { + this._currentLoginResolve = resolve + this._currentLoginReject = reject + }) + const scopeFormatted = typeof scope === 'string' ? [scope] : scope if (!this.config.appName && !this.apiKey) { - this._surfaceError('App name not set (set with `init` method).') - return + this._settleLogin(new Error('App name or API key not set.')) + return promise } if (scopeFormatted.includes('write') && !this.apiKey) { - this._surfaceError( - "The 'write' scope requires Audius SDK to be initialized with an API key" - ) - } - if (!this.loginSuccessCallback) { - this._surfaceError( - 'Login success callback not set (set with `init` method).' + this._settleLogin( + new Error( + "The 'write' scope requires Audius SDK to be initialized with an API key" + ) ) - return + return promise } if (!isOAuthScopeValid(scopeFormatted)) { - this._surfaceError('Scope must be `read` or `write`.') - return + this._settleLogin(new Error('Scope must be `read` or `write`.')) + return promise } const effectiveScope = scopeFormatted.includes('write') @@ -228,8 +246,8 @@ export class OAuth { ? 'write_once' : 'read' if (effectiveScope === 'write_once' && !isWriteOnceParams(params)) { - this._surfaceError('Missing correct params for `oauth.login`.') - return + this._settleLogin(new Error('Missing correct params for `oauth.login`.')) + return promise } // Determine whether to use PKCE (auto-detect: write scope + apiKey + no apiSecret) @@ -251,12 +269,14 @@ export class OAuth { const codeChallenge = await generateCodeChallenge(codeVerifier) pkceParams = `&response_type=code&code_challenge=${encodeURIComponent(codeChallenge)}&code_challenge_method=S256` } catch (e) { - this._surfaceError( - e instanceof Error - ? `PKCE code challenge generation failed: ${e.message}` - : 'PKCE code challenge generation failed.' + this._settleLogin( + new Error( + e instanceof Error + ? `PKCE code challenge generation failed: ${e.message}` + : 'PKCE code challenge generation failed.' + ) ) - return + return promise } } @@ -278,24 +298,47 @@ export class OAuth { }=${appIdURISafe}` const responseModeParam = `response_mode=${responseMode}` + if (!this.config.basePath) { + this._settleLogin( + new Error( + 'OAuth configuration error: basePath is not set. Please provide a valid basePath before calling loginAsync.' + ) + ) + return promise + } const fullOauthUrl = `${ - OAUTH_URL[this.env] - }?scope=${effectiveScope}&state=${csrfToken}&redirect_uri=${redirectUri}&origin=${originURISafe}&${responseModeParam}&${appIdURIParam}${writeOnceParams}${pkceParams}&display=${display}` + this.config.basePath + }/oauth/authorize?scope=${effectiveScope}&state=${csrfToken}&redirect_uri=${redirectUri}&origin=${originURISafe}&${responseModeParam}&${appIdURIParam}${writeOnceParams}${pkceParams}&display=${display}` if (redirectUri === 'postMessage') { + // Register the message listener lazily so it is scoped to this login session + if (!this._boundMessageHandler) { + this._boundMessageHandler = (e: MessageEvent) => this._receiveMessage(e) + window.addEventListener('message', this._boundMessageHandler, false) + } this.activePopupWindow = window.open(fullOauthUrl, '', windowOptions) + if (!this.activePopupWindow) { + this._settleLogin( + new Error( + 'The login popup was blocked. Please allow popups for this site and try again.' + ) + ) + return promise + } this._clearPopupCheckInterval() this.popupCheckInterval = setInterval(() => { if (this.activePopupWindow?.closed) { - this._surfaceError('The login popup was closed prematurely.') - if (this.popupCheckInterval) { - clearInterval(this.popupCheckInterval) - } + this._settleLogin( + new Error('The login popup was closed prematurely.') + ) + clearInterval(this.popupCheckInterval) } }, 500) } else { window.location.href = fullOauthUrl } + + return promise } renderButton({ @@ -344,14 +387,99 @@ export class OAuth { return window.localStorage.getItem(CSRF_TOKEN_KEY) } - /* ------- INTERNAL FUNCTIONS ------- */ + /** + * Returns true if a refresh token is currently stored and a refresh + * exchange could be attempted. + */ + get hasRefreshToken(): boolean { + return !!this.config.tokenStore?.refreshToken + } - _surfaceError(errorMessage: string) { - if (this.loginErrorCallback) { - this.loginErrorCallback(errorMessage) + /** + * Refresh the access token using the stored refresh token. + * Updates the token store on success. + * Returns the new access token, or `null` if refresh failed. + */ + async refreshAccessToken(): Promise { + if (!this.config.tokenStore || !this.config.basePath) { + this.logger.error( + 'Token store and basePath are required for token refresh.' + ) + return null + } + const refreshToken = this.config.tokenStore.refreshToken + if (!refreshToken) { + this.logger.error('No refresh token available.') + return null + } + try { + const res = await fetch(`${this.config.basePath}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: this.apiKey + }) + }) + if (!res.ok) { + return null + } + const tokens = await res.json() + if (tokens.access_token && tokens.refresh_token) { + this.config.tokenStore.setTokens( + tokens.access_token, + tokens.refresh_token + ) + return tokens.access_token + } + return null + } catch { + return null + } + } + + /** + * Revoke the current refresh token server-side, clear all stored tokens + * and PKCE session state. After this call, all API instances revert to + * unauthenticated. + */ + async logout(): Promise { + if (this.config.tokenStore?.refreshToken && this.config.basePath) { + try { + await fetch(`${this.config.basePath}/oauth/revoke`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: this.config.tokenStore.refreshToken, + client_id: this.apiKey + }) + }) + } catch { + // Per RFC 7009, revocation errors are non-fatal + } + } + this.config.tokenStore?.clear() + window.sessionStorage.removeItem(PKCE_VERIFIER_KEY) + window.sessionStorage.removeItem(PKCE_REDIRECT_URI_KEY) + window.localStorage.removeItem(CSRF_TOKEN_KEY) + } + + /* ------- INTERNAL FUNCTIONS ------- */ + private _settleLogin(resultOrError: LoginResult | Error) { + if (resultOrError instanceof Error) { + this._currentLoginReject?.(resultOrError) } else { - this.logger.error(errorMessage) + this._currentLoginResolve?.(resultOrError) + } + this._currentLoginResolve = null + this._currentLoginReject = null + // Deregister the message listener now that the login has settled + if (this._boundMessageHandler) { + window.removeEventListener('message', this._boundMessageHandler, false) + this._boundMessageHandler = null } + this._clearPopupCheckInterval() } _clearPopupCheckInterval() { @@ -361,17 +489,16 @@ export class OAuth { } async _receiveMessage(event: MessageEvent) { - const oauthOrigin = new URL(OAUTH_URL[this.env]).origin - - // PKCE flow — consent screen posts { state, code } if ( - event.origin === oauthOrigin && - event.source === this.activePopupWindow && - event.data.state && - event.data.code && - this.config.tokenStore && - this.config.basePath + !event.data || + !event.data.state || + event.source !== this.activePopupWindow ) { + return + } + + // PKCE flow — consent screen posts { state, code } + if (event.data.code) { this._clearPopupCheckInterval() if (this.activePopupWindow) { if (!this.activePopupWindow.closed) { @@ -381,7 +508,7 @@ export class OAuth { } if (this.getCsrfToken() !== event.data.state) { - this._surfaceError('State mismatch.') + this._settleLogin(new Error('State mismatch.')) return } @@ -394,7 +521,18 @@ export class OAuth { window.sessionStorage.removeItem(PKCE_REDIRECT_URI_KEY) if (!codeVerifier) { - this._surfaceError('PKCE code verifier not found in session storage.') + this._settleLogin( + new Error('PKCE code verifier not found in session storage.') + ) + return + } + + if (!this.config.basePath) { + this._settleLogin( + new Error( + 'basePath is required in SDK configuration for PKCE token exchange.' + ) + ) return } @@ -413,11 +551,13 @@ export class OAuth { }) if (!tokenRes.ok) { const err = await tokenRes.json().catch(() => ({})) - this._surfaceError(err.error_description ?? 'Token exchange failed.') + this._settleLogin( + new Error(err.error_description ?? 'Token exchange failed.') + ) return } const tokens = await tokenRes.json() - this.config.tokenStore.setTokens( + this.config.tokenStore?.setTokens( tokens.access_token, tokens.refresh_token ) @@ -429,144 +569,65 @@ export class OAuth { } }) if (!meRes.ok) { - this._surfaceError('Failed to fetch user profile.') + this._settleLogin(new Error('Failed to fetch user profile.')) return } const profile = (await meRes.json()) as DecodedUserToken - if (this.loginSuccessCallback) { - this.loginSuccessCallback(profile, tokens.access_token) - } + this._settleLogin({ profile, encodedJwt: tokens.access_token }) } catch (e) { - this._surfaceError( - e instanceof Error ? e.message : 'Token exchange failed.' + this._settleLogin( + e instanceof Error ? e : new Error('Token exchange failed.') ) } return } // Implicit flow — consent screen posts { state, token } - if ( - event.origin !== oauthOrigin || - event.source !== this.activePopupWindow || - !event.data.state || - !event.data.token - ) { - return - } - this._clearPopupCheckInterval() - if (this.activePopupWindow) { - if (!this.activePopupWindow.closed) { - this.activePopupWindow.close() - } - this.activePopupWindow = null - } - if (this.getCsrfToken() !== event.data.state) { - this._surfaceError('State mismatch.') - } - // Verify token and decode - if (!this.config.basePath) { - this._surfaceError('basePath is required for token verification.') - return - } - try { - const verifyRes = await fetch( - `${this.config.basePath}/users/verify_token?token=${encodeURIComponent(event.data.token)}` - ) - if (!verifyRes.ok) { - this._surfaceError('The token was invalid.') - return - } - const decoded = (await verifyRes.json()) as { - data?: DecodedUserToken - } - if (decoded?.data) { - if (this.loginSuccessCallback) { - this.loginSuccessCallback(decoded.data, event.data.token) + if (event.data.token) { + this._clearPopupCheckInterval() + if (this.activePopupWindow) { + if (!this.activePopupWindow.closed) { + this.activePopupWindow.close() } - } else { - this._surfaceError('The token was invalid.') + this.activePopupWindow = null } - } catch { - this._surfaceError('Token verification request failed.') - } - } - - /** - * Returns true if a refresh token is currently stored and a refresh - * exchange could be attempted. - */ - get hasRefreshToken(): boolean { - return !!this.config.tokenStore?.refreshToken - } - - /** - * Refresh the access token using the stored refresh token. - * Updates the token store on success. - * Returns the new access token, or `null` if refresh failed. - */ - async refreshAccessToken(): Promise { - if (!this.config.tokenStore || !this.config.basePath) { - this._surfaceError( - 'Token store and basePath are required for token refresh.' - ) - return null - } - const refreshToken = this.config.tokenStore.refreshToken - if (!refreshToken) { - this._surfaceError('No refresh token available.') - return null - } - try { - const res = await fetch(`${this.config.basePath}/oauth/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: this.apiKey - }) - }) - if (!res.ok) { - return null + if (this.getCsrfToken() !== event.data.state) { + this._settleLogin(new Error('State mismatch.')) + return } - const tokens = await res.json() - if (tokens.access_token && tokens.refresh_token) { - this.config.tokenStore.setTokens( - tokens.access_token, - tokens.refresh_token + // Verify token and decode + if (!this.config.basePath) { + this._settleLogin( + new Error('basePath is required for token verification.') ) - return tokens.access_token + return } - return null - } catch { - return null - } - } - - /** - * Revoke the current refresh token server-side, clear all stored tokens - * and PKCE session state. After this call, all API instances revert to - * unauthenticated. - */ - async logout(): Promise { - if (this.config.tokenStore?.refreshToken && this.config.basePath) { try { - await fetch(`${this.config.basePath}/oauth/revoke`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - token: this.config.tokenStore.refreshToken, - client_id: this.apiKey + const verifyRes = await fetch( + `${this.config.basePath}/users/verify_token?token=${encodeURIComponent(event.data.token)}` + ) + if (!verifyRes.ok) { + this._settleLogin(new Error('The token was invalid.')) + return + } + const decoded = (await verifyRes.json()) as { + data?: DecodedUserToken + } + if (decoded?.data) { + this._settleLogin({ + profile: decoded.data, + encodedJwt: event.data.token }) - }) + } else { + this._settleLogin(new Error('The token was invalid.')) + } } catch { - // Per RFC 7009, revocation errors are non-fatal + this._settleLogin(new Error('Token verification request failed.')) } + return } - this.config.tokenStore?.clear() - window.sessionStorage.removeItem(PKCE_VERIFIER_KEY) - window.sessionStorage.removeItem(PKCE_REDIRECT_URI_KEY) - window.localStorage.removeItem(CSRF_TOKEN_KEY) + + this._settleLogin(new Error('Received message with unknown format.')) } } diff --git a/packages/sdk/src/sdk/oauth/types.ts b/packages/sdk/src/sdk/oauth/types.ts index 5a7964e2000..ba2d5eb3116 100644 --- a/packages/sdk/src/sdk/oauth/types.ts +++ b/packages/sdk/src/sdk/oauth/types.ts @@ -1,24 +1,13 @@ -import { z } from 'zod' - -import { isApiKeyValid } from '../utils/apiKey' - -export const IsWriteAccessGrantedSchema = z.object({ - userId: z.string(), - apiKey: z.optional( - z.custom((data: unknown) => { - return isApiKeyValid(data as string) - }) - ) -}) - -export type IsWriteAccessGrantedRequest = z.input< - typeof IsWriteAccessGrantedSchema -> +import type { DecodedUserToken } from '../api/generated/default' export const OAUTH_SCOPE_OPTIONS = ['read', 'write', 'write_once'] as const type OAuthScopesTuple = typeof OAUTH_SCOPE_OPTIONS export type OAuthScopeOption = OAuthScopesTuple[number] export type OAuthScope = OAuthScopeOption | OAuthScopeOption[] +export type LoginResult = { + profile: DecodedUserToken + encodedJwt: string +} export type WriteOnceParams = | { tx: 'connect_dashboard_wallet' @@ -28,9 +17,3 @@ export type WriteOnceParams = tx: 'disconnect_dashboard_wallet' wallet: string } - -export type OAuthEnv = 'production' - -export const OAUTH_URL = { - production: 'https://audius.co/oauth/auth' -} as Record