diff --git a/apps/api/src/auth/middleware.ts b/apps/api/src/auth/middleware.ts new file mode 100644 index 0000000..9f463e0 --- /dev/null +++ b/apps/api/src/auth/middleware.ts @@ -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./callback + * OIDC_AUTH_EXTERNAL_URL — https://familysync. — 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' diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 297824b..bdf528d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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.. +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' })) diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts new file mode 100644 index 0000000..031a5f5 --- /dev/null +++ b/apps/api/src/routes/me.ts @@ -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, + }, + }) +}) diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx index 0f6002e..61326d4 100644 --- a/apps/pwa/src/App.tsx +++ b/apps/pwa/src/App.tsx @@ -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 { return res.json() as Promise } +function ColorSwatch({ color }: { color: string }) { + return ( + + ) +} + +function MemberBadge({ user }: { user: MeUser }) { + return ( +
+ + + {user.displayName ?? 'Member'} + +
+ ) +} + 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 (
-

FamilySync

+

FamilySync

+ + {/* Authenticated member identity (AUTH-03) */} + {meQuery.isLoading && ( +
Loading...
+ )} + {meQuery.isError && ( +
+ Sign-in required +
+ )} + {meQuery.data && ( + + )} + + {/* Stack health indicator (from Plan 01) */}
- {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'}`}
) diff --git a/apps/pwa/src/api/client.ts b/apps/pwa/src/api/client.ts new file mode 100644 index 0000000..56ab270 --- /dev/null +++ b/apps/pwa/src/api/client.ts @@ -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 { + 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 +}