Skoolsewa - School Management System Docs
Developer Resourcesauth

Auth Module Feature Guide

Functional behavior of admin auth and customer mobile auth — login by email or mobile number, sessions, password reset, and verification.

Auth - Feature Guide

1. Feature Overview

Auth is intentionally split by actor surface:

  • Admin/superadmin auth on /api/auth/* (password sign-in by email address or mobile number)
  • Customer mobile auth on /api/mobile/auth/* (password sign-in by email address or mobile number)

There is one password per account, whichever identifier signs in with it. Password reset, verification, one-time codes and invitations are email-based; a mobile number is only ever a second way to name the account at sign-in.

Accounts are created by an administrator through the Users and Team modules. There is no self-service registration and no self-service account deletion. banned is the only account control.


2. Surface Matrix

SurfacePrefixRegister
Admin/api/auth/*No
Customer mobile/api/mobile/auth/*No

3. Account Setup

An administrator creates an account; the person it belongs to finishes it. The account arrives with no password and an unverified address, and one email closes both gaps.

  1. The administrator grants sign-in access. An invitation email goes out with a single-use link valid for seven days.
  2. The recipient opens the link. The panel shows Set Your Password — Create a password for your new account to complete your account setup.
  3. They choose a password and submit. In one transaction the address is marked verified and the password is set.
  4. They are sent to sign in. The account is ready.

There is no second verification email. Clicking the link is the proof that the mailbox belongs to them, so asking for a further confirmation after the password is set would be asking twice for the same fact. The recipient never passes through forgot-password, reset-password, account-verification or resend-verification on this path.

Setting the password does not create a session. The flow ends at the sign-in screen, so a leaked invitation costs an attacker a password they must still use at the front door.

The address is marked verified only when the invitation's channel is email and its stored address still equals the account's. An invitation whose address the account no longer holds is refused outright rather than applied — otherwise a link sent to somebody's former address could be used to claim the account it was issued against.


4. Auth Features

Admin and mobile surfaces both support:

  • password login by email address or mobile number (§4.1)
  • an active role chosen at sign-in, which the two surfaces choose differently for a person holding several roles (§4.2)
  • refresh token rotation, with a refresh window that slides forward on every refresh
  • token expiry in every login and refresh response — accessTokenExpiresAt and refreshTokenExpiresAt — so an app refreshes before its access token lapses instead of finding out from a refused request
  • logout
  • password reset (forgot + reset)
  • email verification resend/confirm
  • self-service email change (request, confirm by code or link, cancel)

Admin-only extras:

  • GET /api/auth/permissions
  • phone verification endpoints under /api/auth/phone/verify/*

4.1 Signing in by email address or mobile number

A person signs in with their password and one of:

  • email — the account's email address, the field every existing client sends; or
  • identifier — an email address or a mobile number, as the person typed it.

Sending both, or neither, is refused with 400 VALIDATION_FAILED. An identifier containing @ is an email address and signs in exactly as email does. Anything else is a mobile number.

Typing the number. Spaces, dashes, dots and parentheses are ignored, a leading +977, 00977 or 977 in front of ten digits is dropped, and so is one leading 0 in front of ten digits. What remains must be exactly ten digits. 9800100001, +977 9800100001, +977-980-0100001, 00977 9800100001 and 09800100001 all name the same number. The stored number on each account is read the same way, so an account saved as +9779800100001 and one saved as 9800100001 both answer to any of those spellings. A number that does not come out as ten digits — a landline, a foreign number, a typo — matches nobody and is answered as a wrong password.

A shared number. A household often shares one mobile number, so a number alone never picks a person. The password does:

Accounts that could sign in on the numberPassword matchesOutcome
none—401 AUTH_INVALID_CREDENTIALS
one to fiveexactly one of themsigned in as that account
one to fivenone of them401 AUTH_INVALID_CREDENTIALS
one to fivetwo or more of them401 AUTH_PHONE_LOGIN_AMBIGUOUS
more than fiveany401 AUTH_PHONE_LOGIN_AMBIGUOUS

AUTH_PHONE_LOGIN_AMBIGUOUS reads "Several accounts use this number. Sign in with your email instead." — and that is the remedy: the email address always names exactly one account.

"Could sign in" means what signing in by email requires of the account: it is not deleted, not banned, allowed to sign in, and its email address is verified. An account that fails any of these is not a candidate at all — it is never the account a number signs into, it does not count towards the five, and a banned relative's ban reason is never shown to whoever types the shared number. An account with no password counts as a candidate but never matches.

The number itself does not have to be verified. A number an operator types on a person's record is a sign-in key for that person from the moment it is saved, without anyone proving they hold it; the password is still what lets them in. This is a deliberate product decision.

Every attempt by number does the same password-checking work, whatever the answer: five checks run whether the number is unknown, held by one person, shared, or held by more than five. That work dominates the reply time, so timing the reply does not show whether, or by how many people, a number is used. Only a successful sign-in does more, after the decision.

A wrong password costs the account nothing. No account is locked or counted against for a failed attempt by number — a family member mistyping their own password must not lock a relative out. The only limit on guessing is the sign-in rate limit described in §7, the same one email sign-in has.

Signing in by number produces the same session, tokens and response as signing in by email, including the device registration on the mobile route. Forgot-password and reset stay email-only: a person who has forgotten their password resets it through their email address whichever identifier they normally sign in with.

4.2 Which role a person acts as after signing in

A person may hold several roles — a teacher is also staff, and a teacher may also be a guardian of a pupil at the school. Each session acts as one of them at a time, the active role, and everything the person may do in that session follows from it.

  • Holding one role, a person starts every session with it, on either route.
  • Holding several, on the admin panel (POST /api/auth/login/email), a person starts with no active role and chooses one. Until they do, GET /api/auth/me reports requiresRoleSelection: true and every permission-gated screen is refused with 403 AUTH_ACTIVE_ROLE_REQUIRED.
  • Holding several, on the mobile app (POST /api/mobile/auth/login/email), a person who holds teacher starts as teacher, whatever their other roles are: a member of staff who teaches, a guardian who teaches, and a superadmin who also holds teacher all land on the teacher screens with nothing to choose, and the sign-in reply's user.role says teacher. The teacher screens also need a live staff record: a person who starts as teacher without one is refused 403 PERMISSION_INSUFFICIENT on every teacher screen and switches to another role. Holding several roles without teacher, they start with no active role, as on the admin panel. Signing in by email address or by mobile number makes no difference.

Switching. A guardian who teaches reaches their child's screens by switching the session to guardian with POST /api/auth/context/switch, sending the id of that role from roles on GET /api/auth/me. The mobile auth surface offers neither a role list nor a switch of its own, so the app calls those two admin routes with the same token. While teacher is active the guardian screens answer 403 PERMISSION_INSUFFICIENT. The switch lasts for the session — a token refresh keeps it — and the next mobile sign-in starts as teacher again. A role the person does not hold is refused with 403 AUTH_ACTIVE_ROLE_NOT_HELD.


5. Account Entry Links

A person receives an emailed link in two situations, and both end on the same screen: choose a password.

PurposeMinted whenLifetime
account_inviteAn administrator grants somebody sign-in access — while creating their record, or afterwards from the people screens7 days
password_resetSomebody uses forgot-password, or an administrator sends a reset link from a person's detail screenThe OTP expiry, 15 minutes by default

An invitation redeems through POST /api/auth/invitation/accept and a reset through POST /api/auth/password/reset, and each link points at its own screen. The reset endpoint also accepts an account_invite token, so a link pasted into the wrong screen still works; the invitation endpoint accepts only its own.

Somebody clicking a link cannot tell which of the two they hold, and it makes no difference to what they do next. They stay separate verification records so the two can carry different lifetimes, be audited apart, and so an invitation nobody has redeemed can be reported on.

An invitation is redeemable by link only. A one-time code is minted alongside it but is never rendered into the invitation email, and the OTP branch of the reset endpoint matches password_reset alone. That is deliberate rather than an omission: the OTP lookup selects on purpose and person with no token hash to separate two live records, so accepting both purposes there would let a correct code be compared against the wrong record, fail, and consume one of the five attempts that retire it.

Redeeming either link retires the other. An invitation and an administrator's reset can be live for the same person at the same time, and once one of them has set a password the other must stop working.

A link that has expired, been redeemed, or been retired this way is refused with AUTH_VERIFICATION_CODE_INVALID. The remedy is a fresh link, not a retry.


6. Self-Service Email Change

A signed-in person changes their own sign-in address from their profile screen, without an administrator. The panel's own profile PATCH has no email field — the sign-in address changes only through this flow, so a person cannot slip a new address past the checks below by editing their profile.

  1. The person enters the new address and their current password. Getting the current password wrong costs nothing but the mistake: the request is refused before anything is minted, so it never uses up the cooldown or an attempt.
  2. A confirmation link and a 6-digit code go to the new address, and a notice with a cancel link goes to the old one. Nothing on the account changes yet.
  3. The person confirms either from inside their session, with the 6-digit code, or from the link itself — the link works without a session, which is why it asks for the current password again: whoever merely reads the new mailbox (a typo, a shared inbox) cannot finish the change alone.
  4. Confirming moves the address, ends every session including the one that confirmed it, retires every other pending credential (a password reset or another email change), and tells the old address it happened. The person signs in again with the new address.

Cancelling uses the link sent to the old address and only ever deletes a still-pending change; it cannot undo one that already completed. Three distinct outcomes come back, and none reads as another: cancelled, nothing was pending, or the change was already completed (the address is already gone, so the person is told to reset their password and contact the school if they did not make the change).

A change and a code both live for 30 minutes — long enough to open a second inbox, short enough that a stale one is not worth guessing against. Requesting a fresh code or link is limited to one per minute per person, and wrong codes across every reissued attempt are capped at ten per day before the address is locked out for that window; the link's own attempt counter is shared with it, so a wrong password on the link route spends the same budget as a wrong code.

Setting a new password anywhere on the account cancels a pending email change. If somebody starts a change and then changes their password — their own choice, or a reset after a suspected compromise — the pending change is silently retired along with every other session. This is the same reasoning as changing a password ending every other session: the notice already sent to the old address is telling its owner to change the password, and that has to actually stop a change somebody else started with a stolen one.


7. Rate-Limit Feature Notes

Throttling is enabled on high-risk routes (login, refresh, password reset, verification, invitation acceptance).

Each caller gets their own counter, provided the calling app is configured for it. Limits are keyed on the address the caller came from, and the admin panel is a proxy, so without configuration the API sees only the panel's own address and every operator in the school shares one counter — password/forgot becomes three requests per hour platform-wide. The panel forwards the real address alongside a shared token (INTERNAL_CLIENT_IP_TOKEN on both sides, and TRUSTED_PROXY_HOPS set to the number of proxies in front of it that rewrite x-forwarded-for), and the API honours the address only when that token matches. With the token unset, behaviour degrades to the single shared counter rather than to an address the caller can choose.

Sign-in and one-time-code requests are keyed on the address and a hash of the submitted identifier, so one person failing to sign in cannot lock out everybody else behind the same address, and an attacker working through a list of addresses still meets the limit on each one. The identifier — email, then phone, then identifier — is counted in the form sign-in matches on: an email address trimmed and lower-cased, and a mobile number reduced to its ten digits, so +977 9800100001, 09800100001 and 9800100001 spend one budget of five. Above that, a ceiling of sixty sign-in attempts per address per fifteen minutes holds whatever identifiers are sent.

The authentication limiters refuse when Redis is unavailable. There is no account lockout and no failed-attempt counter anywhere in this module, so these limiters are the only bound on password guessing — failing open would remove it silently for the duration of an outage. Every other limiter admits the request and logs, because refusing a legitimate read while a cache is down is the worse outcome.