Not on the original roadmap -- surfaced as a genuine blocking gap while planning supporthub-web's own agent/admin UI feature: fastify.authenticate has been a complete no-op stub since 002, and identity/auth's login endpoint has never taken a password. User/UserRole (two seeded-but- passwordless demo accounts) and the AuthUser/JwtPayload types were all already scaffolded and clearly intended for exactly this -- this finishes that original wiring rather than inventing a new design. Scope: real login (password hash + JWT), fastify.authenticate actually rejecting invalid sessions, role-based route gating, a self-identity endpoint, admin-created accounts, and logout. Password reset, MFA, and login rate-limiting are explicitly deferred to Phase 11's own security hardening pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
13 KiB
Feature Specification: Identity and Authentication
Feature Branch: 010-identity-auth
Created: 2026-09-07
Status: Draft
Input: User description: "supporthub-web's admin/agent role-gating (its own Phase 1,
001-agent-admin-ui) has no real backend to build on: fastify.authenticate is a complete
no-op stub, and the existing identity/auth scaffold's login endpoint accepts an email alone
with no password and returns the raw user record, never a session. Build minimal, real
agent/admin authentication in supporthub-api first, as a prerequisite for the frontend feature."
User Scenarios & Testing (mandatory)
User Story 1 - An agent or admin logs in and receives a session (Priority: P1)
A user with a SupportHub-issued account (never a SaaS-delegated identity — this is SupportHub's own staff, per Constitution Principle I's "support org structure" being SupportHub's own authority) logs in with their email and password and receives a session token that authorizes their subsequent requests.
Why this priority: Every other story in this feature, and the entire admin/agent-facing half of supporthub-web, has nothing to build on without this.
Independent Test: Log in with a seeded account's correct credentials; confirm a session token is returned and a subsequent authenticated request using it succeeds.
Acceptance Scenarios:
- Given a user account with a set password, When they submit the correct email and
password, Then they receive a session token and their own
id/email/name/role. - Given a user account, When they submit an incorrect password, Then the request is rejected with no session token issued — the rejection message MUST NOT reveal whether the email itself was valid (never "wrong password" vs "no such user" as distinguishable responses).
- Given no account exists for a submitted email, When login is attempted, Then it is rejected with the same indistinguishable-from-wrong-password response as Scenario 2.
User Story 2 - Protected routes require a valid session; admin-only routes require the admin role (Priority: P1)
Every existing /admin/* route (and any other route already gated by the fastify.authenticate
stub across features 002-009) actually rejects a request with no valid session, and every route
that should be admin-only actually rejects a valid session whose role isn't ADMIN.
Why this priority: This is the entire point of the feature — without it, User Story 1 issues a token that nothing on the backend actually checks, which is no better than the current no-op stub.
Independent Test: Call an existing admin route (e.g. creating a team) with no
Authorization header, with an expired/malformed token, with a valid agent-role token, and
with a valid admin-role token; confirm exactly the last one succeeds.
Acceptance Scenarios:
- Given a request with no
Authorizationheader, When it hits a route gated byfastify.authenticate, Then it's rejected as unauthorized. - Given a request with a malformed, expired, or tampered token, When it hits a gated route, Then it's rejected as unauthorized — never silently treated as anonymous/no-op the way the current stub does.
- Given a valid session for a user whose role is
AGENT, When it hits a route that requires theADMINrole specifically, Then it's rejected as forbidden, distinct from the unauthorized case above. - Given a valid session for a user whose role is
ADMIN, When it hits any route gated by eitherfastify.authenticateor an admin-only requirement, Then it succeeds.
User Story 3 - An authenticated user can identify themselves (Priority: P2)
A logged-in user can ask "who am I" and get back their own identity and role, without needing to decode their own session token client-side.
Why this priority: Depends on User Story 1. supporthub-web's role-gating (rendering the admin portal only for admins) needs a reliable way to know the current session's role after the token is already held — decoding a JWT's claims client-side is a reasonable fallback, but a real endpoint is what lets that identity be revalidated against current server-side state (e.g. a deactivated account) rather than trusting a possibly-stale token's own claims forever.
Independent Test: Log in, then call the "who am I" endpoint with the resulting session; confirm it returns the same identity and role as the login response, and that it's rejected under the same conditions as User Story 2.
Acceptance Scenarios:
- Given a valid session, When the identity endpoint is called, Then it returns the
current
id/email/name/rolefor that session. - Given a session for an account that has since been deactivated, When the identity endpoint (or any gated route) is called, Then it's rejected — a session's validity is re-checked against current account state, not just the token's own unexpired signature.
User Story 4 - An admin creates additional agent/admin accounts (Priority: P2)
An admin creates a new user account (agent or admin role) with an initial password, since there is no public self-signup for SupportHub's own staff accounts.
Why this priority: Depends on User Story 2 (admin-only gating). Without this, the only way to add a second real account is a direct database write — fine for the one seeded bootstrap admin, not for onboarding a real team.
Independent Test: As an admin, create a new agent account with a password; confirm the new account can immediately log in (User Story 1) with those credentials.
Acceptance Scenarios:
- Given an authenticated admin, When they create a new account with an email, name, role, and initial password, Then it's created and can log in immediately.
- Given a non-admin session, When they attempt to create an account, Then it's rejected as forbidden (User Story 2's own guarantee, exercised here specifically).
- Given an email already in use by an existing account, When account creation is attempted, Then it's rejected — never a second account silently sharing one email.
User Story 5 - A user logs out (Priority: P3)
A logged-in user can end their own session explicitly, rather than only ever waiting for it to expire.
Why this priority: Lowest priority — a short-lived token that simply expires already bounds the exposure of a lost/leftover session; an explicit logout is a UX nicety layered on top, not a security-critical gap the way User Stories 1-2 are.
Independent Test: Log in, log out, then attempt to use the same token again; confirm it's now rejected.
Acceptance Scenarios:
- Given a valid session, When the user logs out, Then that specific token is rejected on any subsequent use, even though it hasn't yet expired.
Edge Cases
- What happens to a session already issued to a user whose password is changed or whose account is deactivated? Out of scope for this feature to build a full revocation-on-every-write mechanism (Assumptions) — User Story 3's re-check-on-identity-call is the only server-side re-validation this feature guarantees; a short token lifetime (Assumptions) bounds the rest.
- What happens if two login attempts for the same account happen concurrently with different passwords (e.g. a credential-stuffing attempt racing a real login)? Each is evaluated independently against the stored password hash — no shared mutable state between them, so no new concurrency concern is introduced.
- What happens to the two demo accounts the seed script already creates
(
admin@supporthub.internal,agent@supporthub.internal) which currently have no password? This feature MUST give them real, seeded passwords (documented for local/dev use only) so the existing seed script keeps producing an immediately-usable bootstrap admin — never account IDs that exist but can never actually log in.
Requirements (mandatory)
Functional Requirements
- FR-001: The system MUST let a user log in with email and password, returning a session
token and their own identity (
id/email/name/role) on success. - FR-002: A login attempt with an incorrect password or an unrecognized email MUST be rejected with an indistinguishable response — the system MUST NOT reveal whether a submitted email corresponds to an existing account.
- FR-003: Passwords MUST be stored only as a salted hash, never in plaintext or in any reversible form.
- FR-004:
fastify.authenticateMUST reject a request with a missing, malformed, expired, or otherwise invalid session token — it MUST NOT pass a request through as anonymous/no-op the way the current stub does. - FR-005: The system MUST provide a way to require a specific role (at minimum,
ADMIN) on a route, distinct from and layered on top offastify.authenticate's own valid-session check, returning a distinguishable forbidden (not unauthorized) response when the role requirement fails. - FR-006: Every existing route currently gated by
fastify.authenticate(across 002-009's own admin/read surfaces) MUST continue to work for a valid session and MUST now actually reject an invalid one — this feature does not change which routes are gated, only makes the gate real. - FR-007: The system MUST provide an endpoint that returns the current session's own identity and role, re-validated against current account state (not solely the token's own claims).
- FR-008: The system MUST let an authenticated admin create a new account (email, name, role, initial password), rejecting a duplicate email.
- FR-009: The system MUST let a user invalidate their own current session token before its natural expiry.
- FR-010: The two existing seeded demo accounts MUST be given real, working passwords as part of this feature, documented as local/development credentials.
Key Entities
- User: A SupportHub staff identity — email, name, role (
ADMIN/AGENT), and (new in this feature) a securely hashed password. Distinct fromAgent(the routing/skills/team-membership profile anAGENT-roleUserhas) and from a SaaS-delegated customer identity, which this feature does not touch. - Session: The short-lived, server-issued proof that a
Userauthenticated successfully, carrying theirid,email, androle; revocable before its natural expiry (User Story 5).
Success Criteria (mandatory)
Measurable Outcomes
- SC-001: 100% of requests to a
fastify.authenticate-gated route with no valid session are rejected, verified across every module's existing admin routes (002-009), not just this feature's own new endpoints. - SC-002: 100% of admin-only actions are rejected for a valid non-admin session, verified for at least one action from each module that has one.
- SC-003: 0% of login rejections reveal whether the submitted email corresponds to an existing account, verified by comparing the exact response for both cases.
- SC-004: 100% of passwords are stored only as a hash — verified by inspecting the stored representation directly, never as a value that could be reversed to the original password.
- SC-005: An admin can create a working new account and have it log in successfully within the same test run, with no manual database step.
Assumptions
- No password-reset/forgot-password flow is built in this feature — an admin can create a new account (User Story 4), but resetting an existing one's forgotten password is out of scope; the smallest viable fix today is an admin recreating the account or a direct operational fix, not a self-service flow.
- Session tokens are short-lived JWTs with a fixed expiry, not a server-side session store per token — logout (User Story 5) is implemented via a revocation check (a denylist of logged-out-early tokens), not full server-side session tracking; this keeps token validation fast (no DB round trip on every request) while still making explicit logout meaningfully different from "wait for expiry." The exact expiry duration and revocation mechanism are research.md decisions, not fixed here.
- No account self-registration — every account is created either by the seed script (the two bootstrap demo accounts) or by an existing admin (User Story 4); there is no public sign-up endpoint, consistent with these being SupportHub's own staff accounts, never a SaaS-delegated customer identity.
- This feature does not add a password-strength policy, MFA, or rate-limiting specifically
for login attempts beyond what 002's existing generic rate-limit infrastructure might already
cover incidentally — those are real hardening concerns explicitly named in
docs/10-implementation-roadmap.md's own Phase 11 ("security hardening pass"), not this feature's job to anticipate. - The
CUSTOMERvalue already defined onUserRoleis never assigned by this feature — no code path in this feature creates aUserwithrole: CUSTOMER; per Constitution Principle I, customer identity remains exclusively SaaS-delegated (002's inbound trust boundary), never a localUserrow. The enum value's continued existence is a pre-existing scaffold detail this feature doesn't need to remove to stay correct.