Skip to content

Commit d23812e

Browse files
rickyromboCopilot
andauthored
Support OAuth popups with redirect_uris (#13916)
- Previously `OAuth` logins only listened for postMessage when explicitly set as the redirect_uri. This update makes the OAuth service to listen to messages from an explicit `redirect_uri` as well if `display: 'popup'`. - Adds `getRedirectResult()` to `OAuth` to handle OAuth redirects. When called inside a popup, posts the message back to the opener. Otherwise, exchanges the authorization code for access/refresh tokens and returns the login result. - Mirrors other OAuth implementers, like [Firebase](https://firebase.google.com/docs/reference/js/auth.md#getredirectresult_c35dc1f) - Adds persistence to `OAuthTokenStore` by default, so that the user stays logged in across sessions/refreshes. - Adds a getter `isAuthenticated` to `OAuth` for convenience. - Adds `getUser()` to `OAuth`. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent f1674e2 commit d23812e

4 files changed

Lines changed: 634 additions & 57 deletions

File tree

.changeset/angry-knives-fold.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@audius/sdk': minor
3+
---
4+
5+
Support OAuth popups with redirect_uris
6+
7+
- Previously `OAuth` logins only listened for postMessage when explicitly set as the redirect_uri. This update makes the OAuth service to listen to messages from an explicit `redirect_uri` as well if `display: 'popup'`.
8+
- Adds `getRedirectResult()` to `OAuth` to handle OAuth redirects. When called inside a popup, posts the message back to the opener. Otherwise, exchanges the authorization code for access/refresh tokens and returns the login result.
9+
- Adds persistence to `OAuthTokenStore` by default, so that the user stays logged in across sessions/refreshes.
10+
- Adds a getter `isAuthenticated` to `OAuth` for convenience.
11+
- Adds a `getUser()` to `OAuth` for getting the signed in user.

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

Lines changed: 344 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,6 @@ describe('OAuth message listener lifecycle', () => {
218218
close: vi.fn()
219219
} as unknown as Window)
220220
vi.mocked(window.localStorage.setItem).mockClear()
221-
vi.mocked(window.localStorage.getItem).mockReturnValue('csrf-token')
222221
vi.mocked(window.sessionStorage.setItem).mockClear()
223222
vi.mocked(window.sessionStorage.getItem).mockReturnValue(null)
224223
})
@@ -283,16 +282,356 @@ describe('OAuth message listener lifecycle', () => {
283282
)
284283
})
285284

