Aller au contenu

Story AUTH-005: SignedLinkService foundation — issuance + click verification

Module: identity Slice: S4 reduced (arch §11) — V0 envelope AUTH-001 (arch §0.1 / §0.2 SignedLinkService V0) Side: [BACKEND] Version target: V0 — v0.0.0 Priority: 5 Depends on: AUTH-001; consumes the challenge_hmac computed by ING; calls TENANT getBrandingContext Can develop concurrently with: AUTH-002, AUTH-003 Merge order: After AUTH-001 Estimated complexity: M PRD User Stories: AUTH-CP-01 (customer link issuance), AUTH-CP-02 (link click → landing context) Wireframe: N/A — backend only (the customer landing page is served by the CP module; AUTH provides the verification service + branding context + French copy strings)


Objective

Build the backend that creates a unique signed link per customer file per campaign, and the service that verifies a link click and returns the broker's branding context. In v0.0.0 this is the foundation only: the service exists and is callable by the operator/campaign side (issuance) and by the customer-portal backend (verification). There is no expiry, no attempt-limit, and no HMAC yet — those are deliberate V0 simplifications (arch §0.2). The customer-facing landing UI is built by the CP module; AUTH supplies the service and the exact French copy.


Backend Scope

Entities

Reads/writes signed_links. Token is SecureRandom(32) → Base64url; the SHA-256 hash of the token is stored in signed_links.token_hash (UNIQUE). The cleartext token only ever lives in the URL, never in the DB (arch §7.7, ADR-AUTH-02). The challenge_hmac column stores the HMAC supplied by ING at issuance (AUTH never computes or holds the raw DOB/RCCM value).

Service Layer

Expose the SignedLinkService Spring Modulith boundary bean (arch §10.5) to CP/OP: - void issueLinks(List<LinkIssuanceRequest>) — bulk issuance called by OP/CAMP at campaign validation. Each request carries {fileId, campaignId, tenantId, agencyId, authFactor (DOB|RCCM), challengeHmac, ttlSeconds}. - Blocks a link if challengeHmac is missing (R-CP-018) → returned in a blocked list with reason MISSING_CHALLENGE_HMAC. - Honours the tenant-active guard (R-OP-017): no issuance for a non-ACTIVE tenant. - Generates the token, stores token_hash, sets state ISSUED. v0.0.0: TTL is stored but NOT enforced (no expiry until v0.1.0, arch §0.2); attempt/cycle counters exist but are not consulted. - LinkVerificationResult verifyLink(String token, String channel, InetAddress ip, String userAgent) — called by the CP backend on a link click. - Looks up the row by SHA-256(token) on the UNIQUE index (hot path, < 50 ms p95 — AUTH-NFR-11). - Assembles the branding context via TenantConfigService.getBrandingContext(tenantId, agencyId). - Updates last_clicked_at, last_clicked_channel, sets state CLICKED. - Returns {status:VALID, authFactor, brandingContext, sessionToken:null}. - Internal bulk-issuance REST endpoint for OP/CAMP (not exposed to the frontend).

API Endpoints

Method Path Request Response Auth
POST /api/internal/auth/links {links:[{fileId, campaignId, tenantId, agencyId, authFactor, challengeHmac, ttlSeconds}]} 200 {issued:[{fileId, linkId, token}], blocked:[{fileId, reason}]} internal (OP/CAMP)

Customer-facing verification is reached via the CP module boundary: GET /api/portail/{token}SignedLinkService.verifyLink(...) (arch §3.4). The URL shape is owned by CP.

Branding context (AUTH-NFR-10)

verifyLink returns: commercialName, logoUrl, primaryColor, agencyPhone, supportEmail, legalName, agrementNumber. Source fields owned by TENANT (tenant.commercial_name, tenant.legal_name, tenant.agrement_number, tenant.logo_object_key, tenant.primary_color, tenant.support_email, agency.phone). The branding surface is < 1 KB JSON (arch §5.3).

Validation Rules

  • Token = 32 random bytes, Base64url, ~43 chars.
  • Issuance blocks any link whose authFactor requires a challenge value when challengeHmac is absent (R-CP-018).
  • No issuance for a non-ACTIVE tenant (R-OP-017).
  • v0.0.0 carve-out (arch §0.2): no HMAC on the token, no expiration, no attempt-limit enforcement. These ship later (v0.1.0).

Multi-Tenant Considerations

signed_links rows are scoped by tenant_id; a link issued for tenant A is invisible to a verify call running in tenant B's context (arch §13.3 — LinkNotFoundException). signed_links stays on the platform datasource (ADR-AUTH-01), so verification is a single fast DB read regardless of the tenant's DB profile. country_code is available via the tenant for downstream routing.

Audit & Logging

  • AUTH_LINK_ISSUEDcampaign_id, file_id, channels_dispatched(array), ttl_seconds, auth_factor (dob|rccm).
  • AUTH_LINK_CLICKEDcampaign_id, file_id, channel_clicked (wa|sms|em), ip, user_agent, multi_device_click(bool).

Frontend Scope

N/A — backend only. The customer landing page is a CP module story; AUTH provides the branding context and the French copy below for CP to render.

