Plan: Session Middleware Wiring
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Add sessions table migration to shared infrastructure database |
Done (2026-03-28) |
2 |
Cargo.toml and workspace dependencies |
Done (2026-03-28) |
3 |
Wire SessionManagerLayer into canopy-web (worker portal, 8-hour TTL) |
Done (2026-03-28) |
4 |
Wire SessionManagerLayer into canopy-portal (applicant portal, 30-minute TTL) |
Done (2026-03-28) |
5 |
Integration tests verifying session creation, expiry, and cleanup |
Done (2026-03-28) |
Epic: &38
Branch: feature/session-middleware
MR: !5
Context
Both BFF services (canopy-web at port 8080 and canopy-portal at port 8090) depend on session state for authentication flows and multi-step intake.
The coding conventions mandate tower-sessions-sqlx-store backed by PostgreSQL.
MemoryStore is explicitly banned.
Neither service currently has session middleware wired — this is a security gap.
Multi-step application intake, caseworker case management, and applicant portal flows all require sessions.
This plan has no code dependencies — it can run in parallel with persons-household-model and rules-engine. It should complete in week 1 of Month 1 alongside reference-extensions.
Session lifetime requirements
canopy-web (worker):
-
8-hour TTL, sliding expiry on activity — aligns with a typical work shift
-
Forced re-auth after inactivity: if no request within 30 minutes, require re-authentication (not full logout, just re-verify)
-
Session stores:
worker_id(UUID from JWT sub),role,last_case_id(last viewed case for breadcrumbs)
canopy-portal (applicant):
-
30-minute TTL, non-sliding — ATO (Authority to Operate) security control; applicants must re-authenticate after 30 minutes regardless of activity
-
Session stores:
person_id(UUID from JWT sub),preferred_locale,in_progress_application_id(for multi-step intake continuity)
Both services use the same PostgreSQL sessions table in the shared infrastructure database.
The table is named identically; services are distinguished by the cookie domain.
Scope
In scope:
-
sessionstable migration in the shared infrastructure database (the postgres container at port 5432 used by all infrastructure services) -
SessionManagerLayerwired inservices/canopy-web/src/main.rs -
SessionManagerLayerwired inservices/canopy-portal/src/main.rs -
Session TTL configuration via
ServiceSettings -
Session cleanup: periodic deletion of expired sessions (either continuous background task or scheduled
cargo xtaskcommand) -
Integration tests with testcontainers-rs verifying session creation, read, expiry
Out of scope:
-
Keycloak login redirect flow — the session stores post-auth state; the login redirect is part of the BFF route plans
-
Session data schema beyond what’s documented in this plan — routes will extend session data as they are added
-
canopy-portalFluent i18n locale storage in session — that is part of the applicant portal plan
Design
Database schema
This migration belongs in a shared infrastructure location.
Since there is no canopy-shared service, the migration is placed in canopy-web (first BFF to be implemented) with a comment noting it is shared:
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Shared sessions table used by canopy-web and canopy-portal.
-- tower-sessions-sqlx-store requires this exact schema.
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
data BYTEA NOT NULL,
expiry_date TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions (expiry_date);
The tower-sessions-sqlx-store crate requires exactly this schema — do not add columns.
Store metadata (worker_id, etc.) in the data BYTEA field, not as additional columns.
Cargo.toml additions
In services/canopy-web/Cargo.toml and services/canopy-portal/Cargo.toml:
tower-sessions = { workspace = true }
tower-sessions-sqlx-store = { workspace = true }
Add to workspace Cargo.toml [workspace.dependencies] if not present:
tower-sessions = "0.14"
tower-sessions-sqlx-store = { version = "0.14", features = ["postgres"] }
Check crates.io for the exact latest versions before adding — do not guess.
ServiceSettings additions
In crates/canopy-common/src/settings.rs (or wherever ServiceSettings is defined), add optional session TTL fields:
// SPDX-License-Identifier: AGPL-3.0-or-later
/// Session TTL in seconds. Defaults differ by service:
/// canopy-web: 28800 (8 hours)
/// canopy-portal: 1800 (30 minutes)
#[serde(default)]
pub session_ttl_seconds: u64,
Each service sets its own default in its main.rs via settings.session_ttl_seconds.max(300) (minimum 5 minutes for safety).
canopy-web wiring
In services/canopy-web/src/main.rs, after boot.db is available:
use tower_sessions::SessionManagerLayer;
use tower_sessions_sqlx_store::PostgresStore;
let session_store = PostgresStore::new(boot.db.pool().clone());
session_store.migrate().await
.context("failed to run session store migration")?;
let session_ttl = time::Duration::seconds(
settings.session_ttl_seconds.max(300) as i64
);
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(true)
.with_same_site(tower_sessions::cookie::SameSite::Strict)
.with_http_only(true)
.with_expiry(tower_sessions::Expiry::OnInactivity(session_ttl));
Wire session_layer into the router before the auth middleware:
let router = Router::new()
.merge(api::routes())
.layer(session_layer) // session first — auth reads session
.layer(auth_layer)
.layer(/* other middleware */);
canopy-portal wiring
Same pattern, but with non-sliding expiry (30 minutes, no activity extension):
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(true)
.with_same_site(tower_sessions::cookie::SameSite::Strict)
.with_http_only(true)
.with_expiry(tower_sessions::Expiry::AtDateTime(/* 30 min from now */));
Note: tower-sessions Expiry::OnInactivity is sliding; Expiry::AtDateTime or Expiry::OnSessionEnd with a fixed duration is non-sliding.
Verify the exact API in the current tower-sessions documentation before implementing.
Session cleanup
Expired sessions accumulate in the sessions table.
tower-sessions-sqlx-store may provide a continuously_delete_expired() method that spawns a background task.
If available, call it after migrate().
If not available in the version being used, add a cargo xtask dev cleanup-sessions command that runs:
DELETE FROM sessions WHERE expiry_date < now();
Steps
Step 1: Sessions table migration
Files: services/canopy-web/migrations/20260401000000_create_sessions_table.sql
Create the migration file with the schema above.
Add a comment: -- Shared with canopy-portal. tower-sessions-sqlx-store requires this exact schema.
Step 2: Cargo.toml and workspace dependencies
Files: Cargo.toml (workspace), services/canopy-web/Cargo.toml, services/canopy-portal/Cargo.toml
Look up the current versions of tower-sessions and tower-sessions-sqlx-store on crates.io before adding.
Add to workspace dependencies.
Add to both BFF service Cargo.toml files.
Step 3: Wire canopy-web
Files: services/canopy-web/src/main.rs
Wire SessionManagerLayer after DB pool initialization.
Set 8-hour TTL with sliding expiry.
Add session layer to router before auth middleware.
Step 4: Wire canopy-portal
Files: services/canopy-portal/src/main.rs
Wire SessionManagerLayer after DB pool initialization.
Set 30-minute TTL with non-sliding expiry.
Add session layer to router before auth middleware.
Step 5: Integration tests
Files: services/canopy-web/tests/session_test.rs (new)
Using testcontainers-rs with a PostgreSQL container: - Start canopy-web against test database - Make a request → verify session cookie is set - Make second request with cookie → verify session is readable - Wait for TTL expiry → verify session is rejected (use very short TTL in test config) - Verify expired sessions are cleaned up
Files Touched
| File | Change |
|---|---|
|
Add tower-sessions and tower-sessions-sqlx-store to workspace dependencies |
|
Add tower-sessions, tower-sessions-sqlx-store |
|
Add tower-sessions, tower-sessions-sqlx-store |
|
New: sessions table migration (shared with portal) |
|
Add SessionManagerLayer wiring, 8-hour TTL |
|
Add SessionManagerLayer wiring, 30-minute non-sliding TTL |
|
New: session integration tests |
Verification
-
cargo build --workspace— zero errors -
cargo nextest run -p canopy-web— session integration tests pass -
Manual smoke test:
cargo xtask dev start --profile snap-only→ POST to any canopy-web route → verifySet-Cookie: id=…header withHttpOnly; Secure; SameSite=Strict -
Verify cookie is NOT accessible from JavaScript (HttpOnly)
-
Verify session expires after configured TTL (set to 5 seconds in test)
-
cargo clippy --all-targets — -D warnings— zero warnings
Documentation Updates
-
.claude/docs/services.md— add session configuration to canopy-web and canopy-portal rows -
CHANGELOG.adoc— entry under== Unreleased