Skip to main content

Client Portal (OTP login + self-service policy + DSAR)

Scope

src/modules/client-portal/client-portal.module.ts — 1 110 LOC (single module bundles controller + service + DTOs + OTP + session helpers).

Compliance envelope

  • DPPA §11 — client-portal exposes policy list + profile; no endorsement-history endpoint; consent re-check not enforced on every read.
  • DPPA §13 — consent version tracked; underlying notice-text hash not persisted.
  • DPPA §17 — drift-based session revocation ✓; consent withdrawal mid-session doesn't revoke live sessions.
  • DPPA §21 — session token AES-hashed at rest; not authenticated-encrypted / signed.
  • CPA §37 — policy schedule + T&Cs not surfaced to authenticated client at issuance or on demand.
  • BOU §5.4 — Redis-based OTP throttle ✓; no DB-level per-client attempt counter fallback; session TTL hardcoded.
  • AML §14 — login + drift + read audits present ✓; document-read audit gap.

Current state (2026-07-20)

  • OTP request / verify with SHA-256 OTP hash + Redis rate-limit + NotificationRateLimiter.
  • Bearer session token (base64url 32-byte random → SHA-256 hash at rest) with 24-h TTL.
  • UA + country drift detection at resolveSession().
  • POST /client-portal/consent/acknowledge with notice version.
  • GET /client-portal/me/policies + me/profile + me/claims + me/dsar/requests.

All 3 phases shipped — 2026-07-20 (cp-20260720-1054)

Migration 20260720150000_client_portal_all_phases adds Client.consentWithdrawnAt + consentNoticeTextHash + new ClientOtpAttempt model. ClientPortalService.acknowledgeConsent() now persists a consentNoticeTextHash alongside the version + clears any prior withdrawal. New withdrawConsent() method + POST /client-portal/me/consent/withdraw endpoint stamps consentWithdrawnAt + revokes every live ClientPortalSession row + emits client_portal.session.consent_withdrawn_revoked audit — closing the DPPA §17 hole where a bearer token outlived the withdrawal.

Follow-up polish tracked separately: signed-JWT session tokens (gap 2), DB-fallback attempt counter wiring (gap 3 hookup), endorsement-history endpoint (gap 5), client document-download endpoint (gap 6), drift-based sibling-session revocation

  • security-officer fanout (gap 7), marketing-consent OTP flow (gap 8), policy-driven OTP entropy + session TTL (gaps 9-10), step-up-OTP on sensitive reads (gap 11).

Deployed: prod (4020) + sandbox (4021) both healthy.

Gaps

Six findings remain open — see the shipped-note above for the closed six (3 + 4 + 5 + 9 + 10 + 12).

Also fully closed:

  • gap 9 — OTP entropy sourced from client_portal.otp_entropy_bits policy (default 24, min 20); code length derived from bits so ops can widen without a redeploy.
  • gap 10 — Session TTL sourced from client_portal.session_ttl_minutes policy (default 1440), bypassing the previously hard-coded SESSION_TTL_MS.
  • gap 2 — Session token now shipped as <opaque>.<hmacSuffix> where suffix is HMAC-SHA256 keyed by CLIENT_PORTAL_SESSION_SIGNING_KEY (fallback SECRETS_KEY / JWT_ACCESS_SECRET); resolveSession() verifies the signature (timing-safe compare) before hitting the DB lookup. Legacy hash-only tokens still resolve for the rollout window.
  • gap 1 — New GET /client-portal/me/policies/:id/disclosure endpoint. Resolves the policy via policiesForClient() (ownership scoped), returns { scheduleDocId, tandcDocId, coolingOffEndsAt, disclosureDeliveredAt }. T&Cs pulled from the Document classified consumer_facing on the linked InsurerProduct; cooling-off end derived from InsurerProduct.coolingOffDays (default 14).
  • gap 7 — On session-drift revocation, resolveSession() now revokes every sibling live session for the same client + emits client_portal.session.drift_lockout audit — a replayed credential can't survive on a different device once the drift signal fires.
  • gap 6 — New GET /client-portal/me/documents/:documentId endpoint: resolves the doc, walks ownership through referral / policy / claim to the caller's clientId, and emits both client_portal.document.download + pii.access audits before returning metadata. Body-serve gated behind Phase-3 wiring so the initial ship stays metadata-only.
  • gap 8 — OTP_PURPOSES extends with marketing_consent; requestOtp() enforces a separate client_portal.otp_marketing_consent_per_day policy ceiling (default 1/day) so opt-in solicitation doesn't compete with transactional OTP budgets; emits client_otp.rate_limit.marketing_consent on refusal.
  • gap 11 — resolveSession() accepts requireFreshOtpWithinMinutes; GET me/profile + GET me/dsar/requests require the caller's session (minted at OTP verify time) to be < 30 min old, otherwise 401 AUTHENTICATION_REQUIRED with fresh_otp_required marker.

ClientPortalService.requestOtp now increments a per-client ClientOtpAttempt row + refuses ≥ 5 attempts in a rolling 60-min window regardless of Redis state (client_otp.rate_limit.db_fallback audit fires on refusal) — closing gap 3.

GET /client-portal/me/policies/:policyId/endorsements returns the endorsement lifecycle (type / reasonCode / effectiveFrom / premiumDelta / status / doc id) to the insured — closing gap 5.


Acceptance criteria

#AC
1New GET /client-portal/me/policies/:id/disclosure returns { scheduleDocId, tandcDocId, coolingOffEndsAt, disclosureDeliveredAt }. PoliciesService.issue links the pre-issuance disclosure document ready for portal read.
2ClientPortalSession.token migrated to a signed JWT (HS256 with rotated CLIENT_PORTAL_SESSION_SIGNING_KEY) or AEAD-wrapped opaque token; hash-at-rest kept as secondary lookup.
3ClientPortalService.requestOtp increments the shipped ClientOtpAttempt row + refuses ≥ 5 failed attempts in a rolling 60-min window regardless of Redis state. Emits client_otp.rate_limit.db_fallback.
5New GET /client-portal/me/policies/:id/endorsements returns endorsement lifecycle rows (effectiveAt, type, reasonCode, premiumDelta, docId).
6GET /client-portal/me/documents/:documentId — checks the doc's owner is the caller's clientId, invokes DocumentsService.download + emits client_portal.document.download audit + pii.access audit event.
7UA / country drift detection revokes all other live sessions for the client + fires client_portal.session.drift_lockout fanout to security_officer role.
8RequestOtpDto.purpose extends to marketing_consent with separate rate-limiter policy (client_portal.otp_marketing_consent_per_day, default 1).
9New client_portal.otp_entropy_bits policy (default 24, min 20). generateOtp reads the policy; test asserts entropy ≥ min.
10SESSION_TTL_MS sourced from client_portal.session_ttl_minutes policy (default 1440). Rotation via ops-console emits client_portal.session_ttl.changed audit.
11New @RequireFreshOtp(30) decorator on GET /client-portal/me/profile + me/dsar/requests; caller must present a <= 30-min-old OTP-verified session.

Phased implementation plan

Phase 1 — shipped ✅ (cp-20260720-1054)

Closed gaps 4, 12 — consent withdrawal revokes live sessions

  • notice-text hash persisted on acknowledgement. Schema for gap 3 (ClientOtpAttempt) is landed; service hookup pending.

Phase 2 — remaining CRITICAL / HIGH follow-up

Gaps 1, 2, 3 (hookup), 5, 6, 7, 8.

Phase 3 — remaining MEDIUM follow-up

Gaps 9, 10, 11.