Skip to content

Technical design — MOD-068 Authentication & session management

Module: MOD-068 — Authentication & session management System: SD08 — Customer App & Back Office Platform Repo: bank-app Module type: Hybrid (Lambda + IaC) FR scope: FR-341, FR-342, FR-343, FR-344, FR-466, FR-467, FR-468, FR-469 NFR scope: NFR-009, NFR-023, NFR-024 Policies satisfied: DT-002 (GATE), PRI-001 (GATE) Author: AI agent (Claude Opus 4.7) Date: 2026-05-06 Dependencies (all Built): MOD-044, MOD-043, MOD-045, MOD-075, MOD-103, MOD-104


Objective

The security boundary between the public internet and every customer / operator surface of SD08. Runs the full auth ceremony (biometric → passkey → OTP → PIN), creates a Postgres-backed session row, maintains a per-customer device registry with trust levels, supports step-up re-challenge for high-risk actions, and revokes sessions on logout, password change, or fraud signal. No other SD08 module that accesses customer data may be deployed before MOD-068.

The module wraps Cognito (which is the customer-token issuer per ADR-026) — it does not mint customer JWTs itself. MOD-068's contributions are:

  1. Postgres session state in app.customer_sessions — the authoritative record of which sessions are still considered valid.
  2. Device registry in access.device_registry — trust-level state per (user, device fingerprint).
  3. FIDO2 credentials in access.device_credentials — public keys + signature counters with atomic replay defence.
  4. Step-up tokens issued via MOD-044 — short-lived (5-min TTL) JWTs with stepup:completed claim.
  5. Revocation orchestration — Cognito GlobalSignOut, Postgres status updates, and bank.app.session_revoked emission.

Architecture overview

Customer mobile app
  ├─ Cognito custom auth flow ──► (Lambda triggers in this module)
  │     │
  │     │  CUSTOM_CHALLENGE: WebAuthn assertion
  │     │  Fallback: SMS_OTP after biometric failures (FR-341)
  │     │
  │     ▼
  │  Cognito issues access + refresh tokens (the customer JWT)
  ├─ POST /auth/session ──────────► issueSession Lambda
  │     │
  │     │  - DT-002 GATE: device.trust_level + auth_method establish MFA
  │     │  - FR-343: revoke older same-class active session
  │     │  - INSERT app.customer_sessions
  │     │  - Promote device_registry → TRUSTED on first MFA pass
  │     │  - Emit bank.app.session_created
  │     │  - Audit: session_issued
  │     │  - Idempotent on Idempotency-Key header
  ├─ GET /auth/session ───────────► validateSession Lambda  (PRI-001 GATE)
  │     │
  │     │  - X-Sensitivity: SENSITIVE | READ_ONLY (FR-342)
  │     │  - Returns session metadata; bumps last_active_at
  ├─ POST /auth/step-up ──────────► stepUp Lambda  (FR-468)
  │     │
  │     │  - Verify session is current (sensitivity = SENSITIVE)
  │     │  - Atomic FIDO2 signature counter advance (FR-467)
  │     │  - Invoke MOD-044 issuer → 5-min TTL JWT, stepup:completed claim
  │     │  - EMF metric: session_step_up_total
  └─ DELETE /auth/session ────────► revokeSession Lambda  (FR-469)
        │  - Mark session REVOKED, reason USER_LOGOUT
        │  - Cognito GlobalSignOut
        │  - Emit bank.app.session_revoked
        │  - Audit: session_revoked
        │  - Idempotent

bank-risk-platform bus
  └─ bank.risk.fraud_alert_raised  ► consumeFraudEvent Lambda  (FR-469)
        (filter detail.entity_type=CUSTOMER)
        │  - Map detail.entity_id → user_id
        │  - On action_taken ∈ {BLOCK, STEP_UP_AUTH}: revoke ALL active sessions
        │  - On action_taken = ALERT_ONLY: no-op (logged)
        │  - Cognito GlobalSignOut per cognito_sub
        │  - Emit one bank.app.session_revoked per revoked row
        │  - Audit per revocation

Auth event log:  /aws/bank-app/auth-events-{env}  (CloudWatch, 90d hot)
                  └─ Subscription filter ─► Kinesis Firehose
                                              └─ S3 Object Lock COMPLIANCE bucket (7y)

Postgres schema (Flyway migrations in db/migrations/)

