···22# Copy this file to .env.local and fill in your values
3344# API Configuration
55-# Browser-side requests use relative URLs (empty string) automatically.
66-# API_INTERNAL_URL is only needed for server-side rendering in Docker.
77-# For local dev, the default (http://localhost:3000) works out of the box.
88-# API_INTERNAL_URL=http://localhost:3100
55+#
66+# NEXT_PUBLIC_API_URL -- used by the browser for client-side API requests.
77+# Local dev: set to the API server URL (e.g., http://localhost:3100)
88+# Production (behind Caddy): leave unset -- relative URLs are routed by the reverse proxy
99+#
1010+# API_INTERNAL_URL -- used by Next.js SSR for server-side API requests.
1111+# Local dev: set to the API server URL (e.g., http://localhost:3100)
1212+# Docker: set to Docker service name (e.g., http://barazo-api:3000)
1313+#
1414+NEXT_PUBLIC_API_URL=http://localhost:3100
1515+API_INTERNAL_URL=http://localhost:3100
9161017# Application
1118NEXT_PUBLIC_APP_NAME=Barazo
+19-9
src/__tests__/auth/callback-page.test.tsx
···11/**
22 * Tests for auth callback page.
33+ * The callback page receives ?success=true after API redirects from PDS OAuth.
44+ * It then calls POST /api/auth/refresh (using the HTTP-only cookie set by API)
55+ * to get the access token, stores it in memory via setSessionFromCallback,
66+ * and redirects to the returnTo path.
37 */
4859import { describe, it, expect, vi, beforeEach } from 'vitest'
···12161317const mockSetSessionFromCallback = vi.fn()
14181515-let mockSearchParams = new URLSearchParams('code=test-code&state=test-state')
1919+let mockSearchParams = new URLSearchParams('success=true')
16201721vi.mock('next/navigation', () => ({
1822 useSearchParams: () => mockSearchParams,
···5559describe('AuthCallbackPage', () => {
5660 beforeEach(() => {
5761 mockSetSessionFromCallback.mockClear()
5858- mockSearchParams = new URLSearchParams('code=test-code&state=test-state')
6262+ mockSearchParams = new URLSearchParams('success=true')
5963 })
60646165 it('shows loading spinner while processing', () => {
6262- // Override callback to never resolve
6666+ // Override refresh to never resolve so we can see the spinner
6367 server.use(
6464- http.get(`${API_URL}/api/auth/callback`, () => {
6868+ http.post(`${API_URL}/api/auth/refresh`, () => {
6569 return new Promise(() => {})
6670 })
6771 )
···7680 })
7781 })
78827979- it('shows error when code or state is missing', () => {
8383+ it('shows error when success or error param is missing', () => {
8084 mockSearchParams = new URLSearchParams('')
8185 render(<CallbackPage />)
8282- expect(screen.getByRole('alert')).toHaveTextContent(/missing authorization code or state/i)
8686+ expect(screen.getByRole('alert')).toHaveTextContent(/missing authorization parameters/i)
8787+ })
8888+8989+ it('shows error from error search param', () => {
9090+ mockSearchParams = new URLSearchParams('error=OAuth+callback+failed')
9191+ render(<CallbackPage />)
9292+ expect(screen.getByRole('alert')).toHaveTextContent(/oauth callback failed/i)
8393 })
84948595 it('shows error on API failure', async () => {
8696 server.use(
8787- http.get(`${API_URL}/api/auth/callback`, () => {
8888- return HttpResponse.json({ error: 'Invalid code' }, { status: 400 })
9797+ http.post(`${API_URL}/api/auth/refresh`, () => {
9898+ return HttpResponse.json({ error: 'Session expired' }, { status: 401 })
8999 })
90100 )
91101···9710798108 it('shows retry link on error', async () => {
99109 server.use(
100100- http.get(`${API_URL}/api/auth/callback`, () => {
110110+ http.post(`${API_URL}/api/auth/refresh`, () => {
101111 return HttpResponse.json({ error: 'Failed' }, { status: 500 })
102112 })
103113 )
+15-12
src/app/auth/callback/page.tsx
···11/**
22- * OAuth callback page -- processes auth response from PDS.
33- * URL: /auth/callback?code=...&state=...
44- * On success: stores token in auth context (memory), redirects to returnTo or /.
22+ * OAuth callback page -- completes auth after API redirect.
33+ * URL: /auth/callback?success=true (redirected from API after PDS OAuth)
44+ * On success: refreshes session via cookie, stores token in memory, redirects to returnTo or /.
55 * On failure: shows error with retry link.
66 * @see specs/prd-web.md Section M3 (Auth Flow)
77 */
···1111import { Suspense, useEffect, useMemo, useState, useRef } from 'react'
1212import { useSearchParams } from 'next/navigation'
1313import Link from 'next/link'
1414-import { handleCallback } from '@/lib/api/client'
1414+import { refreshSession } from '@/lib/api/client'
1515import { useAuth } from '@/hooks/use-auth'
16161717function CallbackContent() {
···1919 const { setSessionFromCallback } = useAuth()
2020 const processedRef = useRef(false)
21212222- const code = searchParams.get('code')
2323- const state = searchParams.get('state')
2222+ const success = searchParams.get('success')
2323+ const errorParam = searchParams.get('error')
24242525 // Derive missing-params error synchronously (not in effect)
2626- const missingParamsError = useMemo(
2727- () => (!code || !state ? 'Missing authorization code or state parameter' : null),
2828- [code, state]
2929- )
2626+ const missingParamsError = useMemo(() => {
2727+ if (errorParam) return errorParam
2828+ if (success) return null
2929+ return 'Missing authorization parameters'
3030+ }, [errorParam, success])
30313132 const [error, setError] = useState<string | null>(missingParamsError)
3233···36373738 async function processCallback() {
3839 try {
3939- const session = await handleCallback(code!, state!)
4040+ // API already set the HTTP-only refresh cookie during redirect.
4141+ // Call refresh to get the access token from the cookie.
4242+ const session = await refreshSession()
4043 setSessionFromCallback(session)
41444245 const returnTo = sessionStorage.getItem('auth_returnTo') ?? '/'
···4851 }
49525053 void processCallback()
5151- }, [code, state, missingParamsError, setSessionFromCallback])
5454+ }, [missingParamsError, setSessionFromCallback])
52555356 if (error) {
5457 return (