ADR-027 — Launch tickets and typed sessions (Safe Exam Browser first)¶
Status¶
Accepted (2026-09-26, issue #139, with apps/api/src/auth/launch.ts,
auth/seb.ts, sessions.kind and the sitRefusal rule of modules/guards.ts).
Brings Safe Exam Browser back into scope (00 §0.6).
Addendum (2026-09-30, ADR-035, the classroom merge): what is settled, and what waits for D21 (unified SEB design, settled 2026-10-01 for M6).
- Settled — the exception for the online workspace. §1 rejected a
self-contained signed token. The portal of the online workspace
(ADR-047) is on another VM and has its own database, so the platform
reaches it with two HS256-signed messages (a 2-minute service token for
the sync, a 5-minute launch token), as classroom does. This is a
recorded exception, not a reversal: the portal enforces single use
itself (it consumes the token's
jtiby insert before anything else), the launch token grants no role, and nothing else in Quiz uses a signed token for a session. - D21, settled 2026-10-01, implemented in M6 — a
sebsession per activity. The direction of ADR-035 is that asebsession is confined to an activity (evaluation or project) rather than to an evaluation: a project's session would reach the project page and "Open workspace", nothing else, and the platform would build every.sebfrom a sharedpackages/seb. Until M6 implements it,sessions.evaluation_id,sitRefusaland the routes of §3–4 are unchanged, and no project can require SEB. Whether Browser Exam Keys become optional per activity (§2 rejected a list) is part of D21 too.
Addendum (2026-09-30, ADR-051, attested kiosk stations): a second
confined kind, kiosk, sits beside seb, and an exam accepts either or
both. For seb, §2 changes: the Config Key is stored on the session and
checked on EVERY request, audit-only until proof B. §3 changes: a confined
session's cookie is SameSite=Strict, and a new confined session
supersedes the previous one of the same student and exam. There is still
no Browser Exam Key.
Addendum (2026-10-07, M6-02, D21 point 1): the plist, the Config Key
and the hashes left auth/seb.ts for the pure packages/seb (@quiz/seb),
the full port of classroom's (every Moodle rule: originatorVersion
stripped, empty dictionaries pruned, <data>, <real>, <date>), checked
against all of Moodle's vectors, the 201-key one included. The file Quiz
serves is unchanged byte for byte, and so is its Config Key
(auth/seb.snapshot.test.ts). One change: the absolute URL a request's
header is checked against drops a #fragment (the SEB specification
hashes the URL "without Fragment part"; a browser never sends one); auth/seb.ts keeps the routes and the
evaluation's configuration (Quiz's host only).
Addendum (2026-10-07, M6-07, D21 points 2–5): a seb session per
activity. The ticket and the session carry project_id instead of
evaluation_id for an online_seb project (never both, a check
constraint); SessionAuth.projectId. §3's default deny gains a second
axis: a route declares the activities a confined session it serves may
be confined to (activities, absent: an evaluation), so an evaluation's
session is anonymous on a project's routes and the reverse; a project's
session reaches GET /me, its project's student page and Open workspace
(PROJECT_SEB), each loaded for that project only. The platform builds
every .seb (sendLaunchFile): a project's file allows the workspace
portal's host too, and both add SEB_EXTRA_ALLOWED_HOSTS (empty by
default: the evaluation's file is unchanged). §2's start route reads the
ticket's activity without consuming it, then checks the header against
that activity's file, then consumes: a refused header never burns the
ticket. One session per (user, activity) (ADR-051 §4). Still no Browser
Exam Key in Quiz (ADR-047 amendment "M6-07").
Addendum (2026-10-08): the quit link. The file hides SEB's task bar,
and with it SEB's Quit button: a student who had handed in found no way
out. Every .seb Quiz builds now carries, after allowQuit, a quitURL
(<PUBLIC_URL>/seb/quit, SEB_QUIT_PATH, on the start URL's host, which
the filter already allows) and quitURLConfirm false: SEB quits, without
confirmation, when it is about to navigate there — the key Moodle's
quizaccess_seb sets as its "link to quit SEB". The screens a seb
session shows when it is done (the end of the attempt, You have left the
exam, the refused launch seen in SEB) and a project's page in SEB carry
that link as Quit Safe Exam Browser, with SEB's shortcut (Ctrl+Q, ⌘Q),
which may ask to confirm, beside it.
Outside SEB the address is an SPA page saying the window may be closed.
This changes the bytes of every file, hence every Config Key
(auth/seb.snapshot.test.ts updated deliberately): an open session keeps
the key it was launched with, and a file downloaded before the deploy and
opened after is refused at its start route without burning its ticket —
the student downloads it again.
Amended 2026-10-09: the rule is named sitRefusal (modules/guards.ts), formerly sits; §4 keeps its old anchor.
Amended 2026-10-11 (security audit L5, N-SEC-09), scope: §2, which launch
refusals are audited. The start route takes no session, so any visitor could
write auth.seb_refused rows at will: a secret that names no pending ticket is
now refused without an audit row, and each pending ticket's audited refusals
are capped at AUDITED_SEB_REFUSALS_PER_TICKET (auth/seb.ts) — per ticket,
not per address, since an exam room shares one address; the others are
logged only.
Context¶
An exam sat in Safe Exam Browser (SEB) is a separate browser: the portal's session does not follow the student into it. Signing in again inside SEB means edu-ID, and edu-ID means a second factor on a phone the student may not touch during the exam. The student is already signed in on the portal; what is missing is a way to carry that identity into SEB, for ONE exam, and nothing more.
Two things must hold at once. A session opened this way must not be a portal session: it reaches the exam and nothing else, even from a hand-written HTTP client. And an exam that requires SEB must not be reachable from the portal, or the file is a detour anyone can skip.
Decision¶
1. A one-time launch ticket, not a JWT and not OIDC¶
The student's card downloads a .seb (GET /app/api/evaluations/:id/seb, a
portal session holding a seat). The file's start URL carries a random 256-bit
secret: /app/auth/seb/<secret>. The database keeps launch_tickets, with
the secret's SHA-256 only, the user, the evaluation, the kind of session it
opens, who asked for it, five minutes of validity, consumed_at and
revoked_at. Downloading again revokes the previous unused ticket. The
secret is consumed by ONE conditional UPDATE … RETURNING: of two
concurrent requests, exactly one gets it.
A self-contained signed token was rejected: it cannot be consumed once, nor revoked, without the database row it was meant to avoid. The download is a GET, like the CSV export, so the card is a plain link; another site forcing it only revokes an unused file.
2. The start route checks SEB, then the ticket, then the seat¶
GET /app/auth/seb/<secret> checks SEB's X-SafeExamBrowser-ConfigKeyHash
header first (sha256(URL + Config Key)), so a copied file opened in an
ordinary browser is refused WITHOUT consuming the ticket. Then it consumes the
ticket, checks again that the seat is held and that the exam still requires
SEB (sebSeat, the same check as the download; the ticket is minutes old),
opens a seb
session through openSession — still the one place session cookies are
minted — and lands on /take/<evaluation>. Every refusal looks the same to
the client (/?seb=invalid); the audit log keeps the reason
(auth.seb_refused: config_key, ticket, seat). Amended 2026-10-11
(see Status): only for a secret that names a pending ticket, at most
AUDITED_SEB_REFUSALS_PER_TICKET times per ticket.
The Config Key is computed, not stored: the file is a function of its start
URL, so the start route rebuilds it from the URL it was reached on. The port
of the Config Key (from heig-classroom, itself from Moodle's
quizaccess_seb) is cut down to the plist subset the file uses and is checked
against Moodle's own 201-key SEB-JSON vector. There is no Browser Exam Key
list: it needs one key per SEB version and platform, which one person cannot
maintain.
What the header proves is limited, and said so: a student who reads the file can compute it. It stops the accidental and the lazy — opening the file's URL in Chrome — not the determined. The server-side confinement below is the security boundary; SEB's own restrictions are not.
Noted 2026-10-11: the security audit of 2026-10-10 records exactly this limit as finding M10 (a Config Key header forged without a Browser Exam Key); the decision is unchanged — an accepted risk, and the high-stakes exams sit on attested kiosk stations (ADR-051).
3. Typed sessions and default deny¶
sessions gains kind (portal by default, so every existing row is what
it was), actor_user_id and evaluation_id. The request carries
req.auth = { kind, actorUserId, evaluationId }; modules read that, never the
columns. A route declares the kinds it serves (config: { sessions: [...] },
SITTING for the student routes of live, GET /me and the SSE stream); a
route that declares nothing serves portal only. On any other route a seb
session is simply not there: the request is anonymous, so a 401 wherever a
session is required, and the static files and public routes are unaffected.
A new route is therefore closed to seb until it opts in.
The lifetime of each kind is one table in auth/session.ts: a portal
session slides on SESSION_TTL_HOURS; a seb one never slides, and lives
6 h from the launch. It is not deleted on submit: after it, every write is a 410 and the
session reads its own closed attempt, which is what the screen shows.
4. One rule for who sits what: sitRefusal¶
sitRefusal in modules/guards.ts (first named sits) answers why a request
may not sit an evaluation, or nothing when it may: a seb session sits its own evaluation and
nothing else; any other session sits every evaluation that does not require
SEB. It is applied after the loaders of the sitting routes (enter, retake,
every /attempts/:id/…) and once for every SSE watch, and answered with their
404. A staff member watching somebody else (dashboard, inspector) is not
sitting and is not affected; a teacher REHEARSING the exam through their own
seat (ADR-018) is sitting, and does it with the .seb like a student.
settings.safeExamBrowser switches the requirement on. It is an exam's switch
(sebRequired): shown on exams only, and inert on any other mode, the way
negative marking is on a poll — so no path of an exercise (retakes included)
can be a way around it.
5. Shaped for delegation, not building it¶
actor_user_id (who acts, null when the user acts for themself) is distinct
from user_id (as whom), with the same meaning on the ticket and on the
session, and tracer writes the actor. That is what a teacher
acting as a student would need; nothing else of it exists, and its policy is
an open question (06, row 24).
Built by ADR-034 (2026-09-28, #201): an admin, never a teacher in v1,
opens an impersonation session of a student from a one-time link pasted
into a private window — one hour, fixed; read-only in production by one rule
in the session hook, full write in development; never present in the room;
impersonation.started/ended in the audit. Tickets are now consumed by
kind, and revoked by (user, actor, kind, evaluation) with IS NULL for a
null column.
Consequences¶
- A
sebsession reaches:GET /me, entering its evaluation, the routes of its own attempt, and the SSE stream of its evaluation. Everything else is anonymous to it:auth/seb.db.test.tsasks every route of Fastify's tree with the session and without, and fails on any route outside the declared list that answers differently. - The SPA, on a
sebsession, renders the attempt page of its evaluation and otherwise one screen ("You have left the exam"), with no frame at all. - The request log masks the secret (
redactLaunchUrl). The Caddyfile keeps no access log; if one is ever added, the same path must be masked there. - The
.sebsettings are those of the sibling project and have not been confronted with a pinned SEB binary either (TODO(verify)of its README).
Alternatives considered¶
- edu-ID inside SEB. The second factor is the problem this solves.
- A signed token instead of a ticket row. No single use, no revocation.
- A 403 for a
sebsession on an undeclared route. Needs a list of public paths (static files,/config) to exempt; anonymous needs none. - Deleting the
sebsession on submit. The SPA would then fall to the sign-in page inside SEB instead of the closed attempt.