docs: add ADR-0010 for session management (opaque ID + Redis + AES-GCM)
Pin the session-state architecture: the browser carries only an opaque
crypto-random session id in __Host-portal_session, signed with
SESSION_SECRET. The payload (userId, audience, curated claims, encrypted
tokens, timestamps) lives in self-hosted Redis, accessed via the standard
express-session + connect-redis pair under the NestJS Express adapter.
The id_token / access_token / refresh_token tuple is encrypted with
AES-256-GCM before being stored - per-record IV, GCM auth tag - using
SESSION_ENCRYPTION_KEY. A Redis snapshot or memory dump alone is not
enough to forge a working session; the encryption key must also be
compromised. Tampered or wrong-key records are rejected and audited.
TTL policy: idle 30 min sliding (TTL refreshed on each request) +
absolute 12 h (checked in a global interceptor, triggers DEL on expiry).
Topology: Redis Sentinel (3+ nodes) in prod with TLS and ACL; single node
in dev. Operational specifics deferred to a phase-3 infrastructure ADR.
Revocation is immediate (DEL session:{id}). A secondary index
user_sessions:{userId} supports per-user listing and force-logout. No
PostgreSQL mirror; historical trace lives in the future audit-log ADR.
decisions/README.md index updated. CLAUDE.md gains an explicit 'Sessions'
line pointing to ADR-0010.
This commit is contained in:
@@ -36,7 +36,7 @@ The structural choices are recorded as ADRs and summarized below. Any change to
|
|||||||
- **Frontend (`portal-shell`):** Angular at the latest LTS major — standalone APIs, zoneless change detection, Signals, **CSR only (no SSR)**, Vitest, SCSS — see [ADR-0004](decisions/0004-frontend-stack-angular-csr-zoneless-signals.md).
|
- **Frontend (`portal-shell`):** Angular at the latest LTS major — standalone APIs, zoneless change detection, Signals, **CSR only (no SSR)**, Vitest, SCSS — see [ADR-0004](decisions/0004-frontend-stack-angular-csr-zoneless-signals.md).
|
||||||
- **Backend (`portal-bff`):** NestJS at the latest stable major, mounted on the Express adapter (Fastify adapter swappable later) — see [ADR-0005](decisions/0005-backend-stack-nestjs.md).
|
- **Backend (`portal-bff`):** NestJS at the latest stable major, mounted on the Express adapter (Fastify adapter swappable later) — see [ADR-0005](decisions/0005-backend-stack-nestjs.md).
|
||||||
- **Persistence:** PostgreSQL (latest stable major) via Prisma — see [ADR-0006](decisions/0006-persistence-postgresql-prisma.md).
|
- **Persistence:** PostgreSQL (latest stable major) via Prisma — see [ADR-0006](decisions/0006-persistence-postgresql-prisma.md).
|
||||||
- **Sessions / cache:** Redis self-hosted — to be locked-in in phase 2.
|
- **Sessions:** opaque session id in `__Host-portal_session`, payload in self-hosted Redis (Sentinel HA in prod, single node in dev), tokens encrypted at rest with AES-256-GCM, idle 30 min sliding + absolute 12 h — see [ADR-0010](decisions/0010-session-management-redis.md).
|
||||||
- **Identity:** multi-tenant Microsoft Entra ID with B2B invitation for workforce in v1, dual-audience design ready for future External ID activation — see [ADR-0008](decisions/0008-identity-model-entra-workforce-dual-audience.md).
|
- **Identity:** multi-tenant Microsoft Entra ID with B2B invitation for workforce in v1, dual-audience design ready for future External ID activation — see [ADR-0008](decisions/0008-identity-model-entra-workforce-dual-audience.md).
|
||||||
- **Authentication flow:** OIDC Authorization Code + PKCE via `@azure/msal-node`, executed entirely on the BFF; SPA never holds tokens; `__Host-` prefixed cookies, double-submit CSRF, RP-initiated logout — see [ADR-0009](decisions/0009-auth-flow-oidc-pkce-msal-node.md).
|
- **Authentication flow:** OIDC Authorization Code + PKCE via `@azure/msal-node`, executed entirely on the BFF; SPA never holds tokens; `__Host-` prefixed cookies, double-submit CSRF, RP-initiated logout — see [ADR-0009](decisions/0009-auth-flow-oidc-pkce-msal-node.md).
|
||||||
- **Observability:** Pino structured logs + OpenTelemetry traces with W3C Trace Context propagation — to be locked-in in phase 2.
|
- **Observability:** Pino structured logs + OpenTelemetry traces with W3C Trace Context propagation — to be locked-in in phase 2.
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
---
|
||||||
|
status: accepted
|
||||||
|
date: 2026-04-29
|
||||||
|
decision-makers: R&D Lead
|
||||||
|
tags: [security, backend, infrastructure]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Session management — opaque session IDs in cookies, payload in self-hosted Redis with AES-GCM at rest
|
||||||
|
|
||||||
|
## Context and Problem Statement
|
||||||
|
|
||||||
|
[ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) fixed the authentication flow: the BFF holds id/access/refresh tokens server-side and the browser only carries an opaque `__Host-portal_session` cookie. This ADR pins where the session lives, in what shape, with which TTL policy, and with which security posture.
|
||||||
|
|
||||||
|
Two non-negotiable constraints frame the choices:
|
||||||
|
- the BFF must be able to **revoke a session immediately** (logout, security incident, admin action);
|
||||||
|
- the session payload contains tokens that, if exfiltrated from Redis, would let an attacker impersonate the user — so storage must protect against snapshot or memory-dump leaks, not only against network-level theft.
|
||||||
|
|
||||||
|
## Decision Drivers
|
||||||
|
|
||||||
|
* Immediate, server-side revocation (logout, force-logout-everywhere).
|
||||||
|
* Defense in depth at the storage layer — Redis security is necessary but not sufficient.
|
||||||
|
* Production HA with a self-hosted Redis (per the on-prem constraint of the project).
|
||||||
|
* Battle-tested integration in NestJS, no bricolage on a security-critical path.
|
||||||
|
* TTL policy aligned with enterprise norms (idle + absolute), without harming UX.
|
||||||
|
* Listing and revoking active sessions for a given user must be possible without a second persistence layer.
|
||||||
|
* Configuration is env-driven; no host or secret in source.
|
||||||
|
|
||||||
|
## Considered Options
|
||||||
|
|
||||||
|
### Storage backend
|
||||||
|
* **Redis (self-hosted).** (Chosen.)
|
||||||
|
* PostgreSQL session table.
|
||||||
|
* In-memory (single-node only).
|
||||||
|
* Cookie-session (signed payload in the cookie itself).
|
||||||
|
|
||||||
|
### NestJS integration
|
||||||
|
* **`express-session` + `connect-redis` middleware** mounted under the Express adapter. (Chosen.)
|
||||||
|
* A custom Redis-backed session service.
|
||||||
|
* A NestJS-specific wrapper (`nestjs-session`, etc.).
|
||||||
|
|
||||||
|
### Session ID format
|
||||||
|
* **Opaque crypto-random ≥ 256 bits.** (Chosen.)
|
||||||
|
* JWT-as-session-id.
|
||||||
|
|
||||||
|
### Encryption at rest
|
||||||
|
* **AES-256-GCM applied by the BFF before tokens are stored.** (Chosen.)
|
||||||
|
* No application-level encryption (rely on Redis ACL + TLS only).
|
||||||
|
|
||||||
|
### TTL policy
|
||||||
|
* **Idle (sliding) 30 min + absolute 12 h.** (Chosen.)
|
||||||
|
* Shorter idle (e.g. 15 min) — friction.
|
||||||
|
* Longer absolute (e.g. 24 h) — risk window.
|
||||||
|
* No idle expiry.
|
||||||
|
|
||||||
|
### Production topology
|
||||||
|
* **Redis Sentinel HA (3+ nodes).** (Chosen.) Operational details deferred to a phase-3 infrastructure ADR.
|
||||||
|
* Redis Cluster.
|
||||||
|
* Single node (dev only).
|
||||||
|
|
||||||
|
## Decision Outcome
|
||||||
|
|
||||||
|
**Backend.** Redis, self-hosted, accessed via the standard `express-session` + `connect-redis` middleware mounted at NestJS bootstrap (the BFF runs on the Express adapter per [ADR-0005](0005-backend-stack-nestjs.md)). The Redis client is `ioredis` (Sentinel support, mature in Node).
|
||||||
|
|
||||||
|
**Cookie ↔ session binding.** The browser carries `__Host-portal_session` (defined in [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md)) containing a 256-bit crypto-random session id signed with `SESSION_SECRET`. The BFF dereferences the id against Redis to retrieve the session payload. The browser sees nothing else.
|
||||||
|
|
||||||
|
**Session payload (Redis value, JSON-encoded):**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type SessionPayload = {
|
||||||
|
userId: string;
|
||||||
|
audience: 'workforce' | 'customer';
|
||||||
|
claims: { // curated subset of the id_token, never the full token
|
||||||
|
sub: string;
|
||||||
|
oid: string;
|
||||||
|
tid: string; // home tenant of the user
|
||||||
|
name?: string;
|
||||||
|
preferred_username?: string;
|
||||||
|
roles?: string[];
|
||||||
|
};
|
||||||
|
tokens: EncryptedBlob; // AES-256-GCM ciphertext of { id_token, access_token, refresh_token, expiresAt }
|
||||||
|
createdAt: number; // epoch ms
|
||||||
|
lastSeenAt: number; // epoch ms — updated on each request
|
||||||
|
absoluteExpiresAt: number; // epoch ms — createdAt + SESSION_ABSOLUTE_TIMEOUT
|
||||||
|
ip?: string; // remote IP at session creation, for audit
|
||||||
|
userAgent?: string; // UA at session creation, for audit
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Encryption at rest.** The `tokens` field is encrypted with **AES-256-GCM** before serialisation, using a key read from `SESSION_ENCRYPTION_KEY` (32 bytes, base64-encoded). A fresh 96-bit IV is generated per session and prepended to the ciphertext; the GCM auth tag is appended. A `RedisSessionStore` wrapper around `connect-redis` performs encryption on `set`/`touch` and decryption on `get`. Key rotation procedure (overlap window, re-encryption) is deferred to a future operations ADR.
|
||||||
|
|
||||||
|
**TTL policy.**
|
||||||
|
|
||||||
|
| Field | Default | Source of truth |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Idle (sliding) timeout | 30 min | `SESSION_IDLE_TIMEOUT_SECONDS` |
|
||||||
|
| Absolute timeout | 12 h | `SESSION_ABSOLUTE_TIMEOUT_SECONDS` |
|
||||||
|
|
||||||
|
Mechanics:
|
||||||
|
- The Redis key carries an `EXPIRE` matching the idle timeout; every authenticated request refreshes it via `connect-redis`'s `touch`.
|
||||||
|
- `absoluteExpiresAt` is recorded at session creation and **checked on every request**. If exceeded, the BFF deletes the session key and returns 401.
|
||||||
|
- The two checks are independent: a session ends at whichever timeout fires first.
|
||||||
|
|
||||||
|
Defaults are policy decisions, not technical limits — they can be tuned per environment via env without code changes.
|
||||||
|
|
||||||
|
**Token refresh.** Access tokens (Entra-issued, ~1 h lifetime) are refreshed via `acquireTokenSilent` (MSAL Node) when an authenticated handler observes the access token is within ~5 min of expiry. The refresh token (Entra workforce, ~90-day sliding) is stored in the encrypted blob alongside the access token. Refresh-token rotation is enabled (cf. ADR-0009).
|
||||||
|
|
||||||
|
**Revocation.**
|
||||||
|
- `POST /auth/logout` deletes the session key immediately (`DEL session:{id}`).
|
||||||
|
- An admin "log out user X everywhere" operation lists keys via a secondary index `user_sessions:{userId}` (a Redis set of session ids maintained on session create/destroy) and `DEL`-s them.
|
||||||
|
- An admin "log out everyone" operation is intentionally not provided as a one-shot endpoint — it would be implemented as a runbook, not a feature, to avoid creating an obvious abuse vector.
|
||||||
|
|
||||||
|
**Active-sessions listing.** The optional `user_sessions:{userId}` index supports listing of active sessions per user (for an admin dashboard or a user-side "my active sessions" view). No PostgreSQL mirror — historical trace lives in the audit log (future ADR).
|
||||||
|
|
||||||
|
**Topology.**
|
||||||
|
- **Production:** Redis Sentinel with at least 3 nodes (1 master, 2 replicas, 3 sentinel processes for quorum), TLS in transit, ACL-restricted credentials, AOF persistence at least every second. Hosting and operational specifics (k8s operator, backup strategy, monitoring) are deferred to a phase-3 infrastructure ADR.
|
||||||
|
- **Development:** a single Redis instance (Docker container or bare process), no TLS, no persistence required. Same connection-string interface (`REDIS_URL` or Sentinel-style `REDIS_SENTINEL_HOSTS` / `REDIS_SENTINEL_NAME`) so code paths are identical.
|
||||||
|
|
||||||
|
**Configuration (env-driven).**
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `REDIS_URL` *or* `REDIS_SENTINEL_HOSTS` + `REDIS_SENTINEL_NAME` | connection target |
|
||||||
|
| `REDIS_PASSWORD` | client-side ACL credential |
|
||||||
|
| `REDIS_TLS` | `'true'`/`'false'` — required `true` in prod |
|
||||||
|
| `SESSION_SECRET` | cookie-signing HMAC secret (also used by ADR-0009) |
|
||||||
|
| `SESSION_ENCRYPTION_KEY` | 32-byte base64 AES-GCM key for the encrypted token blob |
|
||||||
|
| `SESSION_IDLE_TIMEOUT_SECONDS` | default `1800` |
|
||||||
|
| `SESSION_ABSOLUTE_TIMEOUT_SECONDS` | default `43200` |
|
||||||
|
|
||||||
|
The BFF refuses to start if any required variable is missing or malformed (e.g. encryption key not exactly 32 bytes after base64 decode).
|
||||||
|
|
||||||
|
### Consequences
|
||||||
|
|
||||||
|
* Good, because server-side state means a session can be invalidated by a single Redis command, instantly, with no race window.
|
||||||
|
* Good, because AES-GCM at rest defends against Redis snapshot, RDB dump, or memory-inspection scenarios — exfiltrated ciphertext is useless without the key.
|
||||||
|
* Good, because the TTL policy (30 min idle / 12 h absolute) is consistent with enterprise standards and well within Entra's token lifetimes.
|
||||||
|
* Good, because Sentinel gives a clean failover story for self-hosted prod — no SaaS dependency.
|
||||||
|
* Good, because `connect-redis` is mature and used by thousands of Node services; no bricolage on a security-critical path.
|
||||||
|
* Good, because the `user_sessions:{userId}` secondary index covers "list / revoke per user" without a Postgres mirror.
|
||||||
|
* Bad, because we now manage two long-lived secrets (`SESSION_SECRET`, `SESSION_ENCRYPTION_KEY`) — both must be backed up, rotated, and distributed safely. The rotation procedure is a real operational item (future ADR).
|
||||||
|
* Bad, because Sentinel deployment, monitoring, and persistence are non-trivial — flagged for the phase-3 infrastructure ADR; this is real ops work, not a footnote.
|
||||||
|
* Bad, because 30-min idle is short for tasks like writing a long form. Mitigation: the SPA can fire a lightweight heartbeat to `/auth/me` (already an existing route) on user activity to keep the session warm; this remains a UX detail, not an architectural one.
|
||||||
|
* Bad, because the secondary `user_sessions:{userId}` index adds two extra Redis writes per session lifecycle event and must be kept consistent — mitigated by treating it as best-effort (orphan entries are tolerated; expired session ids are cleaned during the next list/revoke operation).
|
||||||
|
* Neutral, because no Postgres mirror means "active sessions" exists only in Redis; if Redis is wiped, all users are logged out. That is the intended behaviour — sessions are ephemeral by design.
|
||||||
|
|
||||||
|
### Confirmation
|
||||||
|
|
||||||
|
* `apps/portal-bff/src/session/session.module.ts` configures `express-session` + `connect-redis` against the env-provided Redis target.
|
||||||
|
* The session store is wrapped in a `RedisSessionStore` that applies AES-256-GCM encryption to the `tokens` field on `set`/`touch` and decryption on `get`. The wrapper rejects (and logs as an audit event) any record whose authentication tag fails to verify — this catches both tampering and a wrong key.
|
||||||
|
* The session id is generated via `crypto.randomBytes(32).toString('base64url')`.
|
||||||
|
* Cookie name `__Host-portal_session` (per ADR-0009); cookie attributes asserted by integration tests.
|
||||||
|
* `absoluteExpiresAt` is checked in a global NestJS interceptor before any controller logic; expiry triggers `DEL` and 401.
|
||||||
|
* `user_sessions:{userId}` membership is maintained on session create / destroy; "log out everywhere" is exposed as a controller method on an admin module (future).
|
||||||
|
* Redis client is `ioredis`; in prod, configured via Sentinel with TLS; in dev, against `REDIS_URL`.
|
||||||
|
* Integration tests cover: login → session created; subsequent request → idle TTL refreshed; 30 min idle → 401; 12 h elapsed → 401 even under activity; logout → key deleted; tampered token blob → reject + audit; admin force-logout → all sessions for the target user deleted.
|
||||||
|
* `helmet` and the BFF startup checks reject missing or malformed `SESSION_SECRET`, `SESSION_ENCRYPTION_KEY`.
|
||||||
|
|
||||||
|
## Pros and Cons of the Options
|
||||||
|
|
||||||
|
### Storage backend
|
||||||
|
|
||||||
|
#### Redis self-hosted (chosen)
|
||||||
|
|
||||||
|
* Good, because in-memory fast, native TTL, sub-ms latency on session reads on the hot path.
|
||||||
|
* Good, because immediate revocation, atomic operations, mature ecosystem.
|
||||||
|
* Good, because aligned with the on-prem constraint without a SaaS dependency.
|
||||||
|
* Bad, because operating a Sentinel cluster is non-trivial — flagged for the phase-3 infra ADR.
|
||||||
|
|
||||||
|
#### PostgreSQL session table
|
||||||
|
|
||||||
|
* Good, because we already need PostgreSQL ([ADR-0006](0006-persistence-postgresql-prisma.md)) — no new component.
|
||||||
|
* Bad, because every authenticated request would hit a relational DB for a hot, ephemeral key-value lookup. Slower, more contention, less natural TTL.
|
||||||
|
* Bad, because mixing transient session state with durable business data conflates concerns.
|
||||||
|
|
||||||
|
#### In-memory
|
||||||
|
|
||||||
|
* Bad, because no HA, sessions lost at every restart, scale-out impossible.
|
||||||
|
|
||||||
|
#### Cookie-session (signed payload in cookie)
|
||||||
|
|
||||||
|
* Good, because stateless server.
|
||||||
|
* Bad, because revocation is impossible without an extra blacklist; the session keeps "working" until the cookie naturally expires. Unacceptable for a security-sensitive portal.
|
||||||
|
* Bad, because the cookie carrying tokens (even encrypted) is sent on every request — header bloat and a more attractive target.
|
||||||
|
|
||||||
|
### NestJS integration
|
||||||
|
|
||||||
|
#### `express-session` + `connect-redis` (chosen)
|
||||||
|
|
||||||
|
* Good, because the de facto standard pair in Node, mature, well-understood.
|
||||||
|
* Good, because runs natively under the Express adapter NestJS already uses.
|
||||||
|
* Bad, because two libraries to keep up to date — manageable.
|
||||||
|
|
||||||
|
#### Custom Redis service
|
||||||
|
|
||||||
|
* Bad, because reinvents `connect-redis` poorly. Bricolage on a security path.
|
||||||
|
|
||||||
|
#### NestJS-specific wrapper
|
||||||
|
|
||||||
|
* Neutral, because adds nothing meaningful over the `express-session` + `connect-redis` baseline.
|
||||||
|
|
||||||
|
### Session ID format
|
||||||
|
|
||||||
|
#### Opaque random (chosen)
|
||||||
|
|
||||||
|
* Good, because zero information leaked client-side; revocation is just a `DEL`.
|
||||||
|
* Good, because compatible with `__Host-portal_session` and the BFF pattern.
|
||||||
|
|
||||||
|
#### JWT-as-session-id
|
||||||
|
|
||||||
|
* Bad, because brittle (signature, claims surface), revocation requires a blacklist, and exposes structure that opaque IDs hide.
|
||||||
|
|
||||||
|
### Encryption at rest
|
||||||
|
|
||||||
|
#### AES-256-GCM (chosen)
|
||||||
|
|
||||||
|
* Good, because authenticated encryption — tamper detection comes for free.
|
||||||
|
* Good, because defends against Redis exfiltration scenarios that go beyond the network/ACL boundary.
|
||||||
|
* Bad, because adds a second secret to manage and rotate.
|
||||||
|
|
||||||
|
#### No application-level encryption
|
||||||
|
|
||||||
|
* Bad, because relies entirely on Redis being uncompromisable. Anyone who reads a memory dump or RDB snapshot gets working tokens.
|
||||||
|
|
||||||
|
### TTL policy
|
||||||
|
|
||||||
|
#### 30 min idle / 12 h absolute (chosen)
|
||||||
|
|
||||||
|
* Good, because aligned with enterprise practice and well within Entra refresh-token lifetimes.
|
||||||
|
* Good, because limits the window of damage from a leaked session id.
|
||||||
|
* Bad, because mildly UX-disruptive on long inactive periods (mitigated by heartbeat).
|
||||||
|
|
||||||
|
#### 15 min idle / 8 h absolute
|
||||||
|
|
||||||
|
* Good, because tighter security.
|
||||||
|
* Bad, because more user friction; would require explicit UX consideration.
|
||||||
|
|
||||||
|
#### 60 min idle / 24 h absolute
|
||||||
|
|
||||||
|
* Good, because more user-friendly.
|
||||||
|
* Bad, because doubles the risk window after credential loss / session theft.
|
||||||
|
|
||||||
|
### Production topology
|
||||||
|
|
||||||
|
#### Redis Sentinel HA (chosen)
|
||||||
|
|
||||||
|
* Good, because automatic failover, replica reads possible, simple to reason about.
|
||||||
|
* Good, because matches the operational profile of an on-prem deployment.
|
||||||
|
|
||||||
|
#### Redis Cluster
|
||||||
|
|
||||||
|
* Good, because scales beyond a single master.
|
||||||
|
* Bad, because more complex; we don't need cross-shard scaling for sessions at expected volume.
|
||||||
|
|
||||||
|
## More Information
|
||||||
|
|
||||||
|
* OWASP Session Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
|
||||||
|
* `express-session`: https://github.com/expressjs/session
|
||||||
|
* `connect-redis`: https://github.com/tj/connect-redis
|
||||||
|
* `ioredis`: https://github.com/redis/ioredis
|
||||||
|
* Redis Sentinel: https://redis.io/docs/management/sentinel/
|
||||||
|
* AES-GCM (Node `crypto.createCipheriv`): https://nodejs.org/api/crypto.html#class-cipheriv
|
||||||
|
* Related ADRs: [ADR-0005](0005-backend-stack-nestjs.md) (NestJS on Express adapter), [ADR-0008](0008-identity-model-entra-workforce-dual-audience.md) (identity model), [ADR-0009](0009-auth-flow-oidc-pkce-msal-node.md) (auth flow), and the future ADRs for MFA enforcement, audit trail, and on-prem infrastructure (Sentinel deployment specifics, secret rotation procedure).
|
||||||
@@ -53,3 +53,4 @@ ADRs are listed in numerical order. To slice by topic, filter on the `Tags` colu
|
|||||||
| [0007](0007-pre-commit-hooks-and-conventional-commits.md) | Pre-commit hooks and Conventional Commits | accepted | `process` | 2026-04-29 |
|
| [0007](0007-pre-commit-hooks-and-conventional-commits.md) | Pre-commit hooks and Conventional Commits | accepted | `process` | 2026-04-29 |
|
||||||
| [0008](0008-identity-model-entra-workforce-dual-audience.md) | Identity model — multi-tenant Entra ID for workforce, dual-audience design for future External ID | accepted | `security`, `data` | 2026-04-29 |
|
| [0008](0008-identity-model-entra-workforce-dual-audience.md) | Identity model — multi-tenant Entra ID for workforce, dual-audience design for future External ID | accepted | `security`, `data` | 2026-04-29 |
|
||||||
| [0009](0009-auth-flow-oidc-pkce-msal-node.md) | Authentication flow — OIDC Authorization Code + PKCE via MSAL Node, BFF session pattern | accepted | `security`, `backend` | 2026-04-29 |
|
| [0009](0009-auth-flow-oidc-pkce-msal-node.md) | Authentication flow — OIDC Authorization Code + PKCE via MSAL Node, BFF session pattern | accepted | `security`, `backend` | 2026-04-29 |
|
||||||
|
| [0010](0010-session-management-redis.md) | Session management — opaque session IDs in cookies, payload in self-hosted Redis with AES-GCM at rest | accepted | `security`, `backend`, `infrastructure` | 2026-04-29 |
|
||||||
|
|||||||
Reference in New Issue
Block a user