Files
resolutionflow/frontend/src/pages/OAuthCallbackPage.tsx
Michael Chihlas aad554bb9c feat(ui): handle session_expired_{idle,absolute} in axios interceptor
Seventh commit in the session-expiration-policy series. Wires the
backend taxonomy from commit 2 through to the frontend so users see
the right page (calm banner vs plain logout) when the refresh path
fails for different reasons.

- types/auth.ts: Token gains idle_expires_at + absolute_expires_at
  (Optional ISO 8601 strings). The next commit adds the
  useAuthSessionExpiry hook that reads these.
- api/auth.ts: OAuthCallbackResponse mirrors the same two fields.
- api/client.ts: refresh-failure handler now branches on the response
  detail. session_expired_idle and session_expired_absolute both
  redirect to /login?reason=session_expired (commit 8 adds the
  banner that reads the query param); any other detail (most
  commonly invalid_refresh_token) goes to plain /login. The bare
  redirect is guarded against re-firing when the user is already on
  /login. The refresh-success path now forwards the two new fields
  into setTokens so the store stays current as the session ages.
- pages/OAuthCallbackPage.tsx: setTokens({...}) spreads
  idle_expires_at + absolute_expires_at from the OAuth response.

No new tests — authStore.test still 2/2, tsc clean. The
useAuthSessionExpiry hook and the SessionExpiryToast that consume
the new fields land in commit 8 alongside the AccountSecurity page.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 16:33:56 -04:00

199 lines
7.2 KiB
TypeScript

import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { authApi } from '@/api/auth'
import { useAuthStore } from '@/store/authStore'
import { BrandLogo } from '@/components/common/BrandLogo'
import { PageMeta } from '@/components/common/PageMeta'
import { decodeOAuthState } from '@/lib/oauthState'
type Provider = 'google' | 'microsoft'
/**
* Handles the OAuth redirect leg of the full-page Google / Microsoft sign-in
* flow. Mounted at /auth/google/callback and /auth/microsoft/callback as
* public routes (NOT inside ProtectedRoute).
*
* Reads `?code=...` from the URL, POSTs it to the backend, stores the
* returned tokens, hydrates the auth store via fetchUser(), and redirects.
*
* Two state forms are supported:
* - Legacy: `state` is a raw random hex string. CSRF check against
* sessionStorage('rf-oauth-state').
* - /accept-invite: `state` is base64url(JSON({csrf, accountInviteCode,
* invitedEmail})). The CSRF value is compared against
* sessionStorage('rf-oauth-state'); the invite fields are forwarded to
* the backend so the new user joins the invited account instead of
* getting a personal one.
*/
export function OAuthCallbackPage() {
const navigate = useNavigate()
const location = useLocation()
const { setTokens, fetchUser } = useAuthStore()
const [error, setError] = useState<string | null>(null)
// Derive provider purely from URL pathname — routes are static
// (/auth/google/callback and /auth/microsoft/callback), so there is
// no `:provider` route param to read.
const provider: Provider = location.pathname.includes('/microsoft/')
? 'microsoft'
: 'google'
useEffect(() => {
const search = new URLSearchParams(location.search)
const code = search.get('code')
const oauthError = search.get('error')
const returnedState = search.get('state')
// CSRF: validate state round-trip against the value RegisterPage /
// AcceptInvitePage stashed in sessionStorage before redirecting to the
// provider. Always clear the stored value so a stale entry can't be
// re-used by a later attempt.
let storedState: string | null = null
try {
storedState = sessionStorage.getItem('rf-oauth-state')
sessionStorage.removeItem('rf-oauth-state')
} catch {
// sessionStorage may be unavailable (private mode, etc.) — treat as missing.
storedState = null
}
if (oauthError) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- callback URL validation maps directly into local error state
setError(`OAuth error: ${oauthError}`)
return
}
if (!storedState || !returnedState) {
setError('Invalid OAuth state — possible CSRF. Please try again.')
return
}
// The decoded form encodes the original CSRF value; compare that.
const decoded = decodeOAuthState(returnedState)
const matchesCsrf = decoded
? decoded.csrf === storedState
: returnedState === storedState
if (!matchesCsrf) {
setError('Invalid OAuth state — possible CSRF. Please try again.')
return
}
if (!code) {
setError('Missing authorization code')
return
}
let cancelled = false
void (async () => {
try {
const inviteOptions = decoded
? {
accountInviteCode: decoded.accountInviteCode,
invitedEmail: decoded.invitedEmail,
}
: undefined
const result =
provider === 'microsoft'
? await authApi.microsoftCallback(code, inviteOptions)
: await authApi.googleCallback(code, inviteOptions)
if (cancelled) return
// Persist tokens for apiClient interceptor + zustand store.
localStorage.setItem('access_token', result.access_token)
localStorage.setItem('refresh_token', result.refresh_token)
setTokens({
access_token: result.access_token,
refresh_token: result.refresh_token,
token_type: result.token_type || 'bearer',
idle_expires_at: result.idle_expires_at,
absolute_expires_at: result.absolute_expires_at,
})
// Hydrate user / account / subscription.
await fetchUser()
if (cancelled) return
// Invitee path lands on the dashboard with the teammate-welcome
// marker; new self-serve owners go to the welcome wizard; returning
// users to /.
let dest = '/'
if (decoded?.accountInviteCode) {
dest = '/?welcome=teammate'
} else if (result.is_new_user) {
dest = '/welcome'
}
navigate(dest, { replace: true })
} catch (err: unknown) {
if (cancelled) return
const axiosErr = err as {
response?: { data?: { detail?: unknown } }
}
const detail = axiosErr.response?.data?.detail
// Backend returns { error: "invite_email_mismatch" } etc.
let msg: string | null = null
if (typeof detail === 'string') {
msg = detail
} else if (
detail &&
typeof detail === 'object' &&
'error' in (detail as Record<string, unknown>)
) {
const code = (detail as { error: string }).error
if (code === 'invite_email_mismatch') {
msg =
'The email on your provider account does not match the invited email. ' +
'Sign in with the matching account, or ask your inviter to resend.'
} else if (code === 'invite_invalid_or_expired_or_revoked') {
msg = 'This invite is no longer valid. Ask your inviter to resend.'
} else {
msg = code
}
}
msg =
msg ||
(err instanceof Error ? err.message : 'Sign-in failed')
setError(msg)
}
})()
return () => {
cancelled = true
}
}, [location.search, provider, setTokens, fetchUser, navigate])
return (
<>
<PageMeta title="Signing you in" description="Completing OAuth sign-in" />
<div className="flex min-h-screen items-center justify-center bg-black px-4">
<div className="relative w-full max-w-md space-y-6 text-center">
<div className="flex justify-center">
<BrandLogo size="lg" />
</div>
{error ? (
<>
<h1 className="text-xl font-semibold text-foreground">
Sign-in failed
</h1>
<p className="text-sm text-red-400">{error}</p>
<button
onClick={() => navigate('/login', { replace: true })}
className="rounded-xl bg-primary px-4 py-2 text-sm font-semibold text-white hover:brightness-110"
>
Back to sign in
</button>
</>
) : (
<>
<h1 className="text-xl font-semibold text-foreground">
Signing you in
</h1>
<p className="text-sm text-muted-foreground">
Finishing up the {provider === 'microsoft' ? 'Microsoft' : 'Google'} sign-in flow.
</p>
</>
)}
</div>
</div>
</>
)
}
export default OAuthCallbackPage