Skip to content

Commit ef42d8b

Browse files
committed
Forward OAuth state to the backend callback endpoint during sign in
1 parent bf51674 commit ef42d8b

5 files changed

Lines changed: 44 additions & 38 deletions

File tree

‎src/api/user/auth.test.ts‎

Lines changed: 7 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,6 @@ import { stubFetch } from '@/test/stubFetch'
88
import { waitFor } from '@/test/waitFor'
99
import { userAuth } from './auth'
1010

11-
// window.env's fields are declared read-only (set once at app boot from
12-
// env.json), but tests need to simulate a deployment with no configured app
13-
// URL, so we write through a narrow mutable view instead of widening with
14-
// `as any`.
15-
type MutableEnv = { VITE_APP_URL?: string }
16-
1711
afterEach(() => {
1812
vi.unstubAllGlobals()
1913
})
@@ -30,34 +24,18 @@ describe('userAuth.signOut', () => {
3024
})
3125

3226
describe('userAuth.callback', () => {
33-
it('uses window.env.VITE_APP_URL as the redirect_uri when configured', async () => {
34-
const fetchMock = stubFetch(new Response(JSON.stringify({ apiKey: 'abc' }), { status: 200 }))
27+
it('sends the code and state, and no redirect_uri', async () => {
28+
const fetchMock = stubFetch(new Response(JSON.stringify({ token: 'abc' }), { status: 200 }))
3529

36-
const result = await userAuth.callback('the-code')
30+
const result = await userAuth.callback('the/code', 'the-state')
3731

38-
expect(result).toEqual({ apiKey: 'abc' })
32+
expect(result).toEqual({ token: 'abc' })
3933
const [request] = fetchMock.mock.calls[0]
4034
const url = new URL(request.url)
4135
expect(url.pathname).toBe('/auth/callback')
42-
expect(url.searchParams.get('code')).toBe('the-code')
43-
expect(url.searchParams.get('redirect_uri')).toBe(window.env.VITE_APP_URL)
44-
})
45-
46-
it('falls back to window.location.origin when VITE_APP_URL is not configured', async () => {
47-
const mutableEnv = window.env as MutableEnv
48-
const originalAppUrl = mutableEnv.VITE_APP_URL
49-
mutableEnv.VITE_APP_URL = undefined
50-
const fetchMock = stubFetch(new Response(JSON.stringify({}), { status: 200 }))
51-
52-
try {
53-
await userAuth.callback('another-code')
54-
55-
const [request] = fetchMock.mock.calls[0]
56-
const url = new URL(request.url)
57-
expect(url.searchParams.get('redirect_uri')).toBe(window.location.origin)
58-
} finally {
59-
mutableEnv.VITE_APP_URL = originalAppUrl
60-
}
36+
expect(url.searchParams.get('code')).toBe('the/code')
37+
expect(url.searchParams.get('state')).toBe('the-state')
38+
expect(url.searchParams.has('redirect_uri')).toBe(false)
6139
})
6240
})
6341

‎src/api/user/auth.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ import { apiRequest } from '../client'
77
export const userAuth = {
88
signOut: async () => await apiRequest.get('auth/signout').json<void>(),
99

10-
callback: async (code: string) => {
11-
const frontendOrigin = window.env.VITE_APP_URL || window.location.origin
12-
return await apiRequest
13-
.get(`auth/callback?code=${code}&redirect_uri=${encodeURIComponent(frontendOrigin)}`)
14-
.json<OAuthCallbackResponse>()
15-
},
10+
// The backend checks state against the one it stored in the session at
11+
// auth/authenticate, and reuses the redirect_uri it stored there too.
12+
callback: async (code: string, state: string) =>
13+
await apiRequest
14+
.get('auth/callback', { searchParams: { code, state } })
15+
.json<OAuthCallbackResponse>(),
1616

1717
whoAmI: (isLoggedOut: boolean) =>
1818
useQuery(

‎src/contexts/AuthContext.test.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { type QueryClient, QueryClientProvider } from '@tanstack/react-query'
33
import { useLocation, useSearch } from '@tanstack/react-router'
44
import { act, createElement, type ReactNode } from 'react'
5+
import { toast } from 'sonner'
56
import { afterEach, describe, expect, it, vi } from 'vitest'
67
import { api, apiRequest } from '@/api'
78
import { Loader } from '@/components/ui/Loader'
@@ -43,6 +44,10 @@ vi.mock('@/components/ui/Loader', () => ({ Loader: vi.fn(() => null) }))
4344

4445
vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn() } }))
4546

