9.0 KiB
Auth Profile
Purpose
The auth subsystem owns sign-in, signup, the current-user profile contract
(GET /api/auth/me), password reset / email verification, OAuth (Google /
Microsoft) sign-in, and the permission enforcement model. This document covers
the profile and permission concerns; the HttpOnly cookie session transport
(access/refresh tokens, rotation, CSRF/origin, sign-out) is documented in
backend/docs/cookie-auth.md.
The profile response must not expose passwords, verification tokens, reset tokens, or raw Sequelize model objects.
Slice Files (by layer)
- Route:
src/routes/auth.ts(thin wiring; mounted at/api/authinsrc/index.ts)./me,/password-update,/send-email-address-verification-email, and/profileare guarded bypassport.authenticate('jwt', { session: false }). - Controller:
src/api/controllers/auth.controller.ts(custom — not the CRUD factory). - Service (BLL):
src/services/auth.ts(classAuth, default exportAuthService) with DTO shapes insrc/services/auth.types.ts. - Passport strategies:
src/auth/auth.ts(JWT, Google, Microsoft). - Cookie helpers:
src/auth/cookies.ts(used for the session transport; seecookie-auth.md). - Permission middleware:
src/middlewares/check-permissions.ts(checkPermissions,checkCrudPermissions). - Origin middleware:
src/middlewares/csrf-origin.ts(seecookie-auth.md). - Repositories (DAL):
src/db/api/users.ts(UsersDBApi),src/db/api/auth_refresh_tokens.ts(AuthRefreshTokensDBApi),src/db/api/roles.ts(RolesDBApi, used by the permission middleware). - Models:
src/db/models/users.ts,src/db/models/auth_refresh_tokens.ts, plus theroles,permissions,organizations,staff, andcampusesmodels joined for the profile. - Shared used:
shared/config(auth/cookie/OAuth config),shared/jwt(jwtSign),shared/constants/auth.ts,shared/constants/roles.ts(role/product-role mapping),shared/errors/*(ForbiddenError,ValidationError),services/email/*(verification / reset / invitation emails).
API
Base path /api/auth (mounted in src/index.ts).
Profile / account endpoints:
GET /api/auth/me(JWT-authenticated) ->200the current user profile payload (see Data Contract). ReturnsForbiddenErrorwhen there is no current user.POST /api/auth/signup->200the current user profile. Body:{ email, password, organizationId }. Creates a session and sets cookies.PUT /api/auth/profile(JWT-authenticated) ->200true. Body:{ profile }(passed toUsersDBApi.update).PUT /api/auth/password-update(JWT-authenticated) ->200the updated user. Body:{ currentPassword, newPassword }.PUT /api/auth/password-reset->200the updated user. Body:{ token, password }.PUT /api/auth/verify-email->200result of marking the email verified. Body:{ token }.POST /api/auth/send-password-reset-email->200true. Body:{ email }. The reset link host is derived from the request referer.POST /api/auth/send-email-address-verification-email(JWT-authenticated) ->200true. Sends a verification email to the current user's address.GET /api/auth/email-configured->200boolean (EmailSender.isConfigured).
Sign-in (covered in detail in cookie-auth.md):
POST /api/auth/signin/local->200the current user profile.
OAuth endpoints:
GET /api/auth/signin/google-> redirects to Google (passport.authenticate('google', { scope: ['profile', 'email'], state })).GET /api/auth/signin/google/callback(Passportgoogle,failureRedirect: '/login') -> sets session cookies and redirects toconfig.uiUrl(no token query parameters).GET /api/auth/signin/microsoft-> redirects to Microsoft (passport.authenticate('microsoft', { scope: ['https://graph.microsoft.com/user.read openid'], state })).GET /api/auth/signin/microsoft/callback(Passportmicrosoft,failureRedirect: '/login') -> sets session cookies and redirects toconfig.uiUrl.
OAuth users are resolved by db.users.findOrCreate({ where: { email, provider } })
in src/auth/auth.ts; the Microsoft email comes from profile._json.mail or
profile._json.userPrincipalName.
Access Rules
- JWT routes are protected by
passport.authenticate('jwt', { session: false }), which extracts the access token from the HttpOnly access cookie (cookies.extractAccessCookie) and loads the user by email. A disabled user is rejected by the strategy (done(new Error(...))). - Permission enforcement (
src/middlewares/check-permissions.ts):checkPermissions(permission)allows the request if any of:- self-access bypass —
currentUser.idequalsreq.params.idorreq.body.id; - the user's
custom_permissionsincludepermission; - the effective role's permissions include
permission. The effective role is the user'sapp_role, or the cached seededPublicrole (SPECIAL_ROLE_NAMES.PUBLIC) when there is no assigned role. ThePublicrole is fetched once at module load and cached.
- self-access bypass —
- A denied request is passed
new ValidationError('auth.forbidden'). checkCrudPermissions(name)derives the permission as${METHOD}_${ENTITY}from the HTTP method and entity name, where the method mapsPOST->CREATE,GET->READ,PUT->UPDATE,PATCH->UPDATE,DELETE->DELETEandENTITYisname.toUpperCase(). It then delegates tocheckPermissions. This middleware is applied per generic-CRUD router viarouter.use(permissions.checkCrudPermissions(permission))insrc/api/http/crud-router.ts; the auth routes themselves do not use it.
Tenant Scope
- The profile is loaded for the authenticated user only
(
UsersDBApi.findProfileById(currentUser.id)), so it reflects that user's own organization, role, staff profile, and campus. signupaccepts anorganizationIdand assigns it to the created user.- Tenant filtering for other entities is enforced elsewhere (CRUD repositories
scope by
currentUser.organizationId); the auth profile endpoints do not add tenant filtering beyond loading the current user.
Data Contract
AuthService.currentUserProfile returns (built from findProfileById):
id,email,firstName,lastNameorganizationIdorganizations—OrganizationDto{ id, name }ornullapp_role—RoleDto{ id, name, globalAccess }ornullproductRole— aPRODUCT_ROLE_VALUESvalue (teacher|para|office|director|superintendent)staffProfile—StaffProfileDto{ id, employee_number, job_title, staff_type, status, organizationId, campusId, userId }ornull(first row ofstaff_user)campus—CampusDto{ id, name, code }ornullcampusId— the campus DTO id, else the staff profilecampusId, elsenullpermissions— de-duplicated string names from the role's permissions plus the user'scustom_permissions
Note: the profile payload does not include a phoneNumber field
(findProfileById does not select it and currentUserProfile does not return
it).
productRole resolution order (getProductRole): generated backend role name
via GENERATED_ROLE_TO_PRODUCT_ROLE, then staff type via
STAFF_TYPE_TO_PRODUCT_ROLE, else PRODUCT_ROLE_VALUES.TEACHER. Mappings live
in src/shared/constants/roles.ts.
Signup / signin behavior (src/services/auth.ts):
signinthrowsValidationErrorforauth.userNotFound,auth.userDisabled,auth.wrongPassword, orauth.userNotVerified. When email is not configured (EmailSender.isConfiguredis false),emailVerifiedis treated as true.signuprehashes the password withbcrypt(config.bcrypt.saltRounds). An existing disabled user raisesauth.userDisabled; an existing enabled user has its password updated; a new user is created viaUsersDBApi.createFromAuth(first name defaults to the email local-part, default role fromconfig.roles.user). A verification email is sent when email is configured.passwordUpdaterequires a logged-in user, verifies the current password, rejects reuse of the same password (auth.passwordUpdate.samePassword), and stores the new bcrypt hash.passwordReset/verifyEmaillook up the user by a non-expiredpasswordResetToken/emailVerificationTokenand raise the matchingValidationErroron an invalid token. Tokens are random hex ofEMAIL_ACTION_TOKEN_BYTESwith TTLEMAIL_ACTION_TOKEN_TTL_MS.
Behavior / Notes
- Profile loads use a single trimmed eager query (
UsersDBApi.findProfileById) selecting only the columns and relations the DTO reads. - Auth tokens are never returned in response bodies or redirect URLs; OAuth
callbacks redirect to
config.uiUrlwith cookies only. updateProfileruns inside a Sequelize transaction.
Tests
None yet (no auth unit/e2e test under backend/src).
Related
- Cookie session transport:
backend/docs/cookie-auth.md. - Frontend:
frontend/docs/auth-integration.md. - Role / product-role constants:
src/shared/constants/roles.ts.