Spec 05 Auth - SekiroKenjii/Quizzivy GitHub Wiki
Section of Quizzivy — Frontend Portal & Data Model Specification · Previous · Next
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-permissioninapi/openapi.yaml: a key, a pseudo-key, or a list met by any one of its keys (70 §4.2).httpx.RequirePermissionenforces it on every request, afterRequireAuth; 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/statusand 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 UNAUTHORIZEDwithWWW-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_atand 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,getDashboardandgetTeacherSummary, pass.Own(), so their figures cover only the caller's teaching, admins included; the repository retains its widescope.allreading for the future Admin console. The Admin reaches another teacher's content by id and, from R5, in the Admin console.
Two, both landing on the same users row keyed by verified email:
-
Email + password — student accounts created by staff (
people.students.create), and staff sign-in. - 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.
- 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=/authcookie. Stored server-side as a hash (§13.5). - On 401 the client calls
POST /auth/refreshonce 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
/loginwithoutnextuntil 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 offernextthrough 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/meor 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'srequestId; on the public routes the splash fades out and the page renders as usual. - 503
MAINTENANCE→ the maintenance overlay.
- Every request sends
Accept-Languageset 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_CREDENTIALSand 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_epochends live sessions at once. Sign-in, Google sign-in and refresh put the user's current epoch in the access token (claimsep; 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 (updateStudentwithdisabled: true) and resetting a student's password (resetStudentPassword, and each student ofresetStudentsPasswords). 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/sessionsreturns the caller's own, at most 100, the one the request's refresh cookie names first withcurrent: true: a handle (familyId), adevicelabel ("Mac · Chrome", built at read time from closed lists of systems and browsers and never from the user agent's own text, or null), adeviceKindfor the icon, alocation("City, CC", or null) andlastUsedAt. 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 fromCF-IPCityandCF-IPCountryonly whenCLIENT_IP_HEADERisCF-Connecting-IPand 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, andPOST /auth/sessions/revoke-othersevery 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 answered401 UNAUTHORIZEDby 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 answers404; the caller's own answers409 SESSION_IS_CURRENT(signing out isPOST /auth/logout). Arevoke-othersthat ends nothing writes nothing and moves no epoch. -
A disabled user is refused on the next request. Every gated request reads
disabled_atthrough 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.
- Frontend loads GIS and requests an authorization code (not implicit ID-token) with PKCE.
-
POST /auth/google{ code, codeVerifier, redirectUri, joinCode? }. - Backend exchanges with Google, verifies the ID token (
iss,aud,exp, signature via JWKS), readssub,email,email_verified,name,picture. - Resolution order:
- identity exists → log in;
- verified email matches a user → link identity, log in;
- no match and a valid
joinCodeis present → create account + enroll (§6.3); - no match, no join code →
403 ACCOUNT_NOT_PROVISIONED.
- Returns
{ accessToken, user }, whereuseris theCurrentUser(§7), and sets the refresh cookie.
VITE_GOOGLE_CLIENT_ID is public config. The client secret lives only in the backend.
-
RequireSession→/login?next=<path>when unauthenticated. -
The web guards read the signed-in user's
workspacesandpermissions(CurrentUser, §7), neverrole, throughfeatures/auth/permissions.ts(cananduseCan,hasWorkspaceanduseWorkspace). They decide what the SPA shows; the server enforces the permissions on every request (§5).-
TeacherWorkspaceholds/teacher/*, the teacher console since R4, for theteacherworkspace. Anyone else gets a 403 page, not a redirect (a redirect hides the misconfiguration). -
StudentAreaholds/app/*for theappworkspace. A user without it who has theteacheroradminworkspace is redirected to/teacher; a user with neither gets the 403 page. -
homePathForsends theteacheroradminworkspace to/teacherand anyone else to/app. An Admin with "Take tests" turned on (R5) lands on/teacherand 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/attemptsand/teacher/assignments/:id/attemptsgo 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/joinand 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.mdR-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_passwordsets 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
mustChangePasswordis 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-passwordshows the rules and a strength meter. -
/forgot-passwordmakes 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, never403.-
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 inapp.student_like_roles: the built-in Student, or a custom role granted nothing butlearning.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) anddeleteUserneed the target's permissions, withoutlearning.take_tests, to be a subset of the caller's (access.CanActOn); otherwise403 FORBIDDEN. 70 §4.3 lists the R5 operations it will also cover. -
Disabling.
updateStudentwithdisabled, either value, also needspeople.users.manage; otherwise403 FORBIDDEN. -
Shared students. A reset, single or bulk, or a new
email, by a caller withoutpeople.users.manageneeds 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. Otherwise403 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_admintrigger 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_ADMINarrives with R5. - The built-in Student role cannot lose
learning.take_tests.
-
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
-
/docsand/docs/openapi.jsonare served by the API beside the contract and open only to a holder ofsystem.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) setsquizzivy_docs:Path=/docs; HttpOnly; Secure; SameSite=Strict; Max-Age=900. Its value is a JWT for thedocsaudience 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 withoutsystem.api_reference→403, both in the error envelope with no page or contract leaked.POST /auth/logoutclearsquizzivy_docstogether 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,
openeris cleared, and only then is the session requested and the tab pointed at/docs. -
DOCS_PUBLIC=trueskips only the cookie check, for local development. The server refuses to start with it whenAPP_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.