Database: bank_app (Neon, ap-southeast-2). Connection via PgBouncer pooler at /bank/{env}/neon/pooler-host, credentials at bank-neon/{env}/bank_app/app_user.

Migration Tables / objects
V001__customer_sessions.sql app.customer_sessions (per SD08 data model); app.fn_touch_updated_at() trigger function
V002__access_user_identities.sql access.user_identities (with pin_hash, pin_enrolled_at, pin_failed_attempts per ambiguity #5); FK from app.customer_sessions.user_id
V003__access_access_grants.sql access.access_grants
V004__access_device_registry.sql access.device_registry (FR-466); FK from app.customer_sessions.device_id
V005__access_device_credentials.sql access.device_credentials (FR-467); signature_counter bigint NOT NULL CHECK (>= 0)
V006__app_idempotency_keys.sql app.idempotency_keys (per delivery methodology idempotency standard)

Rollback scripts mirror each migration in db/migrations-rollback/.

DB-enforced invariants (ADR-048)

Table Invariant Enforcement
app.customer_sessions.expires_at strictly later than initiated_at CHECK constraint (Cat 1)
app.customer_sessions.session_status enum domain CHECK constraint (Cat 1)
app.customer_sessions.auth_method enum domain CHECK constraint (Cat 1) — enforces FR-341 "no password-only path" by excluding 'PASSWORD' from the CHECK enumeration
access.device_credentials.signature_counter non-negative; advancing UPDATE filtered to strict >; UPDATE returning 0 rows is treated as replay CHECK + WHERE-clause filter (Cat 2) — defence in depth around FR-467
app.customer_sessions.user_id FK to access.user_identities FOREIGN KEY (Cat 1)
access.device_credentials.{device_id, user_id} FK to registry / identities FOREIGN KEY (Cat 1)

Append-only behaviour is not required for app.customer_sessions (sessions transition through ACTIVE → REVOKED/EXPIRED states by design — see SD08 data model "DB-enforced invariants" section). Auth audit events live in CloudWatch + S3 Object Lock COMPLIANCE, not in Postgres (per ambiguity #2 + #9 rulings).


SSM outputs (consumer contract)

Path convention: /bank/{env}/mod068/{name}. All published via infra/ssm-outputs.ts.

SSM path Value Consumed by
/bank/{env}/mod068/auth-api/url HTTP API base URL MOD-069 app shell, all SD08 BFF clients
/bank/{env}/mod068/auth-api/id API ID Diagnostics, MOD-076 dashboards
/bank/{env}/mod068/issue-session/fn-arn Lambda ARN MOD-069 app shell
/bank/{env}/mod068/validate-session/fn-arn Lambda ARN All SD08 BFF Lambdas as session validator
/bank/{env}/mod068/step-up/fn-arn Lambda ARN MOD-071 payment, MOD-072 profile change, MOD-078 controls, MOD-052 admin
/bank/{env}/mod068/revoke-session/fn-arn Lambda ARN MOD-072 logout, MOD-052 admin revoke
/bank/{env}/mod068/auth-events-log/group-arn Log group ARN MOD-076 dashboards, audit exporters
/bank/{env}/mod068/auth-events-log/group-name Log group name Lambda env injection
/bank/{env}/mod068/auth-events-archive/bucket-name S3 bucket name Compliance team direct read (PAM)
/bank/{env}/mod068/cognito-trigger/define/fn-arn Lambda ARN MOD-104 (re-deploy required to wire trigger)
/bank/{env}/mod068/cognito-trigger/create/fn-arn Lambda ARN MOD-104 (re-deploy required to wire trigger)
/bank/{env}/mod068/cognito-trigger/verify/fn-arn Lambda ARN MOD-104 (re-deploy required to wire trigger)
/bank/{env}/mod068/consume-fraud-event/fn-arn Lambda ARN Diagnostics

Upstream SSM paths consumed

Path Owner Used for
/bank/{env}/iam/lambda/bank-app/arn MOD-104 BankAppRole — every Lambda's execution role
/bank/{env}/observability/adot-layer-arn MOD-076 ADOT layer for X-Ray instrumentation
/bank/{env}/cognito/customers/pool-id MOD-104 Cognito user pool id (env var)
/bank/{env}/eventbridge/bank-app/arn MOD-104 Bus name (publish session_created/revoked)
/bank/{env}/eventbridge/bank-app/dlq-arn MOD-104 DLQ for the cross-bus rule
/bank/{env}/eventbridge/bank-risk-platform/arn MOD-104 Source bus for fraud_alert_raised
/bank/{env}/kms/operational/arn MOD-104 Log group + Firehose KMS encryption
/bank/{env}/neon/pooler-host MOD-103 Postgres host
/bank/{env}/mod044/issuer/arn MOD-044 Step-up token issuance

Secrets (Secrets Manager): bank-neon/{env}/bank_app/app_user.


EventBridge events

Published on bank-app bus

Both schemas registered in MOD-043 EventBridge Schema Registry as part of this module's deploy. JSON schemas in schemas/.

Event DetailType Notes
bank.app.session_created session_created Fields per the wiki event catalogue: customer_id, session_id, device_fingerprint_id, auth_method, idempotency_key, optional ip_region. Does not include user_id, device_id, trace_id, device_type, jurisdiction, mfa_completed — those are deliberately excluded per the published contract.
bank.app.session_revoked session_revoked customer_id, session_id, revocation_reason, idempotency_key. revocation_reason enum: USER_LOGOUT, PASSWORD_CHANGE, FRAUD_SIGNAL, STEP_UP_FAILED, ADMIN_REVOKE, EXPIRED, CONCURRENT_SESSION_REPLACED.

Consumed from bank-risk-platform bus

Event Filter Handler
bank.risk.fraud_alert_raised detail.entity_type = "CUSTOMER" consume-fraud-event.ts — maps entity_id → user_id, revokes sessions

Cross-bus IAM grant required: events:PutRule, events:PutTargets, events:DescribeRule, events:ListTargetsByRule on the bank-risk-platform bus ARN, scoped to BankAppRole. Filed via docs/handoffs/MOD-104-cross-bus-grant.handoff.md. The SST deploy step that creates the EventBridge rule will fail at first run until that grant lands — that is expected and the unblock is via handoff, not by skipping the rule.


Cognito Lambda trigger wiring

The three Cognito triggers (Define, Create, Verify Auth Challenge) live in this module. The user pool itself is owned by MOD-104. Wiring is requested via docs/handoffs/MOD-104-cognito-triggers.handoff.md, which lists the three SSM paths MOD-104 should resolve and attach to its aws.cognito.UserPool resource.


Handler contracts

POST /auth/session — issueSession

Field Required Notes
Header Idempotency-Key 24-hour idempotency window
user_id UUID; FK to access.user_identities
cognito_sub Cognito subject identifier
session_token Plaintext Cognito access token; only SHA-256 hash persisted
device_fingerprint optional Required for trust-level establishment
device_type optional IOS / ANDROID / WEB / DESKTOP
ip_address optional Used for audit and ip_region derivation
jurisdiction NZ / AU
auth_method enum

Response: 201 Created with {session_id, expires_at, device_id, mfa_completed}.

Error responses (per error-handling standard):

HTTP error_code When
401 MFA_REQUIRED DT-002 GATE: auth_method does not establish MFA on this device
422 MISSING_FIELD, INVALID_AUTH_METHOD, INVALID_DEVICE_TYPE request validation
503 DB_TIMEOUT, SERVICE_UNAVAILABLE transient infra

GET /auth/session — validateSession

Header Required Notes
Authorization: Bearer {session_token} The same token presented at issue
X-Sensitivity optional SENSITIVE / READ_ONLY (default: READ_ONLY); SENSITIVE applies the 15-min inactivity check (FR-342)

Response: 200 OK with session metadata; bumps last_active_at.

HTTP error_code When
422 MISSING_FIELD no Authorization header
401 SESSION_INVALID token hash not found
401 SESSION_REVOKED row marked REVOKED
401 SESSION_EXPIRED row past expires_at
401 STEP_UP_REQUIRED SENSITIVE op past 15-min inactivity

POST /auth/step-up — stepUp (FR-468)

Body: {credential_id, presented_signature_counter}. Header: Authorization: Bearer {session_token}.

Response: 200 OK with {step_up_token, expires_in: 300, scope: "stepup:completed"}.

DELETE /auth/session — revokeSession (FR-469 USER_LOGOUT path)

Header: Authorization: Bearer {session_token}, Idempotency-Key. Response: 200 OK.


Policy satisfaction

Policy Mode Mechanism Test
DT-002 GATE SessionService.issue calls DeviceService.establishesMfa(...) before any INSERT into app.customer_sessions. The check is the only path to a session row — there is no skip flag, no admin bypass, no override token. tests/policy/dt-002-mfa-gate.test.ts includes a mandatory negative test (BIOMETRIC + UNRECOGNISED → 401 MFA_REQUIRED, no session row written), a positive control (PASSKEY succeeds), and a source-level scan that fails the build if any bypass token (no_mfa_bypass, skip_mfa_check, mfa_override, bypass_mfa) appears in src/.
PRI-001 GATE SessionService.validate is the only data-access gate. Any caller without a valid, unrevoked, unexpired session token receives 401. The auth events log group's IAM permissions deny logs:DeleteLogStream etc. on the BankAppRole, satisfying NFR-024 audit immutability. tests/policy/pri-001-session-required.test.ts covers four negative cases (no header, unknown token, revoked, expired) plus an IAM SimulatePrincipalPolicy assertion that BankAppRole cannot delete from the auth events log group.

Observability

Per the platform observability standard. Mandatory log fields on every event: trace_id, correlation_id, module_id=MOD-068, jurisdiction, event_type, party_id (null for system events), level, duration_ms on terminal entries, error_code / retryable on errors.

Custom metric (EMF): session_step_up_total — counter; dimensions module_id, jurisdiction, outcome (succeeded / failed).

Dashboard: bank-{env}-MOD-068 — provisioned by MOD-076 from the standard module template, populated with custom EMF metrics + Lambda invocations / errors / latency / throttles + DLQ depth on the bank-app DLQ.

SLOs (per observability-standard.md §service-level-objectives):

SLO Target Alert threshold
session_step_up_total outcome=succeeded ratio ≥ 99% over 5 min < 95%
Step-up p99 latency ≤ 800 ms > 1500 ms
validate-session p99 latency ≤ 50 ms > 100 ms

NFR-023 MTTD ≤ 5 min anomalous access: CloudWatch alarms on session_validation_failed count and auth_method_pin_locked count, routed to the platform alerts SNS topic.


Operational notes

  • Deploy: AWS_PROFILE=bank-dev pnpm sst deploy --stage dev from MOD-068-authentication/. CI is the only permitted deploy path post-bootstrap.
  • Migrations: Flyway via the reusable-lambda workflow, has_postgres: true. Run order V001–V006. Always create a new versioned migration; never edit a committed file.
  • First-deploy gotcha: the EventBridge rule on the bank-risk-platform bus fails at deploy until MOD-104 widens its IAM grants. Submit MOD-104-cross-bus-grant.handoff.md before the first MOD-068 deploy, and MOD-104-cognito-triggers.handoff.md immediately after to wire the custom auth flow.
  • PIN lockout policy: after 5 consecutive PIN failures, access.user_identities.login_status is set to LOCKED and an auth_method_pin_locked audit event is recorded. Unlock requires a back-office action via MOD-052 once that module ships; for now, manual UPDATE by a PAM session.
  • WebAuthn RP_ID and origin are set per stage in infra/functions.ts env vars: WEBAUTHN_RP_ID, WEBAUTHN_EXPECTED_ORIGIN. Update these for UAT and prod when those stages stand up.

  • Wiki spec: bank-wiki/source/entities/modules/MOD-068.{yaml,md}
  • FR register: bank-wiki/source/pages/goals/fr-register.md — FR-341..344, FR-466..469
  • Event catalogue: bank-wiki/source/pages/design/system/event-catalogue.mdbank.app.session_created, bank.app.session_revoked
  • Data model: bank-wiki/source/pages/design/system/data-models/SD08-app.md
  • Handoffs:
  • docs/handoffs/MOD-068-complete.handoff.md
  • docs/handoffs/MOD-104-cross-bus-grant.handoff.md
  • docs/handoffs/MOD-104-cognito-triggers.handoff.md
  • ADRs in effect: ADR-001, ADR-004, ADR-007, ADR-024, ADR-025, ADR-026, ADR-028, ADR-029 (superseded by ADR-051), ADR-030, ADR-031, ADR-033, ADR-042, ADR-043, ADR-044, ADR-045, ADR-048, ADR-051, ADR-052, ADR-053