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>
238 lines
13 KiB
Markdown
238 lines
13 KiB
Markdown
# 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**:
|
|
|
|
1. **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`.
|
|
2. **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).
|
|
3. **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**:
|
|
|
|
1. **Given** a request with no `Authorization` header, **When** it hits a route gated by
|
|
`fastify.authenticate`, **Then** it's rejected as unauthorized.
|
|
2. **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.
|
|
3. **Given** a valid session for a user whose role is `AGENT`, **When** it hits a route that
|
|
requires the `ADMIN` role specifically, **Then** it's rejected as forbidden, distinct from
|
|
the unauthorized case above.
|
|
4. **Given** a valid session for a user whose role is `ADMIN`, **When** it hits any route gated
|
|
by either `fastify.authenticate` or 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**:
|
|
|
|
1. **Given** a valid session, **When** the identity endpoint is called, **Then** it returns the
|
|
current `id`/`email`/`name`/`role` for that session.
|
|
2. **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**:
|
|
|
|
1. **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.
|
|
2. **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).
|
|
3. **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**:
|
|
|
|
1. **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.authenticate` MUST 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 of `fastify.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 from `Agent` (the routing/skills/team-membership
|
|
profile an `AGENT`-role `User` has) and from a SaaS-delegated customer identity, which this
|
|
feature does not touch.
|
|
- **Session**: The short-lived, server-issued proof that a `User` authenticated successfully,
|
|
carrying their `id`, `email`, and `role`; 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 `CUSTOMER` value already defined on `UserRole` is never assigned by this feature** — no
|
|
code path in this feature creates a `User` with `role: CUSTOMER`; per Constitution Principle
|
|
I, customer identity remains exclusively SaaS-delegated (002's inbound trust boundary), never
|
|
a local `User` row. The enum value's continued existence is a pre-existing scaffold detail
|
|
this feature doesn't need to remove to stay correct.
|