47+
vi.mock('@/i18n', () => ({ useIntl: () => ({ t: (_k: string, _v: unknown, d: string) => d }) }))
48+
49+
vi.mock('sonner', () => ({ toast: { error: vi.fn() } }))
50+
4651
describe('validateOAuthState', () => {
4752
afterEach(() => {
4853
clearOAuthState()
@@ -395,7 +400,7 @@ describe('useAuthContext', () => {
395400
await waitFor(() => {
396401
expect(localStorage.getItem('osm_token')).toBe('jwt-token')
397402
})
398-
expect(api.user.callback).toHaveBeenCalledWith('the-code')
403+
expect(api.user.callback).toHaveBeenCalledWith('the-code', 'matching-state')
399404
expect(api.user.refreshAuth).toHaveBeenCalled()
400405
expect(window.location.href).toContain('/dashboard')
401406
expect(getStoredRedirectUrl()).toBeNull()
@@ -476,9 +481,10 @@ describe('useAuthContext', () => {
476481
})
477482
expect(api.user.refreshAuth).toHaveBeenCalled()
478483
expect(logger.error).not.toHaveBeenCalledWith('OAuth callback error', expect.anything())
484+
expect(toast.error).not.toHaveBeenCalled()
479485
})
480486

481-
it('logs the error and does not refresh auth when the callback fails for a non-security reason', async () => {
487+
it('logs the error, tells the user, and does not refresh auth when the callback fails for a non-security reason', async () => {
482488
window.history.pushState({}, '', '/?code=the-code&state=matching-state')
483489
setOAuthState('matching-state')
484490
mockSearch({ code: 'the-code', state: 'matching-state' })
@@ -495,6 +501,11 @@ describe('useAuthContext', () => {
495501
error: expect.any(Error),
496502
})
497503
expect(api.user.refreshAuth).not.toHaveBeenCalled()
504+
await waitFor(() => {
505+
expect(toast.error).toHaveBeenCalledWith(
506+
'Sign-in failed or expired. Please try signing in again.'
507+
)
508+
})
498509
})
499510
})
500511
})

‎src/contexts/AuthContext.tsx‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { useQueryClient } from '@tanstack/react-query'
22
import { useLocation, useSearch } from '@tanstack/react-router'
33
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
4+
import { toast } from 'sonner'
45
import { api, apiRequest } from '@/api'
56
import { Loader } from '@/components/ui/Loader'
7+
import { useIntl } from '@/i18n'
68
import { logger } from '@/lib/logger'
79
import type { OAuthLoginResponse } from '@/types/Oauth'
810
import type { User } from '@/types/User'
@@ -78,6 +80,8 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
7880
const queryClient = useQueryClient()
7981
const { data: user, isLoading, error } = api.user.whoAmI(isLoggedOut)
8082
const [codeUsed, setCodeUsed] = useState<boolean>(false)
83+
const [loginFailed, setLoginFailed] = useState(false)
84+
const { t } = useIntl()
8185

8286
const stripOAuthParamsFromUrl = (): void => {
8387
const url = new URL(window.location.href)
@@ -100,7 +104,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
100104
setIsVerifying(true)
101105

102106
try {
103-
const { token } = await api.user.callback(code)
107+
const { token } = await api.user.callback(code, state)
104108

105109
if (token) {
106110
localStorage.setItem('osm_token', token)
@@ -121,6 +125,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
121125
api.user.refreshAuth(queryClient)
122126
} else {
123127
logger.error('OAuth callback error', { error })
128+
setLoginFailed(true)
124129
}
125130
} finally {
126131
stripOAuthParamsFromUrl()
@@ -145,6 +150,17 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
145150
}
146151
}, [codeUsed, search.code])
147152

153+
// The Toaster is rendered inside this provider, which shows only a Loader
154+
// while loading or verifying, so the toast is deferred until it has mounted.
155+
useEffect(() => {
156+
if (loginFailed && !isLoading && !isVerifying) {
157+
toast.error(
158+
t('auth.loginFailed', {}, 'Sign-in failed or expired. Please try signing in again.')
159+
)
160+
setLoginFailed(false)
161+
}
162+
}, [loginFailed, isLoading, isVerifying, t])
163+
148164
useEffect(() => {
149165
if (user && isLoggedOut) {
150166
setIsLoggedOut(false)

‎src/i18n/messages/en-US.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"appLayout.header.userMenu.theme": "Theme",
7171
"appLayout.header.userMenu.triggerSrOnly": "User menu",
7272
"appLayout.header.userMenu.viewPublicProfile": "View public profile",
73+
"auth.loginFailed": "Sign-in failed or expired. Please try signing in again.",
7374
"browsedChallengePage.actionButtons.alreadyReported": "You have already reported this challenge; it is awaiting review",
7475
"browsedChallengePage.actionButtons.challengeActions": "Challenge actions",
7576
"browsedChallengePage.actionButtons.challengeReported": "Challenge Reported",

0 commit comments

Comments
 (0)