Spec 05 Auth - SekiroKenjii/Quizzivy GitHub Wiki

Section of Quizzivy — Frontend Portal & Data Model Specification · Previous · Next

5. Auth

Signing in (§5.1–§5.3) says who a user is; the user's role (§1.1) says what they may do. The permission catalogue, the built-in grants, the Admin wildcard, the hidden keys and the pseudo-keys (self, workspace.teacher, workspace.admin) are in docs/plan/70-redesign-overview.md §4.1. PermissionKey in api/openapi.yaml equals the rows of app.permissions, and the server refuses to start when the database lacks a key it was built with.

  • Enforcement. Every operation that requires the bearer token declares x-permission in api/openapi.yaml: a key, a pseudo-key, or a list met by any one of its keys (70 §4.2). httpx.RequirePermission enforces it on every request, after RequireAuth; the path is not the gate. The server refuses to start when an operation declares nothing, declares a value outside the catalogue, or declares one that does not belong to its path tree (/teacher/*, /admin/*, /app/*, /auth/*, /me/*).
  • Open operations do not require the bearer token, declare no permission and pass untouched. There are seven: login, Google sign-in, refresh, logout, POST /join/preview, GET /public/status and the integrity beacon (POST /app/attempts/:id/events).
  • Refusals. No valid token, an unknown or disabled user, or a token older than the user's session epoch (§5.2) → 401 UNAUTHORIZED with WWW-Authenticate, which the single-flight refresh settles. An unmet permission → 403 FORBIDDEN, with no detail.
  • The principal. Each gated request resolves the caller's role, permissions, disabled_at and session epoch through an in-process cache that keeps a user for 10 seconds. A write on the same machine forgets the entry at once, so the change applies on that machine's next request and on any other within 10 seconds. Polling does not query the database on every request.
  • Scope is separate from permission. Repositories filter by access.Scope: another teacher's id answers as a missing one does, and their rows never appear in a list or a count. A student is visible to every teacher who reaches them: a member of a class the teacher teaches, an account the teacher created, or an individual target of an assignment the teacher created. scope.all, which only the Admin holds, lifts the filter for a read or a write by id, and in the lists of classes, students and attempts. It does not lift it in the teacher workspace's six content lists (listTests, listQuestions, listQuestionGroups, listMedia, listWordImports, listAssignments, with their facets, tags and counts): those hold the caller's own rows for every caller, the Admin included, and for assignments that is those the caller created or that target a class the caller teaches (DG-53, T-R4.54). Both teacher home operations, getDashboard and getTeacherSummary, pass .Own(), so their figures cover only the caller's teaching, admins included; the repository retains its wide scope.all reading for the future Admin console. The Admin reaches another teacher's content by id and, from R5, in the Admin console.

5.1 Methods

Two, both landing on the same users row keyed by verified email:

  1. Email + password — student accounts created by staff (people.students.create), and staff sign-in.
  2. Google Sign-In — the primary path for students, and the only path for self-join (§6.3).

A user may have both. Linking rule: a Google sign-in whose ID token carries email_verified: true matching an existing user links to that user. An unverified email is rejected outright — no link, no create. This closes an account-takeover path.

5.2 Session model

  • Access token: JWT, ~15 min, held in memory (Zustand). Never localStorage, never sessionStorage. It carries the user, its times and the session epoch (sep) and no role; a token minted before v0.10.0 also names one, which is ignored.
  • Refresh token: opaque, rotating, httpOnly; Secure; SameSite=Lax; Path=/auth cookie. Stored server-side as a hash (§13.5).
  • On 401 the client calls POST /auth/refresh once and retries. The refresh is single-flight.
  • A refused refresh (401 or 403), or a second 401, ends the session.
    • A refusal ends only the session its request was sent under. When the store's access token is no longer the one the refused request carried (a sign-in or a refresh replaced it, or the user signed out), nothing is expired or cleared, and the 401 returns to its caller.
    • A user who was signed in keeps the page: the access token is dropped, the user stays, and the "Vui lòng đăng nhập lại" overlay covers the page (§9).
      • A sign-out closes that overlay if it was raised while the sign-out was on its way.
      • After the user's own sign-out, that tab's guard sends a visitor to /login without next until its next sign-in. The marker is in memory, so a reload does not preserve it. An expired session keeps its return path. A second tab left open after sign-out can still offer next through its expired-session card; that path remains unresolved.
    • "Đăng nhập" clears the session, keeps the answer drafts, and goes to /login?next=<path>.
    • While that overlay is up, a 401 returns to its caller without another refresh.
  • A refresh that fails without refusing (a network failure, a 503 or another 5xx) is not a lost session. Waiting requests fail retryably and nobody is signed out.
  • App load: GET /auth/me, under the boot splash (§9).
    • 401 → signed out, unless a sign-in finished while the request was out.
    • A network failure (no HTTP response at all, on /auth/me or on the refresh it triggers) → the splash's offline state, which retries.
    • Any other failure (a 5xx, a 503 other than MAINTENANCE, or a 4xx other than 401 and 403) → on / and the signed-in routes, the unexpected-error page with the server's requestId; on the public routes the splash fades out and the page renders as usual.
    • 503 MAINTENANCE → the maintenance overlay.
  • Every request sends Accept-Language set to the app's locale, so server messages match the UI rather than the browser.
  • Reuse detection: presenting an already-rotated token revokes the whole family and forces re-login.
  • Races. A sign-in stores its refresh token under a lock on the user and compares the account with what it read: if the password, the session epoch or the disabled state changed meanwhile, a password sign-in answers INVALID_CREDENTIALS and a Google sign-in reads the account again. A rotation and a logout take the lock an update of the user takes, so one user's rotations, reuse detections and logouts run one at a time and none overlaps a reset, a disable or a password change: a session never survives the event that should have ended it.
  • Session epoch. users.session_epoch ends live sessions at once. Sign-in, Google sign-in and refresh put the user's current epoch in the access token (claim sep; a token without it reads as 0), and a token older than the user's epoch is refused (§5). In R2 two writes move it, each in the same transaction that revokes every refresh family the student has: disabling a student (updateStudent with disabled: true) and resetting a student's password (resetStudentPassword, and each student of resetStudentsPasswords). Enabling the account again does not bring those sessions back. R5's sign-out-everywhere, role changes and set-password links move it too (70 §4.2).
  • Signed-in devices. A session is a refresh-token family that is neither revoked nor expired. GET /auth/sessions returns the caller's own, at most 100, the one the request's refresh cookie names first with current: true: a handle (familyId), a device label ("Mac · Chrome", built at read time from closed lists of systems and browsers and never from the user agent's own text, or null), a deviceKind for the icon, a location ("City, CC", or null) and lastUsedAt. The location and the time are those of the latest sign-in or refresh, so the list shows where a device last refreshed, not where it first signed in: a rotation takes the label of its own request, and copies its predecessor's only when the request has none. The label is built from CF-IPCity and CF-IPCountry only when CLIENT_IP_HEADER is CF-Connecting-IP and the request carries that header; under any other configuration it is null. It reaches no log and no audit row. DELETE /auth/sessions/{familyId} ends one session, and POST /auth/sessions/revoke-others every one but the caller's. Each is one command: it takes the lock a rotation takes, revokes the family, moves the caller's session epoch, audits it and, after the commit, forgets the cached principal. A revoked device's access token stops working at once on that machine and within 10 seconds on any other; the caller's own next request answers 401 and recovers through the single-flight refresh, because the caller's family stays live and a refresh does not read the epoch. The caller's session is the one the refresh cookie names. A request whose cookie names no live session of the caller, such as one cleared by a sign-out in another tab or a bearer token alone, is answered 401 UNAUTHORIZED by both writes, which write nothing; in the client that is a refresh that fails, and the sign-in overlay shows. An id that is another user's, revoked, expired or unknown answers 404; the caller's own answers 409 SESSION_IS_CURRENT (signing out is POST /auth/logout). A revoke-others that ends nothing writes nothing and moves no epoch.
  • A disabled user is refused on the next request. Every gated request reads disabled_at through the principal cache (§5): at once on the machine that made the change, within 10 seconds on any other. Refresh refuses a disabled user and revokes the family, so the client's refresh ends the session.

5.3 Google flow

  1. Frontend loads GIS and requests an authorization code (not implicit ID-token) with PKCE.
  2. POST /auth/google { code, codeVerifier, redirectUri, joinCode? }.
  3. Backend exchanges with Google, verifies the ID token (iss, aud, exp, signature via JWKS), reads sub, email, email_verified, name, picture.
  4. Resolution order:
    • identity exists → log in;
    • verified email matches a user → link identity, log in;
    • no match and a valid joinCode is present → create account + enroll (§6.3);
    • no match, no join code → 403 ACCOUNT_NOT_PROVISIONED.
  5. Returns { accessToken, user }, where user is the CurrentUser (§7), and sets the refresh cookie.

VITE_GOOGLE_CLIENT_ID is public config. The client secret lives only in the backend.

5.4 Guards and edge cases

  • RequireSession → /login?next=<path> when unauthenticated.

  • The web guards read the signed-in user's workspaces and permissions (CurrentUser, §7), never role, through features/auth/permissions.ts (can and useCan, hasWorkspace and useWorkspace). They decide what the SPA shows; the server enforces the permissions on every request (§5).

    • TeacherWorkspace holds /teacher/*, the teacher console since R4, for the teacher workspace. Anyone else gets a 403 page, not a redirect (a redirect hides the misconfiguration).
    • StudentArea holds /app/* for the app workspace. A user without it who has the teacher or admin workspace is redirected to /teacher; a user with neither gets the 403 page.
    • homePathFor sends the teacher or admin workspace to /teacher and anyone else to /app. An Admin with "Take tests" turned on (R5) lands on /teacher and may also open /app.
    • A v0.9.x address under /admin/* redirects to the same path under /teacher, in any letter case, keeping its query and hash; /admin/assignments/:id/attempts and /teacher/assignments/:id/attempts go to the assignment's Students tab. The redirect stays until R5 builds the Admin console at /admin.
    • learnsOnly (the student app is the user's only workspace) keeps v0.7.0's rules on /join and the Settings role label (§6.2).
  • mustChangePassword: true → all routes redirect to /change-password. Google-only users never hit this.

  • Logout: POST /auth/logout (revokes refresh token), clear store, queryClient.clear(), → /login. The server revokes the family under a budget of its own, ten seconds, that the end of the request does not cancel: a tab closed or a connection lost during sign-out still ends the session, whose cookie stays in a browser that never saw the answer. A refresh does the opposite and runs on the request's own context (docs/plan/30-risks.md R-06).

  • The frontend applies accepted server locale/theme/display zone/larger text over browser mirrors, with omitted defaults vi/light/Asia/Ho_Chi_Minh/false and no default-materialization PATCH. Anonymous choices remain local until a sign-in whose account lacks that key: a valid device choice (one made on this browser, not a value copied from an account) is then applied and saved to the account once, and a failed save retries silently at the next sign-in. Authenticated controls preview pending changes, restore acknowledged presentation on failure and retain an explicit Retry. A browser-unsupported account zone retains its exact server value, reports compatibility and temporarily presents Vietnam time without saving that fallback. Actor departure invalidates stale asynchronous effects.

  • The frontend orders refresh/login/Google-login/logout cookie sends and global draft cleanup before replacement admission. Refresh remains single-flight. A20s transport/admission UI deadline and10s cleanup deadline show pending status without abandoning raw ownership; late timed-out login never auto-admits. Permanent hangs may keep admission closed. Cross-tab ordering is not promised by this design.

  • Password reset in v1: a holder of people.students.reset_password sets a temporary password (word-word-word-NN, three words and two digits, about 24.4 bits) from the student detail page, under the shared-student rule below, or for up to 40 students at once from the student list. No self-service email flow (§17.1).

  • New passwords have three rules:

    • at least 8 characters;
    • at least one number or symbol ([\p{N}\p{P}\p{S}]);
    • different from the current password (the temporary one, while mustChangePassword is set).

    The contract enforces the first two (400 VALIDATION_FAILED) and the server the third (400 PASSWORD_UNCHANGED). Existing passwords are never re-validated. /change-password shows the rules and a strength meter.

  • /forgot-password makes no request. It tells a Google user that no password is needed, and everyone else to ask the front desk (§4, when configured) or their teacher.

  • Guards that are not permissions (70 §4.3), enforced by the server. A guard on a student runs after the student is found in the caller's scope, so another teacher's student answers 404, never 403.

    • Strict student targets. Every student read and write (the Students list and record, update, reset, bulk reset and delete, class membership, individual assignment targets, the dashboard's counts and maintenance anonymize-student) accepts only a role in app.student_like_roles: the built-in Student, or a custom role granted nothing but learning.take_tests. An Admin with "Take tests" turned on is never a student target. For an assignment's individual targets the rule applies to a target being added: a student who is already a target stays on the assignment when they are disabled or their role stops being student-like, so the teacher can still publish, edit and close it (#210).
    • The subset rule. updateStudent, resetStudentPassword, resetStudentsPasswords (for each student in it) and deleteUser need the target's permissions, without learning.take_tests, to be a subset of the caller's (access.CanActOn); otherwise 403 FORBIDDEN. 70 §4.3 lists the R5 operations it will also cover.
    • Disabling. updateStudent with disabled, either value, also needs people.users.manage; otherwise 403 FORBIDDEN.
    • Shared students. A reset, single or bulk, or a new email, by a caller without people.users.manage needs a student no one else reaches: every class they are in, archived ones included, is the caller's; no other account created them; no other account's assignment targets them individually; and a student in no class was created by the caller. Otherwise 403 STUDENT_SHARED, and nothing is written. Sending the address the student already has is not a change. A new address could take the account over through Google sign-in, which links by email (§5.1).
    • The last Admin. The users_last_admin trigger refuses any demotion, disable or delete that would leave no active Admin, whatever the path. No R2 operation can reach an Admin account; 409 LAST_ADMIN arrives with R5.
    • The built-in Student role cannot lose learning.take_tests.

5.5 API reference session

  • /docs and /docs/openapi.json are served by the API beside the contract and open only to a holder of system.api_reference, a hidden key only the Admin holds (70 §4.1). A browser navigating there sends no bearer token, so they check a separate cookie instead.
  • POST /admin/docs-session (openDocsSession, x-permission: system.api_reference, rate-limited per signed-in user, §6.5) sets quizzivy_docs: Path=/docs; HttpOnly; Secure; SameSite=Strict; Max-Age=900. Its value is a JWT for the docs audience that carries the caller's session epoch, signed with a key derived from the access-token key: a docs token is never accepted as an access token, and an access token never opens the docs.
  • The gate resolves the cookie's user through the same principal cache as the API (§5). A missing, tampered or expired cookie, an unknown or disabled user, or a cookie older than the user's session epoch → 401; a role without system.api_reference → 403, both in the error envelope with no page or contract leaked. POST /auth/logout clears quizzivy_docs together with the refresh cookie, so signing out also ends an open docs session.
  • The SPA's admin settings open the reference: the new tab is opened synchronously in the click, opener is cleared, and only then is the session requested and the tab pointed at /docs.
  • DOCS_PUBLIC=true skips only the cookie check, for local development. The server refuses to start with it when APP_ENV=production; SRI, the page's CSP and the rate limit apply everywhere.

Section of Quizzivy — Frontend Portal & Data Model Specification · Previous · Next


Generated from docs/quizzivy-spec-v0.3.md. Edit that file in the repository; changes made on this page are overwritten.

⚠️ **GitHub.com Fallback** ⚠️