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:
- Postgres session state in
app.customer_sessions— the authoritative record of which sessions are still considered valid. - Device registry in
access.device_registry— trust-level state per (user, device fingerprint). - FIDO2 credentials in
access.device_credentials— public keys + signature counters with atomic replay defence. - Step-up tokens issued via MOD-044 — short-lived (5-min TTL) JWTs with
stepup:completedclaim. - Revocation orchestration — Cognito
GlobalSignOut, Postgres status updates, andbank.app.session_revokedemission.
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 devfromMOD-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.mdbefore the first MOD-068 deploy, andMOD-104-cognito-triggers.handoff.mdimmediately after to wire the custom auth flow. - PIN lockout policy: after 5 consecutive PIN failures,
access.user_identities.login_statusis set toLOCKEDand anauth_method_pin_lockedaudit event is recorded. Unlock requires a back-office action via MOD-052 once that module ships; for now, manualUPDATEby a PAM session. - WebAuthn RP_ID and origin are set per stage in
infra/functions.tsenv vars:WEBAUTHN_RP_ID,WEBAUTHN_EXPECTED_ORIGIN. Update these for UAT and prod when those stages stand up.
Related artefacts¶
- 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.md—bank.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.mddocs/handoffs/MOD-104-cross-bus-grant.handoff.mddocs/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