French copy provided to CP (design §10.1)

  • DOB prompt: « Bonjour, pour accéder à vos contrats, entrez votre date de naissance. » · label « Date de naissance » · placeholder « JJ / MM / AAAA » · button « Voir mes contrats » · security caption « 🔒 Connexion sécurisée » · help « Besoin d'aide ? Appelez votre agence ».
  • RCCM prompt: « Pour accéder aux contrats de {raisonSociale}, entrez le numéro RCCM de l'entreprise. » · label « Numéro RCCM » · helper « Insensible à la casse et aux espaces. » · button « Voir nos contrats » (note: "nos", not "mes").

UI States (ALL REQUIRED)

N/A — backend only (the five customer-landing UI states are implemented by the CP story; AUTH supplies the VALID/branding payload and copy).


Acceptance Criteria

AC-001: Bulk issuance creates links and audits each one
Given a campaign validation requests links for N files with valid challengeHmac values for an ACTIVE tenant
When SignedLinkService.issueLinks is called
Then a unique signed link (token + stored SHA-256 token_hash, state ISSUED) is created per file
And AUTH_LINK_ISSUED is audit-logged per link with channels_dispatched and auth_factor
AC-002: Missing challenge_hmac blocks the link
Given a file whose authFactor requires a challenge value but no challengeHmac is supplied
When issueLinks is called
Then no link is created for that file
And it is returned in the blocked list with reason MISSING_CHALLENGE_HMAC
AC-003: Clicking a valid link returns branding context
Given a valid signed link for an ACTIVE tenant
When the CP backend calls verifyLink(token, channel, ip, ua)
Then the response is status VALID with the auth factor and the broker branding context (commercialName, logoUrl, primaryColor, agencyPhone, supportEmail, legalName, agrementNumber)
And state moves to CLICKED and AUTH_LINK_CLICKED is audit-logged with the channel, ip and user agent
And verification completes in < 50 ms p95
AC-004: Issuance is blocked for a non-active tenant
Given a tenant that is not ACTIVE
When issueLinks is called for that tenant
Then no links are issued (R-OP-017)

Compliance Rules

  • CIMA 001-2024 art. 26 admits a single-use link on the declared channel = VÉRIFIÉ.
  • [LEGAL-REVIEW]: civil opposability of the magic link as PROOF of consent (red flag #5) = INCERTAIN - avocat requis. This story uses the link for authentication only, NOT as proof of consent, so it may proceed; any future use of the link as consent proof stays blocked. The v0.0.0 SHA-256 / no-expiry / no-attempt-limit simplification is acknowledged in arch §0.2.

Standards & Conventions

  • docs/standards/java-spring-guidelines.md (Modulith boundary bean, events).
  • docs/standards/api-guidelines.md, docs/standards/connectivity-low-bandwidth.md (branding payload budget).
  • docs/standards/critical-rules.md (no cleartext token; SHA-256 hash only).
  • docs/standards/multi-tenant-model.md.

Testing Requirements

Unit Tests

  • Token is 32-byte random, Base64url; only the SHA-256 hash is persisted (no cleartext column).
  • Missing challengeHmac → blocked with MISSING_CHALLENGE_HMAC.

Integration Tests

  • Tenant isolation: a link issued for tenant A throws LinkNotFoundException when verified under tenant B's context (arch §13.3).
  • Bulk issuance of 100 links completes in < 2 s; each emits AUTH_LINK_ISSUED.
  • verifyLink returns branding context and emits AUTH_LINK_CLICKED.
  • Non-ACTIVE tenant → no issuance.

What QA Will Validate

  • Links can be issued in bulk and verified; branding context is correct; blocked files are reported; nothing stores the token in cleartext; v0.0.0 links never expire and are never attempt-limited (by design).

Out of Scope

  • Token expiry / TTL enforcement — v0.1.0 (AUTH-010).
  • Attempt-limit, lockout, permanent revoke — v0.1.0 (AUTH-011).
  • HMAC on the token + anti-replay — V1 (arch §0.2 / §0.4).
  • Identity challenge (last-4 phone / DOB / RCCM submission) — v0.0.2 (AUTH-007).
  • Auto-revoke on full payment — v0.0.1 (AUTH-006).
  • Operator re-issue endpoint — V1 (AUTH-CP-08).
  • The customer-facing React landing/challenge page — CP module.

Definition of Done

  • [ ] SignedLinkService.issueLinks and verifyLink implemented and exposed at the module boundary
  • [ ] Internal bulk-issuance endpoint blocks missing challenge_hmac and non-active tenants
  • [ ] Token stored as SHA-256 hash only; cleartext only in the URL
  • [ ] Branding context assembled from TENANT and returned on click
  • [ ] verifyLink < 50 ms p95 (AUTH-NFR-11)
  • [ ] Audit entries AUTH_LINK_ISSUED and AUTH_LINK_CLICKED with required fields
  • [ ] Unit + integration tests written and passing (incl. cross-tenant isolation)
  • [ ] Tenant isolation verified (tenant_id scoping; platform DB; both V1 DB profiles)
  • [ ] No expiry / attempt-limit / HMAC present (deliberate v0.0.0 posture, arch §0.2)
  • [ ] No silent failures on TENANT branding lookup errors