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:

  • sessions table migration in the shared infrastructure database (the postgres container at port 5432 used by all infrastructure services)

  • SessionManagerLayer wired in services/canopy-web/src/main.rs

  • SessionManagerLayer wired in services/canopy-portal/src/main.rs

  • Session TTL configuration via ServiceSettings

  • Session cleanup: periodic deletion of expired sessions (either continuous background task or scheduled cargo xtask command)

  • 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-portal Fluent 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

Cargo.toml

Add tower-sessions and tower-sessions-sqlx-store to workspace dependencies

services/canopy-web/Cargo.toml

Add tower-sessions, tower-sessions-sqlx-store

services/canopy-portal/Cargo.toml

Add tower-sessions, tower-sessions-sqlx-store

services/canopy-web/migrations/20260401000000_create_sessions_table.sql

New: sessions table migration (shared with portal)

services/canopy-web/src/main.rs

Add SessionManagerLayer wiring, 8-hour TTL

services/canopy-portal/src/main.rs

Add SessionManagerLayer wiring, 30-minute non-sliding TTL

services/canopy-web/tests/session_test.rs

New: session integration tests

Verification

  1. cargo build --workspace — zero errors

  2. cargo nextest run -p canopy-web — session integration tests pass

  3. Manual smoke test: cargo xtask dev start --profile snap-only → POST to any canopy-web route → verify Set-Cookie: id=…​ header with HttpOnly; Secure; SameSite=Strict

  4. Verify cookie is NOT accessible from JavaScript (HttpOnly)

  5. Verify session expires after configured TTL (set to 5 seconds in test)

  6. 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

Edit this page · default