diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ff3252a..5ac0ed4 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -11,6 +11,7 @@ import { listsRouter, listItemsRouter } from './routes/lists.js' import { pushRouter } from './routes/push.js' import { oidcAuthMiddleware, processOAuthCallback } from './auth/middleware.js' import { devAuthBypass } from './auth/devBypass.js' +import { persistSessionCookie } from './auth/persistSessionCookie.js' import { startBrokerPoller } from './broker/poller.js' import { startOutboxWorker } from './broker/outboxWorker.js' import { startReminderScheduler } from './broker/reminderScheduler.js' @@ -49,6 +50,8 @@ app.use('/api/*', devAuthBypass()) // redirect_uri (Pitfall 1). Set it to https://familysync.. if (!devBypassActive) { app.use('/api/*', oidcAuthMiddleware()) + // Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02). + app.use('/api/*', persistSessionCookie()) } // Protected API routes (behind oidcAuthMiddleware) diff --git a/apps/api/tests/auth/persistSessionCookie.test.ts b/apps/api/tests/auth/persistSessionCookie.test.ts new file mode 100644 index 0000000..dde4403 --- /dev/null +++ b/apps/api/tests/auth/persistSessionCookie.test.ts @@ -0,0 +1,122 @@ +/** + * persistSessionCookie() middleware — unit tests. + * + * Tests the two critical behavioral properties: + * A (persist path): when oidcAuthJwt is set on context, the response includes an + * oidc-auth Set-Cookie with Max-Age, SameSite=Lax, HttpOnly, Secure. + * B (guard path): when oidcAuthJwt is NOT set, no oidc-auth Set-Cookie is emitted + * (the no-resurrection security guard). + * + * Does NOT call @hono/oidc-auth — sets the context variable directly to keep this a + * pure unit test that runs without MariaDB or any external service. + */ + +import { describe, it, expect, afterEach } from 'vitest' +import { Hono } from 'hono' +import { persistSessionCookie } from '../../src/auth/persistSessionCookie.js' + +// Read the cookie name the same way the implementation does so the test stays correct +// if OIDC_COOKIE_NAME is overridden in the environment. +const COOKIE_NAME = process.env.OIDC_COOKIE_NAME ?? 'oidc-auth' + +afterEach(() => { + // Nothing to restore: these tests do not mutate process.env. +}) + +describe('persistSessionCookie middleware', () => { + describe('Test A — persist path (oidcAuthJwt truthy)', () => { + it('emits an oidc-auth Set-Cookie with Max-Age, SameSite=Lax, HttpOnly, and Secure', async () => { + const app = new Hono() + + // Simulate what @hono/oidc-auth does: set a signed session JWT on context. + app.use('/api/*', async (c, next) => { + c.set('oidcAuthJwt' as never, 'header.payload.sig') + await next() + }) + + app.use('/api/*', persistSessionCookie()) + + app.get('/api/test', (c) => c.json({ ok: true })) + + const res = await app.request('/api/test') + expect(res.status).toBe(200) + + const setCookieHeader = res.headers.get('set-cookie') + expect(setCookieHeader).not.toBeNull() + + // Cookie name must appear in the Set-Cookie value. + expect(setCookieHeader).toContain(COOKIE_NAME) + + // Max-Age must be present (persistent cookie, not session-scoped). + expect(setCookieHeader?.toLowerCase()).toContain('max-age') + + // SameSite=Lax must be present. + expect(setCookieHeader?.toLowerCase()).toContain('samesite=lax') + + // HttpOnly must be present. + expect(setCookieHeader?.toLowerCase()).toContain('httponly') + + // Secure must be present. + expect(setCookieHeader?.toLowerCase()).toContain('secure') + }) + + it('re-issues the same JWT value the library provided (no re-signing)', async () => { + const dummyJwt = 'header.payload.sig' + const app = new Hono() + + app.use('/api/*', async (c, next) => { + c.set('oidcAuthJwt' as never, dummyJwt) + await next() + }) + + app.use('/api/*', persistSessionCookie()) + app.get('/api/test', (c) => c.json({ ok: true })) + + const res = await app.request('/api/test') + const setCookieHeader = res.headers.get('set-cookie') ?? '' + + // The cookie value must contain the exact JWT string (URL-encoded = is fine but + // the JWT characters must all appear). + expect(setCookieHeader).toContain(dummyJwt) + }) + }) + + describe('Test B — guard path (oidcAuthJwt falsy / absent)', () => { + it('emits NO oidc-auth Set-Cookie when oidcAuthJwt is not set (no resurrection)', async () => { + const app = new Hono() + + // Do NOT set oidcAuthJwt — simulates a logged-out or unauthenticated request. + app.use('/api/*', persistSessionCookie()) + app.get('/api/test', (c) => c.json({ ok: true })) + + const res = await app.request('/api/test') + expect(res.status).toBe(200) + + const setCookieHeader = res.headers.get('set-cookie') + + // Either no Set-Cookie header at all, or it must not contain the oidc-auth cookie. + const hasOidcCookie = + setCookieHeader !== null && setCookieHeader.includes(COOKIE_NAME) + expect(hasOidcCookie).toBe(false) + }) + + it('emits NO Set-Cookie when oidcAuthJwt is explicitly set to empty string', async () => { + const app = new Hono() + + app.use('/api/*', async (c, next) => { + c.set('oidcAuthJwt' as never, '') + await next() + }) + + app.use('/api/*', persistSessionCookie()) + app.get('/api/test', (c) => c.json({ ok: true })) + + const res = await app.request('/api/test') + const setCookieHeader = res.headers.get('set-cookie') + + const hasOidcCookie = + setCookieHeader !== null && setCookieHeader.includes(COOKIE_NAME) + expect(hasOidcCookie).toBe(false) + }) + }) +})