docs: spec for identity and authentication (010)

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>
This commit is contained in:
saqib mir
2026-09-07 11:09:46 +05:30
co-authored by Claude Sonnet 5
parent 4e0c90e2cf
commit a49389dc2c
2 changed files with 285 additions and 0 deletions
@@ -0,0 +1,48 @@
# Specification Quality Checklist: Identity and Authentication
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-09-07
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- This feature was not on the original 11-phase roadmap — it surfaced as a genuine blocking gap
while planning supporthub-web's `001-agent-admin-ui`: `fastify.authenticate` has been a
complete no-op stub since 002, and `identity/auth`'s login endpoint has never taken a
password. Numbered 010 in supporthub-api's own sequence since it's a real, immediately-needed
backend prerequisite, not deferred hardening.
- `User`/`UserRole` (with two seeded-but-passwordless demo accounts,
`admin@supporthub.internal`/`agent@supporthub.internal`) and the `AuthUser`/`JwtPayload`
types in `src/common/types` were all found already scaffolded, unwired, and clearly intended
for exactly this feature since the original pre-speckit scaffold — this is a "finish the
originally-intended wiring" feature, not a new design invented from nothing.
- Scope is deliberately narrow: real login + real route gating + role checks + a self-identity
endpoint + admin-created accounts + logout. Password reset, MFA, rate-limiting, and
registration are explicitly out of scope (Assumptions), matching Phase 11's own "security
hardening pass" as the more appropriate later home for those.
- All items pass; no revision iterations were needed.
+237
View File
@@ -0,0 +1,237 @@
# 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.