Test Plan: Authentication & Session Management
stg-rcme.membervu.com) and the super-admin portal (stg-admin.membervu.com) are two separate auth systems that used to hard-expire at the access-token TTL — both now carry a refresh token (org: #359; admin: #531/#532), and as of #539/#540 the two portals' cookies no longer collide when open in the same browser. Reconciled against auth-service/src/authHandlers.ts, authMiddleware.ts, routes.ts, handlers/magicLinkHandlers.ts, handlers/platformAuthHandlers.ts, services/auth/refreshTokenService.ts, services/auth/platformRefreshTokenService.ts, utils/refreshCookie.ts, and config.ts.
- Logout is now session-scoped, not userId-scoped. Previously, logging out (or a password-change / suspension) revoked every concurrent session for that user, so on a shared account one person logging out hard-logged-out everyone else. Logout now revokes only the presenting refresh-token row; other live sessions of the same account stay signed in. Applies to the super-admin/platform portal too. To verify: sign in to the same account in two browsers → log out in one → the other keeps working (no forced logout).
- Refresh-token rotation, org portal (#359, Jun 19 release): login now issues a hashed
RefreshTokenrow +HttpOnly+Securecookie (refresh_token) alongside the 15-min access token.POST /api/auth/refreshvalidates + rotates it (single-use — a replayed already-rotated token is rejected) and mints a fresh access token, so a sitting-idle tab no longer logs out at 15 minutes. Logout / password reset revokes the whole chain. See TC-AUTH-006. - Session-expiry modal + form preservation (#223, Jun 29 release): a genuine refresh failure now shows a blocking "session expired" modal (
SessionExpiredGuard/SessionExpiredModal) instead of a silent bounce to/login; the in-progress route + scroll position are captured and restored after re-auth. Multi-tab refreshes coordinate via aBroadcastChannel("auth"on the org portal) so two tabs don't race each other's rotation. - Renewable super-admin sessions (#531) + admin refresh client (#532) + shared session module (#533), Jul 1 release: platform login now also issues a refresh token (distinct cookie
platform_refresh_token, 12h TTL vs the org portal's 7d) viaPOST /api/auth/platform/refresh, sostg-admin.membervu.comsessions renew instead of dying at 15 min. Both portals now share one implementation (libs/shared/src/session/createSessionRefreshClient) — regression-test both. - Access-token & impersonation TTL alignment (#530, Jul 1 release): normal access tokens use the standard TTL; an impersonation token is single-sourced at a locked 15-minute TTL (
IMPERSONATION_TOKEN_TTL_SECONDS, not env-overridable) — this is deliberate policy, not a bug. - Cross-portal refresh-cookie isolation (#539, Jul 1 release): the org and admin refresh cookies now have distinct names (
refresh_tokenvsplatform_refresh_token) so they coexist instead of clobbering in a shared*.membervu.local/*.membervu.comcookie jar. Logging in/out of one portal no longer silently affects the other. - Bearer preferred over stale cookie (#540, Jul 1 release): when a request carries both a cookie and an
Authorization: Bearerthat decode to differentuserIds,authMiddlewarenow trusts the Bearer — fixes spurious403s in the admin portal caused by a lingering orgauth_tokencookie in a shared browser. - Purpose-aware magic links (#484, Jun 29 release):
MagicLinkToken.purposeis nowACTIVATIONorLOGIN(legacyNULLrows behave asLOGIN). An admin-issued reissue link (POST /api/membership/members/:id/activation/reissue) mints anACTIVATION-purpose token that, on verify, reactivates anINACTIVEmember (status →ACTIVE, roles restored) and logs them in. A self-service LOGIN magic link (POST /api/auth/magic-link/request) for anINACTIVEaccount is still blocked with403 ACCOUNT_DISABLED. See TC-AUTH-007. - Platform-admin self-service password reset (#356/#357, Jun 19 release) + tenant-admin setup token alignment (#409, Jun 19 release):
stg-admin.membervu.comnow has its own forgot/reset-password flow (PlatformAdmin.passwordResetToken, opaquepw-reset-<hex>tokens — not JWTs), fully separate from tenant-user resets. Tenant-admin setup links now use the samepw-reset-token contract. - Deep-link
returnUrlpreserved through login (#346, Jun 19 release): hitting a protected route while logged out redirects to/login?redirect=<path>(seerouter.tsxProtectedRoute); after a successful login you land back on that exact path instead of the role-based default. - Open-redirect fix (#360, Jun 29 release):
validateRedirect()now rejects any candidate path containing a backslash (e.g./\evil.com), an absolute URL (://), or a protocol-relative//host— falls back to/homeinstead of following it off-site. - Deactivate revokes JWTs immediately via
tokenVersionbump (#446):POST /api/membership/members/:id/deactivatecallsrevokeAllTokensForUser, so the deactivated user's next request with their old (still-unexpired) access token returns401— no waiting for the 15-min TTL to lapse. - Membership-application rate limiting (#501):
POST /api/membership/apply(public, unauthenticated) is now throttled to 5 submissions / 15 min / IP (applicationLimiter) — a spam vector close. A normal single application is unaffected. - Transactional email links use
buildTenantOrigin(#341) + fail-loud on unresolvable tenant origin (#509, Jul 1 release): password-reset / invite / activation emails link to the correct tenant host; if the tenant's origin genuinely can't be resolved, the request now throws a clear400instead of silently emitting a malformed link (e.g.https://rcme.http/...). - Tenant-from-host resolution + cross-host guest forgot/reset-password fallback (#337/#492, Jun 29 release): guest forgot-password / reset-password work even on a cold load or a split SPA/API host setup — no spurious "Organization not found" 404.
- Cross-subdomain refresh cookie (#468, Jun 29 release): the refresh cookie's
Domainattribute is tunable viaSESSION_COOKIE_DOMAIN— this is environment config, not code. If cross-subdomain persistence fails on stage, flag the env var before filing a code bug.
1. Introduction
Authentication & Identity covers two independent login systems sharing the same backend process: the org portal (tenant users — members, admins, staff — email+password or magic link) and the super-admin / platform portal (Zeniark operators — email+password only, no magic link, no tenant binding). They use different JWT shapes, different refresh-token allow-lists, different cookies, and — as of this release window — are explicitly hardened against colliding with each other in a shared browser (#539/#540). Also in scope: password management, email verification, magic-link (passwordless) auth, and session lifecycle (expiry, refresh, multi-tab, logout).
2. Where it lives (UI)
- Org portal login:
/login(tenant-prefixed alt:/:tenantSlug/login) →frontend/pwa-app/src/pages/LoginPage.tsx. Deep-link redirect via?redirect=<path>, sanitized byutils/validateRedirect.ts; post-login landing resolved byutils/postLoginTarget.ts(role-priority table, falls back to/home). - Super-admin portal login: separate app at
stg-admin.membervu.com(frontend/admin-portal), own login page hittingPOST /api/auth/platform/login. Rejects any non-PlatformAdmincredentials with403 NOT_PLATFORM_ADMIN— and org login (/api/auth/login) symmetrically rejects platform-admin credentials. - Magic-link request/verify:
/auth/magicand/auth/magic/verify?token=…(tenant-prefixed alts:/:tenantSlug/auth/magic[/verify]) →MagicLinkRequestPage.tsx/MagicLinkVerifyPage.tsx. Primary use case is guest/event-attendee passwordless access, but also used for admin-issued activation/reissue links. - Session-expiry modal: mounted app-wide via
SessionExpiredGuard(wraps the router) →SessionExpiredModal.tsx. Fires on a genuine refresh failure (not on every 401). - Password reset / forgot-password: org portal at
/forgot-password//reset-password; a fully separate platform-admin flow lives on the admin portal, backed byPlatformAdmin.passwordResetToken(not the tenantUsertable). - Admin-issued activation reissue: Admin → Members → edit a pending-activation member → "Reissue activation link" button (
AdminEditMemberPage.tsx,data-testid="reissue-activation-btn") →POST /api/membership/members/:id/activation/reissue. Only rendered for members in a pending-activation state.
3. Endpoints (current code)
Mounted under both /api/auth and the legacy /auth prefix (auth-service/src/server.ts); handlers in authHandlers.ts, routes.ts, handlers/magicLinkHandlers.ts, handlers/platformAuthHandlers.ts.
| Method | Endpoint | Portal | Notes |
|---|---|---|---|
| POST | /api/auth/login | Org | Rate-limited (authLimiter). Rejects platform-admin credentials (AUTH-04). Sets auth_token httpOnly cookie (15-min TTL) + issues a refresh token → refresh_token cookie. Body needs tenantId (slug or id). |
| POST | /api/auth/refresh | Org | Reads only the refresh_token cookie (never platform_refresh_token). Rotates it (single-use) and mints a new 15-min access token. 401 on missing/invalid/already-rotated/revoked token. |
| POST | /api/auth/logout | Org | Best-effort: revokes the specific refresh token presented; clears cookies. |
| GET | /api/auth/me | Org | Returns current user (id, email, roles, tenantId, userType). |
| POST | /api/auth/forgot-password / /reset-password / /password/change | Org | passwordResetLimiter / authLimiter. Password change bumps tokenVersion (revokes all other sessions). |
| POST | /api/auth/magic-link/request | Org (self-service) | authLimiter. Always returns generic success (no email enumeration). Creates a purpose:"LOGIN" token — never reactivates an INACTIVE account. |
| POST | /api/auth/magic-link/verify | Org | Purpose-aware (#484): SUSPENDED → always 403; INACTIVE+non-ACTIVATION → 403 ACCOUNT_DISABLED; INACTIVE+ACTIVATION → reactivates then signs in. |
| POST | /api/membership/members/:id/activation/reissue | Org (admin action) | MEMBERS:CREATE:import permission. Invalidates the member's prior outstanding tokens, mints a fresh purpose:"ACTIVATION" token, emails the activation link. |
| POST | /api/auth/platform/login | Platform | authLimiter. 403 NOT_PLATFORM_ADMIN for any non-platform-admin or wrong password (no distinct "wrong password" message — avoids account enumeration). Issues a 12h refresh token → platform_refresh_token cookie. |
| POST | /api/auth/platform/refresh | Platform | Reads only platform_refresh_token. Dedicated endpoint — does not fall through to the tenant refresh path. |
| POST | /api/auth/platform/logout | Platform | Revokes the whole platform refresh chain + bumps PlatformAdmin.tokenVersion (kills outstanding access tokens too, not just the refresh chain). |
| POST | /api/auth/platform/forgot-password / /reset-password | Platform | Separate from tenant reset — PlatformAdmin.passwordResetToken, opaque pw-reset-<hex> tokens, no tenantId in body. |
| POST | /api/membership/apply | Public (unauth) | applicationLimiter — 5 / 15 min / IP (#501). 429 beyond that. |
4. Session & token model
- Access token: JWT, default TTL 15 minutes in prod/stage (
ACCESS_TOKEN_TTL_SECONDS, envJWT_EXPIRES_IN_SECONDS, default900). ⚠️ Local dev overrides this to 3600s (60 min) viadocker-compose.yml— do not use a local wall-clock wait to infer stage/prod behavior; assert against the documented TTL, not what you observe locally. - Refresh token (org): httpOnly,
Secure(forced in prod) cookie namedrefresh_token, default TTL 7 days. Stored server-side as a SHA-256 hash in theRefreshTokentable (allow-list, not a JWT-only claim) — single-use rotation; a replayed already-rotated token is rejected (returns401, not a new token). - Refresh token (platform): httpOnly cookie named
platform_refresh_token, default TTL 12 hours — deliberately shorter than the org 7-day TTL (higher-privilege principal). Separate allow-list table/service (platformRefreshTokenService.ts). - Impersonation token: TTL locked at 15 minutes, not env-overridable (
IMPERSONATION_TOKEN_TTL_SECONDS) — intentional, do not treat a fast impersonation expiry as a bug. - Cookie isolation (#539): the org and platform refresh cookies have distinct names specifically because a wildcard
SESSION_COOKIE_DOMAIN(e.g..membervu.local/.membervu.com) puts both portals in one shared cookie jar when opened in the same browser — same-name cookies would let one portal's login/refresh clobber the other's. - tokenVersion revocation: every access token embeds the user's
tokenVersionat issue time. Logout, password change, and deactivate all bump the DBtokenVersionviarevokeAllTokensForUser/ the platform equivalent — any still-unexpired access token with a staletokenVersionis rejected with401on its next request, independent of the JWT's ownexp. - Bearer-vs-cookie precedence (#540):
authMiddlewarechecks both anauth_tokencookie and anAuthorization: Bearerheader; when both are present and decode to differentuserIds, the Bearer wins. This matters specifically for the admin portal, which is Bearer-only and can be shadowed by a stray orgauth_tokencookie in a shared browser.
5. Environment (staging)
- Org portal:
stg-rcme.membervu.com· Admin/platform portal:stg-admin.membervu.com(separate login, separate session) · API:stg-api.membervu.com· Outbound email (MailHog):stg-webmail.membervu.com. - Both portals can be open in the same browser at once — that is now an explicitly-tested configuration (#539/#540), not just an edge case.
6. Do NOT test (features that don't exist / out of scope)
- Social/SSO login (Google, Microsoft, etc.) — email+password and magic-link only.
- MFA/2FA — not implemented.
- A "remember me" checkbox that extends TTL beyond the refresh token's own lifetime — refresh-token TTL (7d org / 12h platform) is the ceiling regardless of any such UI.
- Platform-admin magic-link login — magic links are org-portal only.
- Waiting out a real 15-minute idle timer on local dev as a stand-in for the 15-min prod/stage TTL — local overrides to 60 min (see §4). Test the refresh mechanism (rotate, revoke, replay-reject), not a specific wall-clock number, unless you are actually on staging.
7. Test Deliverables
- TC-AUTH-003 — Password Management (forgot/reset/change)
- TC-AUTH-004 — Email Verification
- TC-AUTH-005 — Session Management (login/logout, multi-tab, role-menu visibility — extended this release with deactivate→401, deep-link returnUrl, open-redirect rejection, and membership-application rate-limit) · detailed
- TC-AUTH-006 — 🆕 Session Renewal & Refresh-Token Rotation (idle-past-TTL persistence, two-tab logout, password-reset revocation, replay rejection, cross-portal isolation) · detailed
- TC-AUTH-007 — 🆕 Purpose-Aware Magic Links (admin activation link reactivates INACTIVE, self-service LOGIN link blocked for INACTIVE, legacy null-purpose link) · detailed
8. Risk Areas
- Refresh-token replay/rotation correctness is security-critical — a bug here either kills sessions unexpectedly (bad UX) or lets a stolen refresh cookie mint tokens indefinitely (bad security). Test both directions.
- Cross-portal cookie isolation (#539/#540) only manifests with both portals open in one browser — a single-portal test session will not catch a regression here.
- Local-dev TTL (60 min) vs stage/prod TTL (15 min) divergence — a tester who only exercises local dev cannot validate the actual prod-magnitude session behavior; use staging for TTL-sensitive assertions.
SESSION_COOKIE_DOMAINis environment config (#468) — a cross-subdomain persistence failure on stage may be a config gap, not a code bug; check the env var before filing.