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:
Lucas Berger
2026-06-04 10:22:46 -04:00
parent baabfce9e2
commit 668ed9be0d
5 changed files with 193 additions and 9 deletions
+26
View File
@@ -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
View File
@@ -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' }))
+47
View File
@@ -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,
},
})
})