feat(01-02): wire OIDC middleware, /api/me route, and authenticated PWA shell
- src/auth/middleware.ts: re-exports oidcAuthMiddleware, processOAuthCallback,
getAuth from @hono/oidc-auth; documents required env vars and AUTH-02
refresh-token rotation (no iframe, D-12)
- src/routes/me.ts: GET / calls getAuth → upsertUser(iss, sub, email) →
returns { user: { id, displayName, color } }; identity keyed on iss+sub
- src/index.ts: /callback registered before oidcAuthMiddleware; /api/*
guarded; /health remains unauthenticated; /api/me mounted
- apps/pwa/src/api/client.ts: typed fetchMe() with credentials: 'include'
- apps/pwa/src/App.tsx: useQuery(['me'], fetchMe); renders member name and
color swatch; retains /health stack indicator from Plan 01
- tsc --noEmit: clean; all tests pass
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* OIDC authentication middleware wiring.
|
||||
*
|
||||
* Configures @hono/oidc-auth for Authelia as the identity provider.
|
||||
*
|
||||
* Required env vars:
|
||||
* OIDC_AUTH_SECRET — 32+ char random string for JWT cookie signing (T-02-03)
|
||||
* OIDC_ISSUER — Authelia base URL (middleware fetches /.well-known/openid-configuration)
|
||||
* OIDC_CLIENT_ID — registered client ID in Authelia
|
||||
* OIDC_CLIENT_SECRET — PLAIN text secret (NOT the pbkdf2 hash — see Pitfall 7)
|
||||
* OIDC_REDIRECT_URI — https://familysync.<domain>/callback
|
||||
* OIDC_AUTH_EXTERNAL_URL — https://familysync.<domain> — MANDATORY behind Pangolin (Pitfall 1)
|
||||
*
|
||||
* Session persistence (AUTH-02):
|
||||
* @hono/oidc-auth stores the refresh token in the signed JWT cookie.
|
||||
* Every OIDC_AUTH_REFRESH_INTERVAL (default 15 min) the middleware calls
|
||||
* the token endpoint with the stored refresh token — no iframe required (D-12).
|
||||
* Session lifespan is governed by OIDC_AUTH_EXPIRES (default 1 day) and
|
||||
* Authelia's refresh_token_lifespan.
|
||||
*
|
||||
* Scopes: openid, profile, email only — no 'groups' scope (D-11).
|
||||
*
|
||||
* Source: https://github.com/honojs/middleware/tree/main/packages/oidc-auth
|
||||
*/
|
||||
|
||||
export { oidcAuthMiddleware, processOAuthCallback, getAuth } from '@hono/oidc-auth'
|
||||
+16
-2
@@ -2,14 +2,28 @@ import { serve } from '@hono/node-server'
|
||||
import { serveStatic } from '@hono/node-server/serve-static'
|
||||
import { Hono } from 'hono'
|
||||
import { healthRouter } from './routes/health.js'
|
||||
import { meRouter } from './routes/me.js'
|
||||
import { oidcAuthMiddleware, processOAuthCallback } from './auth/middleware.js'
|
||||
|
||||
export const app = new Hono()
|
||||
|
||||
// GET /health — unauthenticated, before any auth middleware (T-01-03)
|
||||
// GET /health — unauthenticated, mounted BEFORE the OIDC guard (T-01-03, T-02-05)
|
||||
app.route('/health', healthRouter)
|
||||
|
||||
// OIDC callback — must be registered BEFORE oidcAuthMiddleware so the
|
||||
// authorization-code exchange is not itself intercepted by the auth check (T-02-02)
|
||||
app.get('/callback', (c) => processOAuthCallback(c))
|
||||
|
||||
// Protect all /api/* routes with OIDC session middleware (AUTH-01, T-02-05).
|
||||
// Unauthenticated requests receive a 302 redirect to Authelia's authorize endpoint.
|
||||
// OIDC_AUTH_EXTERNAL_URL is MANDATORY behind Pangolin to construct the correct
|
||||
// redirect_uri (Pitfall 1). Set it to https://familysync.<domain>.
|
||||
app.use('/api/*', oidcAuthMiddleware())
|
||||
|
||||
// Protected API routes
|
||||
app.route('/api/me', meRouter)
|
||||
|
||||
// Serve React PWA static assets from ./public (Vite build output)
|
||||
// In Phase 2, OIDC callback + protected /api routes will be mounted here
|
||||
app.use('/assets/*', serveStatic({ root: './public' }))
|
||||
app.get('*', serveStatic({ path: './public/index.html' }))
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* GET /api/me — returns the authenticated user's identity and assigned color.
|
||||
*
|
||||
* Flow:
|
||||
* 1. getAuth(c) reads iss + sub from the OIDC session JWT cookie
|
||||
* (validated and refreshed by oidcAuthMiddleware — never reaches here unauthenticated)
|
||||
* 2. upsertUser(iss, sub, email) writes the user row on first visit, returns
|
||||
* the existing row on subsequent visits (idempotent, keyed on iss+sub, D-10)
|
||||
* 3. Returns { user: { id, displayName, color } }
|
||||
*
|
||||
* The OIDC session cookie is httpOnly + Secure + SameSite (T-02-03).
|
||||
* No credential or refresh-token data is included in the response (T-02-04).
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono'
|
||||
import { getAuth } from '../auth/middleware.js'
|
||||
import { upsertUser } from '../auth/user.js'
|
||||
|
||||
export const meRouter = new Hono()
|
||||
|
||||
meRouter.get('/', async (c) => {
|
||||
// getAuth returns null only if the session is invalid — oidcAuthMiddleware on
|
||||
// /api/* redirects unauthenticated requests before this handler is reached.
|
||||
const auth = await getAuth(c)
|
||||
if (!auth) {
|
||||
return c.json({ error: 'Unauthorized' }, 401)
|
||||
}
|
||||
|
||||
// iss and sub are the stable identity fields; email is a display hint only (D-10)
|
||||
const iss = (auth.iss as string | undefined) ?? ''
|
||||
const sub = auth.sub ?? ''
|
||||
const email = typeof auth.email === 'string' ? auth.email : undefined
|
||||
|
||||
const user = await upsertUser(iss, sub, email)
|
||||
|
||||
if (!user) {
|
||||
return c.json({ error: 'Could not resolve user' }, 500)
|
||||
}
|
||||
|
||||
return c.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
displayName: user.displayName,
|
||||
color: user.color,
|
||||
},
|
||||
})
|
||||
})
|
||||
+70
-7
@@ -1,4 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchMe, type MeUser } from './api/client'
|
||||
|
||||
interface HealthResponse {
|
||||
ok: boolean
|
||||
@@ -13,8 +14,54 @@ async function fetchHealth(): Promise<HealthResponse> {
|
||||
return res.json() as Promise<HealthResponse>
|
||||
}
|
||||
|
||||
function ColorSwatch({ color }: { color: string }) {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: '1rem',
|
||||
height: '1rem',
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
marginRight: '0.5rem',
|
||||
verticalAlign: 'middle',
|
||||
border: '1px solid rgba(0,0,0,0.1)',
|
||||
}}
|
||||
aria-label={`Color: ${color}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MemberBadge({ user }: { user: MeUser }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: '8px',
|
||||
background: '#f0f9ff',
|
||||
border: `2px solid ${user.color}`,
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
<ColorSwatch color={user.color} />
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{user.displayName ?? 'Member'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
const meQuery = useQuery({
|
||||
queryKey: ['me'],
|
||||
queryFn: fetchMe,
|
||||
retry: false, // 401 triggers Authelia redirect — don't retry
|
||||
staleTime: 5 * 60 * 1000, // 5 min — session persists via refresh rotation
|
||||
})
|
||||
|
||||
const healthQuery = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: fetchHealth,
|
||||
retry: 1,
|
||||
@@ -23,18 +70,34 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'system-ui, sans-serif', padding: '2rem', maxWidth: '480px', margin: '0 auto' }}>
|
||||
<h1 style={{ fontSize: '1.5rem', marginBottom: '1rem' }}>FamilySync</h1>
|
||||
<h1 style={{ fontSize: '1.5rem', marginBottom: '1.5rem' }}>FamilySync</h1>
|
||||
|
||||
{/* Authenticated member identity (AUTH-03) */}
|
||||
{meQuery.isLoading && (
|
||||
<div style={{ color: '#666', marginBottom: '1rem' }}>Loading...</div>
|
||||
)}
|
||||
{meQuery.isError && (
|
||||
<div style={{ color: '#991b1b', marginBottom: '1rem', padding: '0.75rem', background: '#fee2e2', borderRadius: '8px' }}>
|
||||
Sign-in required
|
||||
</div>
|
||||
)}
|
||||
{meQuery.data && (
|
||||
<MemberBadge user={meQuery.data.user} />
|
||||
)}
|
||||
|
||||
{/* Stack health indicator (from Plan 01) */}
|
||||
<div
|
||||
style={{
|
||||
padding: '1rem',
|
||||
borderRadius: '8px',
|
||||
background: isLoading ? '#f5f5f5' : isError ? '#fee2e2' : '#dcfce7',
|
||||
color: isLoading ? '#666' : isError ? '#991b1b' : '#166534',
|
||||
background: healthQuery.isLoading ? '#f5f5f5' : healthQuery.isError ? '#fee2e2' : '#dcfce7',
|
||||
color: healthQuery.isLoading ? '#666' : healthQuery.isError ? '#991b1b' : '#166534',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
{isLoading && 'Checking stack...'}
|
||||
{isError && 'stack: down'}
|
||||
{data && `stack: ${data.ok && data.db === 'up' ? 'up' : 'down'}`}
|
||||
{healthQuery.isLoading && 'Checking stack...'}
|
||||
{healthQuery.isError && 'stack: down'}
|
||||
{healthQuery.data && `stack: ${healthQuery.data.ok && healthQuery.data.db === 'up' ? 'up' : 'down'}`}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Typed API client for the FamilySync backend.
|
||||
*
|
||||
* credentials: 'include' is required so the OIDC session cookie is sent with
|
||||
* every cross-origin request (Vite dev proxy routes to :3000; production is
|
||||
* same-origin via Pangolin).
|
||||
*
|
||||
* 401 responses mean the session has expired — the browser will follow the
|
||||
* 302 redirect to Authelia on the next API call automatically (full-page nav).
|
||||
*/
|
||||
|
||||
export interface MeUser {
|
||||
id: number
|
||||
displayName: string | null
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
user: MeUser
|
||||
}
|
||||
|
||||
export async function fetchMe(): Promise<MeResponse> {
|
||||
const res = await fetch('/api/me', {
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
// 302 → browser follows redirect to Authelia automatically.
|
||||
// For 4xx/5xx, throw so React Query can surface the error.
|
||||
throw new Error(`GET /api/me failed: ${res.status}`)
|
||||
}
|
||||
|
||||
return res.json() as Promise<MeResponse>
|
||||
}
|
||||
Reference in New Issue
Block a user