Skip to content

Commit b603eff

Browse files
authored
feat: OAuth PKCE flow and external auth provider UI (#1547)
* feat: add OAuth PKCE flow and external auth provider UI Implement OAuth authorization code flow with PKCE (RFC 7636) for redirect-based authentication via Dex external connectors. Add login page UI for external auth provider buttons with graceful fallback when the provider discovery API is unavailable. - PKCE utilities: code verifier, S256 challenge, state generation - useOAuthFlow hook: initiates PKCE flow and redirects to Dex - CallbackPage: handles authorization code exchange with CSRF validation - useAuthProviders hook: fetches available providers with fallback - Provider button and icon components (Google, GitHub, Microsoft) - Auth divider component between password form and external providers - /callback route added to App.tsx router - MSW handler for /api/auth/providers (404 default for graceful degradation) - 17 new tests covering PKCE, callback, and provider hooks * fix: resolve eslint errors in auth components and callback page - Split provider-icons.tsx to export only components (react-refresh rule) - Move provider icon selection logic into ProviderIcon component - Refactor CallbackPage to use useMemo for synchronous validation instead of setState inside useEffect * fix: remove unused vi import from callback test * fix: address CodeRabbit review feedback - Return empty array on 404 from providers API (password-only fallback) - Update test to assert empty data instead of isError on 404 - Surface error message to user when OAuth flow fails to start * fix: move sessionStorage cleanup out of useMemo into useEffect The validateCallbackParams function is called from useMemo which must be pure. Move the sessionStorage.removeItem calls into the useEffect that performs the code exchange. * fix: use replace navigation to prevent stale callback URL in history Use navigate with { replace: true } on both success and error paths to prevent the callback URL (with spent authorization code) from remaining in browser history. * fix: add AbortController to cancel token exchange on unmount Prevent state updates on unmounted component by aborting the fetch request when the callback page unmounts during token exchange. * fix: defer PKCE cleanup until terminal outcome Keep PKCE verifier and state in sessionStorage until the token exchange succeeds or fails terminally. Transient failures (network errors) preserve the values so a page refresh can retry the flow. --------- Co-authored-by: Ben Coombs <bjcoombs@users.noreply.github.com>
1 parent c399443 commit b603eff

12 files changed

Lines changed: 623 additions & 0 deletions

File tree

frontend/src/App.tsx

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,16 @@ import { PageErrorBoundary, RouteErrorBoundary } from '@/components/error-bounda
77
import { AuthProvider, useAuth } from '@/contexts/auth-context'
88
import { TenantProvider, useTenantContext } from '@/contexts/tenant-context'
99
import { useTenants } from '@/hooks/use-tenants'
10+
import { useAuthProviders, type AuthProvider as AuthProviderType } from '@/hooks/use-auth-providers'
11+
import { useOAuthFlow } from '@/hooks/use-oauth-flow'
1012
import { ApiClientProvider } from '@/api/context'
1113
import { ProtectedRoute, PlatformOnlyRoute, AdminOnlyRoute, TenantSubdomainEnforcer } from '@/components/routing'
1214
import { FeatureGuard } from '@/components/feature-guard'
1315
import { AppShell } from '@/components/layout/app-shell'
1416
import { TooltipProvider } from '@/components/ui/tooltip'
17+
import { CallbackPage } from '@/pages/callback'
18+
import { ProviderButton } from '@/components/auth/provider-button'
19+
import { AuthDivider } from '@/components/auth/auth-divider'
1520
import { AccountsPage, AccountDetailPage } from '@/features/accounts'
1621
import { PaymentsPage, PaymentDetailPage } from '@/features/payments'
1722
import { LedgerPage, BookingLogDetailPage } from '@/features/ledger'
@@ -64,6 +69,10 @@ function LoginPage() {
6469
const [password, setPassword] = useState('')
6570
const [error, setError] = useState('')
6671
const [loading, setLoading] = useState(false)
72+
const { data: providers } = useAuthProviders()
73+
const { startFlow } = useOAuthFlow()
74+
75+
const externalProviders = providers?.filter((p: AuthProviderType) => p.type === 'oidc') ?? []
6776

6877
const handleDexLogin = useCallback(
6978
async (e: FormEvent) => {
@@ -182,6 +191,22 @@ function LoginPage() {
182191
</form>
183192
)}
184193

194+
{/* External auth provider buttons */}
195+
{externalProviders.length > 0 && (
196+
<>
197+
{(import.meta.env.VITE_DEMO_MODE === 'true' || !import.meta.env.DEV) && <AuthDivider />}
198+
<div className="space-y-2">
199+
{externalProviders.map((provider: AuthProviderType) => (
200+
<ProviderButton
201+
key={provider.id}
202+
provider={provider}
203+
onClick={() => startFlow(provider.id)}
204+
/>
205+
))}
206+
</div>
207+
</>
208+
)}
209+
185210
{/* Dev-only fake JWT buttons */}
186211
{import.meta.env.DEV && (
187212
<div className="space-y-2">
@@ -397,6 +422,7 @@ function AuthenticatedApp() {
397422
<BrowserRouter>
398423
<Routes>
399424
<Route path="/login" element={<LoginPage />} />
425+
<Route path="/callback" element={<CallbackPage />} />
400426
<Route
401427
path="/*"
402428
element={
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export function AuthDivider() {
2+
return (
3+
<div className="relative">
4+
<div className="absolute inset-0 flex items-center">
5+
<span className="w-full border-t border-muted" />
6+
</div>
7+
<div className="relative flex justify-center text-xs uppercase">
8+
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
9+
</div>
10+
</div>
11+
)
12+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { useState } from 'react'
2+
import { Button } from '@/components/ui/button'
3+
import { GoogleIcon, GitHubIcon, MicrosoftIcon, DefaultProviderIcon } from './provider-icons'
4+
import type { AuthProvider } from '@/hooks/use-auth-providers'
5+
6+
interface ProviderButtonProps {
7+
provider: AuthProvider
8+
onClick: () => Promise<void>
9+
}
10+
11+
function ProviderIcon({ providerId }: { providerId: string }) {
12+
const normalized = providerId.toLowerCase()
13+
if (normalized.includes('google')) return <GoogleIcon className="size-4" />
14+
if (normalized.includes('github')) return <GitHubIcon className="size-4" />
15+
if (normalized.includes('microsoft')) return <MicrosoftIcon className="size-4" />
16+
return <DefaultProviderIcon className="size-4" />
17+
}
18+
19+
export function ProviderButton({ provider, onClick }: ProviderButtonProps) {
20+
const [loading, setLoading] = useState(false)
21+
const [error, setError] = useState<string | null>(null)
22+
23+
const handleClick = async () => {
24+
setLoading(true)
25+
setError(null)
26+
try {
27+
await onClick()
28+
} catch {
29+
setError('Failed to start sign-in. Please try again.')
30+
setLoading(false)
31+
}
32+
}
33+
34+
return (
35+
<div>
36+
<Button
37+
variant="outline"
38+
className="w-full"
39+
disabled={loading}
40+
onClick={() => void handleClick()}
41+
>
42+
<ProviderIcon providerId={provider.id} />
43+
{loading ? 'Redirecting...' : `Sign in with ${provider.displayName}`}
44+
</Button>
45+
{error && <p className="mt-1 text-xs text-destructive">{error}</p>}
46+
</div>
47+
)
48+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
export function GoogleIcon({ className }: { className?: string }) {
2+
return (
3+
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
4+
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" />
5+
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
6+
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
7+
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
8+
</svg>
9+
)
10+
}
11+
12+
export function GitHubIcon({ className }: { className?: string }) {
13+
return (
14+
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
15+
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2z" />
16+
</svg>
17+
)
18+
}
19+
20+
export function MicrosoftIcon({ className }: { className?: string }) {
21+
return (
22+
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
23+
<path d="M3 3h8.5v8.5H3V3zm9.5 0H21v8.5h-8.5V3zM3 12.5h8.5V21H3v-8.5zm9.5 0H21V21h-8.5v-8.5z" />
24+
</svg>
25+
)
26+
}
27+
28+
export function DefaultProviderIcon({ className }: { className?: string }) {
29+
return (
30+
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
31+
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4" />
32+
<polyline points="10 17 15 12 10 7" />
33+
<line x1="15" y1="12" x2="3" y2="12" />
34+
</svg>
35+
)
36+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { renderHook, waitFor } from '@testing-library/react'
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4+
import { http, HttpResponse } from 'msw'
5+
import { server } from '@/test/msw-handlers'
6+
import { useAuthProviders } from './use-auth-providers'
7+
import type { ReactNode } from 'react'
8+
9+
function createWrapper() {
10+
const queryClient = new QueryClient({
11+
defaultOptions: { queries: { retry: false } },
12+
})
13+
return function Wrapper({ children }: { children: ReactNode }) {
14+
return QueryClientProvider({ client: queryClient, children })
15+
}
16+
}
17+
18+
describe('useAuthProviders', () => {
19+
it('returns empty array when API returns 404 (password-only mode)', async () => {
20+
// Default MSW handler returns 404
21+
const { result } = renderHook(() => useAuthProviders(), { wrapper: createWrapper() })
22+
23+
await waitFor(() => {
24+
expect(result.current.isSuccess).toBe(true)
25+
})
26+
27+
expect(result.current.data).toEqual([])
28+
})
29+
30+
it('returns providers when API succeeds', async () => {
31+
server.use(
32+
http.get('/api/auth/providers', () => {
33+
return HttpResponse.json({
34+
providers: [
35+
{ id: 'local', type: 'password', displayName: 'Email' },
36+
{ id: 'google', type: 'oidc', displayName: 'Google' },
37+
],
38+
})
39+
}),
40+
)
41+
42+
const { result } = renderHook(() => useAuthProviders(), { wrapper: createWrapper() })
43+
44+
await waitFor(() => {
45+
expect(result.current.isSuccess).toBe(true)
46+
})
47+
48+
expect(result.current.data).toHaveLength(2)
49+
expect(result.current.data?.[1]?.id).toBe('google')
50+
expect(result.current.data?.[1]?.type).toBe('oidc')
51+
})
52+
})
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useQuery } from '@tanstack/react-query'
2+
3+
export interface AuthProvider {
4+
id: string
5+
type: 'password' | 'oidc'
6+
displayName: string
7+
iconUrl?: string
8+
}
9+
10+
interface ProvidersResponse {
11+
providers: AuthProvider[]
12+
}
13+
14+
async function fetchProviders(): Promise<AuthProvider[]> {
15+
const response = await fetch('/api/auth/providers')
16+
if (response.status === 404) {
17+
// Provider discovery API not available - password-only mode
18+
return []
19+
}
20+
if (!response.ok) {
21+
throw new Error(`Failed to fetch providers: ${response.status}`)
22+
}
23+
const data = (await response.json()) as ProvidersResponse
24+
return data.providers
25+
}
26+
27+
/**
28+
* Fetches available authentication providers from the backend.
29+
* Gracefully falls back to empty array on error (password-only mode).
30+
*/
31+
export function useAuthProviders() {
32+
return useQuery<AuthProvider[]>({
33+
queryKey: ['auth', 'providers'],
34+
queryFn: fetchProviders,
35+
staleTime: 5 * 60 * 1000,
36+
retry: false,
37+
})
38+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { useCallback } from 'react'
2+
import { generateCodeVerifier, generateCodeChallenge, generateState } from '@/lib/pkce'
3+
4+
const PKCE_VERIFIER_KEY = 'meridian_pkce_verifier'
5+
const PKCE_STATE_KEY = 'meridian_pkce_state'
6+
7+
/**
8+
* Hook that initiates an OAuth authorization code flow with PKCE.
9+
* Generates PKCE parameters, stores them in sessionStorage, and redirects to Dex.
10+
*/
11+
export function useOAuthFlow() {
12+
const startFlow = useCallback(async (connectorId: string) => {
13+
const verifier = generateCodeVerifier()
14+
const challenge = await generateCodeChallenge(verifier)
15+
const state = generateState()
16+
17+
sessionStorage.setItem(PKCE_VERIFIER_KEY, verifier)
18+
sessionStorage.setItem(PKCE_STATE_KEY, state)
19+
20+
const params = new URLSearchParams({
21+
client_id: 'meridian-service',
22+
response_type: 'code',
23+
scope: 'openid email profile',
24+
redirect_uri: `${window.location.origin}/callback`,
25+
code_challenge: challenge,
26+
code_challenge_method: 'S256',
27+
state,
28+
connector_id: connectorId,
29+
})
30+
31+
window.location.href = `/dex/auth?${params.toString()}`
32+
}, [])
33+
34+
return { startFlow }
35+
}
36+
37+
export { PKCE_VERIFIER_KEY, PKCE_STATE_KEY }
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { generateCodeVerifier, generateCodeChallenge, generateState } from '../pkce'
3+
4+
describe('PKCE utilities', () => {
5+
describe('generateCodeVerifier', () => {
6+
it('generates a 64-character string', () => {
7+
const verifier = generateCodeVerifier()
8+
expect(verifier).toHaveLength(64)
9+
})
10+
11+
it('uses only unreserved characters (RFC 7636 4.1)', () => {
12+
const verifier = generateCodeVerifier()
13+
expect(verifier).toMatch(/^[A-Za-z0-9\-._~]+$/)
14+
})
15+
16+
it('generates unique verifiers', () => {
17+
const v1 = generateCodeVerifier()
18+
const v2 = generateCodeVerifier()
19+
expect(v1).not.toEqual(v2)
20+
})
21+
})
22+
23+
describe('generateCodeChallenge', () => {
24+
it('produces a base64url string without padding', async () => {
25+
const verifier = generateCodeVerifier()
26+
const challenge = await generateCodeChallenge(verifier)
27+
expect(challenge).toMatch(/^[A-Za-z0-9\-_]+$/)
28+
expect(challenge).not.toContain('=')
29+
})
30+
31+
it('produces a consistent challenge for the same verifier', async () => {
32+
const verifier = 'test-verifier-for-consistency'
33+
const c1 = await generateCodeChallenge(verifier)
34+
const c2 = await generateCodeChallenge(verifier)
35+
expect(c1).toEqual(c2)
36+
})
37+
38+
it('produces different challenges for different verifiers', async () => {
39+
const c1 = await generateCodeChallenge('verifier-one')
40+
const c2 = await generateCodeChallenge('verifier-two')
41+
expect(c1).not.toEqual(c2)
42+
})
43+
})
44+
45+
describe('generateState', () => {
46+
it('generates a base64url string', () => {
47+
const state = generateState()
48+
expect(state).toMatch(/^[A-Za-z0-9\-_]+$/)
49+
})
50+
51+
it('generates unique states', () => {
52+
const s1 = generateState()
53+
const s2 = generateState()
54+
expect(s1).not.toEqual(s2)
55+
})
56+
})
57+
})

frontend/src/lib/pkce.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* PKCE (Proof Key for Code Exchange) utilities for OAuth 2.0 authorization code flow.
3+
* Implements RFC 7636 sections 4.1 and 4.2.
4+
*/
5+
6+
const VERIFIER_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'
7+
const VERIFIER_LENGTH = 64
8+
9+
/**
10+
* Generate a cryptographically random code verifier (RFC 7636 4.1).
11+
* Returns a 64-character string from the unreserved character set.
12+
*/
13+
export function generateCodeVerifier(): string {
14+
const array = new Uint8Array(VERIFIER_LENGTH)
15+
crypto.getRandomValues(array)
16+
return Array.from(array, (byte) => VERIFIER_CHARSET[byte % VERIFIER_CHARSET.length]).join('')
17+
}
18+
19+
/**
20+
* Generate a S256 code challenge from a code verifier (RFC 7636 4.2).
21+
* challenge = BASE64URL(SHA256(verifier))
22+
*/
23+
export async function generateCodeChallenge(verifier: string): Promise<string> {
24+
const encoder = new TextEncoder()
25+
const data = encoder.encode(verifier)
26+
const digest = await crypto.subtle.digest('SHA-256', data)
27+
return base64UrlEncode(digest)
28+
}
29+
30+
/**
31+
* Base64 URL encoding without padding (RFC 4648 section 5).
32+
*/
33+
function base64UrlEncode(buffer: ArrayBuffer): string {
34+
const bytes = new Uint8Array(buffer)
35+
let binary = ''
36+
for (const byte of bytes) {
37+
binary += String.fromCharCode(byte)
38+
}
39+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
40+
}
41+
42+
/**
43+
* Generate a random state parameter for CSRF protection.
44+
*/
45+
export function generateState(): string {
46+
const array = new Uint8Array(32)
47+
crypto.getRandomValues(array)
48+
return base64UrlEncode(array.buffer)
49+
}

0 commit comments

Comments
 (0)