Story AUTH-002: Operator login (password-only ROPC) + JWT filter + tenant-status guard¶
Module: identity Slice: S1 reduced (arch §11) — V0 envelope AUTH-001 (arch §0.1, "Flow opérateur ROPC uniquement") Side: [OPERATOR] Version target: V0 — v0.0.0 Priority: 2 Depends on: AUTH-001 Can develop concurrently with: AUTH-003, AUTH-005 Merge order: After AUTH-001 Estimated complexity: M PRD User Stories: AUTH-OP-02 (login portion only — MFA portion is V1), AUTH-OP-08/09 (role on token) Wireframe: N/A — no prototype generated (design ref: docs/design/identity-design.md §5.1 / §7.2)
Objective¶
Let an operator sign in to the back-office with username + password and receive a tenant-scoped JWT. In v0.0.0 there is no MFA and no remember-device (arch §0.1 locks operator login to ROPC only; MFA is deferred to V1). This story also completes the JWT filter chain — building the TenantContext from claims and reading the tenant-status guard — so every later operator endpoint is protected.
Backend Scope¶
Entities¶
Reads/writes users (state, password_failures_counter). No new tables.
Service Layer¶
KeycloakAdminClient.directGrant(username, password)implementation — calls KeycloakPOST /realms/papillon-platform/protocol/openid-connect/tokenwithgrant_type=passwordvia the confidentialpapillon-backendclient (arch §10.1, ADR-AUTH-04). The frontend never calls Keycloak directly.- Login handler: validate password via Keycloak; on success build the session and return the access + refresh tokens. v0.0.0: no OTP branch, no remember-device cookie — a valid password lands the operator straight on the dashboard.
- Account-state gate before/around password check:
PENDING_ACTIVATION→ reject (account not activated).DEACTIVATED→ reject.LOCKED(5 consecutive password failures, R-OP-013) → reject; admin unlock required.password_failures_counterincrements on bad password; locks the account at 5 (R-OP-013).- Full
JwtValidationFilterchain (arch §7.3): extract Bearer → validate signature (JWKS cached) → check exp + 30 s skew → extractpapillon_role→ ifPLATFORM_ADMIN+ nulltenant_idsetTenantContext.platformAdmin()(sentinel logic itself is AUTH-009/PA but the branch must exist) → else extracttenant_id→ readTenantStatusCache→ buildTenantContext(tenantId, agencyScope, countryCode, role)→ setSecurityContextHolder. - Tenant-status guard read-path (arch §4.4 / §7.3):
GET auth:tenant-status:{tenantId}. In v0.0.0 the only value written isACTIVE(the suspension/reactivation cascade that writes SUSPENDED/DELETED is V1, AUTH-ADM-05/06). On cache miss, read platform DB and repopulate (TTL 30 s). The guard branch logic (SUSPENDED→403/503, DELETED→410, PENDING→503) must be coded now even though only ACTIVE occurs in v0.0.0.
API Endpoints¶
| Method | Path | Request | Response | Auth |
|---|---|---|---|---|
| POST | /api/auth/operator/login |
{username, password} (no deviceToken in v0.0.0) |
200 {mfaRequired:false, accessToken, refreshToken, expiresIn:300, user:{userId, displayName, role, tenantId, agencyScope}} / 401 generic |
none |
| POST | /api/auth/operator/logout |
— | 204 | Bearer |
| POST | /api/auth/operator/refresh |
{refreshToken} |
200 new tokens | none |
| GET | /api/auth/operator/me |
— | 200 {userId, displayName, role, tenantId, agencyScope} |
Bearer |
Note: the V1 response shape includes
mfaRequiredand an MFA branch; in v0.0.0mfaRequiredis alwaysfalse. Keep the field for forward-compat.
Validation Rules¶
usernamenon-empty;passwordnon-empty.- On bad credentials return the SAME generic error whether the username exists or not (anti-enumeration):
auth.operator.login.bad_credentials= "Identifiant ou mot de passe incorrect." (R-OP-001). - Access-token TTL 300 s (5 min), refresh 30 min, absolute session 8 h (arch §7.2).
Multi-Tenant Considerations¶
The JWT carries tenant_id, agency_scope, country_code, papillon_role as custom claims. Every protected request resolves these into TenantContext. AUTH itself queries only the platform datasource (arch §4.2); country_code is propagated through TenantContext for downstream per-country routing. Both V1 DB profiles are unaffected (auth stays on platform DB).
Audit & Logging¶
operator_login_attempt— fields:username,success(bool),failure_reason(BAD_PASSWORD | LOCKED | INACTIVE | DEACTIVATED),mfa_factor(PASSWORD_ONLY in v0.0.0),ip,user_agent.operator_session_started—user_id,session_id.operator_session_explicit_logout—user_id,session_id.
Frontend Scope¶
Pages & Routes¶
- Route
/login(étape A only — no MFA étape B in v0.0.0). Operator console. Design §5.1.
Components¶
Card(centred, max 420 px),FormField,Input(identifiant),Input type=passwordwith native eye-toggle,Buttonprimary fullWidth.- "Mot de passe oublié ?" ghost link is hidden in v0.0.0 (password reset is V1; reset is handled by an OPS runbook in V0 — design decision D-v0.0.0-2).
UI States (ALL REQUIRED)¶
| State | Behavior | French Copy |
|---|---|---|
| Loading | « Se connecter » button in loading state (inline DS spinner) | — |
| Empty | Blank form | — |
| Error | Helper text under the card; focus returns to the identifiant field | « Identifiant ou mot de passe incorrect. » (generic). For specific account states: « Compte non activé. Cliquez sur le lien d'invitation reçu. » / « Compte désactivé. Contactez votre administrateur. » / « Compte verrouillé après plusieurs tentatives. Contactez votre administrateur. » |
| Offline / low network | Persistent toast; button stays clickable (server decides) | « Connexion lente. Vérifiez votre réseau. » |
| Success | In v0.0.0 (no MFA) go straight to the dashboard | (no toast) |
Responsive Behavior¶
- Desktop-first. 768 / 1024 / 1440: card centred, max 420 px (design §8.2). Login layout does not change across breakpoints.
Interactions¶
- Password eye-toggle (native). On error, focus returns to the identifiant field.
Acceptance Criteria¶
AC-001: Valid password signs the operator in (no MFA in v0.0.0)
Given an ACTIVE operator with a valid username and password
When they submit the login form
Then Keycloak validates the password via Direct Grant
And a tenant-scoped JWT (access + refresh) is returned with mfaRequired:false
And the operator lands on the dashboard
And operator_login_attempt (success=true, mfa_factor=PASSWORD_ONLY) and operator_session_started are audit-logged
AC-002: Bad password returns a generic error and increments the failure counter
Given any login form submission with a wrong password
When the operator submits
Then the response is 401 with the generic copy "Identifiant ou mot de passe incorrect." (no account-existence leak)
And password_failures_counter is incremented
And after 5 consecutive failures the account moves to LOCKED (admin unlock required)
And operator_login_attempt (success=false, failure_reason=BAD_PASSWORD) is audit-logged
AC-003: Non-active accounts are rejected with the correct copy
Given a user in PENDING_ACTIVATION
When they submit valid credentials
Then login is rejected with "Compte non activé. Cliquez sur le lien d'invitation reçu."
And given a DEACTIVATED user, login is rejected with "Compte désactivé. Contactez votre administrateur."
AC-004: Protected endpoints require a valid JWT and build TenantContext
Given a valid access token
When GET /api/auth/operator/me is called
Then the response is 200 with the user's role, tenantId and agencyScope
And given a missing or invalid token the response is 401
Compliance Rules¶
- RBAC opérateur + traçabilité = VÉRIFIÉ (CIMA art. 41 + Décision ARTCI 2023-0877). The login event must be audit-logged.
- All authentication strings are in French, fr-FR (AUTH-NFR-07).
Standards & Conventions¶
docs/standards/java-spring-guidelines.md,docs/standards/api-guidelines.md(response envelope{data}/{error:{code,message}}).docs/standards/react-ts-guidelines.md,docs/standards/design-system.md(Card, Input, Button).docs/standards/multi-tenant-model.md(role enum, claim mappers).docs/standards/critical-rules.md(tenant isolation).
Testing Requirements¶
Unit Tests¶
- Generic error returned for both unknown user and wrong password (anti-enumeration).
- Failure counter increments; account locks at 5.
- PENDING_ACTIVATION / DEACTIVATED / LOCKED states each rejected with the correct copy.
Integration Tests¶
- Direct Grant against Testcontainers Keycloak: valid password → JWT; the filter builds TenantContext from claims.
- Tenant isolation: a JWT for tenant A cannot read
/medata belonging to tenant B. /mereturns 401 without a token; 200 with a valid token.
What QA Will Validate¶
- Login works with password only (no OTP step appears in v0.0.0); the forgot-password link is hidden; error copy is exact French; locked account behaviour after 5 failures.
Out of Scope¶
- SMS OTP / MFA, WhatsApp fallback, remember-device cookie — V1 (AUTH-OP-02 MFA portion, AUTH-OP-03). v0.0.0 is ROPC password-only.
- Step-up MFA, bypass codes, session idle/absolute timeout enforcement — V1.
- Password reset / forgot-password flow — V1 (OPS runbook in V0).
- Writing SUSPENDED/DELETED/PENDING into the tenant-status cache — that comes from the lifecycle cascade in V1 (AUTH-ADM-05/06). The guard branch logic is coded but only ACTIVE occurs in v0.0.0.
Definition of Done¶
- [ ] Login returns a tenant-scoped JWT on valid password (no MFA step in v0.0.0)
- [ ] Generic anti-enumeration error on bad credentials; counter locks at 5
- [ ] PENDING_ACTIVATION / DEACTIVATED / LOCKED rejected with exact French copy
- [ ] JWT filter builds TenantContext and protects
/me,/logout,/refresh - [ ] Login page renders at 768 / 1024 / 1440 px; forgot-password link hidden
- [ ] All five UI states implemented with exact French copy
- [ ] Audit entries: operator_login_attempt, operator_session_started, operator_session_explicit_logout
- [ ] Unit + integration tests written and passing
- [ ] Tenant isolation verified (tenant_id scoping; both V1 DB profiles)
- [ ] No silent failures on network/Keycloak errors (Keycloak down → 503, never skip auth)