Barazo default frontend barazo.forum
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #47 from barazo-forum/fix/oauth-client-integration

fix(auth): align OAuth client with API contract

authored by

Guido X Jansen and committed by
GitHub
f60cab51 45f690b3

+64 -37
+11 -4
.env.example
··· 2 2 # Copy this file to .env.local and fill in your values 3 3 4 4 # API Configuration 5 - # Browser-side requests use relative URLs (empty string) automatically. 6 - # API_INTERNAL_URL is only needed for server-side rendering in Docker. 7 - # For local dev, the default (http://localhost:3000) works out of the box. 8 - # API_INTERNAL_URL=http://localhost:3100 5 + # 6 + # NEXT_PUBLIC_API_URL -- used by the browser for client-side API requests. 7 + # Local dev: set to the API server URL (e.g., http://localhost:3100) 8 + # Production (behind Caddy): leave unset -- relative URLs are routed by the reverse proxy 9 + # 10 + # API_INTERNAL_URL -- used by Next.js SSR for server-side API requests. 11 + # Local dev: set to the API server URL (e.g., http://localhost:3100) 12 + # Docker: set to Docker service name (e.g., http://barazo-api:3000) 13 + # 14 + NEXT_PUBLIC_API_URL=http://localhost:3100 15 + API_INTERNAL_URL=http://localhost:3100 9 16 10 17 # Application 11 18 NEXT_PUBLIC_APP_NAME=Barazo
+19 -9
src/__tests__/auth/callback-page.test.tsx
··· 1 1 /** 2 2 * Tests for auth callback page. 3 + * The callback page receives ?success=true after API redirects from PDS OAuth. 4 + * It then calls POST /api/auth/refresh (using the HTTP-only cookie set by API) 5 + * to get the access token, stores it in memory via setSessionFromCallback, 6 + * and redirects to the returnTo path. 3 7 */ 4 8 5 9 import { describe, it, expect, vi, beforeEach } from 'vitest' ··· 12 16 13 17 const mockSetSessionFromCallback = vi.fn() 14 18 15 - let mockSearchParams = new URLSearchParams('code=test-code&state=test-state') 19 + let mockSearchParams = new URLSearchParams('success=true') 16 20 17 21 vi.mock('next/navigation', () => ({ 18 22 useSearchParams: () => mockSearchParams, ··· 55 59 describe('AuthCallbackPage', () => { 56 60 beforeEach(() => { 57 61 mockSetSessionFromCallback.mockClear() 58 - mockSearchParams = new URLSearchParams('code=test-code&state=test-state') 62 + mockSearchParams = new URLSearchParams('success=true') 59 63 }) 60 64 61 65 it('shows loading spinner while processing', () => { 62 - // Override callback to never resolve 66 + // Override refresh to never resolve so we can see the spinner 63 67 server.use( 64 - http.get(`${API_URL}/api/auth/callback`, () => { 68 + http.post(`${API_URL}/api/auth/refresh`, () => { 65 69 return new Promise(() => {}) 66 70 }) 67 71 ) ··· 76 80 }) 77 81 }) 78 82 79 - it('shows error when code or state is missing', () => { 83 + it('shows error when success or error param is missing', () => { 80 84 mockSearchParams = new URLSearchParams('') 81 85 render(<CallbackPage />) 82 - expect(screen.getByRole('alert')).toHaveTextContent(/missing authorization code or state/i) 86 + expect(screen.getByRole('alert')).toHaveTextContent(/missing authorization parameters/i) 87 + }) 88 + 89 + it('shows error from error search param', () => { 90 + mockSearchParams = new URLSearchParams('error=OAuth+callback+failed') 91 + render(<CallbackPage />) 92 + expect(screen.getByRole('alert')).toHaveTextContent(/oauth callback failed/i) 83 93 }) 84 94 85 95 it('shows error on API failure', async () => { 86 96 server.use( 87 - http.get(`${API_URL}/api/auth/callback`, () => { 88 - return HttpResponse.json({ error: 'Invalid code' }, { status: 400 }) 97 + http.post(`${API_URL}/api/auth/refresh`, () => { 98 + return HttpResponse.json({ error: 'Session expired' }, { status: 401 }) 89 99 }) 90 100 ) 91 101 ··· 97 107 98 108 it('shows retry link on error', async () => { 99 109 server.use( 100 - http.get(`${API_URL}/api/auth/callback`, () => { 110 + http.post(`${API_URL}/api/auth/refresh`, () => { 101 111 return HttpResponse.json({ error: 'Failed' }, { status: 500 }) 102 112 }) 103 113 )
+15 -12
src/app/auth/callback/page.tsx
··· 1 1 /** 2 - * OAuth callback page -- processes auth response from PDS. 3 - * URL: /auth/callback?code=...&state=... 4 - * On success: stores token in auth context (memory), redirects to returnTo or /. 2 + * OAuth callback page -- completes auth after API redirect. 3 + * URL: /auth/callback?success=true (redirected from API after PDS OAuth) 4 + * On success: refreshes session via cookie, stores token in memory, redirects to returnTo or /. 5 5 * On failure: shows error with retry link. 6 6 * @see specs/prd-web.md Section M3 (Auth Flow) 7 7 */ ··· 11 11 import { Suspense, useEffect, useMemo, useState, useRef } from 'react' 12 12 import { useSearchParams } from 'next/navigation' 13 13 import Link from 'next/link' 14 - import { handleCallback } from '@/lib/api/client' 14 + import { refreshSession } from '@/lib/api/client' 15 15 import { useAuth } from '@/hooks/use-auth' 16 16 17 17 function CallbackContent() { ··· 19 19 const { setSessionFromCallback } = useAuth() 20 20 const processedRef = useRef(false) 21 21 22 - const code = searchParams.get('code') 23 - const state = searchParams.get('state') 22 + const success = searchParams.get('success') 23 + const errorParam = searchParams.get('error') 24 24 25 25 // Derive missing-params error synchronously (not in effect) 26 - const missingParamsError = useMemo( 27 - () => (!code || !state ? 'Missing authorization code or state parameter' : null), 28 - [code, state] 29 - ) 26 + const missingParamsError = useMemo(() => { 27 + if (errorParam) return errorParam 28 + if (success) return null 29 + return 'Missing authorization parameters' 30 + }, [errorParam, success]) 30 31 31 32 const [error, setError] = useState<string | null>(missingParamsError) 32 33 ··· 36 37 37 38 async function processCallback() { 38 39 try { 39 - const session = await handleCallback(code!, state!) 40 + // API already set the HTTP-only refresh cookie during redirect. 41 + // Call refresh to get the access token from the cookie. 42 + const session = await refreshSession() 40 43 setSessionFromCallback(session) 41 44 42 45 const returnTo = sessionStorage.getItem('auth_returnTo') ?? '/' ··· 48 51 } 49 52 50 53 void processCallback() 51 - }, [code, state, missingParamsError, setSessionFromCallback]) 54 + }, [missingParamsError, setSessionFromCallback]) 52 55 53 56 if (error) { 54 57 return (
+4 -2
src/app/login/page.tsx
··· 66 66 alt="Barazo" 67 67 width={160} 68 68 height={42} 69 - className="h-10 w-auto dark:hidden" 69 + className="h-10 dark:hidden" 70 + style={{ width: 'auto' }} 70 71 priority 71 72 /> 72 73 <Image ··· 74 75 alt="Barazo" 75 76 width={160} 76 77 height={42} 77 - className="hidden h-10 w-auto dark:block" 78 + className="hidden h-10 dark:block" 79 + style={{ width: 'auto' }} 78 80 priority 79 81 /> 80 82 </Link>
+4 -2
src/components/layout/forum-layout.tsx
··· 34 34 alt="Barazo" 35 35 width={120} 36 36 height={32} 37 - className="h-8 w-auto dark:hidden" 37 + className="h-8 dark:hidden" 38 + style={{ width: 'auto' }} 38 39 priority 39 40 /> 40 41 <Image ··· 42 43 alt="Barazo" 43 44 width={120} 44 45 height={32} 45 - className="hidden h-8 w-auto dark:block" 46 + className="hidden h-8 dark:block" 47 + style={{ width: 'auto' }} 46 48 priority 47 49 /> 48 50 </Link>
+2 -2
src/context/auth-context.tsx
··· 108 108 }, [setSession]) 109 109 110 110 const login = useCallback(async (handle: string) => { 111 - const { redirectUrl } = await initiateLogin(handle) 112 - window.location.href = redirectUrl 111 + const { url } = await initiateLogin(handle) 112 + window.location.href = url 113 113 }, []) 114 114 115 115 const logout = useCallback(async () => {
+3 -1
src/lib/api/auth-fetch.ts
··· 8 8 import type { AuthSession } from './types' 9 9 10 10 const API_URL = 11 - typeof window !== 'undefined' ? '' : (process.env.API_INTERNAL_URL ?? 'http://localhost:3000') 11 + typeof window !== 'undefined' 12 + ? (process.env.NEXT_PUBLIC_API_URL ?? '') 13 + : (process.env.API_INTERNAL_URL ?? 'http://localhost:3000') 12 14 13 15 interface AuthFetchOptions { 14 16 method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
+5 -4
src/lib/api/client.ts
··· 46 46 } from './types' 47 47 48 48 const API_URL = 49 - typeof window !== 'undefined' ? '' : (process.env.API_INTERNAL_URL ?? 'http://localhost:3000') 49 + typeof window !== 'undefined' 50 + ? (process.env.NEXT_PUBLIC_API_URL ?? '') 51 + : (process.env.API_INTERNAL_URL ?? 'http://localhost:3000') 50 52 51 53 interface FetchOptions { 52 54 headers?: Record<string, string> ··· 95 97 96 98 // --- Auth endpoints --- 97 99 98 - export function initiateLogin(handle: string): Promise<{ redirectUrl: string }> { 100 + export function initiateLogin(handle: string): Promise<{ url: string }> { 99 101 const query = buildQuery({ handle }) 100 - return apiFetch<{ redirectUrl: string }>(`/api/auth/login${query}`) 102 + return apiFetch<{ url: string }>(`/api/auth/login${query}`) 101 103 } 102 104 103 105 export function handleCallback(code: string, state: string): Promise<AuthSession> { ··· 110 112 const response = await fetch(url, { 111 113 method: 'POST', 112 114 credentials: 'include', 113 - headers: { 'Content-Type': 'application/json' }, 114 115 }) 115 116 116 117 if (!response.ok) {
+1 -1
src/mocks/handlers.ts
··· 42 42 return HttpResponse.json({ error: 'handle is required' }, { status: 400 }) 43 43 } 44 44 return HttpResponse.json({ 45 - redirectUrl: `https://bsky.social/oauth/authorize?handle=${encodeURIComponent(handle)}&state=mock-state-123`, 45 + url: `https://bsky.social/oauth/authorize?handle=${encodeURIComponent(handle)}&state=mock-state-123`, 46 46 }) 47 47 }), 48 48