# Authentication

This document describes the current implementation. Authentication uses the CSMS backend when `CSMS_API_BASE_URL` is configured; operational dashboard records still use mocks. An unset API base URL enables the local UI preview, including preview sign-in rather than backend authentication.

## Request boundary

Browser forms call same-origin `POST /api/auth/<action>` Route Handlers. The server validates origin, JSON input, and action-specific fields, then calls `/v1/admin/auth/<action>` on the configured backend. Only safe session metadata or safe errors return to JavaScript; tokens are written to cookies.

Server-only settings are `CSMS_API_BASE_URL`, `CSMS_FORWARDED_HOST`, `CSMS_APP_NAME`, and `APP_ORIGIN`. Set `APP_ORIGIN` to the browser-facing origin (for example, `https://dev.csms.parkingplus.ai`) when Next.js runs behind a reverse proxy; otherwise the route handlers compare browser `Origin` to Next.js's internal request URL and reject same-site requests. An invalid configured origin fails closed. Without `APP_ORIGIN`, local/direct requests use the request URL as before. Upstream auth requests use trusted configured headers, no caching, bounded timeouts, and no redirect following. Browser input cannot select a different upstream host or supply bearer tokens.

## Connected auth actions

| Local action | Behavior | Access bearer |
| --- | --- | --- |
| `/api/auth/login` | Password login; establish session | No |
| `/api/auth/otp` | Request/resend email OTP | No |
| `/api/auth/verify` | Verify OTP; establish session | No |
| `/api/auth/verify-link` | Check the current account, then verify an email-link OTP through the same backend action | No |
| `/api/auth/reset` | Request reset email while signed out | No |
| `/api/auth/reset-password` | Submit email-link token and matching new passwords; clear local session on success | No |
| `/api/auth/set-password` | Complete initial password setup; replace session tokens | Yes |
| `/api/auth/change-password` | Change password from Profile Settings; replace session tokens | Yes |
| `/api/auth/refresh` | Renew using access cookie and refresh cookie; replace rotated tokens | Yes |
| `/api/auth/logout` | Attempt upstream logout and clear local cookies even if upstream fails | Yes upstream |

There is no `/check-email` handler or registration/onboarding wizard. `/set-password` is for existing authenticated accounts that require initial password setup. `/reset-password` reads the reset token from the email link's `?token=...` parameter. The backend creates and validates OTPs/reset tokens and owns password policy.

## Profile settings

The account dropdown contains Profile Settings and Sign out. Profile Settings loads the current user through `GET /api/profile`, backed by `GET /v1/admin/users/me`. Personal details and address updates use same-origin `PATCH /api/profile`, backed by `PATCH /v1/admin/users/{user_id}`. The server derives the user ID from the validated access token; the browser cannot choose which account to update. The profile screen reuses the authenticated change-password flow. Subscription and Support are not included in the account dropdown.

## OTP email links

`/otp-verify?email=...&code=...` supports the verification link from the OTP email.
It validates one email and one six-digit code, then calls the existing same-origin
`POST /api/auth/verify-link` once automatically. This wrapper checks the current
session again while the shared auth lock is held, then delegates to the existing
backend verify action. The ordinary token/session parser and
HttpOnly cookie flow are reused, including redirection to `/set-password` when
required. `app_name` and `organization_code` in the email URL are ignored: upstream
app/host context comes from trusted server configuration, and the verification
payload remains only `email` and `code`. Localhost must use the backend/app/host
configuration that issued the OTP; changing the link domain does not change that context.

The page removes query parameters from the current browser history entry after
capturing the credentials in component memory. It does not display or persist the
code, uses a no-referrer policy, and has no mock verification success in preview.
Effect replay reuses the pending request; failures do not trigger automatic retries.
Invalid/expired links return to Sign In, while transient failures offer explicit retry.
An existing session for the same email continues without consuming the OTP. A
different signed-in account requires an explicit Sign Out & Continue action. An
account that changes in another tab after page load is rechecked by the wrapper;
its OTP is not consumed and the UI offers Review Current Account.
Successful live email-link verification requires a fresh OTP and remains unverified.

## Roles and tenant display

| Sign-in label | Role value sent to the backend |
| --- | --- |
| Admin | `admin` |
| Operator | `operator` |
| Site Manager | `site_manager` |

Login sends the selected role unchanged as `role`; OTP requests send it unchanged as `role_name`. Authenticated identity comes from JWT `roles[0]` and keeps the same backend role value, ignoring the legacy `role` object. Unsupported roles reject the session. Account display uses JWT `tenant_code`; backend organization identifiers remain distinct. UI navigation is not authorization.

## Frontend feature access

The dashboard uses backend role values directly, with this product mapping: CSMS Admin follows the legacy dashboard Operator feature set, CSMS Site Manager follows Site Manager / Building Management (BM), and CSMS Operator follows Field Operator. The frontend permission map controls sidebar visibility and dashboard page access:

