diff --git a/lib/auth/access.test.ts b/lib/auth/access.test.ts new file mode 100644 index 0000000..33b6c75 --- /dev/null +++ b/lib/auth/access.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest'; + +import { decideAccess } from './access'; + +describe('decideAccess', () => { + it('sends an anonymous browser to the sign-in screen', () => { + expect(decideAccess({ pathname: '/admin/horaires', method: 'GET', role: undefined })).toEqual({ + kind: 'redirect', + }); + }); + + it('answers an anonymous fetch with 401 rather than an HTML page', () => { + expect(decideAccess({ pathname: '/api/admin/messages', method: 'GET', role: undefined })).toEqual( + { kind: 'deny', status: 401, error: 'Non authentifié' }, + ); + }); + + it('lets a read-only account read pages and API routes', () => { + expect(decideAccess({ pathname: '/admin', method: 'GET', role: 'viewer' })).toEqual({ + kind: 'allow', + }); + expect(decideAccess({ pathname: '/api/admin/messages', method: 'GET', role: 'viewer' })).toEqual( + { kind: 'allow' }, + ); + }); + + it('refuses a write to the API from a read-only account', () => { + expect(decideAccess({ pathname: '/api/admin/messages', method: 'POST', role: 'viewer' })).toEqual( + { kind: 'deny', status: 403, error: 'Compte en lecture seule' }, + ); + }); + + it('allows a write to the API from an administrator', () => { + expect(decideAccess({ pathname: '/api/admin/messages', method: 'DELETE', role: 'admin' })).toEqual( + { kind: 'allow' }, + ); + }); + + it('lets a read-only account POST to a page', () => { + // Regression: a React server action POSTs to the URL of the page it lives + // on. Gating pages by method refused every form to a viewer, sign-out + // included, which showed up as "an unexpected response was received from + // the server". Page writes are checked inside the action instead. + expect(decideAccess({ pathname: '/admin', method: 'POST', role: 'viewer' })).toEqual({ + kind: 'allow', + }); + expect(decideAccess({ pathname: '/admin/horaires', method: 'POST', role: 'viewer' })).toEqual({ + kind: 'allow', + }); + }); + + it('still requires a session before a page POST', () => { + expect(decideAccess({ pathname: '/admin', method: 'POST', role: undefined })).toEqual({ + kind: 'redirect', + }); + }); +}); diff --git a/lib/auth/access.ts b/lib/auth/access.ts new file mode 100644 index 0000000..3a2b597 --- /dev/null +++ b/lib/auth/access.ts @@ -0,0 +1,46 @@ +/** + * What the edge should do with a request, decided in one pure function. + * + * Extracted from the middleware so it can be tested: the rules here are short + * but they are the only thing standing in front of the administration, and one + * of them has already been got wrong once. + */ + +import type { Role } from './roles'; + +export type AccessDecision = + | { kind: 'allow' } + /** Send a browser to the sign-in screen. */ + | { kind: 'redirect' } + /** Answer a fetch with a status code rather than an HTML page. */ + | { kind: 'deny'; status: 401 | 403; error: string }; + +export type AccessRequest = { + pathname: string; + method: string; + role: Role | undefined; +}; + +export function decideAccess({ pathname, method, role }: AccessRequest): AccessDecision { + const isApi = pathname.startsWith('/api/admin'); + + if (!role) { + // A fetch that receives an HTML login page is a confusing way to learn + // you are signed out. + return isApi + ? { kind: 'deny', status: 401, error: 'Non authentifié' } + : { kind: 'redirect' }; + } + + const isRead = method === 'GET' || method === 'HEAD'; + + // Only API routes are gated by method. A React server action POSTs to the + // URL of the page it lives on, so gating pages this way would refuse every + // form to a read-only account — including the sign-out button. Pages carry + // their own check inside the action, where the intent is known. + if (isApi && !isRead && role !== 'admin') { + return { kind: 'deny', status: 403, error: 'Compte en lecture seule' }; + } + + return { kind: 'allow' }; +} diff --git a/lib/auth/actions.ts b/lib/auth/actions.ts new file mode 100644 index 0000000..7369345 --- /dev/null +++ b/lib/auth/actions.ts @@ -0,0 +1,48 @@ +/** + * Authorisation for React server actions. + * + * A server action POSTs to the URL of the page it sits on, so the edge + * middleware cannot tell a form that changes the shop's hours from a form that + * signs someone out. It therefore only authenticates page requests, and every + * action that writes states its own requirement here. + */ + +import { auth } from './index'; +import type { Role } from './roles'; +import { canWrite } from './roles'; + +export type Caller = { email: string; role: Role }; + +export type ActionResult = { ok: true; value: T } | { ok: false; error: string }; + +export async function currentCaller(): Promise { + const session = await auth(); + if (!session?.user) { + return null; + } + return { + // The subject is the e-mail address, which is what the audit trail records. + email: session.user.email ?? 'inconnu', + role: session.user.role, + }; +} + +/** + * Resolves the caller, or an error to show the user. + * + * Returns rather than throws: an action that throws renders the Next error + * overlay, and "you do not have permission" is a normal outcome, not a crash. + */ +export async function requireAdmin(): Promise> { + const caller = await currentCaller(); + if (!caller) { + return { ok: false, error: 'Session expirée. Reconnectez-vous.' }; + } + if (!canWrite(caller.role)) { + return { + ok: false, + error: `Ce compte est en lecture seule. Demandez à être ajouté au groupe d’administration.`, + }; + } + return { ok: true, value: caller }; +} diff --git a/middleware.ts b/middleware.ts index 564eca2..701bec2 100644 --- a/middleware.ts +++ b/middleware.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; +import { decideAccess } from '@/lib/auth/access'; import { auth } from '@/lib/auth'; /** @@ -10,33 +11,32 @@ import { auth } from '@/lib/auth'; * screen; API routes get a status code, because a fetch that receives an HTML * login page is a confusing way to learn you are signed out. * - * Write access is enforced here too: a `viewer` may read anything and change - * nothing. Doing it at the edge means a read-only account cannot reach a - * handler that mutates, whatever that handler remembers to check. + * Write access is enforced here for /api/admin only, and deliberately NOT for + * pages. A React server action POSTs to the URL of the page it lives on, so a + * method check over /admin would refuse every form on the site to a read-only + * account — including the sign-out button, which is not a write by any useful + * definition. Page-level write protection belongs inside the actions + * themselves, via lib/auth/actions.ts, where the intent is actually known. * * The device API (/api/setup, /api/display, /api/log) is deliberately outside * this matcher: the panel cannot sign in, and carries its own bearer token. */ export default auth((request) => { const { pathname } = request.nextUrl; - const isApi = pathname.startsWith('/api/admin'); - const session = request.auth; + const decision = decideAccess({ + pathname, + method: request.method, + role: request.auth?.user?.role, + }); - if (!session?.user) { - if (isApi) { - return NextResponse.json({ error: 'Non authentifié' }, { status: 401 }); - } + if (decision.kind === 'redirect') { const target = new URL('/login', request.nextUrl.origin); target.searchParams.set('from', pathname); return NextResponse.redirect(target); } - const isRead = request.method === 'GET' || request.method === 'HEAD'; - if (!isRead && session.user.role !== 'admin') { - return NextResponse.json( - { error: 'Compte en lecture seule' }, - { status: 403 }, - ); + if (decision.kind === 'deny') { + return NextResponse.json({ error: decision.error }, { status: decision.status }); } return NextResponse.next();