286-
it('does not attach a listener when redirectUri is not postMessage', async () => {
285+
it('does not attach a listener when display is fullScreen', async () => {
287286
const oauth = makeOAuth({ basePath: 'https://api.example.com' })
288-
// When redirectUri is a real URL the code does window.location.href = …
289-
// and never enters the postMessage branch — don't await the never-settling promise
290-
oauth.loginAsync({ redirectUri: 'https://myapp.example.com/callback' })
287+
// When display is fullScreen the code does window.location.href = …
288+
// and never enters the popup branch
289+
oauth.loginAsync({
290+
redirectUri: 'https://myapp.example.com/callback',
291+
display: 'fullScreen'
292+
})
291293
await Promise.resolve()
292294
expect(window.addEventListener).not.toHaveBeenCalledWith(
293295
'message',
294296
expect.any(Function),
295297
false
296298
)
297299
})
300+
301+
it('attaches a message listener for popup even with a real redirectUri', async () => {
302+
const oauth = makeOAuth({ basePath: 'https://api.example.com' })
303+
oauth.loginAsync({
304+
redirectUri: 'https://myapp.example.com/callback',
305+
display: 'popup'
306+
})
307+
await Promise.resolve()
308+
expect(window.addEventListener).toHaveBeenCalledWith(
309+
'message',
310+
expect.any(Function),
311+
false
312+
)
313+
})
314+
})
315+
316+
describe('OAuth._exchangeCodeForTokens (via getRedirectResult)', () => {
317+
let tokenStore: OAuthTokenStore
318+
319+
const mockProfile = {
320+
userId: 1,
321+
email: 'test@example.com',
322+
name: 'Test User',
323+
handle: 'testuser',
324+
verified: false,
325+
profilePicture: null,
326+
apiKey: 'test-api-key',
327+
sub: 1,
328+
iat: '2026-01-01'
329+
}
330+
331+
beforeEach(() => {
332+
tokenStore = new OAuthTokenStore()
333+
})
334+
335+
afterEach(() => {
336+
vi.restoreAllMocks()
337+
})
338+
339+
function setLocationWithCode(code: string, state: string) {
340+
Object.defineProperty(window, 'location', {
341+
value: {
342+
href: `https://example.com/callback?code=${code}&state=${state}`,
343+
origin: 'https://example.com',
344+
search: `?code=${code}&state=${state}`,
345+
hash: ''
346+
},
347+
writable: true
348+
})
349+
}
350+
351+
function resetLocation() {
352+
Object.defineProperty(window, 'location', {
353+
value: { href: '', origin: 'https://example.com', search: '', hash: '' },
354+
writable: true
355+
})
356+
}
357+
358+
it('exchanges code for tokens and returns LoginResult', async () => {
359+
vi.mocked(window.sessionStorage.getItem).mockImplementation(
360+
(key: string) => {
361+
if (key === 'audiusOauthState') return 'test-state'
362+
if (key === 'audiusPkceCodeVerifier') return 'test-verifier'
363+
if (key === 'audiusPkceRedirectUri')
364+
return 'https://example.com/callback'
365+
return null
366+
}
367+
)
368+
setLocationWithCode('auth-code-123', 'test-state')
369+
;(window as any).history = { replaceState: vi.fn() }
370+
371+
const fetchMock = vi.fn()
372+
// First call: /oauth/token
373+
fetchMock.mockResolvedValueOnce(
374+
new Response(
375+
JSON.stringify({
376+
access_token: 'access-123',
377+
refresh_token: 'refresh-123'
378+
}),
379+
{ status: 200 }
380+
)
381+
)
382+
// Second call: /oauth/me
383+
fetchMock.mockResolvedValueOnce(
384+
new Response(JSON.stringify(mockProfile), { status: 200 })
385+
)
386+
vi.stubGlobal('fetch', fetchMock)
387+
388+
const oauth = new OAuth({
389+
apiKey: 'test-api-key',
390+
basePath: 'https://api.example.com',
391+
tokenStore
392+
})
393+
394+
expect(oauth.hasRedirectResult).toBe(true)
395+
const result = await oauth.getRedirectResult()
396+
397+
expect(result).not.toBeNull()
398+
expect(result!.profile.handle).toBe('testuser')
399+
expect(result!.encodedJwt).toBe('access-123')
400+
expect(tokenStore.accessToken).toBe('access-123')
401+
expect(tokenStore.refreshToken).toBe('refresh-123')
402+
403+
// Verify correct POST body for token exchange
404+
expect(fetchMock).toHaveBeenCalledWith(
405+
'https://api.example.com/oauth/token',
406+
expect.objectContaining({
407+
method: 'POST',
408+
body: JSON.stringify({
409+
grant_type: 'authorization_code',
410+
code: 'auth-code-123',
411+
code_verifier: 'test-verifier',
412+
client_id: 'test-api-key',
413+
redirect_uri: 'https://example.com/callback'
414+
})
415+
})
416+
)
417+
418+
// Second call returns null (consumed)
419+
expect(oauth.hasRedirectResult).toBe(false)
420+
expect(await oauth.getRedirectResult()).toBeNull()
421+
422+
resetLocation()
423+
})
424+
425+
it('returns null when no code/state in URL', async () => {
426+
resetLocation()
427+
const oauth = makeOAuth({
428+
basePath: 'https://api.example.com',
429+
tokenStore
430+
})
431+
expect(oauth.hasRedirectResult).toBe(false)
432+
expect(await oauth.getRedirectResult()).toBeNull()
433+
})
434+
435+
it('returns null when code verifier is missing from sessionStorage', async () => {
436+
vi.mocked(window.sessionStorage.getItem).mockReturnValue(null)
437+
setLocationWithCode('auth-code-123', 'test-state')
438+
;(window as any).history = { replaceState: vi.fn() }
439+
440+
const oauth = new OAuth({
441+
apiKey: 'test-api-key',
442+
basePath: 'https://api.example.com',
443+
tokenStore
444+
})
445+
// URL has code+state, so hasRedirectResult is true before detection
446+
expect(oauth.hasRedirectResult).toBe(true)
447+
// But getRedirectResult returns null because verifier is missing
448+
expect(await oauth.getRedirectResult()).toBeNull()
449+
// After detection, hasRedirectResult reflects consumed state
450+
expect(oauth.hasRedirectResult).toBe(false)
451+
452+
resetLocation()
453+
})
454+
455+
it('does not exchange when state does not match', async () => {
456+
vi.mocked(window.sessionStorage.getItem).mockImplementation(
457+
(key: string) => {
458+
if (key === 'audiusPkceCodeVerifier') return 'test-verifier'
459+
return null
460+
}
461+
)
462+
setLocationWithCode('auth-code-123', 'wrong-state')
463+
;(window as any).history = { replaceState: vi.fn() }
464+
465+
const oauth = new OAuth({
466+
apiKey: 'test-api-key',
467+
basePath: 'https://api.example.com',
468+
tokenStore
469+
})
470+
expect(oauth.hasRedirectResult).toBe(true)
471+
expect(await oauth.getRedirectResult()).toBeNull()
472+
expect(oauth.hasRedirectResult).toBe(false)
473+
474+
resetLocation()
475+
})
476+
477+
it('cleans up the URL after detecting redirect params', async () => {
478+
vi.mocked(window.sessionStorage.getItem).mockImplementation(
479+
(key: string) => {
480+
if (key === 'audiusOauthState') return 'test-state'
481+
if (key === 'audiusPkceCodeVerifier') return 'test-verifier'
482+
return null
483+
}
484+
)
485+
setLocationWithCode('auth-code-123', 'test-state')
486+
const replaceStateSpy = vi.fn()
487+
;(window as any).history = { replaceState: replaceStateSpy }
488+
489+
// Mock fetch so the exchange doesn't fail
490+
const fetchMock = vi.fn()
491+
fetchMock.mockResolvedValueOnce(
492+
new Response(JSON.stringify({ access_token: 'a', refresh_token: 'r' }), {
493+
status: 200
494+
})
495+
)
496+
fetchMock.mockResolvedValueOnce(
497+
new Response(JSON.stringify({ userId: 1, handle: 'x' }), { status: 200 })
498+
)
499+
vi.stubGlobal('fetch', fetchMock)
500+
501+
const oauth = new OAuth({
502+
apiKey: 'test-api-key',
503+
basePath: 'https://api.example.com',
504+
tokenStore
505+
})
506+
507+
// URL cleanup doesn't happen until getRedirectResult triggers detection
508+
expect(replaceStateSpy).not.toHaveBeenCalled()
509+
await oauth.getRedirectResult()
510+
511+
expect(replaceStateSpy).toHaveBeenCalledTimes(1)
512+
const cleanedUrl = replaceStateSpy.mock.calls[0]?.[2]
513+
expect(cleanedUrl).not.toContain('code=')
514+
expect(cleanedUrl).not.toContain('state=')
515+
516+
resetLocation()
517+
})
518+
519+
it('cleans up sessionStorage keys on redirect detection', async () => {
520+
vi.mocked(window.sessionStorage.getItem).mockImplementation(
521+
(key: string) => {
522+
if (key === 'audiusOauthState') return 'test-state'
523+
if (key === 'audiusPkceCodeVerifier') return 'test-verifier'
524+
if (key === 'audiusPkceRedirectUri')
525+
return 'https://example.com/callback'
526+
return null
527+
}
528+
)
529+
setLocationWithCode('auth-code-123', 'test-state')
530+
;(window as any).history = { replaceState: vi.fn() }
531+
const fetchMock = vi.fn()
532+
fetchMock.mockResolvedValueOnce(
533+
new Response(JSON.stringify({ access_token: 'a', refresh_token: 'r' }), {
534+
status: 200
535+
})
536+
)
537+
fetchMock.mockResolvedValueOnce(
538+
new Response(JSON.stringify({ userId: 1, handle: 'x' }), { status: 200 })
539+
)
540+
vi.stubGlobal('fetch', fetchMock)
541+
542+
const oauth = new OAuth({
543+
apiKey: 'test-api-key',
544+
basePath: 'https://api.example.com',
545+
tokenStore
546+
})
547+
548+
// Trigger detection
549+
await oauth.getRedirectResult()
550+
551+
expect(window.sessionStorage.removeItem).toHaveBeenCalledWith(
552+
'audiusPkceCodeVerifier'
553+
)
554+
expect(window.sessionStorage.removeItem).toHaveBeenCalledWith(
555+
'audiusPkceRedirectUri'
556+
)
557+
558+
resetLocation()
559+
})
560+
561+
it('detects code in URL fragment (responseMode=fragment)', async () => {
562+
vi.mocked(window.sessionStorage.getItem).mockImplementation(
563+
(key: string) => {
564+
if (key === 'audiusOauthState') return 'test-state'
565+
if (key === 'audiusPkceCodeVerifier') return 'test-verifier'
566+
return null
567+
}
568+
)
569+
Object.defineProperty(window, 'location', {
570+
value: {
571+
href: 'https://example.com/callback#code=frag-code&state=test-state',
572+
origin: 'https://example.com',
573+
search: '',
574+
hash: '#code=frag-code&state=test-state'
575+
},
576+
writable: true
577+
})
578+
;(window as any).history = { replaceState: vi.fn() }
579+
580+
const fetchMock = vi.fn()
581+
fetchMock.mockResolvedValueOnce(
582+
new Response(
583+
JSON.stringify({
584+
access_token: 'access-frag',
585+
refresh_token: 'refresh-frag'
586+
}),
587+
{ status: 200 }
588+
)
589+
)
590+
fetchMock.mockResolvedValueOnce(
591+
new Response(JSON.stringify(mockProfile), { status: 200 })
592+
)
593+
vi.stubGlobal('fetch', fetchMock)
594+
595+
const oauth = new OAuth({
596+
apiKey: 'test-api-key',
597+
basePath: 'https://api.example.com',
598+
tokenStore
599+
})
600+
601+
expect(oauth.hasRedirectResult).toBe(true)
602+
const result = await oauth.getRedirectResult()
603+
expect(result).not.toBeNull()
604+
expect(result!.encodedJwt).toBe('access-frag')
605+
606+
resetLocation()
607+
})
608+
609+
it('forwards code+state to opener via postMessage when in a popup', async () => {
610+
setLocationWithCode('popup-code', 'test-state')
611+
const postMessageSpy = vi.fn()
612+
const closeSpy = vi.fn()
613+
;(window as any).opener = { postMessage: postMessageSpy }
614+
;(window as any).close = closeSpy
615+
;(window as any).history = { replaceState: vi.fn() }
616+
617+
const oauth = new OAuth({
618+
apiKey: 'test-api-key',
619+
basePath: 'https://api.example.com',
620+
tokenStore
621+
})
622+
623+
// Nothing happens until getRedirectResult is called
624+
expect(postMessageSpy).not.toHaveBeenCalled()
625+
await oauth.getRedirectResult()
626+
627+
expect(postMessageSpy).toHaveBeenCalledWith(
628+
{ code: 'popup-code', state: 'test-state' },
629+
'https://example.com'
630+
)
631+
expect(closeSpy).toHaveBeenCalled()
632+
// Should NOT start a local exchange
633+
expect(oauth.hasRedirectResult).toBe(false)
634+
;(window as any).opener = null
635+
resetLocation()
636+
})
298637
})

0 commit comments

Comments
 (0)