| CSMS role | Visible features | Additional rules |
| --- | --- | --- |
| Admin | Dashboard, Locations, Chargepoints, Chargepoint DLM, Sessions, RFID Cards, Organization, Customer, Notifications, FAQs, Terms, Support | Notifications is Admin-only. |
| Site Manager | Locations, Chargepoint DLM | Dashboard is hidden; `/` redirects to Locations. Location access is limited to `scope_id` assignments from `/v1/admin/tenant/site-managers/{user_id}`. |
| Operator | Locations, Customer | View-only; no Dashboard, create, edit, or delete controls. |

Creating, updating, and deleting Site Manager and Operator accounts is Admin-only. Customer create and update are also Admin-only; Operators can view Customers in read-only mode. The current frontend has no Customer delete action or endpoint.

These are frontend visibility and page-navigation rules. API Route Handlers and the upstream CSMS remain responsible for enforcing authorization on data and write requests.

## `/me`, expiry, refresh, and sign-out

The application already calls backend **`GET /v1/admin/users/me`** in `getServerSession`. There is no local `/api/auth/me` endpoint.

1. Server session checks read the access cookie and validate expected JWT claims, including expiry. JWT decoding alone is not signature verification.
2. For a locally valid access token, the server calls `/v1/admin/users/me` with its bearer token. Backend 401/403 or a rejected response returns no session. Service outages throw a safe error rather than allowing dashboard access.
3. The dashboard layout redirects missing/invalid sessions to `/signin`, and setup-required sessions to `/set-password`. This server guard does not itself delete cookies.
4. While a dashboard is mounted, `SessionMonitor` attempts `/api/auth/refresh` **60 seconds before access expiry** and checks again on focus/visibility resume. Successful refresh replaces cookies and refreshes the rendered session.
5. If a protected page reaches sign-in with expired access claims and both cookies still present, `SessionRecovery` attempts renewal and returns to the dashboard on success.
6. Missing refresh credentials or terminal refresh rejection (401/403) clears cookies and requires sign-in. Malformed refresh responses also clear cookies. The monitor redirects on 401/403; other failures show an error and retry after 30 seconds. Transient transport/service failures preserve cookies for retry.

**Access-token expiry alone does not immediately log the user out:** the application attempts refresh first. `/me` validates sessions when server session checks run; it is not a periodic browser polling endpoint. Revocation while a page stays open is therefore not detected instantly by `/me` polling.

Same-tab renewals share one in-flight promise. All browser requests that change auth cookies (login, OTP verification, password completion/change/reset, refresh, and logout) use one same-tab queue and the same Web Lock across tabs where supported. Logout waits for a pending refresh response before clearing cookies. A non-sensitive generation marker in localStorage invalidates queued renewals and late monitor/recovery callbacks after logout or a new sign-in; it contains no tokens or account data. Failed logout keeps renewal disabled until a successful sign-in. Public password reset remains available while signed out. Without Web Locks, ordering is limited to this tab; without usable localStorage, generation checks are limited to this tab. These browser safeguards do not cancel responses from other clients or requests that bypass this coordinator; backend revocation remains the server's responsibility.

## Token storage

Tokens are stored in `csms_access_token` and `csms_refresh_token` cookies with HttpOnly, SameSite=Lax, path `/`, and Secure in production. JavaScript receives only safe session metadata: role, email, tenant code, expiry, password-setup state, and optional first and last name from the validated user profile. Tokens remain unavailable to JavaScript.

Both cookie lifetimes follow the returned refresh expiry, allowing an expired access bearer to remain available for renewal. Cookie presence does not make an expired JWT valid for protected pages. Token parsers use backend expiry values and support the explicit PascalCase login/password token envelope and snake_case refresh envelope.

Do not store tokens/passwords in localStorage/sessionStorage or log credentials, OTPs, reset links, or raw auth responses. Password fields and entered OTPs are temporary form values.

## Code locations and verification

| Location | Responsibility |
| --- | --- |
| `src/lib/auth/server-session.ts` | JWT checks, backend `/me` validation, expired-session recovery eligibility |
| `src/features/auth/session-lifecycle.tsx` | Refresh timing, deduplication, retries, and recovery |
| `src/lib/auth/route-handlers.ts` | Origin/input validation and auth action orchestration |
| `src/lib/auth/api-contract.ts` | Role mapping, payload/token parsing, protected endpoint policy |
| `src/lib/auth/upstream.ts` | Backend requests and safe error normalization |
| `src/lib/auth/session-cookies.ts` | Cookie options, lifetimes, and clearing |
| `src/features/auth/auth-client.ts` | Browser requests and safe session parsing |
| `src/app/(dashboard)/layout.tsx` | Dashboard session guard and monitor |
| `src/app/(auth)/signin/page.tsx` | Sign-in guard and expired-session recovery |
| `tests/auth-*.test.ts` | Deterministic auth contract, claim, cookie, client, and error tests |

Use `npm test`, `npm run lint`, `npm run typecheck`, and `npm run build` for code verification. Automated tests should mock transport. Successful inbox-dependent OTP/reset completion and initial setup require the appropriate code/token/account; do not infer their success from contract tests. Successful reset completion with an email token was deferred by the user and remains unverified live.
