Remediate security audit findings

- Replace gorilla/csrf with net/http CrossOriginProtection
- Require valkey-password and add TLS options for session store
- End session at /logout and revoke refresh tokens
- Re-derive identity and roles from provider every five minutes
- Process each Stripe webhook event in its own Temporal workflow
- Give each outbox entry its own workflow with Temporal retries
- Guard against stale Stripe events with provider timestamps
- Derive transport security from base-url scheme
This commit is contained in:
2026-09-09 13:25:43 -05:00
parent 1b4c887d16
commit 0b28a9dc29
200 changed files with 12019 additions and 970 deletions
+7 -2
View File
@@ -1,5 +1,8 @@
# Build stage
FROM golang:1.23-alpine AS builder
# Pinned to a supported Go release. Go 1.23 left the upstream support window,
# so the image shipped without stdlib security fixes. Flagged by the 2026-09
# security audit; the project moved to 1.27 in the same pass.
FROM golang:1.27.1-alpine AS builder
# Install build dependencies
RUN apk add --no-cache gcc musl-dev
@@ -20,7 +23,9 @@ COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -a -tags musl -o member-console .
# Runtime stage
FROM alpine:latest
# Pinned rather than floating: `alpine:latest` made builds irreproducible and
# silently changed the runtime base. Bump this tag deliberately.
FROM alpine:3.23
# Run as a dedicated non-root user; port 8080 is unprivileged and the app
# performs no runtime filesystem writes, so no ownership setup is needed.
+1 -1
View File
@@ -35,10 +35,10 @@ func validVCConfig(dsn string) {
viper.Reset()
viper.Set("db-dsn", dsn)
viper.Set("valkey-addr", "localhost:6379")
viper.Set("valkey-password", "test-store-password")
viper.Set("oidc-idp-issuer-url", "https://idp.example.com/realms/main")
viper.Set("oidc-sp-client-id", "member-console")
viper.Set("base-url", "https://console.example.com")
viper.Set("csrf-secret", "0123456789abcdef0123456789abcdef")
viper.Set("deployment-name", config.DefaultDeploymentName)
}
+1 -1
View File
@@ -132,7 +132,7 @@ func TestInitScaffoldGeneratedSections(t *testing.T) {
t.Errorf("embedded template still hand-lists integration key %q", strings.TrimSpace(banned))
}
}
for _, required := range []string{"base-url:", "db-dsn:", "valkey-addr:", "oidc-idp-issuer-url:", "oidc-sp-client-id:", "csrf-secret:"} {
for _, required := range []string{"base-url:", "db-dsn:", "valkey-addr:", "valkey-password:", "oidc-idp-issuer-url:", "oidc-sp-client-id:"} {
if !strings.Contains(scaffold, required) {
t.Errorf("scaffold missing required core key %q", required)
}
+9 -8
View File
@@ -97,7 +97,7 @@ var startCmd = &cobra.Command{
}
// Resolve *-secret / *-secret-file pairs into Viper before validating or
// starting any service, so file-backed secrets (e.g. csrf-secret-file)
// starting any service, so file-backed secrets (e.g. stripe-api-key-file)
// are present when ValidateStart reads them. Shared with config validate
// (D10, typed-config-keys), so both read a deployment's secret-file
// wiring identically.
@@ -210,7 +210,6 @@ var startCmd = &cobra.Command{
// Retrieve the configuration values from Viper
port := viper.GetString("port")
csrfSecret := viper.GetString("csrf-secret")
// Claim lifecycle policy (design D6): read once here and carried on
// server.Config → server.Deps, so core's Domains page and every
@@ -355,7 +354,6 @@ var startCmd = &cobra.Command{
serverConfig := server.Config{
Port: port,
Env: env,
CSRFSecret: csrfSecret,
Logger: logger,
Database: database,
IdentityQ: identity.New(database),
@@ -392,16 +390,19 @@ func init() {
// General configuration
startCmd.Flags().StringP("port", "p", "", "Port to listen on")
startCmd.Flags().String("base-url", "", "Address at which the server is exposed")
startCmd.Flags().String("env", "", "Environment (development/production)")
startCmd.Flags().String("env", "", "Environment label; development selects text logs, anything else JSON")
startCmd.Flags().String("deployment-name", "", "Name this deployment presents for itself on the member and operator mastheads, page titles, and OpenGraph tags (default \"Member Console\")")
startCmd.Flags().String("db-dsn", "", "PostgreSQL connection string (e.g., postgres://user:pass@localhost:5432/dbname?sslmode=disable)")
startCmd.Flags().String("valkey-addr", "", "Valkey/Redis address for session storage (host:port)")
startCmd.Flags().String("csrf-secret", "", "Secret key for CSRF protection (must be exactly 32 bytes)")
startCmd.Flags().String("csrf-secret-file", "", "Path to file containing CSRF secret key")
// OIDC configuration
startCmd.Flags().String("oidc-sp-client-id", "", "OIDC Client ID")
startCmd.Flags().String("oidc-idp-issuer-url", "", "OIDC Identity Provider Issuer URL")
startCmd.Flags().String("valkey-username", "", "Username for the session store (ACL auth; optional)")
startCmd.Flags().String("valkey-password", "", "Password for the session store (required; or valkey-password-file)")
startCmd.Flags().String("valkey-password-file", "", "Path to file containing the session store password")
startCmd.Flags().Bool("valkey-tls", false, "Connect to the session store over TLS")
startCmd.Flags().Bool("valkey-tls-skip-verify", false, "Do not verify the session store's TLS certificate (self-signed stores only)")
startCmd.Flags().String("oidc-idp-account-url", "", "URL of the identity provider's self-service account console, opened by the shell's Identity and Access control (default: issuer URL + /account, Keycloak's shape)")
startCmd.Flags().String("oidc-sp-client-secret", "", "OIDC Client Secret")
startCmd.Flags().String("oidc-sp-client-secret-file", "", "Path to file containing OIDC Client Secret")
@@ -547,7 +548,7 @@ func loadFromFile(path string) (string, error) {
// resolveSecretFiles loads every "<name>-file" secret variant (core's own
// small, hand-listed set plus every installed integration's declared
// Secret keys, config.SecretPairsFrom) into Viper, so a file-backed
// secret (e.g. csrf-secret-file) is present before any validation reads it.
// secret (e.g. stripe-api-key-file) is present before any validation reads it.
// Both a direct value and a file path for the same key is a fatal
// misconfiguration, reported as an error rather than exiting here: shared
// between start's own boot sequence and config validate (design D10,
@@ -557,8 +558,8 @@ func loadFromFile(path string) (string, error) {
func resolveSecretFiles(integrationConfigSpecs []config.ConfigKey) error {
secretPairs := []config.SecretPair{
{Name: "oidc-sp-client-secret", FileName: "oidc-sp-client-secret-file"},
{Name: "csrf-secret", FileName: "csrf-secret-file"},
{Name: "temporal-oauth-client-secret", FileName: "temporal-oauth-client-secret-file"},
{Name: "valkey-password", FileName: "valkey-password-file"},
}
secretPairs = append(secretPairs, config.SecretPairsFrom(integrationConfigSpecs)...)
for _, pair := range secretPairs {
+3 -3
View File
@@ -370,9 +370,9 @@ type WorkflowProvider interface {
`RegisterWorkflows` runs at worker construction and registers your Temporal
workflows/activities against the shared worker. `Startup` runs once per boot,
after the Temporal client is connected and the worker has started: use it for
one-off workflow starts (Stripe hand-starts its webhook-processor and
outbox-poller workflows here) or schedule creation (FedWiki sets up its sync
schedule here). By convention, `Startup` implementations log their own
one-off workflow starts (Stripe hand-starts its outbox-poller workflow here
and sweeps unfinished webhook events into their per-event workflows) or
schedule creation (FedWiki sets up its sync schedule here). By convention, `Startup` implementations log their own
failures and return `nil` — a failing integration should degrade that
integration's background functionality, not halt boot — though the
composition root also logs a non-nil error defensively. Integration-specific
+2 -1
View File
@@ -37,7 +37,8 @@ Tiers are not hardcoded concepts in member-console. "Public" and "Standard" are
| `/register` | Redirects to IDP registration page | OIDC registration endpoint with PKCE. After IDP registration, callback auto-provisions the user. |
| `/login` | Redirects to IDP login page | Standard OIDC authorization code flow with PKCE. |
| `/callback` | Handles IDP redirect | Exchanges code, verifies token, provisions new users or loads existing ones. |
| `/logout` | Initiates logout | Destroys session, redirects to IDP logout. |
| `/logout` | Initiates logout (POST only; a GET redirects to `/`) | Destroys the session, revokes the refresh token at the IDP, then sends the browser to the IDP's end-session endpoint with a valid `id_token_hint` where one can be had. |
| `/logout-callback` | Returns from IDP logout | Redirects to `/login`. Nothing is verified: the session ended at `/logout`. |
An external site integrates by linking to these endpoints on the console's domain (e.g., `console.example.com/register`).
+10 -5
View File
@@ -345,11 +345,16 @@ non-interactive header (name, email); "Identity and Access" with the
external-link icon nowrap-bound to the label and the plain-words tooltip,
`target="_blank"`, `hx-boost="false"`; "Get help" to the configured
`support-url` (`target="_blank"`, `hx-boost="false"`), rendered only when
that key resolves to a value; a divider; "Sign out" to `/logout` with
`hx-boost="false"`. Sign out is plain text, never the destructive colour
(that colour is for destructive data actions), and always last. Nothing
else goes in this menu, and no session or account control renders
anywhere else.
that key resolves to a value; a divider; "Sign out", a `<button>` that
posts to `/logout` (`hx-post`, `hx-swap="none"`) and is declared as an
action trigger, since it is a single-intent control with nothing to fill
in. It posts because sign-out changes state: a link would be a GET any
page on any origin could send the browser to, and a GET at `/logout` now
leads back to the console with the session intact. The handler answers the
htmx request with `HX-Redirect` to the identity provider. Sign out is plain
text, never the destructive colour (that colour is for destructive data
actions), and always last. Nothing else goes in this menu, and no session
or account control renders anywhere else.
### Rail
+7 -2
View File
@@ -46,8 +46,8 @@ from `internal/config/validate.go`'s (`ValidateStart`) checks.
| Key | Type | Env override | Purpose | Default | Required |
|---|---|---|---|---|---|
| `port` | string | `MC_PORT` | Port the HTTP server listens on. | `8080` | Optional |
| `base-url` | url | `MC_BASE_URL` | Public URL of this console, e.g. `https://console.example.com`. Also the console's sole CSRF trusted origin; see [production-deployment.md](production-deployment.md). | none | Required |
| `env` | string | `MC_ENV` | `development` or `production`. Controls the CSP `upgrade-insecure-requests` directive, the CSRF cookie's `Secure` flag, and JSON vs. text log formatting. | `development` | Optional |
| `base-url` | url | `MC_BASE_URL` | Public URL of this console, e.g. `https://console.example.com`; `http` or `https`. Its origin is the console's sole cross-origin trusted origin, and its scheme decides the session cookie's `Secure` flag, the CSP `upgrade-insecure-requests` directive, and the `Strict-Transport-Security` header (all on for `https`, off for `http`); see [production-deployment.md](production-deployment.md). | none | Required |
| `env` | string | `MC_ENV` | Environment label. `development` selects text log formatting; any other value selects JSON. Nothing security-relevant hangs off it. | `development` | Optional |
| `deployment-name` | string | `MC_DEPLOYMENT_NAME` | Name this deployment presents for itself on the member and operator mastheads, page titles, and OpenGraph tags. | `Member Console` | Optional (rejected if set to blank/whitespace-only) |
| `support-url` | url | `MC_SUPPORT_URL` | URL shown in error messages for users to get support. | none | Optional |
@@ -57,6 +57,11 @@ from `internal/config/validate.go`'s (`ValidateStart`) checks.
|---|---|---|---|---|---|
| `db-dsn` | url (postgres) | `MC_DB_DSN` | PostgreSQL connection string, e.g. `postgres://user:pass@host:5432/dbname?sslmode=disable`. | none | Required |
| `valkey-addr` | string | `MC_VALKEY_ADDR` | Valkey/Redis address (`host:port`) for server-side session storage. | `localhost:6379` | Required (satisfied by the default unless explicitly cleared) |
| `valkey-username` | string | `MC_VALKEY_USERNAME` | ACL username for the session store, when it uses one. | none | Optional |
| `valkey-password` | string | `MC_VALKEY_PASSWORD` | Password for the session store. Use this or `valkey-password-file`, never both. | none | **Required** (the store must not be reachable unauthenticated) |
| `valkey-password-file` | path | `MC_VALKEY_PASSWORD_FILE` | File holding the session-store password, read at startup. | none | One of this or `valkey-password` |
| `valkey-tls` | bool | `MC_VALKEY_TLS` | Connect to the session store over TLS. | `false` | Optional |
| `valkey-tls-skip-verify` | bool | `MC_VALKEY_TLS_SKIP_VERIFY` | Do not verify the session store's TLS certificate. Self-signed stores only; logs a warning at every boot. | `false` | Optional |
## CSRF and identity (OIDC)
+12 -32
View File
@@ -28,38 +28,18 @@ docker buildx build \
> The canonical image is published to `git.coopcloud.tech/wiki-cafe/member-console`.
> Substitute your own registry when self-hosting.
## Generating secrets
## Secrets
Generate the CSRF secret before deploying, and store it securely:
Three configuration keys are secrets, each with a `-file` variant that reads
the value from a file at startup (`docs/environment-reference.md`):
```bash
openssl rand -hex 16 # csrf-secret (32-character hex string)
```
- `oidc-sp-client-secret` — the OIDC client's secret, issued by the identity
provider.
- `valkey-password` — the session store's password. Required; the console
refuses to start without one.
- `temporal-oauth-client-secret` — only when Temporal is behind OAuth.
> Sessions are stored server-side in Valkey/Redis and need no signing secret;
> point `valkey-addr` at your instance (default `localhost:6379`).
## Rotating the CSRF secret
`csrf-secret` is the HMAC key that signs the anti-CSRF tokens embedded in
every page and form. Rotating it does **not** log anyone out — sessions live
server-side in Valkey and are unaffected — but it invalidates the CSRF token
in every page that is already open in a browser tab. Any form submitted from
such a tab fails its CSRF check until the page is reloaded.
To rotate:
1. Generate a new value (`openssl rand -hex 16`).
2. Update the deployment's configuration and restart the service. Prefer a
low-traffic window: the restart plus token invalidation means in-flight
form fills are lost and open tabs need a reload.
3. There is no step 3; old tokens are invalid the moment the new key loads.
Rotate after any suspected leak of the configured value and whenever someone
with access to production configuration departs. Routine time-based rotation
is optional; the secret authenticates only same-session form posts.
> Zero-downtime rotation (accepting tokens signed by both the old and new
> key during a grace window) is not yet supported; it is tracked as an open
> issue in `status/issues.md` ("Session/CSRF secret generation and rotation
> strategy").
None of them is generated by or for the console: each is a credential for
something external, obtained from that system. Sessions are stored server-side
in Valkey and carry no signing secret of their own; rotating any of the three
means updating the deployment's configuration and restarting.
+3
View File
@@ -12,6 +12,9 @@ This guide covers configuring your OIDC identity provider to work with the membe
- An OIDC identity provider with a client configured for Authorization Code + PKCE flow
- The client ID and secret configured in `mc-config.yaml` (or equivalent flags)
- Refresh tokens issued to the client (Keycloak's default). Two things use them: every five minutes a signed-in session re-derives its identity and roles from a fresh ID token, so a role removed, an account disabled, or a session ended at the provider takes effect here within that bound; and sign-out mints a fresh ID token for a valid `id_token_hint`, so the provider ends its session without asking. Without a refresh token a session keeps its sign-in roles for its lifetime and the provider prompts at sign-out. The roles claim must be present in the ID token a refresh returns, not only the one sign-in returns (Keycloak's mappers do both).
- `end_session_endpoint` published in the provider's discovery document (OpenID Connect RP-Initiated Logout 1.0). The console refuses to start without it.
- `revocation_endpoint` published in the discovery document (RFC 7009), so the console can revoke the refresh token at sign-out. Optional; without it the console warns once at startup and skips revocation.
## Standard OIDC Claims
+1 -1
View File
@@ -71,7 +71,7 @@ An invariant is a rule the model guarantees everywhere; code that would break on
- **Capability rows vs operational rows** — boot-stamped from code and reconciled every boot, vs database-canonical and operator-owned. The line runs through the middle of `core.providers`.
- **Mandatory surface vs optional hooks** — every integration has a key, a manifest, and migrations; routes, workflows, config, UI, and dashboard cards are opt-in interfaces discovered at composition time.
- **Dispatch transport** — outbox handoff when dispatch must be atomic with a domain commit; direct Temporal when the workflow owns its own writes. A capability decision per integration, not a doctrine.
- **Inbound vs outbound substrate** — `webhook_events` (verified events in, status-polled) vs `outbox` (actions out, retried to dead-letter).
- **Inbound vs outbound substrate** — `webhook_events` (verified events in; each Stripe row is one Temporal workflow, retried on Temporal's schedule to dead-letter) vs `outbox` (actions out; a relay polls pending rows and each becomes its own Temporal workflow, retried the same way to dead-letter).
- **Key ownership × shape × metering** — ownership (the `provider` column) and metering (whether usage of the key is measured, via the usage counters in the entitlements model) are independent: a platform-owned key may be metered. Shape is not fully independent — as built, only numeric keys have usage machinery, so a boolean key cannot be metered.
- **The three roles of a key** — the bare stored id (`fedwiki_sites`), the grouping attribute (the `provider` column), and the display name shown to humans. A dotted `provider.key` form does not exist in storage and must never be parsed out of a key string.
- **Config key classes** — secret vs overridable; boot-required vs runtime-managed; enum-closed vs free. Orthogonal, all declared in the spec.
+30 -16
View File
@@ -66,23 +66,37 @@ of it (Caddy, nginx, Traefik, your platform's ingress) that:
- Terminates TLS and forwards plain HTTP to the console.
- Forwards the `Host` header unchanged, or otherwise ensures the console
sees a request whose host matches `base-url`'s host exactly.
- Passes the `Origin` and `Referer` headers through unmodified (do not strip
them — the CSRF check below depends on `Origin`).
- Passes the `Sec-Fetch-Site` and `Origin` headers through unmodified; the
cross-origin check below reads them.
Set `base-url` (`MC_BASE_URL`) to the console's public URL, e.g.
`https://console.example.com`. At boot, the console parses `base-url` and
sets its **sole CSRF trusted origin** to that URL's host
(`internal/server/server.go`, `internal/middleware/csrf.go`); every
state-changing request's `Origin` must match it or the request is rejected
with a CSRF error. There is no separate "trusted origins" list to configure
— it is derived from `base-url` alone, so a reverse proxy that rewrites the
Host header to something other than `base-url`'s host, or that fronts the
console at more than one public hostname, breaks CSRF validation.
`https://console.example.com`. Three things follow from it at boot:
Set `env` (`MC_ENV`) to `production`. Among other effects
(`internal/middleware/security.go`), this marks the CSRF and session cookies
`Secure`, so browsers refuse to send them over a plain-HTTP connection —
another reason TLS at the proxy is not optional.
- Its origin, scheme included, is the console's **sole trusted origin** for
state-changing requests (`net/http.CrossOriginProtection`, wired in
`internal/server/server.go`). A request whose `Sec-Fetch-Site` says
cross-site, or whose `Origin` does not match, is rejected. There is no
separate trusted-origins list, so a reverse proxy that fronts the console at
a second public hostname breaks form posts from that hostname.
- Its scheme decides the session cookie's `Secure` flag: `https` sets it, so
browsers refuse to send the cookie over a plain-HTTP connection. This is
why TLS at the proxy is not optional, and why `base-url` must say `https`
when the proxy serves it: a plain-http `base-url` behind a TLS proxy ships
the cookie without `Secure`.
- The same scheme turns on the CSP `upgrade-insecure-requests` directive and
the `Strict-Transport-Security` header (`max-age=31536000;
includeSubDomains`). HSTS is a commitment: a browser that has seen it will
refuse plain HTTP for the console's hostname, and will treat any
certificate error there as fatal with no click-through, for a year after
its last visit. Keep certificate renewal automatic. `includeSubDomains`
reaches subdomains of the console's host only, which is nothing else at
`console.example.com`; at an apex hostname it would reach every subdomain
you have. If the host must ever go back to plain HTTP, serve
`Strict-Transport-Security: max-age=0` over https first, from the proxy or
by hand, until visitors have picked it up.
`env` (`MC_ENV`) no longer affects any of this; it selects the log format
only (`development` for text, anything else for JSON).
## 4. Identity provider
@@ -173,8 +187,8 @@ disabled.
## 7. First boot
With the database reachable, `base-url`/`env` set, the CSRF secret in place,
and the OIDC keys pointed at your realm, start the process (`member-console
With the database reachable, `base-url` set, the session store's password
configured, and the OIDC keys pointed at your realm, start the process (`member-console
start`, or your image's equivalent entrypoint). At startup it, in order:
runs pending migrations, validates the aggregated configuration (failing
fast with every problem named at once if something required is missing —
+33 -42
View File
@@ -1,30 +1,30 @@
module git.coopcloud.tech/wiki-cafe/member-console
go 1.23.0
go 1.27.1
require (
github.com/CAFxX/httpcompression v0.0.9
github.com/alexedwards/scs/redisstore v0.0.0-20251002162104-209de6e426de
github.com/alexedwards/scs/v2 v2.9.0
github.com/coreos/go-oidc/v3 v3.12.0
github.com/go-jose/go-jose/v4 v4.1.5
github.com/go-rod/rod v0.116.2
github.com/gomodule/redigo v1.9.3
github.com/google/uuid v1.6.0
github.com/gorilla/csrf v1.7.3
github.com/jackc/pgx/v5 v5.7.4
github.com/jackc/pgx/v5 v5.11.0
github.com/lib/pq v1.12.0
github.com/pressly/goose/v3 v3.24.3
github.com/rs/cors v1.11.1
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
go.temporal.io/sdk v1.38.0
)
require (
github.com/alexedwards/scs/redisstore v0.0.0-20251002162104-209de6e426de
github.com/alexedwards/scs/v2 v2.9.0
github.com/go-rod/rod v0.116.2
github.com/gomodule/redigo v1.9.3
github.com/lib/pq v1.12.0
github.com/sqlc-dev/pqtype v0.3.0
github.com/stretchr/testify v1.10.0
github.com/stretchr/testify v1.11.1
github.com/stripe/stripe-go/v81 v81.4.0
go.temporal.io/api v1.54.0
golang.org/x/net v0.40.0
go.temporal.io/sdk v1.38.0
golang.org/x/net v0.59.0
golang.org/x/oauth2 v0.36.0
golang.org/x/sync v0.23.0
golang.org/x/time v0.5.0
)
@@ -32,55 +32,46 @@ require (
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/mock v1.6.0 // indirect
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/nexus-rpc/sdk-go v0.5.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/robfig/cron v1.2.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/ysmood/fetchup v0.2.3 // indirect
github.com/ysmood/goob v0.4.0 // indirect
github.com/ysmood/got v0.40.0 // indirect
github.com/ysmood/gson v0.7.3 // indirect
github.com/ysmood/leakless v0.9.0 // indirect
golang.org/x/sync v0.14.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect
google.golang.org/grpc v1.71.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
)
require (
github.com/coreos/go-oidc/v3 v3.12.0
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
golang.org/x/oauth2 v0.26.0
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.83.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+44 -48
View File
@@ -7,6 +7,8 @@ github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gv
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-oidc/v3 v3.12.0 h1:sJk+8G2qq94rDI6ehZ71Bol3oUHy63qNYmkiSjrc/Jo=
github.com/coreos/go-oidc/v3 v3.12.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
@@ -22,10 +24,10 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-jose/go-jose/v4 v4.1.5 h1:RjgjO2LOtWOJKUC5wpwY9LR3B3vwVAz6JS2YHfYU6eA=
github.com/go-jose/go-jose/v4 v4.1.5/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
@@ -41,16 +43,10 @@ github.com/gomodule/redigo v1.9.3 h1:dNPSXeXv6HCq2jdyWfjgmhBdqnR6PRO3m/G05nvpPC8
github.com/gomodule/redigo v1.9.3/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f h1:jopqB+UTSdJGEJT8tEqYyE29zN91fi2827oLET8tl7k=
github.com/google/brotli/go/cbrotli v0.0.0-20230829110029-ed738e842d2f/go.mod h1:nOPhAkwVliJdNTkj3gXpljmWhjc4wCaVqbMJcPKWP4s=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys=
@@ -63,8 +59,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
@@ -142,8 +138,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stripe/stripe-go/v81 v81.4.0 h1:AuD9XzdAvl193qUCSaLocf8H+nRopOouXhxqJUzCLbw=
github.com/stripe/stripe-go/v81 v81.4.0/go.mod h1:C/F4jlmnGNacvYtBp/LUHCvVUJEZffFQCobkzwY1WOo=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
@@ -170,18 +166,18 @@ github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.temporal.io/api v1.54.0 h1:/sy8rYZEykgmXRjeiv1PkFHLXIus5n6FqGhRtCl7Pc0=
go.temporal.io/api v1.54.0/go.mod h1:iaxoP/9OXMJcQkETTECfwYq4cw/bj4nwov8b3ZLVnXM=
go.temporal.io/sdk v1.38.0 h1:4Bok5LEdED7YKpsSjIa3dDqram5VOq+ydBf4pyx0Wo4=
@@ -191,8 +187,6 @@ go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN8
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -204,16 +198,16 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE=
golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -221,14 +215,14 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -240,14 +234,16 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24=
google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+14 -11
View File
@@ -59,8 +59,8 @@ The `internal/auth` RegisterHandlers method hooks the following HTTP routes on t
- `GET /login` — `LoginHandler`: Initiate authorization (PKCE + OIDC).
- `GET /callback` — `CallbackHandler`: Handle authorization code, exchange tokens, verify ID token, provision user and create session.
- `GET /logout` — `LogoutHandler`: Initiate signout at the IdP; sets a `logout_state` and redirects the user to the IdP logout endpoint.
- `GET /logout-callback` — `LogoutCallbackHandler`: Verify `state` at signout return, clear the session, and redirect to `/login`.
- `POST /logout` — `LogoutHandler`: Destroys the session, revokes the refresh token at the IdP, then sends the browser to the IdP's end-session endpoint with a valid `id_token_hint` where one can be had: `HX-Redirect` for the account menu's htmx request, a 303 for any other POST. A GET at `/logout` is a 303 to `/` and ends nothing, so a link on another origin cannot sign a person out.
- `GET /logout-callback` — `LogoutCallbackHandler`: Where the IdP sends the browser after its own sign-out; redirects to `/login`. Nothing is verified here because nothing is left to protect.
- `GET /register` — `RegistrationHandler`: Redirects to the IdP registration path (or the auth endpoint with registration params) using `code_challenge` and `nonce` similarly to `/login`.
---
@@ -90,8 +90,7 @@ Required config keys (from `viper`):
- `MaxAge: 7 days` by default
- The session stores these keys:
- `state`, `nonce`, `code_verifier` during the authentication initiation
- `authenticated`, `id_token`, `user_db_id`, `oidc_subject`, `email`, `name`, `username` after authentication
- `logout_state` during sign-out
- `authenticated`, `id_token`, `refresh_token`, `person_id`, `org_id`, `workspace_id`, `oidc_subject`, `email`, `name`, `username`, `roles`, `identity_refreshed_at` after authentication; `identity_retry_at` while the provider is not answering
---
@@ -109,11 +108,18 @@ Required config keys (from `viper`):
---
## Identity Re-derivation
- Every five minutes, on the first authenticated request after the interval, the middleware re-derives the session's identity and roles with a refresh-token grant: the new ID token is verified as at sign-in, its roles replace the session's, and the new ID token and refresh token replace the stored ones.
- `invalid_grant` (session ended at the IdP, account disabled, token revoked) ends the console session and bounces to `/login`. No answer keeps the session and retries after a minute. A token naming a different subject ends the session.
- Concurrent requests crossing the interval share one refresh (`singleflight`, keyed by session token).
## Logout Flow
- `LogoutHandler` builds a sign-out URL to the IdP's logout endpoint (Keycloak-style URL shown in code) and sets a `logout_state` in session for verification on return.
- If available, `id_token_hint` is included in the logout request to help the IdP identify the session.
- `LogoutCallbackHandler` validates the `state`, then clears the session by setting `session.Options.MaxAge = -1` and saving it.
- Sign-out is a POST, made by the account menu's button; the cross-origin protection covers it and a GET at `/logout` only redirects to `/`. `LogoutHandler` destroys the console session first, then sends the browser to the IdP's `end_session_endpoint`, read from discovery at startup (`HX-Redirect` to the htmx request, since fetch cannot follow a redirect to another origin). A provider that publishes none fails startup; there is no fallback path.
- The request carries `id_token_hint` whenever a valid ID token can be had: the stored one while it is still valid, otherwise a fresh one minted from the stored refresh token in one bounded call to the token endpoint. A valid hint is what lets the IdP end its session without asking the person to confirm. When no hint can be had the IdP asks; the console session is gone either way.
- Between the two, every refresh token that was live (the stored one, and a rotated one when the refresh returned it) is revoked at the IdP's `revocation_endpoint` (RFC 7009) when discovery publishes one, so no copy can mint tokens after sign-out. Best effort: a failure is logged and the sign-out proceeds.
- `LogoutCallbackHandler` destroys the session once more (expiring a cookie the browser may still carry) and redirects to `/login`.
---
@@ -144,7 +150,7 @@ Once configured, the app can be started and these endpoints will be active on th
- PKCE is mandatory for public clients (e.g., SPA, mobile) and recommended for confidential clients to protect against code interception attacks.
- The `nonce` is stored in session and validated against the ID token `nonce` claim as an additional replay protection.
- If using a different IdP, you may need to adjust registration and logout endpoint paths since some providers expose different routes for registration and logout.
- The logout and revocation endpoints are discovered (`end_session_endpoint`, `revocation_endpoint`). The registration path is still Keycloak's; another IdP may need a different route for registration.
- When deploying to production, ensure `base-url` reflects your secure domain and `valkey-addr` points at your session store.
---
@@ -155,6 +161,3 @@ Once configured, the app can be started and these endpoints will be active on th
- `internal/auth/` — this README and other auth related files
- `internal/db/*` — database schema & SQLC queries (`GetUserByOIDCSubject`, `CreateUser`, `UpdateUser`)
---
If you want, I can also create short usage examples (cURL) to simulate login and logout sequences or add inline code examples for environment setup and deployment.
+686 -122
View File
File diff suppressed because it is too large Load Diff
+201
View File
@@ -0,0 +1,201 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package auth
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fakeFailurePage records what the handler asked to be rendered, standing in
// for internal/server's template set (which this package cannot import).
type fakeFailurePage struct {
called bool
status int
heading string
actionLabel string
actionHref string
}
func (f *fakeFailurePage) RenderAuthFailure(w http.ResponseWriter, r *http.Request, status int, heading, actionLabel, actionHref string) {
f.called = true
f.status = status
f.heading = heading
f.actionLabel = actionLabel
f.actionHref = actionHref
w.WriteHeader(status)
fmt.Fprintf(w, `<h1>%s</h1><a href="%s">%s</a>`, heading, actionHref, actionLabel)
}
// callbackWithStaleState drives /callback with a session holding savedState
// and a query carrying queryState, which is the shape a sign-in tab left open
// too long comes back in.
func callbackWithStaleState(t *testing.T, cfg *Config, savedState, queryState string) *httptest.ResponseRecorder {
t.Helper()
seed := httptest.NewRequest(http.MethodGet, "/login", nil)
token := tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) {
cfg.SessionManager.Put(r.Context(), sessionKeyState, savedState)
}, seed)
if token == "" {
t.Fatal("setup: no session token was issued for the seeding request")
}
req := httptest.NewRequest(http.MethodGet, "/callback?state="+queryState+"&code=abc", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.CallbackHandler)).ServeHTTP(rec, req)
return rec
}
// The defect this closes: a Keycloak authorize page left open for 45 minutes
// and then submitted answered with `400 Invalid state` as unstyled http.Error
// text, on a page with no way back to sign-in. The refusal is correct and
// stays; what changes is that the person is told what happened and given the
// link that fixes it.
func TestStaleCallbackStateRendersTheStyledPageWithAWayBack(t *testing.T) {
cfg := newTestConfig()
page := &fakeFailurePage{}
cfg.Failures = page
rec := callbackWithStaleState(t, cfg, "the-state-login-stored", "a-different-state")
if !page.called {
t.Fatal("a stale callback state did not reach the failure page")
}
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d: the refusal must stay a refusal", rec.Code, http.StatusBadRequest)
}
if page.heading != "Sign-in expired" {
t.Errorf("heading = %q, want %q", page.heading, "Sign-in expired")
}
if page.actionHref != "/login" {
t.Errorf("action href = %q, want /login: the page must lead back into sign-in", page.actionHref)
}
if page.actionLabel == "" {
t.Error("the action has no label")
}
if body := rec.Body.String(); !strings.Contains(body, `href="/login"`) {
t.Errorf("rendered body carries no sign-in link: %q", body)
}
if body := rec.Body.String(); strings.Contains(body, "Invalid state") {
t.Errorf("the person is still shown the raw refusal: %q", body)
}
}
// A Config with no renderer (every other test here, and any embedder that has
// not wired one) must still refuse, in plain text, rather than dereference nil.
func TestRefusalWithoutARendererFallsBackToPlainText(t *testing.T) {
cfg := newTestConfig()
rec := callbackWithStaleState(t, cfg, "the-state-login-stored", "a-different-state")
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if body := rec.Body.String(); !strings.Contains(body, "Sign-in expired") {
t.Errorf("plain-text fallback = %q, want the heading", body)
}
}
// callbackWithProviderError drives /callback with a session whose state
// matches, and a query carrying the identity provider's error response
// instead of an authorization code.
func callbackWithProviderError(t *testing.T, cfg *Config, code, description string) *httptest.ResponseRecorder {
t.Helper()
const state = "the-state-login-stored"
seed := httptest.NewRequest(http.MethodGet, "/login", nil)
token := tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) {
cfg.SessionManager.Put(r.Context(), sessionKeyState, state)
}, seed)
if token == "" {
t.Fatal("setup: no session token was issued for the seeding request")
}
req := httptest.NewRequest(http.MethodGet, "/callback", nil)
q := req.URL.Query()
q.Set("state", state)
q.Set("error", code)
q.Set("error_description", description)
req.URL.RawQuery = q.Encode()
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.CallbackHandler)).ServeHTTP(rec, req)
return rec
}
// The live failure this closes: the identity provider answered the authorize
// request with `error=temporarily_unavailable&error_description=
// authentication_expired`, the callback ignored the parameter, and the token
// exchange then failed with an empty code -- an internal error for something
// that was neither internal nor an error on this side.
func TestAProviderErrorResponseIsNamedAsAnExpiredSignIn(t *testing.T) {
cfg := newTestConfig()
page := &fakeFailurePage{}
cfg.Failures = page
rec := callbackWithProviderError(t, cfg, "temporarily_unavailable", "authentication_expired")
if !page.called {
t.Fatal("the provider's error response did not reach the failure page")
}
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if page.heading != "Sign-in expired" {
t.Errorf("heading = %q, want %q: a transient provider error clears on a retry", page.heading, "Sign-in expired")
}
if page.actionHref != "/login" {
t.Errorf("action href = %q, want /login", page.actionHref)
}
}
// A provider error a retry will not clear says so, so the person is not sent
// round the same loop.
func TestANonRetryableProviderErrorIsNamedAsAFailure(t *testing.T) {
cfg := newTestConfig()
page := &fakeFailurePage{}
cfg.Failures = page
rec := callbackWithProviderError(t, cfg, "access_denied", "user denied the request")
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if page.heading != "Sign-in failed" {
t.Errorf("heading = %q, want %q", page.heading, "Sign-in failed")
}
}
// The state is checked before the error parameter is read. An error response
// carries a state too, so an unsolicited one must not be able to end a flow
// the person is still in.
func TestAProviderErrorWithTheWrongStateIsRefusedOnTheState(t *testing.T) {
cfg := newTestConfig()
page := &fakeFailurePage{}
cfg.Failures = page
const state = "the-state-login-stored"
seed := httptest.NewRequest(http.MethodGet, "/login", nil)
token := tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) {
cfg.SessionManager.Put(r.Context(), sessionKeyState, state)
}, seed)
req := httptest.NewRequest(http.MethodGet, "/callback?state=someone-elses&error=access_denied", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.CallbackHandler)).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
if page.heading != "Sign-in expired" {
t.Errorf("heading = %q: the state mismatch must be what refuses, before the error parameter is read", page.heading)
}
}
+70
View File
@@ -0,0 +1,70 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package auth
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Log injection, found by the 2026-09 security audit: the callback logged the
// state it was handed with a %s verb, so a state carrying newlines wrote
// additional lines into the log and could forge records around it. The state
// is attacker-controlled -- it arrives in the query string of a URL anyone can
// send a person to.
//
// What closes it is slog rather than the attribute alone: both handlers the
// project runs (text in development, JSON in production) escape a newline
// wherever it appears, in an attribute's value and in the message. Measured
// on Go 1.27.1: `log.Printf("...: %s", state)` writes two records for the
// value below, and every slog form writes one.
//
// So this test guards the migration itself. It fails if internal/auth goes
// back to the standard logger's raw interpolation, which is the mistake that
// produced the finding, and it holds whichever slog form a later author
// reaches for.
func TestAStateCarryingNewlinesStaysOneLogRecord(t *testing.T) {
cfg := newTestConfig()
ctx, logs := captureLog(t)
forged := "abc\nlevel=INFO msg=\"sign-in complete\" email=attacker@example.test\nmore"
seed := httptest.NewRequest(http.MethodGet, "/login", nil)
token := tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) {
cfg.SessionManager.Put(r.Context(), sessionKeyState, "the-state-login-stored")
}, seed)
if token == "" {
t.Fatal("setup: no session token was issued for the seeding request")
}
req := httptest.NewRequest(http.MethodGet, "/callback", nil)
q := req.URL.Query()
q.Set("state", forged)
req.URL.RawQuery = q.Encode()
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
req = req.WithContext(ctx)
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.CallbackHandler)).ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
written := strings.TrimSuffix(logs.String(), "\n")
if written == "" {
t.Fatal("the refusal logged nothing")
}
if got := strings.Count(written, "\n"); got != 0 {
t.Errorf("the refusal wrote %d log records, want 1:\n%s", got+1, written)
}
if strings.Contains(written, `msg="sign-in complete"`) {
t.Errorf("a forged record survived into the log:\n%s", written)
}
if !strings.Contains(written, "callback state does not match the session") {
t.Errorf("the refusal's own message is missing:\n%s", written)
}
}
+23 -14
View File
@@ -5,8 +5,9 @@ package auth
import (
"bytes"
"context"
"encoding/json"
"log"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
@@ -17,6 +18,8 @@ import (
"github.com/alexedwards/scs/v2"
"golang.org/x/oauth2"
"git.coopcloud.tech/wiki-cafe/member-console/internal/logging"
)
// newLoginTestConfig builds a Config with enough OAuth wiring for
@@ -189,15 +192,21 @@ func (c idTokenClaims) Claims(v any) error {
return json.Unmarshal([]byte(c), v)
}
// captureLog routes the standard logger into a buffer for the test's
// lifetime and returns it.
func captureLog(t *testing.T) *bytes.Buffer {
// captureLog returns a context carrying a logger that writes into the
// returned buffer, so a test can assert on what a call logged. The handler
// is slog's text handler, which is what a development deployment runs, and
// it writes exactly one record per line -- which is what makes the
// one-record-per-refusal assertions below meaningful.
//
// The logger is injected through the context rather than by redirecting the
// standard logger: internal/auth logs through logging.FromContext, and a
// test that captured the standard logger would pass only by way of slog's
// default handler happening to write through it.
func captureLog(t *testing.T) (context.Context, *bytes.Buffer) {
t.Helper()
var buf bytes.Buffer
prev := log.Writer()
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(prev) })
return &buf
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
return logging.WithContext(t.Context(), logger), &buf
}
func TestRolesFromIDTokenReadsVerifiedIDToken(t *testing.T) {
@@ -206,9 +215,9 @@ func TestRolesFromIDTokenReadsVerifiedIDToken(t *testing.T) {
"roles": ["operator-member"],
"resource_access": {"test-client": {"roles": ["billing"]}}
}`)
logs := captureLog(t)
ctx, logs := captureLog(t)
got := rolesFromIDToken(token, "test-client", "a@example")
got := rolesFromIDToken(ctx, token, "test-client", "a@example")
want := []string{"operator-member", "billing"}
if !reflect.DeepEqual(got, want) {
t.Errorf("rolesFromIDToken = %v, want %v", got, want)
@@ -223,9 +232,9 @@ func TestRolesFromIDTokenNoRoleLocationLogsMisconfiguration(t *testing.T) {
// resource_access at all. This is the shape that would otherwise lock
// every operator out with no symptom but "cannot get in".
token := idTokenClaims(`{"sub": "abc", "email": "a@example"}`)
logs := captureLog(t)
ctx, logs := captureLog(t)
got := rolesFromIDToken(token, "test-client", "a@example")
got := rolesFromIDToken(ctx, token, "test-client", "a@example")
if len(got) != 0 {
t.Errorf("rolesFromIDToken = %v, want none", got)
}
@@ -240,9 +249,9 @@ func TestRolesFromIDTokenEmptyLocationIsAMemberNotAMisconfiguration(t *testing.T
// A present-but-empty location is an ordinary member with no roles; it
// must not be mistaken for an IdP that never mapped roles.
token := idTokenClaims(`{"sub": "abc", "roles": []}`)
logs := captureLog(t)
ctx, logs := captureLog(t)
got := rolesFromIDToken(token, "test-client", "a@example")
got := rolesFromIDToken(ctx, token, "test-client", "a@example")
if len(got) != 0 {
t.Errorf("rolesFromIDToken = %v, want none", got)
}
+7
View File
@@ -27,6 +27,13 @@ func TestValidReturnTo(t *testing.T) {
{"absolute http", "http://evil.example/steal", "", false},
{"scheme-relative no slash", "evil.example/steal", "", false},
{"javascript scheme", "javascript:alert(1)", "", false},
// A browser reads "\\" as "/" in an http(s) URL (WHATWG URL parsing,
// special schemes), so these are protocol-relative to it and
// same-origin to net/url.
{"backslash host", "/\\evil.example/steal", "", false},
{"slash backslash host", "/\\/evil.example/steal", "", false},
{"backslash after a path", "/operator\\evil.example", "", false},
{"encoded-then-raw backslash", "/operator/persons?next=\\evil.example", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package auth
import (
"net/http"
"net/http/httptest"
"testing"
"golang.org/x/oauth2"
)
// tokenAfter runs h through the session manager for one request and returns
// the session token the response sets, or "" when the token was not rotated.
func tokenAfter(t *testing.T, cfg *Config, h http.HandlerFunc, req *http.Request) string {
t.Helper()
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(h).ServeHTTP(rec, req)
for _, c := range rec.Result().Cookies() {
if c.Name == cfg.SessionManager.Cookie.Name {
return c.Value
}
}
return ""
}
// Session fixation, found by the 2026-09 security audit: LoginHandler rotated
// the session token before starting the OIDC flow but RegistrationHandler did
// not, so a session id planted in a victim's browser survived their sign-in
// through /register.
//
// The handler is exercised only as far as the rotation, which happens before
// any OIDC or Viper wiring is touched; the request is expected to fail later
// (there is no configured issuer here) and that is fine — what is asserted is
// that a new session token was issued regardless.
func TestRegistrationHandlerRotatesTheSessionToken(t *testing.T) {
cfg := newTestConfig()
// Establish a session and capture its token, standing in for the id an
// attacker would have planted.
seed := httptest.NewRequest(http.MethodGet, "/", nil)
planted := tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) {
cfg.SessionManager.Put(r.Context(), "seeded", true)
}, seed)
if planted == "" {
t.Fatal("setup: no session token was issued for the seeding request")
}
// Replay that session id into the registration path.
req := httptest.NewRequest(http.MethodGet, "/register", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: planted})
rotated := tokenAfter(t, cfg, cfg.RegistrationHandler, req)
if rotated == "" {
t.Fatal("RegistrationHandler issued no new session token: the planted id survives")
}
if rotated == planted {
t.Errorf("session token was not rotated (%q); a fixed session id survives registration", rotated)
}
}
// The login path carries the same guarantee, so a regression there is caught
// too. LoginHandler needs OAuthConfig only for AuthCodeURL, which builds a
// string and talks to nothing, so a dummy config is enough — no live identity
// provider, no network.
func TestLoginHandlerRotatesTheSessionToken(t *testing.T) {
cfg := newTestConfig()
cfg.OAuthConfig = &oauth2.Config{
ClientID: "test-client",
RedirectURL: "http://example.test/callback",
Endpoint: oauth2.Endpoint{AuthURL: "http://idp.test/auth", TokenURL: "http://idp.test/token"},
Scopes: []string{"openid"},
}
seed := httptest.NewRequest(http.MethodGet, "/", nil)
planted := tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) {
cfg.SessionManager.Put(r.Context(), "seeded", true)
}, seed)
if planted == "" {
t.Fatal("setup: no session token was issued for the seeding request")
}
req := httptest.NewRequest(http.MethodGet, "/login", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: planted})
rotated := tokenAfter(t, cfg, cfg.LoginHandler, req)
if rotated == "" {
t.Fatal("LoginHandler issued no new session token: the planted id survives")
}
if rotated == planted {
t.Errorf("session token was not rotated (%q); a fixed session id survives sign-in", rotated)
}
}
+217
View File
@@ -0,0 +1,217 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package auth
import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/go-jose/go-jose/v4"
"github.com/spf13/viper"
"golang.org/x/oauth2"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
)
// signingKey is a test RSA key, generated once per process: the verifier is
// built over its public half, so a token signed with it is as verifiable
// here as a provider's would be in production.
var signingKey = func() *rsa.PrivateKey {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic(err)
}
return key
}()
// signIDToken produces a real, signed ID token carrying claims, the way the
// identity provider's token endpoint would.
func signIDToken(t *testing.T, claims map[string]any) string {
t.Helper()
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: signingKey}, nil)
if err != nil {
t.Fatalf("signer: %v", err)
}
payload, err := json.Marshal(claims)
if err != nil {
t.Fatal(err)
}
jws, err := signer.Sign(payload)
if err != nil {
t.Fatalf("sign: %v", err)
}
raw, err := jws.CompactSerialize()
if err != nil {
t.Fatalf("serialize: %v", err)
}
return raw
}
// Returning-person fakes: the two queriers CallbackHandler consults when the
// subject is already known. The interface is embedded so only the methods
// this path calls need bodies; any other call is a nil dereference, which is
// the failure a test should see.
type fakeIdentity struct {
identity.Querier
user identity.User
person identity.Person
}
func (f fakeIdentity) GetUserByOIDCSubject(context.Context, string) (identity.User, error) {
return f.user, nil
}
func (f fakeIdentity) UpdateUserLogin(context.Context, identity.UpdateUserLoginParams) (identity.User, error) {
return f.user, nil
}
func (f fakeIdentity) GetPersonByUserID(context.Context, string) (identity.Person, error) {
return f.person, nil
}
func (f fakeIdentity) UpdatePerson(context.Context, identity.UpdatePersonParams) (identity.Person, error) {
return f.person, nil
}
type fakeOrganization struct {
organization.Querier
}
func (fakeOrganization) GetOrganizationsByOwner(context.Context, string) ([]organization.Organization, error) {
return []organization.Organization{{OrgID: "org-1"}}, nil
}
func (fakeOrganization) GetWorkspacesByOrgID(context.Context, string) ([]organization.Workspace, error) {
return []organization.Workspace{{WorkspaceID: "ws-1"}}, nil
}
// The refresh token the provider issues at sign-in is kept in the session,
// because sign-out mints a fresh id_token_hint from it. This drives the whole
// callback: a real signed ID token, verified against the signing key, with
// the nonce the session holds, exchanged at a fake token endpoint.
func TestCallbackStoresTheRefreshToken(t *testing.T) {
const (
issuer = "https://idp.test/realms/main"
clientID = "test-client"
nonce = "the-nonce"
state = "the-state"
)
viper.Set("base-url", "http://console.test")
t.Cleanup(viper.Reset)
idToken := signIDToken(t, map[string]any{
"iss": issuer,
"sub": "subject-1",
"aud": clientID,
"exp": time.Now().Add(5 * time.Minute).Unix(),
"iat": time.Now().Unix(),
"nonce": nonce,
"email": "bob@example.test",
"email_verified": true,
"name": "Bob Builder",
"preferred_username": "bob",
"roles": []string{},
})
var exchanged struct{ grant, code string }
tokenEndpoint := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
t.Errorf("token endpoint: %v", err)
}
exchanged.grant = r.PostForm.Get("grant_type")
exchanged.code = r.PostForm.Get("code")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"access_token": "access-1",
"token_type": "Bearer",
"expires_in": 300,
"refresh_token": "refresh-from-the-provider",
"id_token": idToken,
})
}))
t.Cleanup(tokenEndpoint.Close)
cfg := newTestConfig()
cfg.OAuthConfig = &oauth2.Config{
ClientID: clientID,
ClientSecret: "test-secret",
RedirectURL: "http://console.test/callback",
Endpoint: oauth2.Endpoint{AuthURL: issuer + "/auth", TokenURL: tokenEndpoint.URL},
}
cfg.Verifier = oidc.NewVerifier(issuer,
&oidc.StaticKeySet{PublicKeys: []crypto.PublicKey{&signingKey.PublicKey}},
&oidc.Config{ClientID: clientID})
cfg.IdentityQ = fakeIdentity{
user: identity.User{UserID: "user-1", OidcSubject: "subject-1"},
person: identity.Person{PersonID: "person-1", UserID: "user-1", DisplayName: "Bob Builder", PrimaryEmail: "bob@example.test"},
}
cfg.OrgQ = fakeOrganization{}
// The session /login would have left behind.
seed := httptest.NewRequest(http.MethodGet, "/login", nil)
token := tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) {
cfg.SessionManager.Put(r.Context(), sessionKeyState, state)
cfg.SessionManager.Put(r.Context(), sessionKeyNonce, nonce)
cfg.SessionManager.Put(r.Context(), sessionKeyCodeVerifier, "verifier-1")
}, seed)
if token == "" {
t.Fatal("setup: no session token was issued")
}
req := httptest.NewRequest(http.MethodGet, "/callback?state="+state+"&code=code-1", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.CallbackHandler)).ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/" {
t.Fatalf("status %d Location %q body %q; want a 302 to /", rec.Code, rec.Header().Get("Location"), rec.Body.String())
}
if exchanged.grant != "authorization_code" || exchanged.code != "code-1" {
t.Errorf("token endpoint saw grant %q code %q", exchanged.grant, exchanged.code)
}
// Read the session back through the manager, as a later request would.
var got struct {
authenticated bool
personID string
refreshToken string
idToken string
}
after := httptest.NewRequest(http.MethodGet, "/", nil)
after.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: callbackSessionToken(t, rec, cfg)})
cfg.SessionManager.LoadAndSave(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got.authenticated = cfg.SessionManager.GetBool(r.Context(), sessionKeyAuthenticated)
got.personID = cfg.SessionManager.GetString(r.Context(), sessionKeyPersonID)
got.refreshToken = cfg.SessionManager.GetString(r.Context(), sessionKeyRefreshToken)
got.idToken = cfg.SessionManager.GetString(r.Context(), sessionKeyIDToken)
})).ServeHTTP(httptest.NewRecorder(), after)
if !got.authenticated || got.personID != "person-1" {
t.Errorf("session after sign-in: authenticated=%v person=%q", got.authenticated, got.personID)
}
if got.refreshToken != "refresh-from-the-provider" {
t.Errorf("refresh token in session = %q, want the one the provider issued", got.refreshToken)
}
if got.idToken != idToken {
t.Error("the ID token in the session is not the one the provider issued")
}
}
// callbackSessionToken returns the session token the callback response set.
func callbackSessionToken(t *testing.T, rec *httptest.ResponseRecorder, cfg *Config) string {
t.Helper()
for _, c := range rec.Result().Cookies() {
if c.Name == cfg.SessionManager.Cookie.Name {
return c.Value
}
}
t.Fatal("the callback response set no session cookie")
return ""
}
+281
View File
@@ -0,0 +1,281 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package auth
import (
"crypto"
"net/http"
"net/http/httptest"
"slices"
"sync"
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc"
)
const (
refreshIssuer = "https://idp.test/realms/main"
refreshSubject = "subject-1"
)
// refreshConfig is logoutConfig plus a verifier over the test signing key, so
// the ID token the fake token endpoint hands back verifies as a provider's
// would.
func refreshConfig(t *testing.T, endpoint *fakeTokenEndpoint) *Config {
t.Helper()
cfg := logoutConfig(t, endpoint)
cfg.Verifier = oidc.NewVerifier(refreshIssuer,
&oidc.StaticKeySet{PublicKeys: []crypto.PublicKey{&signingKey.PublicKey}},
&oidc.Config{ClientID: "test-client"})
return cfg
}
// currentIDToken is a signed ID token for refreshSubject carrying roles, as
// the provider's refresh response would carry it.
func currentIDToken(t *testing.T, subject string, roles ...string) string {
t.Helper()
if roles == nil {
roles = []string{}
}
return signIDToken(t, map[string]any{
"iss": refreshIssuer,
"sub": subject,
"aud": "test-client",
"exp": time.Now().Add(5 * time.Minute).Unix(),
"iat": time.Now().Unix(),
"email": "bob@example.test",
"roles": roles,
})
}
// sessionAged seeds a signed-in session for refreshSubject whose identity was
// last confirmed refreshedAgo ago, holding roles, and returns its cookie
// token.
func sessionAged(t *testing.T, cfg *Config, refreshedAgo time.Duration, refreshToken string, roles ...string) string {
t.Helper()
if roles == nil {
roles = []string{}
}
seed := httptest.NewRequest(http.MethodGet, "/", nil)
token := tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
cfg.SessionManager.Put(ctx, sessionKeyAuthenticated, true)
cfg.SessionManager.Put(ctx, sessionKeyPersonID, "person-1")
cfg.SessionManager.Put(ctx, sessionKeyOIDCSubject, refreshSubject)
cfg.SessionManager.Put(ctx, sessionKeyIDToken, "stale-id-token")
cfg.SessionManager.Put(ctx, sessionKeyRoles, roles)
cfg.SessionManager.Put(ctx, sessionKeyIdentityRefreshedAt, time.Now().Add(-refreshedAgo).Unix())
if refreshToken != "" {
cfg.SessionManager.Put(ctx, sessionKeyRefreshToken, refreshToken)
}
}, seed)
if token == "" {
t.Fatal("setup: no session token was issued")
}
return token
}
// through sends one authenticated request through the middleware and reports
// whether it reached the handler, plus the roles the session held when it did.
func through(t *testing.T, cfg *Config, token string) (reached bool, roles []string, rec *httptest.ResponseRecorder) {
t.Helper()
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reached = true
roles = cfg.getRoles(r.Context())
})
req := httptest.NewRequest(http.MethodGet, "/operator", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec = httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(cfg.Middleware()(next)).ServeHTTP(rec, req)
return reached, roles, rec
}
// Inside the interval the provider is not consulted at all.
func TestIdentityIsNotReDerivedInsideTheInterval(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, currentIDToken(t, refreshSubject, "operator-member"))
cfg := refreshConfig(t, endpoint)
token := sessionAged(t, cfg, time.Minute, "refresh-1", "operator-member")
reached, roles, _ := through(t, cfg, token)
if !reached || !slices.Equal(roles, []string{"operator-member"}) {
t.Errorf("reached=%v roles=%v", reached, roles)
}
if n := endpoint.calls.Load(); n != 0 {
t.Errorf("token endpoint called %d times inside the interval", n)
}
}
// Finding 3 of the 2026-09 security audit: a role removed at the identity
// provider stayed in the session for a week. Past the interval the session's
// roles are re-read from a fresh ID token, so the removal takes effect on the
// next request after it.
func TestARoleRemovedAtTheProviderIsGoneAfterTheInterval(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, currentIDToken(t, refreshSubject))
cfg := refreshConfig(t, endpoint)
token := sessionAged(t, cfg, 6*time.Minute, "refresh-1", "operator-member")
reached, roles, _ := through(t, cfg, token)
if !reached {
t.Fatal("the request did not reach the handler")
}
if len(roles) != 0 {
t.Errorf("roles after re-derivation = %v, want none: the provider no longer grants operator-member", roles)
}
if endpoint.lastGrant != "refresh_token" || endpoint.lastRefresh != "refresh-1" {
t.Errorf("token endpoint saw grant %q with token %q", endpoint.lastGrant, endpoint.lastRefresh)
}
}
// The inverse: a role granted at the provider arrives the same way, and the
// session's tokens and clock are replaced with it.
func TestARoleGrantedAtTheProviderArrivesAfterTheInterval(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, currentIDToken(t, refreshSubject, "operator-member"))
cfg := refreshConfig(t, endpoint)
token := sessionAged(t, cfg, 6*time.Minute, "refresh-1")
reached, roles, _ := through(t, cfg, token)
if !reached || !slices.Equal(roles, []string{"operator-member"}) {
t.Fatalf("reached=%v roles=%v, want the granted role", reached, roles)
}
// The next request is inside the new interval: no second call, and the
// rotated refresh token and fresh ID token are what the session holds.
var idToken, refreshToken string
var refreshedAt int64
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
idToken = cfg.SessionManager.GetString(r.Context(), sessionKeyIDToken)
refreshToken = cfg.SessionManager.GetString(r.Context(), sessionKeyRefreshToken)
refreshedAt = cfg.SessionManager.GetInt64(r.Context(), sessionKeyIdentityRefreshedAt)
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
cfg.SessionManager.LoadAndSave(cfg.Middleware()(next)).ServeHTTP(httptest.NewRecorder(), req)
if n := endpoint.calls.Load(); n != 1 {
t.Errorf("token endpoint called %d times, want 1: the clock restarted", n)
}
if idToken == "stale-id-token" || idToken == "" {
t.Error("the session still holds the stale ID token")
}
if refreshToken != "rotated-refresh" {
t.Errorf("refresh token = %q, want the rotated one", refreshToken)
}
if time.Since(time.Unix(refreshedAt, 0)) > time.Minute {
t.Errorf("identity_refreshed_at was not restarted: %v", time.Unix(refreshedAt, 0))
}
}
// A definitive refusal -- the session ended at the provider, the account
// disabled, the token revoked -- ends the console session on the spot.
func TestARefusedRefreshEndsTheSession(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "")
endpoint.fail = true
cfg := refreshConfig(t, endpoint)
token := sessionAged(t, cfg, 6*time.Minute, "refresh-1", "operator-member")
reached, _, rec := through(t, cfg, token)
if reached {
t.Fatal("the request reached the handler after the provider refused the session")
}
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/login" {
t.Errorf("status %d Location %q, want a bounce to /login", rec.Code, rec.Header().Get("Location"))
}
if stillAuthenticated(t, cfg, token) {
t.Error("the session survived the provider's refusal")
}
}
// No answer at all keeps the session on its last known state and asks again
// only after the backoff, so a provider outage signs nobody out and does not
// get hammered.
func TestNoAnswerKeepsTheSessionAndBacksOff(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "")
endpoint.unavailable = true
cfg := refreshConfig(t, endpoint)
token := sessionAged(t, cfg, 6*time.Minute, "refresh-1", "operator-member")
reached, roles, _ := through(t, cfg, token)
if !reached || !slices.Equal(roles, []string{"operator-member"}) {
t.Fatalf("reached=%v roles=%v, want the last known state kept", reached, roles)
}
reached, _, _ = through(t, cfg, token)
if !reached {
t.Fatal("the second request did not reach the handler")
}
// The oauth2 client may retry a failed call once with the other
// client-auth style, so one attempt is one or two requests.
if n := endpoint.calls.Load(); n < 1 || n > 2 {
t.Errorf("token endpoint called %d times across two requests, want one attempt: the second is inside the backoff", n)
}
}
// A refreshed ID token that names someone else is not this session's, whatever
// the provider says; the session ends rather than take on another identity.
func TestARefreshForADifferentSubjectEndsTheSession(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, currentIDToken(t, "someone-else", "operator-member"))
cfg := refreshConfig(t, endpoint)
token := sessionAged(t, cfg, 6*time.Minute, "refresh-1")
reached, _, _ := through(t, cfg, token)
if reached {
t.Fatal("the request reached the handler with a token for a different subject")
}
if stillAuthenticated(t, cfg, token) {
t.Error("the session survived a subject mismatch")
}
}
// A provider that issued no refresh token leaves the session on its sign-in
// snapshot, which is what every session did before this existed.
func TestNoRefreshTokenKeepsTheSnapshot(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, currentIDToken(t, refreshSubject))
cfg := refreshConfig(t, endpoint)
token := sessionAged(t, cfg, 6*time.Minute, "", "operator-member")
reached, roles, _ := through(t, cfg, token)
if !reached || !slices.Equal(roles, []string{"operator-member"}) {
t.Errorf("reached=%v roles=%v", reached, roles)
}
if n := endpoint.calls.Load(); n != 0 {
t.Errorf("token endpoint called %d times with no refresh token to present", n)
}
}
// Requests that cross the interval together share one refresh. A provider that
// retires a refresh token on use would otherwise refuse the second as a
// replay and end a live session.
func TestConcurrentRequestsShareOneRefresh(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, currentIDToken(t, refreshSubject, "operator-member"))
endpoint.delay = 150 * time.Millisecond
cfg := refreshConfig(t, endpoint)
token := sessionAged(t, cfg, 6*time.Minute, "refresh-1")
const parallel = 6
var wg sync.WaitGroup
reachedCount := make(chan bool, parallel)
for i := 0; i < parallel; i++ {
wg.Add(1)
go func() {
defer wg.Done()
reached, _, _ := through(t, cfg, token)
reachedCount <- reached
}()
}
wg.Wait()
close(reachedCount)
for reached := range reachedCount {
if !reached {
t.Error("a concurrent request did not reach the handler")
}
}
if n := endpoint.calls.Load(); n != 1 {
t.Errorf("token endpoint called %d times for %d concurrent requests, want 1", n, parallel)
}
}
+544
View File
@@ -0,0 +1,544 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package auth
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/spf13/viper"
"golang.org/x/oauth2"
)
// fakeTokenEndpoint stands in for the identity provider's token endpoint
// (at /token) and revocation endpoint (at /revoke). It answers a
// refresh_token grant with the ID token it was given and a rotated refresh
// token, records what the revocation endpoint received, and counts calls so
// a test can assert an endpoint was not consulted.
type fakeTokenEndpoint struct {
srv *httptest.Server
calls atomic.Int32
idToken string
fail bool // answer 400 invalid_grant
unavailable bool // answer 503 with no body: no usable answer
delay time.Duration // hold each token call this long
lastRefresh string
lastGrant string
revokeCalls atomic.Int32
revokeFail bool
revokedTokens []string
revokedHint string
revokeUser string
revokePassword string
}
func newFakeTokenEndpoint(t *testing.T, idToken string) *fakeTokenEndpoint {
t.Helper()
f := &fakeTokenEndpoint{idToken: idToken}
mux := http.NewServeMux()
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
f.calls.Add(1)
if err := r.ParseForm(); err != nil {
t.Errorf("token endpoint: parse form: %v", err)
}
f.lastGrant = r.PostForm.Get("grant_type")
f.lastRefresh = r.PostForm.Get("refresh_token")
if f.delay > 0 {
time.Sleep(f.delay)
}
if f.unavailable {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
if f.fail {
// JSON, as RFC 6749 section 5.2 has it; the oauth2 client reads
// the error code from a JSON body only.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(`{"error":"invalid_grant","error_description":"Session not active"}`))
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"access_token": "fresh-access",
"token_type": "Bearer",
"expires_in": 300,
"refresh_token": "rotated-refresh",
"id_token": f.idToken,
})
})
mux.HandleFunc("/revoke", func(w http.ResponseWriter, r *http.Request) {
f.revokeCalls.Add(1)
if err := r.ParseForm(); err != nil {
t.Errorf("revocation endpoint: parse form: %v", err)
}
f.revokedTokens = append(f.revokedTokens, r.PostForm.Get("token"))
f.revokedHint = r.PostForm.Get("token_type_hint")
f.revokeUser, f.revokePassword, _ = r.BasicAuth()
if f.revokeFail {
http.Error(w, `{"error":"server_error"}`, http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
})
f.srv = httptest.NewServer(mux)
t.Cleanup(f.srv.Close)
return f
}
// logoutConfig builds a Config whose token endpoint is the fake, with the
// Viper keys LogoutHandler reads set for the test's lifetime.
func logoutConfig(t *testing.T, endpoint *fakeTokenEndpoint) *Config {
t.Helper()
viper.Set("oidc-idp-issuer-url", "http://idp.test/realms/main")
viper.Set("base-url", "http://console.test")
viper.Set("oidc-sp-client-id", "test-client")
t.Cleanup(viper.Reset)
cfg := newTestConfig()
cfg.OAuthConfig = &oauth2.Config{
ClientID: "test-client",
ClientSecret: "test-secret",
Endpoint: oauth2.Endpoint{AuthURL: "http://idp.test/auth", TokenURL: endpoint.srv.URL + "/token"},
}
cfg.EndSessionEndpoint = "http://idp.test/realms/main/protocol/openid-connect/logout"
cfg.RevocationEndpoint = endpoint.srv.URL + "/revoke"
return cfg
}
// signedIn seeds an authenticated session carrying the given tokens and
// returns its cookie token.
func signedIn(t *testing.T, cfg *Config, idToken, refreshToken string) string {
t.Helper()
seed := httptest.NewRequest(http.MethodGet, "/", nil)
token := tokenAfter(t, cfg, func(w http.ResponseWriter, r *http.Request) {
cfg.SessionManager.Put(r.Context(), sessionKeyAuthenticated, true)
cfg.SessionManager.Put(r.Context(), sessionKeyPersonID, "person-1")
cfg.SessionManager.Put(r.Context(), sessionKeyIDToken, idToken)
if refreshToken != "" {
cfg.SessionManager.Put(r.Context(), sessionKeyRefreshToken, refreshToken)
}
}, seed)
if token == "" {
t.Fatal("setup: no session token was issued")
}
return token
}
// logout posts to /logout with the given session cookie, as the account
// menu does, and returns the recorder and the parsed redirect target.
func logout(t *testing.T, cfg *Config, token string) (*httptest.ResponseRecorder, *url.URL) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.LogoutHandler)).ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303; body %q", rec.Code, rec.Body.String())
}
dest, err := url.Parse(rec.Header().Get("Location"))
if err != nil {
t.Fatalf("Location %q: %v", rec.Header().Get("Location"), err)
}
return rec, dest
}
// stillAuthenticated reports whether a request carrying token still passes
// the auth middleware, which is the only definition of "signed in" that
// matters.
func stillAuthenticated(t *testing.T, cfg *Config, token string) bool {
t.Helper()
reached := false
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { reached = true })
req := httptest.NewRequest(http.MethodGet, "/some/page", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(cfg.Middleware()(next)).ServeHTTP(rec, req)
return reached
}
// Finding 11 of the 2026-09 security audit: the session was destroyed only in
// the logout callback, so a person who closed the tab at the identity
// provider's confirmation prompt stayed signed in for the rest of the
// session's week. Sign-out now ends the session before the redirect, and the
// cookie the browser still holds names nothing.
func TestLogoutEndsTheSessionBeforeTheRedirect(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "")
cfg := logoutConfig(t, endpoint)
token := signedIn(t, cfg, fakeIDToken(t, time.Now().Add(time.Hour).Unix()), "")
if !stillAuthenticated(t, cfg, token) {
t.Fatal("setup: the seeded session does not pass the middleware")
}
rec, dest := logout(t, cfg, token)
if stillAuthenticated(t, cfg, token) {
t.Error("the session survived /logout: the round trip to the provider was still in charge of ending it")
}
expired := false
for _, c := range rec.Result().Cookies() {
if c.Name == cfg.SessionManager.Cookie.Name && (c.MaxAge < 0 || (!c.Expires.IsZero() && c.Expires.Before(time.Now()))) {
expired = true
}
}
if !expired {
t.Error("the response did not expire the session cookie")
}
if dest.Host != "idp.test" || !strings.HasSuffix(dest.Path, "/protocol/openid-connect/logout") {
t.Errorf("redirected to %q, want the provider's logout endpoint", dest)
}
if got := dest.Query().Get("post_logout_redirect_uri"); got != "http://console.test/logout-callback" {
t.Errorf("post_logout_redirect_uri = %q", got)
}
if dest.Query().Has("state") {
t.Error("a logout state is still sent; it guards a round trip that no longer ends the session")
}
}
// While the stored ID token is still valid it is the hint, and the provider's
// token endpoint is not consulted.
func TestLogoutUsesTheStoredHintWhileItIsValid(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "should-not-be-minted")
cfg := logoutConfig(t, endpoint)
stored := fakeIDToken(t, time.Now().Add(time.Hour).Unix())
token := signedIn(t, cfg, stored, "refresh-1")
_, dest := logout(t, cfg, token)
if got := dest.Query().Get("id_token_hint"); got != stored {
t.Errorf("id_token_hint = %q, want the stored token", got)
}
if n := endpoint.calls.Load(); n != 0 {
t.Errorf("token endpoint called %d times for a hint that was already valid", n)
}
}
// The reason the refresh token is kept: an aged session's stored ID token is
// expired, and an expired hint makes the provider show its confirmation
// prompt -- the step a person abandons. A fresh ID token minted from the
// refresh token is a valid hint, so the provider signs out without asking.
func TestLogoutMintsAFreshHintFromTheRefreshToken(t *testing.T) {
fresh := fakeIDToken(t, time.Now().Add(5*time.Minute).Unix())
endpoint := newFakeTokenEndpoint(t, fresh)
cfg := logoutConfig(t, endpoint)
expired := fakeIDToken(t, time.Now().Add(-48*time.Hour).Unix())
token := signedIn(t, cfg, expired, "refresh-1")
_, dest := logout(t, cfg, token)
if got := dest.Query().Get("id_token_hint"); got != fresh {
t.Errorf("id_token_hint = %q, want the freshly minted token", got)
}
if endpoint.lastGrant != "refresh_token" || endpoint.lastRefresh != "refresh-1" {
t.Errorf("token endpoint saw grant %q with refresh token %q", endpoint.lastGrant, endpoint.lastRefresh)
}
if stillAuthenticated(t, cfg, token) {
t.Error("the session survived a sign-out that minted a hint")
}
}
// A refresh that fails costs the hint and nothing else: the session still
// ends and the provider is still asked, and it will prompt as it did before
// the refresh token was kept.
func TestLogoutWithoutAUsableHintStillEndsTheSession(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "")
endpoint.fail = true
cfg := logoutConfig(t, endpoint)
expired := fakeIDToken(t, time.Now().Add(-48*time.Hour).Unix())
token := signedIn(t, cfg, expired, "refresh-1")
_, dest := logout(t, cfg, token)
if dest.Query().Has("id_token_hint") {
t.Errorf("a hint was sent after the refresh failed: %q", dest.Query().Get("id_token_hint"))
}
if stillAuthenticated(t, cfg, token) {
t.Error("the session survived because the refresh failed; the hint must never gate the sign-out")
}
// One attempt from this side. The oauth2 library, left to auto-detect
// the client-auth style, retries a 4xx once with the other style, so
// the endpoint may see two requests for the one call idTokenHint makes.
if n := endpoint.calls.Load(); n < 1 || n > 2 {
t.Errorf("token endpoint called %d times, want one attempt (at most two requests)", n)
}
}
// A session with no refresh token (a provider that issues none) behaves as
// before: no hint past the ID token's lifetime, and the session still ends.
func TestLogoutWithNoRefreshTokenOmitsTheHint(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "unreachable")
cfg := logoutConfig(t, endpoint)
expired := fakeIDToken(t, time.Now().Add(-48*time.Hour).Unix())
token := signedIn(t, cfg, expired, "")
_, dest := logout(t, cfg, token)
if dest.Query().Has("id_token_hint") {
t.Error("a hint was sent with nothing valid to send")
}
if n := endpoint.calls.Load(); n != 0 {
t.Errorf("token endpoint called %d times with no refresh token to present", n)
}
if stillAuthenticated(t, cfg, token) {
t.Error("the session survived")
}
}
// The callback has nothing left to verify once the session ended at /logout.
// It never refuses: with no session, with a stale cookie, or with whatever
// query the provider (or anyone) appends, the browser goes to /login.
func TestLogoutCallbackNeverRefuses(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "")
cfg := logoutConfig(t, endpoint)
for _, tc := range []struct {
name string
token string
query string
}{
{"no session, no query", "", ""},
{"no session, a state nobody stored", "", "?state=whatever"},
{"a cookie for a session that no longer exists", "stale-token-value", "?state=whatever"},
} {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/logout-callback"+tc.query, nil)
if tc.token != "" {
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: tc.token})
}
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.LogoutCallbackHandler)).ServeHTTP(rec, req)
if rec.Code != http.StatusFound || rec.Header().Get("Location") != "/login" {
t.Errorf("status %d Location %q, want 302 to /login", rec.Code, rec.Header().Get("Location"))
}
})
}
}
// fakeDiscovery serves an OpenID discovery document whose issuer is its own
// URL, with the endpoints given, so a real *oidc.Provider can be built from
// it without a network.
func fakeDiscovery(t *testing.T, extra map[string]any) *oidc.Provider {
t.Helper()
var issuer string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/.well-known/openid-configuration" {
http.NotFound(w, r)
return
}
doc := map[string]any{
"issuer": issuer,
"authorization_endpoint": issuer + "/authorize",
"token_endpoint": issuer + "/token",
"jwks_uri": issuer + "/keys",
}
for k, v := range extra {
doc[k] = v
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(doc)
}))
t.Cleanup(srv.Close)
issuer = srv.URL
provider, err := oidc.NewProvider(context.Background(), issuer)
if err != nil {
t.Fatalf("discovery: %v", err)
}
return provider
}
// The two endpoints sign-out needs come from discovery, once, at Setup. The
// end-session endpoint is required (OpenID Connect RP-Initiated Logout 1.0,
// section 2.1): a provider without one cannot end its session for the person,
// and there is no path worth guessing. The revocation endpoint (RFC 7009) is
// optional.
func TestProviderEndpointsComeFromDiscovery(t *testing.T) {
t.Run("both published", func(t *testing.T) {
provider := fakeDiscovery(t, map[string]any{
"end_session_endpoint": "https://idp.example/session/end",
"revocation_endpoint": "https://idp.example/revoke",
})
endSession, revocation, err := providerEndpoints(provider)
if err != nil {
t.Fatalf("providerEndpoints: %v", err)
}
if endSession != "https://idp.example/session/end" || revocation != "https://idp.example/revoke" {
t.Errorf("got %q, %q", endSession, revocation)
}
})
t.Run("no revocation endpoint is allowed", func(t *testing.T) {
provider := fakeDiscovery(t, map[string]any{"end_session_endpoint": "https://idp.example/session/end"})
_, revocation, err := providerEndpoints(provider)
if err != nil || revocation != "" {
t.Errorf("got revocation %q, err %v", revocation, err)
}
})
t.Run("no end-session endpoint refuses to start", func(t *testing.T) {
provider := fakeDiscovery(t, nil)
if _, _, err := providerEndpoints(provider); err == nil || !strings.Contains(err.Error(), "end_session_endpoint") {
t.Errorf("want an error naming end_session_endpoint, got %v", err)
}
})
}
// The handler sends the browser to the endpoint Setup discovered and nothing
// else; a Config without one is a construction error, refused rather than
// guessed around.
func TestLogoutGoesToTheDiscoveredEndSessionEndpoint(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "")
cfg := logoutConfig(t, endpoint)
cfg.EndSessionEndpoint = "https://idp.example/session/end"
token := signedIn(t, cfg, fakeIDToken(t, time.Now().Add(time.Hour).Unix()), "")
_, dest := logout(t, cfg, token)
if got := dest.Scheme + "://" + dest.Host + dest.Path; got != "https://idp.example/session/end" {
t.Errorf("redirected to %q, want the discovered endpoint", got)
}
cfg.EndSessionEndpoint = ""
token = signedIn(t, cfg, fakeIDToken(t, time.Now().Add(time.Hour).Unix()), "")
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.LogoutHandler)).ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Errorf("with no end-session endpoint: status %d, want 500 rather than a guessed path", rec.Code)
}
}
// The refresh token is revoked at the provider once the session is gone (RFC
// 7009), authenticated as the client, so that a copy of it kept anywhere no
// longer mints tokens.
func TestLogoutRevokesTheRefreshToken(t *testing.T) {
t.Run("the stored token, when no refresh was needed", func(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "")
cfg := logoutConfig(t, endpoint)
token := signedIn(t, cfg, fakeIDToken(t, time.Now().Add(time.Hour).Unix()), "refresh-1")
logout(t, cfg, token)
if len(endpoint.revokedTokens) != 1 || endpoint.revokedTokens[0] != "refresh-1" || endpoint.revokedHint != "refresh_token" {
t.Errorf("revocation endpoint got tokens %q hint %q", endpoint.revokedTokens, endpoint.revokedHint)
}
if endpoint.revokeUser != "test-client" || endpoint.revokePassword != "test-secret" {
t.Errorf("revocation was not authenticated as the client: user %q", endpoint.revokeUser)
}
})
t.Run("both the stored and the rotated token, when the refresh issued a new one", func(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, fakeIDToken(t, time.Now().Add(5*time.Minute).Unix()))
cfg := logoutConfig(t, endpoint)
token := signedIn(t, cfg, fakeIDToken(t, time.Now().Add(-48*time.Hour).Unix()), "refresh-1")
logout(t, cfg, token)
want := []string{"refresh-1", "rotated-refresh"}
if len(endpoint.revokedTokens) != 2 || endpoint.revokedTokens[0] != want[0] || endpoint.revokedTokens[1] != want[1] {
t.Errorf("revoked %q, want both tokens that were live: %q", endpoint.revokedTokens, want)
}
})
t.Run("no revocation endpoint, no call", func(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "")
cfg := logoutConfig(t, endpoint)
cfg.RevocationEndpoint = ""
token := signedIn(t, cfg, fakeIDToken(t, time.Now().Add(time.Hour).Unix()), "refresh-1")
logout(t, cfg, token)
if n := endpoint.revokeCalls.Load(); n != 0 {
t.Errorf("revocation endpoint called %d times with none configured", n)
}
})
t.Run("a failed revocation costs nothing else", func(t *testing.T) {
endpoint := newFakeTokenEndpoint(t, "")
endpoint.revokeFail = true
cfg := logoutConfig(t, endpoint)
token := signedIn(t, cfg, fakeIDToken(t, time.Now().Add(time.Hour).Unix()), "refresh-1")
_, dest := logout(t, cfg, token)
if stillAuthenticated(t, cfg, token) {
t.Error("the session survived because revocation failed")
}
if !dest.Query().Has("id_token_hint") {
t.Error("the hint was dropped because revocation failed")
}
})
}
// The 2026-09 audit's "Logout CSRF" candidate: /logout was a GET, exempt from
// the cross-origin protection by its method, so any page could end a
// person's session by sending the browser there. Sign-out is now a POST the
// account menu's button makes; a GET is a link someone was sent and leads
// back to the console with the session intact.
func TestLogoutGetNeverEndsTheSession(t *testing.T) {
cfg := logoutConfig(t, newFakeTokenEndpoint(t, ""))
token := signedIn(t, cfg, fakeIDToken(t, time.Now().Add(time.Hour).Unix()), "refresh-1")
mux := http.NewServeMux()
cfg.RegisterHandlers(mux)
req := httptest.NewRequest(http.MethodGet, "/logout", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(mux).ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/" {
t.Errorf("GET /logout: %d to %q, want 303 to /", rec.Code, rec.Header().Get("Location"))
}
if !stillAuthenticated(t, cfg, token) {
t.Error("a GET at /logout ended the session")
}
// The handler itself refuses anything but a POST, for a caller that
// wires it without RegisterHandlers.
req = httptest.NewRequest(http.MethodGet, "/logout", nil)
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec = httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.LogoutHandler)).ServeHTTP(rec, req)
if rec.Code != http.StatusMethodNotAllowed || rec.Header().Get("Allow") != http.MethodPost {
t.Errorf("LogoutHandler on GET: %d, Allow %q; want 405 allowing POST", rec.Code, rec.Header().Get("Allow"))
}
if !stillAuthenticated(t, cfg, token) {
t.Error("a refused GET at LogoutHandler ended the session")
}
}
// The account menu's Sign out is an htmx request. fetch cannot follow a
// redirect to the provider's origin, so the answer is 200 with HX-Redirect
// naming the end-session URL, and the session is gone before it is sent.
func TestLogoutFromHTMXAnswersWithHXRedirect(t *testing.T) {
cfg := logoutConfig(t, newFakeTokenEndpoint(t, ""))
token := signedIn(t, cfg, fakeIDToken(t, time.Now().Add(time.Hour).Unix()), "refresh-1")
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
req.Header.Set("HX-Request", "true")
req.AddCookie(&http.Cookie{Name: cfg.SessionManager.Cookie.Name, Value: token})
rec := httptest.NewRecorder()
cfg.SessionManager.LoadAndSave(http.HandlerFunc(cfg.LogoutHandler)).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 with HX-Redirect; body %q", rec.Code, rec.Body.String())
}
dest, err := url.Parse(rec.Header().Get("HX-Redirect"))
if err != nil || dest.Scheme+"://"+dest.Host+dest.Path != cfg.EndSessionEndpoint {
t.Errorf("HX-Redirect = %q, want the end-session endpoint %q", rec.Header().Get("HX-Redirect"), cfg.EndSessionEndpoint)
}
if dest.Query().Get("id_token_hint") == "" {
t.Error("the htmx answer dropped the id_token_hint")
}
if rec.Header().Get("Location") != "" {
t.Error("an htmx request must not also get a Location redirect")
}
if stillAuthenticated(t, cfg, token) {
t.Error("the session survived an htmx sign-out")
}
}
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package auth
import (
"testing"
"github.com/alexedwards/scs/v2"
"github.com/spf13/viper"
)
// Finding 5 of the 2026-09 security audit: the session cookie was Secure only
// when env was exactly "production", so a TLS deployment under any other label
// shipped the cookie without it. It now follows base-url's scheme, the one
// declaration of the transport the browser sees.
func TestSessionCookieSecureFollowsTheBaseURLScheme(t *testing.T) {
t.Cleanup(viper.Reset)
for _, tc := range []struct {
name string
baseURL string
env string
want bool
}{
{"https, labelled production", "https://console.example.coop", "production", true},
{"https, labelled staging", "https://console.example.coop", "staging", true},
{"https, labelled development", "https://console.example.coop", "development", true},
{"plain http for local work", "http://member-console.localhost:9431", "development", false},
{"plain http mislabelled production", "http://localhost:8081", "production", false},
} {
t.Run(tc.name, func(t *testing.T) {
viper.Set("base-url", tc.baseURL)
viper.Set("env", tc.env)
sm := newSessionManager(scs.New().Store)
if sm.Cookie.Secure != tc.want {
t.Errorf("Cookie.Secure = %v, want %v", sm.Cookie.Secure, tc.want)
}
if !sm.Cookie.HttpOnly {
t.Error("Cookie.HttpOnly must stay set")
}
})
}
}
+75
View File
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package auth
import (
"context"
"crypto/tls"
"log/slog"
"time"
"github.com/gomodule/redigo/redis"
"github.com/spf13/viper"
"git.coopcloud.tech/wiki-cafe/member-console/internal/logging"
)
// Session-store connection tuning. These bound a dial against an unreachable or
// wedged store so the boot probe fails with an error instead of hanging, and so
// a store that stops answering mid-request cannot hold a handler open past the
// server's own write budget.
const (
valkeyConnectTimeout = 5 * time.Second
valkeyReadTimeout = 3 * time.Second
valkeyWriteTimeout = 3 * time.Second
)
// valkeyDialOptions builds the session store's dial options from configuration.
//
// Until the 2026-09 security audit the store was reached with an address and
// nothing else, so anyone who could reach the port could read and forge
// sessions, and sessions carry the person, organization and workspace ids that
// authorization is decided from. A password is now required: config validation
// refuses to start without valkey-password, so the branch below is reached with
// a password in every configuration that boots. It stays a conditional because
// this function is also exercised by tests that build partial configurations.
//
// TLS and the username are opt-in and absent by default, for a store that
// terminates TLS or runs ACL users. A single-host stack that sets neither dials
// as before, now authenticated.
func valkeyDialOptions(ctx context.Context) []redis.DialOption {
options := []redis.DialOption{
redis.DialConnectTimeout(valkeyConnectTimeout),
redis.DialReadTimeout(valkeyReadTimeout),
redis.DialWriteTimeout(valkeyWriteTimeout),
}
// A username without a password is an ACL user relying on a passwordless
// rule; both are passed through as configured rather than second-guessed.
if username := viper.GetString("valkey-username"); username != "" {
options = append(options, redis.DialUsername(username))
}
if password := viper.GetString("valkey-password"); password != "" {
options = append(options, redis.DialPassword(password))
}
if viper.GetBool("valkey-tls") {
options = append(options, redis.DialUseTLS(true))
if viper.GetBool("valkey-tls-skip-verify") {
// Certificate verification off means an attacker who can
// intercept the connection can present any certificate, so the
// encryption stops proving who is on the other end. It exists for
// a store with a self-signed certificate and is worth saying out
// loud at every boot.
logging.FromContext(ctx).Warn("valkey-tls-skip-verify is on: the session store's certificate is NOT verified",
slog.String("env", viper.GetString("env")))
options = append(options,
redis.DialTLSSkipVerify(true),
redis.DialTLSConfig(&tls.Config{InsecureSkipVerify: true}), //nolint:gosec // explicit opt-in, warned above
)
}
}
return options
}
+68
View File
@@ -0,0 +1,68 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package auth
import (
"testing"
"github.com/spf13/viper"
)
// baseOptions is the count valkeyDialOptions always returns: connect, read and
// write timeouts. Anything above it is a configured credential or TLS.
const baseOptions = 3
func withViper(t *testing.T, kv map[string]any) {
t.Helper()
for k, v := range kv {
viper.Set(k, v)
}
t.Cleanup(func() {
for k := range kv {
viper.Set(k, nil)
}
})
}
// An unconfigured deployment must dial exactly as it did before these keys
// existed, so upgrading cannot break a running stack.
func TestValkeyDialOptionsDefaultToNoCredentials(t *testing.T) {
if got := len(valkeyDialOptions(t.Context())); got != baseOptions {
t.Errorf("unconfigured store produced %d options, want %d (timeouts only)", got, baseOptions)
}
}
func TestValkeyDialOptionsAddCredentialsWhenConfigured(t *testing.T) {
cases := []struct {
name string
cfg map[string]any
extra int
}{
{"password only", map[string]any{"valkey-password": "s3cret"}, 1},
{"username and password", map[string]any{"valkey-username": "app", "valkey-password": "s3cret"}, 2},
{"tls", map[string]any{"valkey-tls": true}, 1},
{"tls with skip-verify", map[string]any{"valkey-tls": true, "valkey-tls-skip-verify": true}, 3},
// skip-verify without tls is inert: nothing is encrypted, so there is
// nothing to skip verifying.
{"skip-verify without tls", map[string]any{"valkey-tls-skip-verify": true}, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
withViper(t, tc.cfg)
want := baseOptions + tc.extra
if got := len(valkeyDialOptions(t.Context())); got != want {
t.Errorf("got %d options, want %d", got, want)
}
})
}
}
// An empty password must not register an option: a blank value would otherwise
// send an AUTH with no secret and fail against a store that wants none.
func TestValkeyDialOptionsIgnoreBlankCredentials(t *testing.T) {
withViper(t, map[string]any{"valkey-username": "", "valkey-password": ""})
if got := len(valkeyDialOptions(t.Context())); got != baseOptions {
t.Errorf("blank credentials produced %d options, want %d", got, baseOptions)
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ const (
// the flag's help text. Secret marks the key as sensitive: cmd also binds
// a "<name>-file" flag and, before validation runs, resolves a configured
// file path into Name (loading the value from disk) — mirroring the
// handling core's own secrets (csrf-secret, oidc-sp-client-secret, ...)
// handling core's own secrets (oidc-sp-client-secret, stripe-api-key, ...)
// already receive. RequiredGroup, when non-empty, ties this key to every
// other declared key sharing the same tag: ValidateStart rejects a
// configuration where some but not all of a group's keys are set (e.g.
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package config
import (
"net/url"
"github.com/spf13/viper"
)
// ServesHTTPS reports whether base-url is https: the one declaration of the
// transport a browser sees. The session cookie's Secure flag and the CSP
// upgrade-insecure-requests directive follow it.
//
// Until the 2026-09 security audit (finding 5, decision D3) both followed the
// env key instead, and only when it was exactly "production"; a staging
// deployment behind TLS with any other label shipped its session cookie
// without Secure. The scheme already drives the OIDC redirect URI and the
// cross-origin trusted origin, so a deployment cannot mislabel it without
// breaking sign-in, which is the property a security flag wants to hang off.
func ServesHTTPS() bool {
u, err := url.Parse(viper.GetString("base-url"))
return err == nil && u.Scheme == "https"
}
+30
View File
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package config
import (
"testing"
"github.com/spf13/viper"
)
func TestServesHTTPSFollowsTheBaseURLScheme(t *testing.T) {
t.Cleanup(viper.Reset)
for _, tc := range []struct {
baseURL string
want bool
}{
{"https://console.example.coop", true},
{"HTTPS://console.example.coop", true},
{"http://member-console.localhost:9431", false},
{"http://localhost:8081", false},
{"", false},
{"not a url", false},
} {
viper.Set("base-url", tc.baseURL)
if got := ServesHTTPS(); got != tc.want {
t.Errorf("ServesHTTPS() with base-url %q = %v, want %v", tc.baseURL, got, tc.want)
}
}
}
+27 -7
View File
@@ -12,7 +12,6 @@ import (
"net/url"
"strings"
"git.coopcloud.tech/wiki-cafe/member-console/internal/middleware"
"github.com/spf13/viper"
)
@@ -55,6 +54,11 @@ func ValidateStart(integrationSpecs []ConfigKey) error {
if err := requireURL("base-url", "the public URL of this console, e.g. https://console.example.com"); err != nil {
errs = append(errs, err)
} else if scheme := baseURLScheme(); scheme != "http" && scheme != "https" {
// The scheme decides the session cookie's Secure flag and the CSP
// upgrade directive (config.ServesHTTPS), so it must be one a
// browser reaches the console over.
errs = append(errs, fmt.Errorf("base-url must be http or https, got %q", viper.GetString("base-url")))
}
// deployment-name defaults to config.DefaultDeploymentName ("Member
@@ -62,14 +66,20 @@ func ValidateStart(integrationSpecs []ConfigKey) error {
// explicitly overrides it to blank or whitespace — the one input shape
// the default cannot protect against (design decision 5,
// ux-honest-surfaces).
if strings.TrimSpace(viper.GetString("deployment-name")) == "" {
errs = append(errs, errors.New("deployment-name cannot be blank or whitespace-only; leave it unset to use the default (\"Member Console\") or set a non-blank name"))
// The session store holds credentials: a session carries the person,
// organization and workspace ids that authorization is decided from, so an
// unauthenticated store lets anyone who reaches the port read and forge
// them. Required rather than optional because there are no production
// deployments to break (README: pre-production; 10d Slice 3 still lists
// "deploy to prod"), so the secure posture can be the only posture.
// TLS stays optional: it needs certificates, and a same-host connection is
// a weaker case than an unauthenticated one.
if strings.TrimSpace(viper.GetString("valkey-password")) == "" {
errs = append(errs, required("valkey-password", "the session store's password (or valkey-password-file); the store must not be reachable unauthenticated"))
}
// Reuse the authoritative 32-byte check so the rule lives in one place and
// fires here at boot instead of last, inside server.Start.
if _, err := middleware.ParseCSRFKey(viper.GetString("csrf-secret")); err != nil {
errs = append(errs, fmt.Errorf("csrf-secret is invalid: %w", err))
if strings.TrimSpace(viper.GetString("deployment-name")) == "" {
errs = append(errs, errors.New("deployment-name cannot be blank or whitespace-only; leave it unset to use the default (\"Member Console\") or set a non-blank name"))
}
// --- Conditional: Temporal ---
@@ -174,6 +184,16 @@ func requireURL(key, hint string) error {
return nil
}
// baseURLScheme is base-url's scheme, lower-cased; requireURL has already
// established that it parses and carries one.
func baseURLScheme() string {
u, err := url.Parse(strings.TrimSpace(viper.GetString("base-url")))
if err != nil {
return ""
}
return strings.ToLower(u.Scheme)
}
// required formats a missing-key error that names both ways to set the value.
func required(key, hint string) error {
env := "MC_" + strings.ToUpper(strings.ReplaceAll(key, "-", "_"))
+4 -3
View File
@@ -18,10 +18,10 @@ func validConfig() {
viper.Reset()
viper.Set("db-dsn", "postgres://u:p@localhost:5432/db?sslmode=disable")
viper.Set("valkey-addr", "localhost:6379")
viper.Set("valkey-password", "test-store-password")
viper.Set("oidc-idp-issuer-url", "https://idp.example.com/realms/main")
viper.Set("oidc-sp-client-id", "member-console")
viper.Set("base-url", "https://console.example.com")
viper.Set("csrf-secret", "0123456789abcdef0123456789abcdef") // exactly 32 bytes
viper.Set("deployment-name", DefaultDeploymentName)
}
@@ -53,11 +53,12 @@ func TestValidateStart_Errors(t *testing.T) {
{"missing db-dsn", func() { viper.Set("db-dsn", "") }, "db-dsn is required"},
{"malformed db-dsn", func() { viper.Set("db-dsn", "mysql://x") }, "db-dsn is not a valid PostgreSQL URL"},
{"missing valkey-addr", func() { viper.Set("valkey-addr", "") }, "valkey-addr is required"},
{"missing valkey-password", func() { viper.Set("valkey-password", "") }, "valkey-password is required"},
{"base-url with a scheme a browser cannot use", func() { viper.Set("base-url", "ftp://console.example.com") }, "base-url must be http or https"},
{"missing issuer", func() { viper.Set("oidc-idp-issuer-url", "") }, "oidc-idp-issuer-url is required"},
{"malformed issuer", func() { viper.Set("oidc-idp-issuer-url", "not-a-url") }, "oidc-idp-issuer-url is not a valid URL"},
{"missing client id", func() { viper.Set("oidc-sp-client-id", "") }, "oidc-sp-client-id is required"},
{"missing base-url", func() { viper.Set("base-url", "") }, "base-url is required"},
{"short csrf", func() { viper.Set("csrf-secret", "tooshort") }, "csrf-secret is invalid"},
{"blank deployment-name", func() { viper.Set("deployment-name", "") }, "deployment-name cannot be blank"},
{"whitespace-only deployment-name", func() { viper.Set("deployment-name", " ") }, "deployment-name cannot be blank"},
{"partial stripe", func() { viper.Set("stripe-api-key", "sk_live_x") }, "Stripe is partially configured"},
@@ -93,10 +94,10 @@ func TestValidateStart_AggregatesAll(t *testing.T) {
for _, want := range []string{
"db-dsn is required",
"valkey-addr is required",
"valkey-password is required",
"oidc-idp-issuer-url is required",
"oidc-sp-client-id is required",
"base-url is required",
"csrf-secret is invalid",
"deployment-name cannot be blank",
} {
if !strings.Contains(err.Error(), want) {
@@ -0,0 +1,20 @@
-- SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
-- SPDX-FileCopyrightText: 2025-2026 Christian Galo
-- +goose Up
-- The provider's own timestamp for the event (Stripe's `created`), as
-- distinct from received_at, which is when it reached us. Stripe does not
-- guarantee delivery order; the product, price and customer handlers skip
-- an event when a completed event for the same object carries a later
-- provider time, and this column plus the index is that check. The object
-- id is the payload's own `id`, which the scrubber never touches. Rows from
-- before this migration keep NULL and are never treated as superseded.
ALTER TABLE core.webhook_events ADD COLUMN provider_event_at TIMESTAMPTZ;
CREATE INDEX idx_webhook_events_object_time
ON core.webhook_events (provider, (payload->>'id'), provider_event_at)
WHERE status = 'completed';
-- +goose Down
DROP INDEX IF EXISTS core.idx_webhook_events_object_time;
ALTER TABLE core.webhook_events DROP COLUMN provider_event_at;
+16 -5
View File
@@ -9,6 +9,9 @@
# --- Required ---
# Public URL this console is served at (used for OAuth redirect and CSRF origin).
# The scheme decides transport security: https sets the session cookie's Secure
# flag and the CSP upgrade-insecure-requests directive; http, for local plain-HTTP
# work, sets neither.
base-url: "https://console.example.com"
port: "8080"
@@ -18,6 +21,16 @@ db-dsn: "postgres://user:password@localhost:5432/member_console?sslmode=disable"
# Valkey/Redis address for the server-side session store.
valkey-addr: "localhost:6379"
# Session-store credentials and transport.
# Sessions are credentials: they carry the person, organization and workspace
# ids that authorization is decided from, so anyone who can reach this store
# unauthenticated can read and forge them. A password is REQUIRED; startup
# fails without one. TLS stays optional because it needs certificates.
valkey-password: "" # required; or valkey-password-file, never both
#valkey-username: "" # ACL user, if the store uses one
#valkey-tls: false # connect over TLS
#valkey-tls-skip-verify: false # self-signed stores only; disables certificate checks
# OIDC identity provider. Point at a dedicated application realm — not an
# administration realm (e.g. Keycloak's `master`), whose account console is not a
# supported end-user surface.
@@ -26,11 +39,9 @@ oidc-sp-client-id: "member-console"
# Client secret for a confidential client; leave empty for a public (PKCE) client.
oidc-sp-client-secret: ""
# CSRF signing key — exactly 32 bytes. Generate one with: openssl rand -hex 16
csrf-secret: ""
# Environment: "production" enables Secure session cookies and the CSP
# upgrade-insecure-requests directive. Use "development" for local, plain-HTTP work.
# Environment label. "development" selects text logs; anything else selects JSON.
# The session cookie's Secure flag and the CSP upgrade directive follow
# base-url's scheme above, not this value.
env: "production"
# --- Optional: Temporal (durable workflows). Leave temporal-host empty to disable. ---
+1 -1
View File
@@ -29,7 +29,7 @@
<link href="/static/app.css" rel="stylesheet">
</head>
<body class="d-flex flex-column vh-100" hx-headers:inherited='{"X-CSRF-Token": "{{ .CSRFToken }}"}'>
<body class="d-flex flex-column vh-100">
{{ template "shell_topbar.html" .Shell }}
<div class="d-flex flex-column flex-lg-row flex-grow-1">
+2 -2
View File
@@ -5,7 +5,7 @@
<html lang="en">
<head>
<title>{{ .Status }} {{ .StatusText }} - {{ deploymentName }}</title>
<title>{{ .Header.Title }} - {{ deploymentName }}</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -32,7 +32,7 @@
<div class="container">
<div class="col-lg-8 col-xl-6 mx-auto">
{{ template "pageHeader" .Header }}
<a class="btn btn-primary mt-2" href="/">Back to the dashboard</a>
<a class="btn btn-primary mt-2" href="{{ .Action.Href }}">{{ .Action.Label }}</a>
</div>
</div>
</main>
+1 -1
View File
@@ -40,7 +40,7 @@
<link href="/static/app.css" rel="stylesheet">
</head>
<body class="d-flex flex-column vh-100" hx-headers:inherited='{"X-CSRF-Token": "{{ .CSRFToken }}"}'>
<body class="d-flex flex-column vh-100">
{{ template "shell_topbar.html" .Shell }}
<div class="d-flex flex-column flex-lg-row flex-grow-1">
+1 -2
View File
@@ -42,8 +42,7 @@
</head>
<body class="d-flex flex-column vh-100"
hx-boost:inherited="true"
hx-headers:inherited='{"X-CSRF-Token": "{{ .CSRFToken }}"}'>
hx-boost:inherited="true">
{{ template "shell_topbar.html" .Shell }}
<div class="d-flex flex-column flex-lg-row flex-grow-1">
@@ -9,7 +9,7 @@
offered here — it starts only from the site-creation flow. The release
button is hx-post: a native form POST would fail CSRF (Origin is null
under the no-referrer policy), while HTMX inherits the page's
X-CSRF-Token hx-headers. -->
cross-origin protection, which needs no token. -->
{{ if .Error }}
<div class="alert alert-danger" role="alert">{{ .Error }}</div>
{{ end }}
@@ -6,7 +6,7 @@
claim is pending this element polls its own GET route every 5s and swaps
itself (outerHTML) — the established in-place pattern: no page reload, no
inline script, and the mutating buttons inherit the page's
X-CSRF-Token hx-headers. Reopening this view is a pure read: it
cross-origin protection, which needs no token. Reopening this view is a pure read: it
re-initiates nothing and mints no new records. -->
<div class="domain-claim-status" {{ if .Polling }}hx-get="{{ routeURL "/partials/domains/claims/{claimID}" .ClaimID }}"
hx-trigger="every 5s" hx-swap="outerHTML" {{ end }}>
@@ -77,3 +77,46 @@
{{ else }}
<p class="small text-body-secondary">Queue health is unavailable.</p>
{{ end }}
{{/* Inbound events: Stripe's webhook events that failed processing through
every retry round (stripe-integration-infrastructure "Failed events
are retried, then dead-lettered"). The mirror of the delivery queue
above, same alarm rule: only dead-lettered events carry it. */}}
{{ template "sectionHeader" .InboundEventsHeader }}
{{ if .InboundEvents.Available }}
{{ if .InboundEvents.NeedsAttention }}
<p class="small text-danger">
{{ .InboundEvents.DeadLetter }} event{{ if ne .InboundEvents.DeadLetter 1 }}s{{ end }} could not be processed after retries and need{{ if eq .InboundEvents.DeadLetter 1 }}s{{ end }} an operator.
</p>
{{ if .InboundEventEntries }}
<div class="table-responsive">
<table class="table table-hover table-sm table-record">
<thead>
<tr>
<th>Event</th>
<th>Error</th>
<th>Attempts</th>
<th>Last attempt</th>
</tr>
</thead>
<tbody>
{{ range .InboundEventEntries }}
<tr class="table-danger">
<th scope="row"><code class="text-nowrap">{{ .OperationType }}</code></th>
<td>{{ if .ErrorMessage }}{{ .ErrorMessage }}{{ else }}<span class="text-muted"></span>{{ end }}</td>
<td>{{ .Attempts }}</td>
<td class="text-nowrap">{{ .UpdatedAt }}</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
{{ end }}
{{ else }}
<p class="small text-body-secondary">
Inbound events are processing normally.
</p>
{{ end }}
{{ else }}
<p class="small text-body-secondary">Inbound event health is unavailable.</p>
{{ end }}
@@ -13,7 +13,9 @@
surface instead, design D18). The toggler opens the rail (#app-rail)
as a drawer below lg and is hidden at lg and up, where the rail is
always visible. The account menu's trigger is a <button> so hx-boost
never treats it as a link. */}}
never treats it as a link. Sign out is a button too: it posts, so a
link from another origin cannot end the session, and the handler
answers with HX-Redirect to the identity provider. */}}
<nav class="navbar navbar-expand navbar-dark bg-dark app-topbar" aria-label="Account">
<div class="container-fluid">
<button class="navbar-toggler d-lg-none me-2" type="button" data-bs-toggle="offcanvas" data-bs-target="#app-rail" aria-controls="app-rail" aria-label="Open navigation">
@@ -28,7 +30,7 @@
<li><a class="dropdown-item" href="{{ .KeycloakAccountURL }}" target="_blank" hx-boost="false" title="Manage your account and sign-in. Opens in a new tab.">Identity and <span class="text-nowrap">Access<svg class="external-link-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></span></a></li>
{{ with supportURL }}<li><a class="dropdown-item" href="{{ . }}" target="_blank" hx-boost="false" title="Get help. Opens in a new tab.">Get help</a></li>{{ end }}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/logout" hx-boost="false">Sign out</a></li>
<li><button type="button" class="dropdown-item" hx-post="/logout" hx-swap="none">Sign out</button></li>
</ul>
</li>
</ul>
+1 -1
View File
@@ -29,7 +29,7 @@
<link href="/static/app.css" rel="stylesheet">
</head>
<body class="d-flex flex-column vh-100" hx-headers:inherited='{"X-CSRF-Token": "{{ .CSRFToken }}"}'>
<body class="d-flex flex-column vh-100">
{{ template "shell_topbar.html" .Shell }}
<div class="d-flex flex-column flex-lg-row flex-grow-1">
+34 -11
View File
@@ -55,6 +55,7 @@ package stripe
import (
"context"
"database/sql"
"errors"
"log/slog"
"net/http"
@@ -65,9 +66,11 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/web"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/workflows"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/queues"
"github.com/spf13/viper"
stripego "github.com/stripe/stripe-go/v81"
enumspb "go.temporal.io/api/enums/v1"
"go.temporal.io/api/serviceerror"
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/worker"
)
@@ -133,6 +136,14 @@ func (Adapter) RegisterRoutes(mux *http.ServeMux, deps server.Deps) error {
WebhookSecret: webhookSecret,
Logger: deps.Logger,
}
if deps.TemporalClient != nil {
temporalClient := deps.TemporalClient
handler.StartProcessing = func(ctx context.Context, e web.QueuedEvent) error {
return workflows.StartWebhookEvent(ctx, temporalClient, queues.Main, workflows.WebhookEvent{
ID: e.ID, Provider: "stripe", ProviderEventID: e.ProviderEventID, EventType: e.EventType,
})
}
}
mux.Handle("POST "+webhookPath, handler)
return nil
}
@@ -150,8 +161,9 @@ func (Adapter) CSRFExemptPaths() []string {
// RegisterWorkflows registers Stripe's Temporal workflows and activities
// (webhook processing, outbox draining) against the shared worker.
func (Adapter) RegisterWorkflows(w worker.Worker, database *sql.DB, logger *slog.Logger) {
w.RegisterWorkflow(workflows.ProcessStripeWebhooks)
w.RegisterWorkflow(workflows.ProcessStripeWebhookEvent)
w.RegisterWorkflow(workflows.PollIntegrationOutbox)
w.RegisterWorkflow(workflows.ExecuteStripeOutboxEntry)
w.RegisterActivity(workflows.NewWebhookActivities(database, logger))
w.RegisterActivity(workflows.NewOutboxActivities(database, logger))
}
@@ -169,18 +181,29 @@ func (Adapter) Startup(ctx context.Context, c client.Client, taskQueue string, d
stripego.Key = apiKey
}
_, err := c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
ID: "stripe-webhook-processor",
TaskQueue: taskQueue,
WorkflowIDConflictPolicy: enumspb.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING,
}, workflows.ProcessStripeWebhooks, workflows.ProcessStripeWebhooksInput{})
if err != nil {
logger.Error("failed to start stripe webhook processor workflow", slog.Any("error", err))
} else {
logger.Info("started stripe webhook processor workflow")
// Webhook events are processed one workflow per event, started by the
// endpoint. The polling processor that preceded them (through 2026-09)
// is terminated if a deployment still runs one, and the rows it left
// unfinished are handed to their workflows; on every later boot the
// sweep only covers rows a dying process recorded without starting.
if err := c.TerminateWorkflow(ctx, "stripe-webhook-processor", "", "replaced by per-event workflows"); err != nil {
var notFound *serviceerror.NotFound
if !errors.As(err, &notFound) {
logger.Warn("could not terminate the retired stripe webhook processor workflow", slog.Any("error", err))
}
}
if err := workflows.SweepUnfinishedWebhookEvents(ctx, c, taskQueue, database, logger); err != nil {
logger.Error("stripe webhook boot sweep failed", slog.Any("error", err))
}
// Outbox entries are executed one workflow per entry as well, started by
// the relay below for `pending` rows; the sweep covers `failed` and
// `processing` rows, including those the retired in-activity retry loop
// left (through 2026-09).
if err := workflows.SweepUnfinishedOutboxEntries(ctx, c, taskQueue, database, logger); err != nil {
logger.Error("stripe outbox boot sweep failed", slog.Any("error", err))
}
_, err = c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
_, err := c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
ID: "stripe-outbox-poller",
TaskQueue: taskQueue,
WorkflowIDConflictPolicy: enumspb.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING,
+69 -15
View File
@@ -6,8 +6,10 @@
package web
import (
"context"
"database/sql"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
@@ -63,18 +65,38 @@ func ScrubPII(v interface{}) interface{} {
}
}
// QueuedEvent names a recorded webhook event for the workflow that will
// process it: the row id and what Stripe called it.
type QueuedEvent struct {
ID int64
ProviderEventID string
EventType string
}
// StripeWebhookHandler handles incoming Stripe webhook events.
type StripeWebhookHandler struct {
DB *sql.DB
WebhookSecret string
Logger *slog.Logger
// StartProcessing starts the recorded event's workflow. It must be
// idempotent for an execution already running, since a redelivery of an
// unfinished event calls it again. Nil records events without starting
// anything, which only a handler wired without Temporal does.
StartProcessing func(ctx context.Context, e QueuedEvent) error
}
// ServeHTTP verifies the Stripe signature, scrubs PII, and inserts the event
// idempotently into core.webhook_events. It acknowledges with 2xx only once
// the event is durably recorded or identified as a duplicate of an
// already-recorded delivery; if the insert fails, it answers 5xx so Stripe
// redelivers instead of the event being silently dropped.
// unfinishedStatuses are the row states in which a redelivery re-issues the
// workflow start: the event was recorded but its workflow may never have
// started (the process died in between and Stripe got no 200).
var unfinishedStatuses = map[string]bool{"received": true, "processing": true, "failed": true}
// ServeHTTP verifies the Stripe signature, scrubs PII, inserts the event
// idempotently into core.webhook_events, and starts the event's workflow.
// It acknowledges with 2xx only once the event is durably recorded and its
// workflow started, or the event is a duplicate of a finished one; a failed
// insert or a failed start answers 5xx so Stripe redelivers instead of the
// event being silently dropped. The redelivery of an unfinished duplicate
// starts the workflow again, which is a no-op when it is already running.
func (h *StripeWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -135,17 +157,42 @@ func (h *StripeWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
// insert a second row and reprocess. WHERE NOT EXISTS dedupes on
// (provider, provider_event_id) across time; the remaining
// concurrent-delivery race is the same one ON CONFLICT left open.
_, err = h.DB.ExecContext(r.Context(),
var rowID int64
err = h.DB.QueryRowContext(r.Context(),
`INSERT INTO core.webhook_events
(provider, provider_event_id, event_type, payload, status)
SELECT $1, $2, $3, $4, 'received'
(provider, provider_event_id, event_type, payload, status, provider_event_at)
SELECT $1, $2, $3, $4, 'received', CASE WHEN $5::bigint > 0 THEN to_timestamp($5::bigint) END
WHERE NOT EXISTS (
SELECT 1 FROM core.webhook_events
WHERE provider = $1 AND provider_event_id = $2
)`,
"stripe", event.ID, string(event.Type), scrubbedJSON,
)
if err != nil {
)
RETURNING id`,
"stripe", event.ID, string(event.Type), scrubbedJSON, event.Created,
).Scan(&rowID)
switch {
case err == nil:
h.Logger.Info("webhook event received",
slog.String("event_id", event.ID),
slog.String("event_type", string(event.Type)))
case errors.Is(err, sql.ErrNoRows):
// A duplicate delivery. Finished events are acknowledged and left
// alone; an unfinished one gets its workflow (re)started, since
// this redelivery may be Stripe's answer to a process that died
// between recording the row and starting the workflow.
var status string
if err := h.DB.QueryRowContext(r.Context(),
`SELECT id, status FROM core.webhook_events WHERE provider = $1 AND provider_event_id = $2
ORDER BY received_at ASC LIMIT 1`,
"stripe", event.ID).Scan(&rowID, &status); err != nil {
h.Logger.Error("failed to read the recorded webhook event", slog.String("event_id", event.ID), slog.Any("error", err))
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if !unfinishedStatuses[status] {
w.WriteHeader(http.StatusOK)
return
}
default:
h.Logger.Error("failed to insert webhook event",
slog.String("event_id", event.ID),
slog.Any("error", err))
@@ -156,9 +203,16 @@ func (h *StripeWebhookHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
return
}
h.Logger.Info("webhook event received",
slog.String("event_id", event.ID),
slog.String("event_type", string(event.Type)))
if h.StartProcessing != nil {
if err := h.StartProcessing(r.Context(), QueuedEvent{ID: rowID, ProviderEventID: event.ID, EventType: string(event.Type)}); err != nil {
h.Logger.Error("failed to start the webhook event's workflow; the row stays recorded",
slog.String("event_id", event.ID), slog.Any("error", err))
// Recorded but not started: answer 500 so Stripe redelivers,
// and the redelivery starts it (the unfinished branch above).
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
}
w.WriteHeader(http.StatusOK)
}
@@ -9,7 +9,9 @@ package web_test
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"net/http"
@@ -105,3 +107,100 @@ func TestWebhookDuplicateDeliveryAcrossTime(t *testing.T) {
t.Errorf("stored payload retains PII email: %s", stored)
}
}
// recordingStarter stands in for the per-event workflow start: it records
// what the endpoint asked to start and can refuse, the way a Temporal
// outage would.
type recordingStarter struct {
calls []web.QueuedEvent
err error
}
func (s *recordingStarter) start(_ context.Context, e web.QueuedEvent) error {
s.calls = append(s.calls, e)
return s.err
}
// The endpoint records the event, then starts its workflow, and answers 200
// only when both happened (stripe-integration-infrastructure "Stripe webhook
// endpoint ingests events asynchronously", 2026-09 audit remediation D8).
func TestWebhookStartsTheEventWorkflow(t *testing.T) {
database := testDB(t)
starter := &recordingStarter{}
h := &web.StripeWebhookHandler{DB: database, WebhookSecret: testSecret, Logger: slog.Default(), StartProcessing: starter.start}
eventID := "evt_start_" + uuid.New().String()[:12]
t.Cleanup(func() {
_, _ = database.Exec(`DELETE FROM core.webhook_events WHERE provider = 'stripe' AND provider_event_id = $1`, eventID)
})
payload := []byte(fmt.Sprintf(`{"id": %q, "type": "customer.created", "created": 1757400000, "data": {"object": {"id": "cus_start"}}}`, eventID))
if code := deliver(t, h, payload); code != http.StatusOK {
t.Fatalf("delivery = %d, want 200", code)
}
var rowID int64
var eventAt time.Time
if err := database.QueryRow(`SELECT id, provider_event_at FROM core.webhook_events WHERE provider = 'stripe' AND provider_event_id = $1`, eventID).Scan(&rowID, &eventAt); err != nil {
t.Fatalf("read row: %v", err)
}
if eventAt.Unix() != 1757400000 {
t.Errorf("the event's own time must be recorded, got %v", eventAt)
}
if len(starter.calls) != 1 || starter.calls[0] != (web.QueuedEvent{ID: rowID, ProviderEventID: eventID, EventType: "customer.created"}) {
t.Fatalf("the workflow must be started once with the recorded row, got %+v", starter.calls)
}
// A redelivery of an unfinished event starts the workflow again (a
// no-op when it is running), which is what recovers a process that died
// between recording and starting.
if code := deliver(t, h, payload); code != http.StatusOK {
t.Fatalf("redelivery = %d, want 200", code)
}
if len(starter.calls) != 2 {
t.Errorf("a redelivery of an unfinished event must re-issue the start, got %d calls", len(starter.calls))
}
// A redelivery of a finished event is acknowledged and starts nothing.
if _, err := database.Exec(`UPDATE core.webhook_events SET status = 'completed' WHERE id = $1`, rowID); err != nil {
t.Fatal(err)
}
if code := deliver(t, h, payload); code != http.StatusOK {
t.Fatalf("redelivery of a finished event = %d, want 200", code)
}
if len(starter.calls) != 2 {
t.Errorf("a finished event must not be started again, got %d calls", len(starter.calls))
}
}
// A start that fails leaves the row recorded and answers 500, so Stripe
// redelivers; the redelivery, with Temporal back, starts the workflow.
func TestWebhookAnswers500WhenTheWorkflowCannotStart(t *testing.T) {
database := testDB(t)
starter := &recordingStarter{err: errors.New("temporal unavailable")}
h := &web.StripeWebhookHandler{DB: database, WebhookSecret: testSecret, Logger: slog.Default(), StartProcessing: starter.start}
eventID := "evt_nostart_" + uuid.New().String()[:12]
t.Cleanup(func() {
_, _ = database.Exec(`DELETE FROM core.webhook_events WHERE provider = 'stripe' AND provider_event_id = $1`, eventID)
})
payload := []byte(fmt.Sprintf(`{"id": %q, "type": "customer.created", "data": {"object": {"id": "cus_nostart"}}}`, eventID))
if code := deliver(t, h, payload); code != http.StatusInternalServerError {
t.Fatalf("delivery with no Temporal = %d, want 500", code)
}
var rows int
if err := database.QueryRow(`SELECT COUNT(*) FROM core.webhook_events WHERE provider = 'stripe' AND provider_event_id = $1`, eventID).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 1 {
t.Fatalf("the event must stay recorded, got %d rows", rows)
}
starter.err = nil
if code := deliver(t, h, payload); code != http.StatusOK {
t.Fatalf("redelivery with Temporal back = %d, want 200", code)
}
if len(starter.calls) != 2 {
t.Errorf("the redelivery must start the workflow, got %d calls", len(starter.calls))
}
}
@@ -144,10 +144,7 @@ func TestOutboxExecutor_CreateStripeCustomer(t *testing.T) {
Attempts: 0,
MaxAttempts: 5,
}
err = acts.ExecuteOutboxEntry(ctx, entry)
if err != nil {
t.Fatalf("ExecuteOutboxEntry: %v", err)
}
execErr := acts.ExecuteOutboxEntry(ctx, entry)
// Check the outbox entry status: without a valid STRIPE_API_KEY, the
// executor marks the entry as 'failed' (not 'completed'). With a key,
@@ -161,13 +158,19 @@ func TestOutboxExecutor_CreateStripeCustomer(t *testing.T) {
}
if status == "failed" {
// Expected without STRIPE_API_KEY the dispatch reached Stripe and
// correctly handled the auth error via the retry path.
// Expected without STRIPE_API_KEY: the attempt reached Stripe, was
// recorded, and its error goes back to Temporal to retry.
if execErr == nil {
t.Fatal("a failed attempt must be returned to Temporal, not swallowed")
}
t.Logf("Outbox entry marked 'failed' (expected without STRIPE_API_KEY)")
return
}
// If we have a real key, verify the mapping was written
if execErr != nil {
t.Fatalf("ExecuteOutboxEntry: %v", execErr)
}
if status != "completed" {
t.Fatalf("expected outbox status 'completed' or 'failed', got %q", status)
}
+226 -87
View File
@@ -7,9 +7,9 @@ import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math"
"time"
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
@@ -18,18 +18,23 @@ import (
"github.com/stripe/stripe-go/v81/customer"
"github.com/stripe/stripe-go/v81/price"
"github.com/stripe/stripe-go/v81/product"
enumspb "go.temporal.io/api/enums/v1"
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/workflow"
)
// OutboxEntry represents a row from core.outbox.
type OutboxEntry struct {
ID int64 `db:"id"`
Provider string `db:"provider"`
ActionType string `db:"action_type"`
Payload json.RawMessage `db:"payload"`
Status string `db:"status"`
Attempts int `db:"attempts"`
MaxAttempts int `db:"max_attempts"`
ID int64 `db:"id"`
Provider string `db:"provider"`
ActionType string `db:"action_type"`
Payload json.RawMessage `db:"payload"`
Status string `db:"status"`
Attempts int `db:"attempts"`
// MaxAttempts is no longer read: Temporal's retry budget bounds the
// attempts. It stays for the handles tests build from rows.
MaxAttempts int `db:"max_attempts"`
}
// PollIntegrationOutboxInput configures the outbox polling workflow.
@@ -38,9 +43,29 @@ type PollIntegrationOutboxInput struct {
PollInterval time.Duration
}
// PollIntegrationOutbox is a Temporal workflow that polls the integration
// outbox for pending actions and executes them. Uses ContinueAsNew for
// long-running operation.
// OutboxEntryWorkflowID is the Workflow ID for one outbox entry, so a second
// dispatch of the same entry lands on the running execution.
func OutboxEntryWorkflowID(entryID int64) string {
return fmt.Sprintf("stripe-outbox-%d", entryID)
}
// The retry budget for one outbox entry, the same as a webhook event's:
// Temporal retries the activity from one second, doubling to ten minutes,
// for a day; a Stripe answer that says the request itself is wrong
// (classifyStripeError) ends it at once; the spent budget dead-letters the
// entry for the Stripe provider page. Every Stripe create carries the
// entry's idempotency key, so a retry after a lost result reuses the object
// the first attempt made rather than making another.
const (
outboxRetryBudget = 24 * time.Hour
outboxRetryMaxInterval = 10 * time.Minute
outboxAttemptTimeout = 2 * time.Minute
)
// PollIntegrationOutbox is the transactional outbox's relay: a long-running
// workflow that polls `pending` entries, the rows domain transactions wrote,
// and hands each to its own workflow. It retries nothing itself; the entry's
// workflow owns that. Uses ContinueAsNew for long-running operation.
func PollIntegrationOutbox(ctx workflow.Context, input PollIntegrationOutboxInput) error {
logger := workflow.GetLogger(ctx)
@@ -53,31 +78,123 @@ func PollIntegrationOutbox(ctx workflow.Context, input PollIntegrationOutboxInpu
actCtx := workflow.WithActivityOptions(ctx, common.DefaultActivityOptions())
// Poll for actionable entries
var acts *OutboxActivities
var entries []OutboxEntry
err := workflow.ExecuteActivity(actCtx, acts.PollOutboxEntries, input.BatchSize).Get(ctx, &entries)
err := workflow.ExecuteActivity(actCtx, acts.PollPendingOutboxEntries, input.BatchSize).Get(ctx, &entries)
if err != nil {
logger.Error("failed to poll outbox entries", "error", err)
return err
}
// Execute each entry
for _, entry := range entries {
err := workflow.ExecuteActivity(actCtx, acts.ExecuteOutboxEntry, entry).Get(ctx, nil)
childCtx := workflow.WithChildOptions(ctx, workflow.ChildWorkflowOptions{
WorkflowID: OutboxEntryWorkflowID(entry.ID),
ParentClosePolicy: enumspb.PARENT_CLOSE_POLICY_ABANDON,
})
var exec workflow.Execution
err := workflow.ExecuteChildWorkflow(childCtx, ExecuteStripeOutboxEntry, entry).GetChildWorkflowExecution().Get(ctx, &exec)
if err != nil {
logger.Error("outbox execution failed",
"entry_id", entry.ID,
"error", err)
// Failure handling is inside the activity
var already *temporal.ChildWorkflowExecutionAlreadyStartedError
if errors.As(err, &already) {
// Dispatched by an earlier poll and still running: the
// entry's activity has not marked it `processing` yet.
continue
}
logger.Error("could not start the outbox entry's workflow; the entry stays pending for the next poll",
"entry_id", entry.ID, "error", err)
}
}
// Sleep then continue-as-new
_ = workflow.Sleep(ctx, input.PollInterval)
return workflow.NewContinueAsNewError(ctx, PollIntegrationOutbox, input)
}
// ExecuteStripeOutboxEntry is one outbox entry's whole life after the
// relay picked it up: execute it, retrying on Temporal's schedule, and
// either mark it done or dead-letter it. Started as a child of the relay,
// abandoned so it outlives the relay's continue-as-new, and by the boot
// sweep for entries an earlier process left unfinished.
func ExecuteStripeOutboxEntry(ctx workflow.Context, entry OutboxEntry) error {
var acts *OutboxActivities
execCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
StartToCloseTimeout: outboxAttemptTimeout,
ScheduleToCloseTimeout: outboxRetryBudget,
RetryPolicy: &temporal.RetryPolicy{
InitialInterval: time.Second,
BackoffCoefficient: 2.0,
MaximumInterval: outboxRetryMaxInterval,
},
})
err := workflow.ExecuteActivity(execCtx, acts.ExecuteOutboxEntry, entry).Get(ctx, nil)
if err == nil {
return nil
}
workflow.GetLogger(ctx).Error("outbox entry exhausted its retries; dead-lettering",
"entry_id", entry.ID, "action_type", entry.ActionType, "error", err)
markCtx := workflow.WithActivityOptions(ctx, common.DefaultActivityOptions())
if markErr := workflow.ExecuteActivity(markCtx, acts.MarkOutboxDeadLetter, entry.ID, err.Error()).Get(ctx, nil); markErr != nil {
workflow.GetLogger(ctx).Error("could not dead-letter the outbox entry", "entry_id", entry.ID, "error", markErr)
}
return err
}
// StartOutboxEntry starts an entry's workflow from outside a workflow (the
// boot sweep), idempotently.
func StartOutboxEntry(ctx context.Context, c client.Client, taskQueue string, entry OutboxEntry) error {
_, err := c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
ID: OutboxEntryWorkflowID(entry.ID),
TaskQueue: taskQueue,
WorkflowIDConflictPolicy: enumspb.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING,
}, ExecuteStripeOutboxEntry, entry)
if err != nil && temporal.IsWorkflowExecutionAlreadyStartedError(err) {
return nil
}
return err
}
// unfinishedOutboxEntriesSQL selects the entries the boot sweep hands to
// their workflows: `failed` (retrying, or left by the retired retry loop)
// and `processing` (left by a process that died mid-attempt). `pending`
// rows are the relay's, on its next poll.
const unfinishedOutboxEntriesSQL = `SELECT id, provider, action_type, payload, status, attempts
FROM core.outbox
WHERE provider = 'stripe' AND status IN ('failed', 'processing')
ORDER BY created_at ASC`
// SweepUnfinishedOutboxEntries starts a workflow for every Stripe outbox
// entry left `failed` or `processing`. Run once at boot.
func SweepUnfinishedOutboxEntries(ctx context.Context, c client.Client, taskQueue string, db *sql.DB, logger *slog.Logger) error {
rows, err := db.QueryContext(ctx, unfinishedOutboxEntriesSQL)
if err != nil {
return fmt.Errorf("list unfinished outbox entries: %w", err)
}
defer rows.Close()
var started, failed int
for rows.Next() {
var e OutboxEntry
if err := rows.Scan(&e.ID, &e.Provider, &e.ActionType, &e.Payload, &e.Status, &e.Attempts); err != nil {
return fmt.Errorf("scan unfinished outbox entry: %w", err)
}
if err := StartOutboxEntry(ctx, c, taskQueue, e); err != nil {
failed++
logger.Warn("boot sweep: could not start the outbox entry's workflow",
slog.Int64("entry_id", e.ID), slog.Any("error", err))
continue
}
started++
}
if err := rows.Err(); err != nil {
return fmt.Errorf("unfinished outbox entries: %w", err)
}
if started+failed > 0 {
logger.Info("boot sweep: unfinished stripe outbox entries handed to their workflows",
slog.Int("started", started), slog.Int("failed", failed))
}
return nil
}
// OutboxActivities holds dependencies for outbox processing activities.
type OutboxActivities struct {
DB *sql.DB
@@ -89,14 +206,13 @@ func NewOutboxActivities(db *sql.DB, logger *slog.Logger) *OutboxActivities {
return &OutboxActivities{DB: db, Logger: logger}
}
// PollOutboxEntries selects entries ready for processing.
func (a *OutboxActivities) PollOutboxEntries(ctx context.Context, batchSize int) ([]OutboxEntry, error) {
// PollPendingOutboxEntries selects the entries no workflow has taken yet.
func (a *OutboxActivities) PollPendingOutboxEntries(ctx context.Context, batchSize int) ([]OutboxEntry, error) {
rows, err := a.DB.QueryContext(ctx,
`SELECT id, provider, action_type, payload, status, attempts, max_attempts
`SELECT id, provider, action_type, payload, status, attempts
FROM core.outbox
WHERE status IN ('pending', 'failed')
AND next_attempt_at <= NOW()
ORDER BY next_attempt_at ASC
WHERE status = 'pending'
ORDER BY created_at ASC
LIMIT $1`,
batchSize,
)
@@ -108,7 +224,7 @@ func (a *OutboxActivities) PollOutboxEntries(ctx context.Context, batchSize int)
var entries []OutboxEntry
for rows.Next() {
var e OutboxEntry
if err := rows.Scan(&e.ID, &e.Provider, &e.ActionType, &e.Payload, &e.Status, &e.Attempts, &e.MaxAttempts); err != nil {
if err := rows.Scan(&e.ID, &e.Provider, &e.ActionType, &e.Payload, &e.Status, &e.Attempts); err != nil {
return nil, fmt.Errorf("scan outbox entry: %w", err)
}
entries = append(entries, e)
@@ -116,75 +232,94 @@ func (a *OutboxActivities) PollOutboxEntries(ctx context.Context, batchSize int)
return entries, rows.Err()
}
// ExecuteOutboxEntry marks an entry as processing, attempts the external action,
// and transitions to completed/failed/dead_letter.
// ExecuteOutboxEntry marks an entry `processing`, performs its action, and
// marks it `completed`. A failed attempt is recorded on the row (`failed`,
// Temporal's attempt number, the error) before the error goes back to
// Temporal to schedule the next one; an answer from Stripe that the request
// itself is wrong is returned non-retryable. The row is re-read first: an
// entry already completed by an earlier attempt whose result was lost is
// not executed twice.
func (a *OutboxActivities) ExecuteOutboxEntry(ctx context.Context, entry OutboxEntry) error {
// Transition to processing
_, err := a.DB.ExecContext(ctx,
`UPDATE core.outbox
SET status = 'processing', updated_at = NOW()
WHERE id = $1`,
entry.ID,
)
if err != nil {
var status string
if err := a.DB.QueryRowContext(ctx,
`SELECT status, payload, action_type FROM core.outbox WHERE id = $1`, entry.ID,
).Scan(&status, &entry.Payload, &entry.ActionType); err != nil {
return fmt.Errorf("read outbox entry %d: %w", entry.ID, err)
}
if status == "completed" {
return nil
}
if _, err := a.DB.ExecContext(ctx,
`UPDATE core.outbox SET status = 'processing', updated_at = NOW() WHERE id = $1`, entry.ID,
); err != nil {
return fmt.Errorf("mark processing: %w", err)
}
// Phase 0: no real execution logic yet — just mark completed.
// Future phases will dispatch to action-type-specific executors.
execErr := a.executeAction(ctx, entry)
if execErr == nil {
_, err = a.DB.ExecContext(ctx,
`UPDATE core.outbox
SET status = 'completed', attempts = attempts + 1, updated_at = NOW()
WHERE id = $1`,
entry.ID,
)
if err != nil {
return fmt.Errorf("mark completed: %w", err)
}
return nil
if execErr := a.executeAction(ctx, entry); execErr != nil {
execErr = classifyStripeError(execErr)
a.recordFailedOutboxAttempt(ctx, entry.ID, currentAttempt(ctx), execErr)
return execErr
}
// Failure: increment attempts and compute backoff
newAttempts := entry.Attempts + 1
if newAttempts >= entry.MaxAttempts {
// Dead letter
_, err = a.DB.ExecContext(ctx,
`UPDATE core.outbox
SET status = 'dead_letter', attempts = $1, error_message = $2, updated_at = NOW()
WHERE id = $3`,
newAttempts, execErr.Error(), entry.ID,
)
if err != nil {
return fmt.Errorf("mark dead_letter: %w", err)
}
a.Logger.Warn("outbox entry moved to dead letter",
slog.Int64("entry_id", entry.ID),
slog.String("action_type", entry.ActionType))
return nil
}
// Exponential backoff: base 5s * 2^attempts, capped at 1h
backoff := time.Duration(math.Min(
float64(5*time.Second)*math.Pow(2, float64(newAttempts)),
float64(time.Hour),
))
nextAttempt := time.Now().Add(backoff)
_, err = a.DB.ExecContext(ctx,
`UPDATE core.outbox
SET status = 'failed', attempts = $1, next_attempt_at = $2, error_message = $3, updated_at = NOW()
WHERE id = $4`,
newAttempts, nextAttempt, execErr.Error(), entry.ID,
)
if err != nil {
return fmt.Errorf("mark failed with backoff: %w", err)
if _, err := a.DB.ExecContext(ctx,
`UPDATE core.outbox SET status = 'completed', attempts = $2, error_message = NULL, updated_at = NOW() WHERE id = $1`,
entry.ID, currentAttempt(ctx),
); err != nil {
return fmt.Errorf("mark completed: %w", err)
}
return nil
}
// recordFailedOutboxAttempt writes a failed attempt to the entry. Best
// effort, for the operator page's Retrying count and the error it shows.
func (a *OutboxActivities) recordFailedOutboxAttempt(ctx context.Context, entryID int64, attempt int32, execErr error) {
if _, err := a.DB.ExecContext(ctx,
`UPDATE core.outbox SET status = 'failed', attempts = $2, error_message = $3, updated_at = NOW() WHERE id = $1`,
entryID, attempt, execErr.Error(),
); err != nil {
a.Logger.Warn("could not record the failed attempt on the outbox entry",
slog.Int64("entry_id", entryID), slog.Any("error", err))
}
}
// MarkOutboxDeadLetter records that an entry's workflow gave up. Only an
// operator moves it from here (the Stripe provider page lists them).
func (a *OutboxActivities) MarkOutboxDeadLetter(ctx context.Context, entryID int64, errMsg string) error {
if _, err := a.DB.ExecContext(ctx,
`UPDATE core.outbox SET status = 'dead_letter', error_message = $2, updated_at = NOW() WHERE id = $1`,
entryID, errMsg,
); err != nil {
return fmt.Errorf("mark dead_letter: %w", err)
}
a.Logger.Warn("outbox entry moved to dead letter", slog.Int64("entry_id", entryID), slog.String("error", errMsg))
return nil
}
// classifyStripeError turns a Stripe answer that the request itself is
// wrong (a 4xx other than a rate limit or a conflict) into a non-retryable
// error, so Temporal stops at once instead of asking the same question for
// a day. Anything else, a network fault, a 5xx, a 429, stays retryable.
func classifyStripeError(err error) error {
var stripeErr *stripego.Error
if !errors.As(err, &stripeErr) {
return err
}
switch stripeErr.HTTPStatusCode {
case 400, 401, 402, 403, 404, 422:
return temporal.NewNonRetryableApplicationError(err.Error(), "StripeRequestRejected", err)
}
return err
}
// outboxIdempotencyKey is the Stripe idempotency key for an entry's create:
// the same key on every attempt, so a retry after a lost result returns the
// object the first attempt made (Stripe keeps keys for 24 hours, the retry
// budget).
func outboxIdempotencyKey(entryID int64) string {
return fmt.Sprintf("member-console-outbox-%d", entryID)
}
// executeAction dispatches to the appropriate action executor based on action type.
func (a *OutboxActivities) executeAction(ctx context.Context, entry OutboxEntry) error {
switch entry.ActionType {
@@ -217,6 +352,7 @@ func (a *OutboxActivities) executeCreateStripeCustomer(ctx context.Context, entr
params.AddMetadata("org_id", payload.OrgID)
params.AddMetadata("org_name", payload.OrgName)
params.AddMetadata("billing_account_id", payload.BillingAccountID)
params.SetIdempotencyKey(outboxIdempotencyKey(entry.ID))
cust, err := customer.New(params)
if err != nil {
@@ -276,7 +412,9 @@ func (a *OutboxActivities) executeCreateStripeProduct(ctx context.Context, entry
return fmt.Errorf("parse create_stripe_product payload: %w", err)
}
prod, err := product.New(buildStripeProductParams(payload))
params := buildStripeProductParams(payload)
params.SetIdempotencyKey(outboxIdempotencyKey(entry.ID))
prod, err := product.New(params)
if err != nil {
return fmt.Errorf("stripe create product: %w", err)
}
@@ -337,6 +475,7 @@ func (a *OutboxActivities) executeCreateStripePrice(ctx context.Context, entry O
}
params.AddMetadata("price_id", payload.PriceID)
params.AddMetadata("product_id", payload.ProductID)
params.SetIdempotencyKey(outboxIdempotencyKey(entry.ID))
p, err := price.New(params)
if err != nil {
@@ -0,0 +1,137 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"context"
"database/sql"
"io"
"log/slog"
"testing"
"go.temporal.io/sdk/testsuite"
)
func insertOutboxRow(t *testing.T, db *sql.DB, actionType, payload, status string) int64 {
t.Helper()
var id int64
if err := db.QueryRowContext(context.Background(),
`INSERT INTO core.outbox (provider, action_type, payload, status) VALUES ('stripe', $1, $2, $3) RETURNING id`,
actionType, payload, status).Scan(&id); err != nil {
t.Fatalf("insert outbox row: %v", err)
}
t.Cleanup(func() { _, _ = db.ExecContext(context.Background(), `DELETE FROM core.outbox WHERE id = $1`, id) })
return id
}
func outboxRow(t *testing.T, db *sql.DB, id int64) (status string, attempts int, errMsg string) {
t.Helper()
var msg sql.NullString
if err := db.QueryRowContext(context.Background(),
`SELECT status, attempts, error_message FROM core.outbox WHERE id = $1`, id).Scan(&status, &attempts, &msg); err != nil {
t.Fatalf("read outbox row %d: %v", id, err)
}
return status, attempts, msg.String
}
// A failed attempt is written to the entry with Temporal's attempt number
// and the error, and returned so Temporal schedules the next one. The
// payload here names a product with no mapping, which the price executor
// refuses before any Stripe call.
func TestExecuteOutboxEntryRecordsAFailedAttemptOnTheRow(t *testing.T) {
db := testDB(t)
acts := NewOutboxActivities(db, slog.New(slog.NewTextHandler(io.Discard, nil)))
id := insertOutboxRow(t, db, "create_stripe_price", `{"price_id":"p","product_id":"00000000-0000-0000-0000-000000000000","unit_amount":1,"currency":"usd"}`, "pending")
var suite testsuite.WorkflowTestSuite
env := suite.NewTestActivityEnvironment()
env.RegisterActivity(acts)
if _, err := env.ExecuteActivity(acts.ExecuteOutboxEntry, OutboxEntry{ID: id}); err == nil {
t.Fatal("a price without a synced product must fail the attempt")
}
status, attempts, msg := outboxRow(t, db, id)
if status != "failed" || attempts != 1 || msg == "" {
t.Errorf("row after the failed attempt: status %q, attempts %d, error %q; want failed/1/<the error>", status, attempts, msg)
}
}
// An entry an earlier attempt already completed (its result lost on the way
// back) is not executed again.
func TestExecuteOutboxEntryDoesNotRepeatACompletedEntry(t *testing.T) {
db := testDB(t)
acts := NewOutboxActivities(db, slog.New(slog.NewTextHandler(io.Discard, nil)))
id := insertOutboxRow(t, db, "create_stripe_price", `{"price_id":"p","product_id":"00000000-0000-0000-0000-000000000000"}`, "completed")
if err := acts.ExecuteOutboxEntry(context.Background(), OutboxEntry{ID: id}); err != nil {
t.Fatalf("a completed entry must be a no-op, got %v", err)
}
if status, _, _ := outboxRow(t, db, id); status != "completed" {
t.Errorf("status must stay completed, got %q", status)
}
}
func TestMarkOutboxDeadLetter(t *testing.T) {
db := testDB(t)
acts := NewOutboxActivities(db, slog.New(slog.NewTextHandler(io.Discard, nil)))
id := insertOutboxRow(t, db, "create_stripe_product", `{}`, "failed")
if err := acts.MarkOutboxDeadLetter(context.Background(), id, "schedule-to-close timeout"); err != nil {
t.Fatalf("mark dead letter: %v", err)
}
if status, _, msg := outboxRow(t, db, id); status != "dead_letter" || msg != "schedule-to-close timeout" {
t.Errorf("row: status %q, error %q; want dead_letter with the error", status, msg)
}
}
// The relay's poll takes pending rows only; the boot sweep takes failed and
// processing rows only.
func TestOutboxPollAndSweepSelectDisjointStates(t *testing.T) {
db := testDB(t)
acts := NewOutboxActivities(db, slog.New(slog.NewTextHandler(io.Discard, nil)))
rows := map[string]int64{}
for _, status := range []string{"pending", "failed", "processing", "completed", "dead_letter"} {
rows[status] = insertOutboxRow(t, db, "create_stripe_product", `{}`, status)
}
polled, err := acts.PollPendingOutboxEntries(context.Background(), 1000)
if err != nil {
t.Fatal(err)
}
seen := map[int64]bool{}
for _, e := range polled {
seen[e.ID] = true
}
if !seen[rows["pending"]] {
t.Error("the poll must take the pending row")
}
for _, s := range []string{"failed", "processing", "completed", "dead_letter"} {
if seen[rows[s]] {
t.Errorf("the poll must not take the %s row", s)
}
}
swept := map[int64]bool{}
r, err := db.QueryContext(context.Background(), unfinishedOutboxEntriesSQL)
if err != nil {
t.Fatal(err)
}
defer r.Close()
for r.Next() {
var e OutboxEntry
if err := r.Scan(&e.ID, &e.Provider, &e.ActionType, &e.Payload, &e.Status, &e.Attempts); err != nil {
t.Fatal(err)
}
swept[e.ID] = true
}
for _, s := range []string{"failed", "processing"} {
if !swept[rows[s]] {
t.Errorf("the sweep must take the %s row", s)
}
}
for _, s := range []string{"pending", "completed", "dead_letter"} {
if swept[rows[s]] {
t.Errorf("the sweep must not take the %s row", s)
}
}
}
@@ -0,0 +1,161 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"context"
"errors"
"net/http"
"testing"
"github.com/stretchr/testify/mock"
stripego "github.com/stripe/stripe-go/v81"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/testsuite"
"go.temporal.io/sdk/workflow"
)
func entryUnderTest() OutboxEntry {
return OutboxEntry{ID: 7, Provider: "stripe", ActionType: "create_stripe_product", Payload: []byte(`{}`), Status: "pending"}
}
type outboxDeadLetterRecorder struct {
calls int
lastID int64
}
func mockOutboxDeadLetter(env *testsuite.TestWorkflowEnvironment, rec *outboxDeadLetterRecorder) {
var acts *OutboxActivities
env.OnActivity(acts.MarkOutboxDeadLetter, mock.Anything, mock.Anything, mock.Anything).
Return(func(_ context.Context, id int64, _ string) error {
rec.calls++
rec.lastID = id
return nil
})
}
// The relay hands every pending entry to its own workflow and continues as
// new; it retries nothing itself.
func TestOutboxRelayDispatchesEachPendingEntryToItsWorkflow(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
var acts *OutboxActivities
env.OnActivity(acts.PollPendingOutboxEntries, mock.Anything, mock.Anything).
Return([]OutboxEntry{{ID: 1, ActionType: "create_stripe_product"}, {ID: 2, ActionType: "create_stripe_price"}}, nil)
var dispatched []int64
env.RegisterWorkflow(ExecuteStripeOutboxEntry)
env.OnWorkflow(ExecuteStripeOutboxEntry, mock.Anything, mock.Anything).
Return(func(_ workflow.Context, e OutboxEntry) error {
dispatched = append(dispatched, e.ID)
return nil
})
env.ExecuteWorkflow(PollIntegrationOutbox, PollIntegrationOutboxInput{})
err := env.GetWorkflowError()
if !workflow.IsContinueAsNewError(err) {
t.Fatalf("the relay must continue as new after a cycle, got %v", err)
}
if len(dispatched) != 2 || dispatched[0] != 1 || dispatched[1] != 2 {
t.Errorf("both pending entries must be dispatched in order, got %v", dispatched)
}
}
func TestOutboxEntryWorkflowCompletesWhenTheActionSucceeds(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
var acts *OutboxActivities
rec := &outboxDeadLetterRecorder{}
mockOutboxDeadLetter(env, rec)
env.OnActivity(acts.ExecuteOutboxEntry, mock.Anything, mock.Anything).Return(nil)
env.ExecuteWorkflow(ExecuteStripeOutboxEntry, entryUnderTest())
if err := env.GetWorkflowError(); err != nil {
t.Fatalf("workflow must complete cleanly, got %v", err)
}
if rec.calls != 0 {
t.Errorf("a completed entry must not be dead-lettered, got %d calls", rec.calls)
}
}
// A price whose product is not synced yet fails retryably; the next attempt
// finds the product and the entry completes with nothing dead-lettered.
func TestOutboxEntryWorkflowRetriesAFailedAttempt(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
var acts *OutboxActivities
rec := &outboxDeadLetterRecorder{}
mockOutboxDeadLetter(env, rec)
attempts := 0
env.OnActivity(acts.ExecuteOutboxEntry, mock.Anything, mock.Anything).
Return(func(_ context.Context, _ OutboxEntry) error {
attempts++
if attempts < 3 {
return errors.New("product not yet synced (retriable)")
}
return nil
})
env.ExecuteWorkflow(ExecuteStripeOutboxEntry, entryUnderTest())
if err := env.GetWorkflowError(); err != nil {
t.Fatalf("workflow must complete once an attempt succeeds, got %v", err)
}
if attempts != 3 || rec.calls != 0 {
t.Errorf("want three attempts and no dead letter, got attempts=%d deadLetters=%d", attempts, rec.calls)
}
}
// Stripe saying the request itself is wrong ends the retries at once.
func TestOutboxEntryWorkflowDeadLettersARejectedRequest(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
var acts *OutboxActivities
rec := &outboxDeadLetterRecorder{}
mockOutboxDeadLetter(env, rec)
attempts := 0
env.OnActivity(acts.ExecuteOutboxEntry, mock.Anything, mock.Anything).
Return(func(_ context.Context, _ OutboxEntry) error {
attempts++
return classifyStripeError(&stripego.Error{HTTPStatusCode: http.StatusBadRequest, Msg: "parameter_invalid_empty: description"})
})
env.ExecuteWorkflow(ExecuteStripeOutboxEntry, entryUnderTest())
if err := env.GetWorkflowError(); err == nil {
t.Fatal("the workflow must fail so the Temporal UI shows the dead letter")
}
if attempts != 1 {
t.Errorf("a rejected request must not be retried, got %d attempts", attempts)
}
if rec.calls != 1 || rec.lastID != 7 {
t.Errorf("the entry must be dead-lettered once, got calls=%d id=%d", rec.calls, rec.lastID)
}
}
// classifyStripeError: a 4xx that means "wrong request" is terminal; a rate
// limit, a server error and a plain network error stay retryable.
func TestClassifyStripeError(t *testing.T) {
terminal := func(err error) bool {
var appErr *temporal.ApplicationError
return errors.As(err, &appErr) && appErr.NonRetryable()
}
for _, code := range []int{400, 401, 402, 403, 404, 422} {
if !terminal(classifyStripeError(&stripego.Error{HTTPStatusCode: code})) {
t.Errorf("status %d must be terminal", code)
}
}
for _, code := range []int{409, 429, 500, 502, 503} {
if terminal(classifyStripeError(&stripego.Error{HTTPStatusCode: code})) {
t.Errorf("status %d must stay retryable", code)
}
}
if terminal(classifyStripeError(errors.New("connection reset"))) {
t.Error("a plain error must stay retryable")
}
}
@@ -81,10 +81,7 @@ func TestOutboxExecutor_CreateStripeProduct_Success(t *testing.T) {
Attempts: 0,
MaxAttempts: 5,
}
err = acts.ExecuteOutboxEntry(ctx, entry)
if err != nil {
t.Fatalf("ExecuteOutboxEntry: %v", err)
}
execErr := acts.ExecuteOutboxEntry(ctx, entry)
var status string
err = database.QueryRowContext(ctx,
@@ -95,6 +92,9 @@ func TestOutboxExecutor_CreateStripeProduct_Success(t *testing.T) {
}
if status == "failed" {
if execErr == nil {
t.Fatal("a failed attempt must be returned to Temporal, not swallowed")
}
t.Logf("Outbox entry marked 'failed' (expected without STRIPE_API_KEY)")
// Verify no mapping was written
q := internalstripe.New(database)
@@ -105,6 +105,9 @@ func TestOutboxExecutor_CreateStripeProduct_Success(t *testing.T) {
return
}
if execErr != nil {
t.Fatalf("ExecuteOutboxEntry: %v", execErr)
}
if status != "completed" {
t.Fatalf("expected outbox status 'completed' or 'failed', got %q", status)
}
@@ -182,12 +185,12 @@ func TestOutboxExecutor_CreateStripePrice_MissingProductMapping(t *testing.T) {
Attempts: 0,
MaxAttempts: 5,
}
err = acts.ExecuteOutboxEntry(ctx, entry)
if err != nil {
t.Fatalf("ExecuteOutboxEntry: %v", err)
}
execErr := acts.ExecuteOutboxEntry(ctx, entry)
// Should be failed because no product mapping exists
if execErr == nil {
t.Fatal("ExecuteOutboxEntry must return the error so Temporal retries; the product mapping is missing")
}
// Recorded as failed (retryable) because no product mapping exists
var status string
err = database.QueryRowContext(ctx,
`SELECT status FROM core.outbox WHERE id = $1`, entryID,
@@ -275,10 +278,7 @@ func TestOutboxExecutor_CreateStripePrice_Success(t *testing.T) {
Attempts: 0,
MaxAttempts: 5,
}
err = acts.ExecuteOutboxEntry(ctx, entry)
if err != nil {
t.Fatalf("ExecuteOutboxEntry: %v", err)
}
execErr := acts.ExecuteOutboxEntry(ctx, entry)
var status string
err = database.QueryRowContext(ctx,
@@ -289,10 +289,16 @@ func TestOutboxExecutor_CreateStripePrice_Success(t *testing.T) {
}
if status == "failed" {
if execErr == nil {
t.Fatal("a failed attempt must be returned to Temporal, not swallowed")
}
t.Logf("Outbox entry marked 'failed' (expected without STRIPE_API_KEY)")
return
}
if execErr != nil {
t.Fatalf("ExecuteOutboxEntry: %v", execErr)
}
if status != "completed" {
t.Fatalf("expected outbox status 'completed' or 'failed', got %q", status)
}
+198 -76
View File
@@ -15,66 +15,140 @@ import (
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/workflows/common"
enumspb "go.temporal.io/api/enums/v1"
"go.temporal.io/sdk/activity"
"go.temporal.io/sdk/client"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/workflow"
)
// WebhookEvent represents a row from core.webhook_events.
// WebhookEvent is the handle a workflow carries for one core.webhook_events
// row: enough to name the row and the event in the Temporal UI, never the
// payload (activities read that from the row).
type WebhookEvent struct {
ID int64 `db:"id"`
Provider string `db:"provider"`
ProviderEventID string `db:"provider_event_id"`
EventType string `db:"event_type"`
Status string `db:"status"`
RetryCount int `db:"retry_count"`
// Status and RetryCount are the row's state when the handle was built.
// Nothing in the workflow reads them: Temporal counts the attempts and
// the row is re-read by every activity. They stay for the tests that
// build handles from rows.
Status string `db:"status"`
RetryCount int `db:"retry_count"`
}
// ProcessStripeWebhooksInput configures the polling workflow.
type ProcessStripeWebhooksInput struct {
BatchSize int
PollInterval time.Duration
// WebhookEventWorkflowID is the Workflow ID for one Stripe event: Stripe's
// own event id, which is unique per event, so a redelivery that reaches the
// endpoint and starts the workflow again lands on the running execution
// (WorkflowIDConflictPolicy USE_EXISTING) instead of a second one.
func WebhookEventWorkflowID(providerEventID string) string {
return "stripe-webhook-" + providerEventID
}
// ProcessStripeWebhooks is a Temporal workflow that polls for unprocessed
// webhook events and processes them in batches. It runs as a long-running
// polling workflow using ContinueAsNew to reset history.
func ProcessStripeWebhooks(ctx workflow.Context, input ProcessStripeWebhooksInput) error {
logger := workflow.GetLogger(ctx)
// The retry budget for processing one event. Temporal owns the schedule:
// the activity is retried from one second, doubling to ten minutes, for as
// long as the budget allows; a handler that declares its error
// non-retryable (checkedInt32's AmountOutOfRange) ends it at once. When the
// budget is spent the workflow dead-letters the row for an operator (the
// Stripe provider page lists them) and fails, which is what the Temporal UI
// should show. A day covers a database outage, a worker that needs a
// deploy, and an invoice.paid that arrived before its invoice.finalized
// was projected. (2026-09 security audit, remediation design D8.)
const (
webhookRetryBudget = 24 * time.Hour
webhookRetryMaxInterval = 10 * time.Minute
webhookAttemptTimeout = time.Minute
)
if input.BatchSize <= 0 {
input.BatchSize = 50
}
if input.PollInterval <= 0 {
input.PollInterval = 15 * time.Second
}
actCtx := workflow.WithActivityOptions(ctx, common.DefaultActivityOptions())
// Poll for received events
// ProcessStripeWebhookEvent is one Stripe event's whole life after the
// endpoint recorded it: process it, retrying on Temporal's schedule, and
// either mark it done or dead-letter it. One execution per event, started
// by the webhook endpoint (and by the boot sweep for rows left unfinished).
func ProcessStripeWebhookEvent(ctx workflow.Context, evt WebhookEvent) error {
var acts *WebhookActivities
var events []WebhookEvent
err := workflow.ExecuteActivity(actCtx, acts.PollReceivedEvents, input.BatchSize).Get(ctx, &events)
processCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
StartToCloseTimeout: webhookAttemptTimeout,
ScheduleToCloseTimeout: webhookRetryBudget,
RetryPolicy: &temporal.RetryPolicy{
InitialInterval: time.Second,
BackoffCoefficient: 2.0,
MaximumInterval: webhookRetryMaxInterval,
// MaximumAttempts stays 0: the budget, not a count, bounds it.
},
})
err := workflow.ExecuteActivity(processCtx, acts.ProcessWebhookEvent, evt).Get(ctx, nil)
if err == nil {
return nil
}
workflow.GetLogger(ctx).Error("stripe webhook event exhausted its retries; dead-lettering",
"event_id", evt.ProviderEventID, "event_type", evt.EventType, "error", err)
markCtx := workflow.WithActivityOptions(ctx, common.DefaultActivityOptions())
if markErr := workflow.ExecuteActivity(markCtx, acts.MarkEventDeadLetter, evt.ID, err.Error()).Get(ctx, nil); markErr != nil {
workflow.GetLogger(ctx).Error("could not dead-letter the event row", "event_id", evt.ProviderEventID, "error", markErr)
}
return err
}
// StartWebhookEvent starts the event's workflow, idempotently: a start for
// an execution that is already running is not an error. The endpoint calls
// this after recording the row; the boot sweep calls it for rows an earlier
// process left unfinished.
func StartWebhookEvent(ctx context.Context, c client.Client, taskQueue string, evt WebhookEvent) error {
_, err := c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
ID: WebhookEventWorkflowID(evt.ProviderEventID),
TaskQueue: taskQueue,
WorkflowIDConflictPolicy: enumspb.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING,
}, ProcessStripeWebhookEvent, evt)
if err != nil && temporal.IsWorkflowExecutionAlreadyStartedError(err) {
return nil
}
return err
}
// unfinishedStripeEventsSQL selects the rows the boot sweep hands to their
// workflows: Stripe's, in any state short of finished.
const unfinishedStripeEventsSQL = `SELECT id, provider, provider_event_id, event_type
FROM core.webhook_events
WHERE provider = 'stripe' AND status IN ('received', 'processing', 'failed')
ORDER BY received_at ASC`
// SweepUnfinishedWebhookEvents starts a workflow for every Stripe row that
// is not finished (`received`, `processing`, `failed`). Run once at boot: it
// picks up rows recorded by a process that died before starting their
// workflow, and, on the release that replaced the polling processor, the
// rows that processor left behind. Each start is idempotent, so a row whose
// workflow is already running costs one no-op call.
func SweepUnfinishedWebhookEvents(ctx context.Context, c client.Client, taskQueue string, db *sql.DB, logger *slog.Logger) error {
rows, err := db.QueryContext(ctx, unfinishedStripeEventsSQL)
if err != nil {
logger.Error("failed to poll webhook events", "error", err)
return err
return fmt.Errorf("list unfinished webhook events: %w", err)
}
// Process each event
for _, evt := range events {
var processErr error
err := workflow.ExecuteActivity(actCtx, acts.ProcessWebhookEvent, evt).Get(ctx, &processErr)
if err != nil {
logger.Error("activity execution failed",
"event_id", evt.ProviderEventID,
"error", err)
// Mark as failed via activity
_ = workflow.ExecuteActivity(actCtx, acts.MarkEventFailed, evt.ID, err.Error()).Get(ctx, nil)
defer rows.Close()
var started, failed int
for rows.Next() {
var e WebhookEvent
if err := rows.Scan(&e.ID, &e.Provider, &e.ProviderEventID, &e.EventType); err != nil {
return fmt.Errorf("scan unfinished webhook event: %w", err)
}
if err := StartWebhookEvent(ctx, c, taskQueue, e); err != nil {
failed++
logger.Warn("boot sweep: could not start the event's workflow",
slog.String("event_id", e.ProviderEventID), slog.Any("error", err))
continue
}
started++
}
// Sleep then continue-as-new for the next poll cycle
_ = workflow.Sleep(ctx, input.PollInterval)
return workflow.NewContinueAsNewError(ctx, ProcessStripeWebhooks, input)
if err := rows.Err(); err != nil {
return fmt.Errorf("unfinished webhook events: %w", err)
}
if started+failed > 0 {
logger.Info("boot sweep: unfinished stripe webhook events handed to their workflows",
slog.Int("started", started), slog.Int("failed", failed))
}
return nil
}
// WebhookActivities holds dependencies for webhook processing activities.
@@ -88,34 +162,11 @@ func NewWebhookActivities(db *sql.DB, logger *slog.Logger) *WebhookActivities {
return &WebhookActivities{DB: db, Logger: logger}
}
// PollReceivedEvents selects a batch of webhook events with status = 'received'.
func (a *WebhookActivities) PollReceivedEvents(ctx context.Context, batchSize int) ([]WebhookEvent, error) {
rows, err := a.DB.QueryContext(ctx,
`SELECT id, provider, provider_event_id, event_type, status, retry_count
FROM core.webhook_events
WHERE status = 'received'
ORDER BY received_at ASC
LIMIT $1`,
batchSize,
)
if err != nil {
return nil, fmt.Errorf("poll webhook events: %w", err)
}
defer rows.Close()
var events []WebhookEvent
for rows.Next() {
var e WebhookEvent
if err := rows.Scan(&e.ID, &e.Provider, &e.ProviderEventID, &e.EventType, &e.Status, &e.RetryCount); err != nil {
return nil, fmt.Errorf("scan webhook event: %w", err)
}
events = append(events, e)
}
return events, rows.Err()
}
// ProcessWebhookEvent transitions an event to 'processing', dispatches it,
// and marks it 'completed' or 'skipped'.
// and marks it 'completed' or 'skipped'. A failed attempt is recorded on the
// row (`failed`, the attempt number Temporal reports, the error) before the
// error goes back to Temporal to schedule the next one, so the operator
// page and the row agree with the Temporal UI about where an event stands.
func (a *WebhookActivities) ProcessWebhookEvent(ctx context.Context, evt WebhookEvent) error {
// Transition to processing
_, err := a.DB.ExecContext(ctx,
@@ -131,6 +182,7 @@ func (a *WebhookActivities) ProcessWebhookEvent(ctx context.Context, evt Webhook
// Dispatch to event-type handler.
finalStatus, err := a.dispatchEvent(ctx, evt)
if err != nil {
a.recordFailedAttempt(ctx, evt.ID, currentAttempt(ctx), err)
// A non-retryable *temporal.ApplicationError (e.g. the checked money
// conversions in webhook_invoice.go — schema-hardening D6) must reach
// the Temporal worker as-is: the SDK's failure conversion type-switches
@@ -160,21 +212,76 @@ func (a *WebhookActivities) ProcessWebhookEvent(ctx context.Context, evt Webhook
return nil
}
// MarkEventFailed updates an event to 'failed' with an error message and
// increments retry_count.
func (a *WebhookActivities) MarkEventFailed(ctx context.Context, eventID int64, errMsg string) error {
_, err := a.DB.ExecContext(ctx,
// currentAttempt is Temporal's attempt number for this activity execution,
// or 1 when the activity runs outside Temporal (tests call it directly).
func currentAttempt(ctx context.Context) int32 {
if activity.IsActivity(ctx) {
return activity.GetInfo(ctx).Attempt
}
return 1
}
// recordFailedAttempt writes a failed attempt to the row. Best effort: the
// error that matters is the one going back to Temporal, and a row that
// could not be updated is usually the same outage.
func (a *WebhookActivities) recordFailedAttempt(ctx context.Context, eventID int64, attempt int32, procErr error) {
if _, err := a.DB.ExecContext(ctx,
`UPDATE core.webhook_events
SET status = 'failed', error_message = $1, retry_count = retry_count + 1, updated_at = NOW()
SET status = 'failed', retry_count = $1, error_message = $2, updated_at = NOW()
WHERE id = $3 AND received_at = (SELECT received_at FROM core.webhook_events WHERE id = $3)`,
attempt, procErr.Error(), eventID,
); err != nil {
a.Logger.Warn("could not record the failed attempt on the webhook event row",
slog.Int64("event_id", eventID), slog.Any("error", err))
}
}
// MarkEventDeadLetter records that an event's workflow gave up: the retry
// budget is spent or the failure was terminal. The row keeps the last
// attempt count and error; only an operator moves it from here.
func (a *WebhookActivities) MarkEventDeadLetter(ctx context.Context, eventID int64, errMsg string) error {
if _, err := a.DB.ExecContext(ctx,
`UPDATE core.webhook_events
SET status = 'dead_letter', error_message = $1, updated_at = NOW()
WHERE id = $2 AND received_at = (SELECT received_at FROM core.webhook_events WHERE id = $2)`,
errMsg, eventID,
)
if err != nil {
return fmt.Errorf("mark failed: %w", err)
); err != nil {
return fmt.Errorf("mark dead_letter: %w", err)
}
a.Logger.Warn("webhook event moved to dead letter", slog.Int64("event_id", eventID), slog.String("error", errMsg))
return nil
}
// supersededByNewerEvent reports whether a completed event for the same
// Stripe object carries a later event time than evt. Stripe does not
// guarantee delivery order, and the product, price and customer handlers
// project what an event says about its object (synced, deleted, active),
// so an older event applied after a newer one would put back a state Stripe
// has already moved past. The event time is Stripe's `created` on the
// event, recorded by the endpoint; rows from before it was recorded compare
// as never superseded.
func (a *WebhookActivities) supersededByNewerEvent(ctx context.Context, evt WebhookEvent, objectID string) (bool, error) {
var superseded bool
err := a.DB.QueryRowContext(ctx,
`SELECT EXISTS (
SELECT 1 FROM core.webhook_events newer
WHERE newer.provider = 'stripe'
AND newer.status = 'completed'
AND newer.payload->>'id' = $1
AND newer.provider_event_at > (SELECT provider_event_at FROM core.webhook_events WHERE id = $2)
)`,
objectID, evt.ID,
).Scan(&superseded)
if err != nil {
return false, fmt.Errorf("check for a newer applied event: %w", err)
}
if superseded {
a.Logger.Info("stale webhook event skipped: a newer event for the object was already applied",
slog.String("event_id", evt.ProviderEventID), slog.String("event_type", evt.EventType), slog.String("object_id", objectID))
}
return superseded, nil
}
// dispatchEvent routes webhook events to type-specific handlers.
// Returns the final status string ("completed" or "skipped").
func (a *WebhookActivities) dispatchEvent(ctx context.Context, evt WebhookEvent) (string, error) {
@@ -228,6 +335,11 @@ func (a *WebhookActivities) handleCustomerEvent(ctx context.Context, evt Webhook
if stripeCustomerID == "" {
return "", fmt.Errorf("customer webhook payload missing id")
}
if stale, err := a.supersededByNewerEvent(ctx, evt, stripeCustomerID); err != nil {
return "", err
} else if stale {
return "skipped", nil
}
q := internalstripe.New(a.DB)
@@ -326,6 +438,11 @@ func (a *WebhookActivities) handleProductEvent(ctx context.Context, evt WebhookE
if stripeProductID == "" {
return "", fmt.Errorf("product webhook payload missing id")
}
if stale, err := a.supersededByNewerEvent(ctx, evt, stripeProductID); err != nil {
return "", err
} else if stale {
return "skipped", nil
}
q := internalstripe.New(a.DB)
@@ -421,6 +538,11 @@ func (a *WebhookActivities) handlePriceEvent(ctx context.Context, evt WebhookEve
if stripePriceID == "" {
return "", fmt.Errorf("price webhook payload missing id")
}
if stale, err := a.supersededByNewerEvent(ctx, evt, stripePriceID); err != nil {
return "", err
} else if stale {
return "skipped", nil
}
q := internalstripe.New(a.DB)
@@ -0,0 +1,131 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"context"
"database/sql"
"io"
"log/slog"
"testing"
"github.com/google/uuid"
"go.temporal.io/sdk/testsuite"
)
func insertEventRow(t *testing.T, db *sql.DB, eventType, payload string) WebhookEvent {
t.Helper()
evt := WebhookEvent{Provider: "stripe", ProviderEventID: "evt_act_" + uuid.New().String()[:12], EventType: eventType}
if err := db.QueryRowContext(context.Background(),
`INSERT INTO core.webhook_events (provider, provider_event_id, event_type, payload, status)
VALUES ($1, $2, $3, $4, 'received') RETURNING id`,
evt.Provider, evt.ProviderEventID, evt.EventType, payload).Scan(&evt.ID); err != nil {
t.Fatalf("insert event row: %v", err)
}
t.Cleanup(func() {
_, _ = db.ExecContext(context.Background(), `DELETE FROM core.webhook_events WHERE id = $1`, evt.ID)
})
return evt
}
func eventRow(t *testing.T, db *sql.DB, id int64) (status string, retryCount int, errMsg string) {
t.Helper()
var msg sql.NullString
if err := db.QueryRowContext(context.Background(),
`SELECT status, retry_count, error_message FROM core.webhook_events WHERE id = $1`, id).Scan(&status, &retryCount, &msg); err != nil {
t.Fatalf("read row %d: %v", id, err)
}
return status, retryCount, msg.String
}
// A failed attempt is written to the row before the error goes back to
// Temporal: the status, the attempt number Temporal reports, and the error.
// The operator page and the Temporal UI then tell the same story.
func TestProcessWebhookEventRecordsAFailedAttemptOnTheRow(t *testing.T) {
db := testDB(t)
acts := NewWebhookActivities(db, slog.New(slog.NewTextHandler(io.Discard, nil)))
// An invoice event without an id fails in the handler, retryably.
evt := insertEventRow(t, db, "invoice.paid", `{}`)
var suite testsuite.WorkflowTestSuite
env := suite.NewTestActivityEnvironment()
env.RegisterActivity(acts)
_, err := env.ExecuteActivity(acts.ProcessWebhookEvent, evt)
if err == nil {
t.Fatal("processing an invoice event without an id must fail")
}
status, attempts, msg := eventRow(t, db, evt.ID)
if status != "failed" || attempts != 1 || msg == "" {
t.Errorf("row after the failed attempt: status %q, retry_count %d, error %q; want failed/1/<the error>", status, attempts, msg)
}
}
func TestMarkEventDeadLetter(t *testing.T) {
db := testDB(t)
acts := NewWebhookActivities(db, slog.New(slog.NewTextHandler(io.Discard, nil)))
evt := insertEventRow(t, db, "invoice.paid", `{}`)
if err := acts.MarkEventDeadLetter(context.Background(), evt.ID, "schedule-to-close timeout"); err != nil {
t.Fatalf("mark dead letter: %v", err)
}
status, _, msg := eventRow(t, db, evt.ID)
if status != "dead_letter" || msg != "schedule-to-close timeout" {
t.Errorf("row: status %q, error %q; want dead_letter with the error", status, msg)
}
}
// The boot sweep hands every unfinished Stripe row to its workflow and
// leaves finished rows and other providers' rows alone. The starter is
// exercised through a fake client-free path: SweepUnfinishedWebhookEvents
// takes the Temporal client, so this test covers only the selection by
// reading what it would start, through the same query.
func TestSweepSelectsOnlyUnfinishedStripeRows(t *testing.T) {
db := testDB(t)
want := map[int64]bool{}
for _, status := range []string{"received", "processing", "failed"} {
evt := insertEventRow(t, db, "customer.updated", `{}`)
if _, err := db.ExecContext(context.Background(), `UPDATE core.webhook_events SET status = $1 WHERE id = $2`, status, evt.ID); err != nil {
t.Fatal(err)
}
want[evt.ID] = true
}
skip := map[int64]bool{}
for _, status := range []string{"completed", "skipped", "dead_letter"} {
evt := insertEventRow(t, db, "customer.updated", `{}`)
if _, err := db.ExecContext(context.Background(), `UPDATE core.webhook_events SET status = $1 WHERE id = $2`, status, evt.ID); err != nil {
t.Fatal(err)
}
skip[evt.ID] = true
}
other := insertEventRow(t, db, "user_created", `{}`)
if _, err := db.ExecContext(context.Background(), `UPDATE core.webhook_events SET provider = 'discourse' WHERE id = $1`, other.ID); err != nil {
t.Fatal(err)
}
skip[other.ID] = true
rows, err := db.QueryContext(context.Background(), unfinishedStripeEventsSQL)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
selected := map[int64]bool{}
for rows.Next() {
var e WebhookEvent
if err := rows.Scan(&e.ID, &e.Provider, &e.ProviderEventID, &e.EventType); err != nil {
t.Fatal(err)
}
selected[e.ID] = true
}
for id := range want {
if !selected[id] {
t.Errorf("unfinished row %d must be swept", id)
}
}
for id := range skip {
if selected[id] {
t.Errorf("row %d must not be swept", id)
}
}
}
@@ -260,7 +260,17 @@ func (a *WebhookActivities) handleInvoiceFinalized(ctx context.Context, evt Webh
}
func (a *WebhookActivities) handleInvoicePaid(ctx context.Context, evt WebhookEvent, inv webhookInvoicePayload, stripeQ *internalstripe.Queries) (string, error) {
// Idempotency: skip if payment mapping already exists for this payment intent.
// Resolve core invoice_id from stripe invoice mapping.
invoiceMapping, err := stripeQ.GetInvoiceMappingByStripeID(ctx, sql.NullString{String: inv.ID, Valid: true})
if err != nil {
return "", fmt.Errorf("resolve invoice mapping for %s: %w", inv.ID, err)
}
// Idempotency. With a payment intent, the payment mapping is the record
// of having handled it. Without one (a zero-amount invoice, one paid out
// of band) the mapping is never written, so the guard is the invoice's
// own succeeded payment; before it, a second invoice.paid for such an
// invoice created a second payment row (2026-09 audit candidate).
if inv.PaymentIntent != "" {
_, err := stripeQ.GetPaymentMappingByStripePaymentIntentID(ctx, sql.NullString{String: inv.PaymentIntent, Valid: true})
if err == nil {
@@ -271,12 +281,18 @@ func (a *WebhookActivities) handleInvoicePaid(ctx context.Context, evt WebhookEv
if err != sql.ErrNoRows {
return "", fmt.Errorf("check existing payment mapping: %w", err)
}
}
// Resolve core invoice_id from stripe invoice mapping.
invoiceMapping, err := stripeQ.GetInvoiceMappingByStripeID(ctx, sql.NullString{String: inv.ID, Valid: true})
if err != nil {
return "", fmt.Errorf("resolve invoice mapping for %s: %w", inv.ID, err)
} else {
payments, err := billing.New(a.DB).GetPaymentsByInvoiceID(ctx, invoiceMapping.InvoiceID)
if err != nil {
return "", fmt.Errorf("check existing payments for invoice %s: %w", invoiceMapping.InvoiceID, err)
}
for _, existing := range payments {
if existing.Status == "succeeded" {
a.Logger.Info("invoice.paid: a succeeded payment already exists for the invoice, skipping",
slog.String("invoice_id", invoiceMapping.InvoiceID), slog.String("payment_id", existing.PaymentID))
return "completed", nil
}
}
}
// Resolve billing_account_id from customer mapping.
@@ -0,0 +1,133 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows_test
import (
"context"
"database/sql"
"fmt"
"io"
"log/slog"
"math/rand"
"testing"
"time"
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
stripewf "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/workflows"
)
// insertTimedEvent records a product event with Stripe's own event time; a
// nil at leaves provider_event_at NULL, as rows from before the column do.
func insertTimedEvent(t *testing.T, db *sql.DB, eventType, objectID string, at *time.Time) stripewf.WebhookEvent {
t.Helper()
evt := stripewf.WebhookEvent{Provider: "stripe", ProviderEventID: fmt.Sprintf("evt_order_%d", rand.Int63()), EventType: eventType}
var ts sql.NullTime
if at != nil {
ts = sql.NullTime{Time: *at, Valid: true}
}
if err := db.QueryRowContext(context.Background(),
`INSERT INTO core.webhook_events (provider, provider_event_id, event_type, payload, status, provider_event_at)
VALUES ('stripe', $1, $2, $3, 'received', $4) RETURNING id`,
evt.ProviderEventID, eventType, fmt.Sprintf(`{"id": %q}`, objectID), ts).Scan(&evt.ID); err != nil {
t.Fatalf("insert event: %v", err)
}
t.Cleanup(func() {
_, _ = db.ExecContext(context.Background(), `DELETE FROM core.webhook_events WHERE id = $1`, evt.ID)
})
return evt
}
func eventStatus(t *testing.T, db *sql.DB, id int64) string {
t.Helper()
var status string
if err := db.QueryRowContext(context.Background(), `SELECT status FROM core.webhook_events WHERE id = $1`, id).Scan(&status); err != nil {
t.Fatalf("read event %d: %v", id, err)
}
return status
}
// Stripe does not guarantee delivery order. A product.created that arrives
// after a later product.deleted must not put the mapping back to synced:
// the handler compares the events' own timestamps and skips the stale one.
// A genuinely newer product.created still applies, and an event recorded
// without a timestamp (from before the column existed) is never treated as
// stale.
func TestStaleProductEventIsSkipped(t *testing.T) {
database := testDB(t)
ctx := context.Background()
acts := stripewf.NewWebhookActivities(database, slog.New(slog.NewTextHandler(io.Discard, nil)))
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
productID, _ := createTestProduct(t, ctx, tx)
if err := tx.Commit(); err != nil {
t.Fatal(err)
}
stripeProductID := fmt.Sprintf("prod_order_%d", rand.Int63())
q := internalstripe.New(database)
if _, err := q.UpsertProductMapping(ctx, internalstripe.UpsertProductMappingParams{
ProductID: productID, StripeProductID: sql.NullString{String: stripeProductID, Valid: true}, SyncStatus: "synced",
}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = database.ExecContext(ctx, `DELETE FROM stripe.product_mappings WHERE product_id = $1`, productID)
})
mappingStatus := func() string {
m, err := q.GetProductMappingByProductID(ctx, productID)
if err != nil {
t.Fatalf("read mapping: %v", err)
}
return m.SyncStatus
}
base := time.Now().Add(-time.Hour)
at := func(d time.Duration) *time.Time { ts := base.Add(d); return &ts }
deleted := insertTimedEvent(t, database, "product.deleted", stripeProductID, at(10*time.Second))
if err := acts.ProcessWebhookEvent(ctx, deleted); err != nil {
t.Fatalf("product.deleted: %v", err)
}
if got := mappingStatus(); got != "deleted" {
t.Fatalf("after product.deleted the mapping must be deleted, got %q", got)
}
stale := insertTimedEvent(t, database, "product.created", stripeProductID, at(0))
if err := acts.ProcessWebhookEvent(ctx, stale); err != nil {
t.Fatalf("stale product.created: %v", err)
}
if got := eventStatus(t, database, stale.ID); got != "skipped" {
t.Errorf("the stale event must be skipped, got %q", got)
}
if got := mappingStatus(); got != "deleted" {
t.Errorf("the stale created must not resurrect the mapping, got %q", got)
}
newer := insertTimedEvent(t, database, "product.created", stripeProductID, at(20*time.Second))
if err := acts.ProcessWebhookEvent(ctx, newer); err != nil {
t.Fatalf("newer product.created: %v", err)
}
if got := mappingStatus(); got != "synced" {
t.Errorf("a newer created must apply, got %q", got)
}
// Back to deleted by a later event, then an untimed created: applied,
// because nothing can say it is older.
later := insertTimedEvent(t, database, "product.deleted", stripeProductID, at(30*time.Second))
if err := acts.ProcessWebhookEvent(ctx, later); err != nil {
t.Fatalf("later product.deleted: %v", err)
}
untimed := insertTimedEvent(t, database, "product.created", stripeProductID, nil)
if err := acts.ProcessWebhookEvent(ctx, untimed); err != nil {
t.Fatalf("untimed product.created: %v", err)
}
if got := eventStatus(t, database, untimed.ID); got != "completed" {
t.Errorf("an event without a provider timestamp must be applied, got %q", got)
}
if got := mappingStatus(); got != "synced" {
t.Errorf("the untimed created must apply, got %q", got)
}
}
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package workflows
import (
"context"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/mock"
"go.temporal.io/sdk/temporal"
"go.temporal.io/sdk/testsuite"
)
// One event's workflow, run in Temporal's test environment with the
// activities mocked: Temporal owns the retry schedule, the workflow owns
// only the outcome (done, or dead-lettered and failed).
func eventUnderTest() WebhookEvent {
return WebhookEvent{ID: 41, Provider: "stripe", ProviderEventID: "evt_wf_test", EventType: "invoice.paid"}
}
type deadLetterRecorder struct {
calls int
lastID int64
lastMsg string
}
func mockDeadLetter(env *testsuite.TestWorkflowEnvironment, rec *deadLetterRecorder) {
var acts *WebhookActivities
env.OnActivity(acts.MarkEventDeadLetter, mock.Anything, mock.Anything, mock.Anything).
Return(func(_ context.Context, id int64, msg string) error {
rec.calls++
rec.lastID, rec.lastMsg = id, msg
return nil
})
}
func TestEventWorkflowCompletesWhenProcessingSucceeds(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
var acts *WebhookActivities
rec := &deadLetterRecorder{}
mockDeadLetter(env, rec)
env.OnActivity(acts.ProcessWebhookEvent, mock.Anything, mock.Anything).Return(nil)
env.ExecuteWorkflow(ProcessStripeWebhookEvent, eventUnderTest())
if !env.IsWorkflowCompleted() || env.GetWorkflowError() != nil {
t.Fatalf("workflow must complete cleanly, got completed=%v err=%v", env.IsWorkflowCompleted(), env.GetWorkflowError())
}
if rec.calls != 0 {
t.Errorf("a successful event must not be dead-lettered, got %d calls", rec.calls)
}
}
// The audit's finding: a failed attempt used to be the end. Temporal's
// retry policy runs the activity again after the backoff, and a second
// attempt that succeeds finishes the event with nothing dead-lettered.
func TestEventWorkflowRetriesAFailedAttempt(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
var acts *WebhookActivities
rec := &deadLetterRecorder{}
mockDeadLetter(env, rec)
attempts := 0
env.OnActivity(acts.ProcessWebhookEvent, mock.Anything, mock.Anything).
Return(func(_ context.Context, _ WebhookEvent) error {
attempts++
if attempts < 3 {
return errors.New("resolve invoice mapping: no rows")
}
return nil
})
env.ExecuteWorkflow(ProcessStripeWebhookEvent, eventUnderTest())
if err := env.GetWorkflowError(); err != nil {
t.Fatalf("workflow must complete once an attempt succeeds, got %v", err)
}
if attempts != 3 {
t.Errorf("processing must have been attempted three times, got %d", attempts)
}
if rec.calls != 0 {
t.Errorf("a recovered event must not be dead-lettered, got %d calls", rec.calls)
}
}
// A handler that declares its error non-retryable (checkedInt32's
// AmountOutOfRange) ends the retries at once: the row is dead-lettered with
// that error and the workflow fails, so the Temporal UI shows it too.
func TestEventWorkflowDeadLettersATerminalFailure(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
var acts *WebhookActivities
rec := &deadLetterRecorder{}
mockDeadLetter(env, rec)
attempts := 0
env.OnActivity(acts.ProcessWebhookEvent, mock.Anything, mock.Anything).
Return(func(_ context.Context, _ WebhookEvent) error {
attempts++
return temporal.NewNonRetryableApplicationError("amount_paid is out of int32 range", "AmountOutOfRange", nil)
})
env.ExecuteWorkflow(ProcessStripeWebhookEvent, eventUnderTest())
if err := env.GetWorkflowError(); err == nil {
t.Fatal("the workflow must fail so the Temporal UI shows the dead letter")
}
if attempts != 1 {
t.Errorf("a terminal failure must not be retried, got %d attempts", attempts)
}
if rec.calls != 1 || rec.lastID != 41 || !strings.Contains(rec.lastMsg, "out of int32 range") {
t.Errorf("the row must be dead-lettered once with the handler's error, got calls=%d id=%d msg=%q", rec.calls, rec.lastID, rec.lastMsg)
}
}
// When every attempt within the budget fails, the schedule-to-close
// timeout ends the retries and the event is dead-lettered.
func TestEventWorkflowDeadLettersWhenTheBudgetIsSpent(t *testing.T) {
var suite testsuite.WorkflowTestSuite
env := suite.NewTestWorkflowEnvironment()
var acts *WebhookActivities
rec := &deadLetterRecorder{}
mockDeadLetter(env, rec)
attempts := 0
env.OnActivity(acts.ProcessWebhookEvent, mock.Anything, mock.Anything).
Return(func(_ context.Context, _ WebhookEvent) error {
attempts++
return errors.New("database unavailable")
})
env.ExecuteWorkflow(ProcessStripeWebhookEvent, eventUnderTest())
if err := env.GetWorkflowError(); err == nil {
t.Fatal("the workflow must fail once the budget is spent")
}
if attempts < 10 {
t.Errorf("the budget must allow many attempts before giving up, got %d", attempts)
}
if rec.calls != 1 {
t.Errorf("the row must be dead-lettered exactly once, got %d", rec.calls)
}
}
+46 -135
View File
@@ -5,154 +5,65 @@ package middleware
import (
"fmt"
"html/template"
"net/http"
"github.com/gorilla/csrf"
)
// CSRFConfig defines the configuration options for CSRF middleware
// CSRFConfig configures cross-origin request protection.
//
// This wraps net/http's CrossOriginProtection, which rejects non-safe
// cross-origin browser requests by reading Sec-Fetch-Site (sent by every
// browser since 2023) and falling back to comparing the Origin header's
// hostname with Host. There is no token: nothing is issued, stored in a
// cookie, embedded in a form, or echoed in a header.
//
// It replaced gorilla/csrf in 2026-09 because that library carries
// GO-2025-3884 (CVE-2025-47909) with no fixed release: its trusted-origin
// comparison ignored the scheme, so a policy written for https was satisfied
// by plain http. AddTrustedOrigin here requires a full origin, scheme
// included, which is the defect's direct answer.
//
// Two consequences worth knowing. GET, HEAD and OPTIONS are always allowed, so
// no handler may change state on those methods. And a request carrying neither
// Sec-Fetch-Site nor Origin is treated as same-origin or non-browser and
// allowed, which is what lets server-to-server callers through.
type CSRFConfig struct {
// Secret is the 32-byte secret key used to generate tokens
Secret []byte
// Cookie defines cookie options
Cookie struct {
Name string
Domain string
HttpOnly bool
MaxAge int
Path string
SameSite csrf.SameSiteMode
Secure bool
}
// ErrorHandler is a custom error handler for CSRF errors
ErrorHandler http.Handler
// FieldName is the name of the hidden form field containing the CSRF token
FieldName string
// RequestHeader is the name of the request header containing the CSRF token
RequestHeader string
// TrustedOrigins defines trusted origins for CSRF protection
// TrustedOrigins are additional origins allowed to make non-safe
// requests, each a full origin such as "https://console.example.coop".
// An entry without a scheme is rejected at construction.
TrustedOrigins []string
// Path defines URL paths where CSRF protection applies
// If empty, all paths are protected
Path string
// BypassPatterns are net/http.ServeMux patterns exempted from the check
// entirely, for endpoints that authenticate their caller some other way
// (a provider webhook verifying its own signature). Each one is a
// deliberate hole; see the server's exempt-path list for the contract.
BypassPatterns []string
// Ignore functions determine if a request should skip CSRF protection
Ignore []func(r *http.Request) bool
// DenyHandler serves a rejected request. When nil the standard library's
// default 403 is used.
DenyHandler http.Handler
}
// CSRF middleware provides Cross-Site Request Forgery protection
func CSRF(config CSRFConfig) Middleware {
// Only set options that are explicitly configured
var options []csrf.Option
// CSRF returns middleware enforcing cross-origin protection.
//
// An invalid trusted origin is a configuration error the caller must handle;
// it is never silently dropped, because a trusted origin that fails to
// register would leave a legitimate deployment rejecting its own form posts.
func CSRF(config CSRFConfig) (Middleware, error) {
protection := http.NewCrossOriginProtection()
// Cookie options
if config.Cookie.Name != "" {
options = append(options, csrf.CookieName(config.Cookie.Name))
for _, origin := range config.TrustedOrigins {
if err := protection.AddTrustedOrigin(origin); err != nil {
return nil, fmt.Errorf("trusted origin %q: %w", origin, err)
}
}
if config.Cookie.Path != "" {
options = append(options, csrf.Path(config.Cookie.Path))
for _, pattern := range config.BypassPatterns {
protection.AddInsecureBypassPattern(pattern)
}
if config.Cookie.MaxAge != 0 {
options = append(options, csrf.MaxAge(config.Cookie.MaxAge))
if config.DenyHandler != nil {
protection.SetDenyHandler(config.DenyHandler)
}
if config.Cookie.Domain != "" {
options = append(options, csrf.Domain(config.Cookie.Domain))
}
if !config.Cookie.Secure {
options = append(options, csrf.Secure(false))
}
if !config.Cookie.HttpOnly {
options = append(options, csrf.HttpOnly(false))
}
if config.Cookie.SameSite != 0 {
options = append(options, csrf.SameSite(config.Cookie.SameSite))
}
// Other options
if config.FieldName != "" {
options = append(options, csrf.FieldName(config.FieldName))
}
if config.RequestHeader != "" {
options = append(options, csrf.RequestHeader(config.RequestHeader))
}
if config.ErrorHandler != nil {
options = append(options, csrf.ErrorHandler(config.ErrorHandler))
}
if len(config.TrustedOrigins) > 0 {
options = append(options, csrf.TrustedOrigins(config.TrustedOrigins))
}
// Create CSRF protection middleware
csrfHandler := csrf.Protect(config.Secret, options...)
return func(next http.Handler) http.Handler {
// Handle protection path
if config.Path != "" {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == config.Path || (len(r.URL.Path) >= len(config.Path) &&
r.URL.Path[:len(config.Path)] == config.Path) {
// Check if the request should be ignored
if config.Ignore != nil {
for _, ignoreFunc := range config.Ignore {
if ignoreFunc(r) {
next.ServeHTTP(w, r)
return
}
}
}
csrfHandler(next).ServeHTTP(w, r)
return
}
next.ServeHTTP(w, r)
})
}
// Handle ignore functions
if len(config.Ignore) > 0 {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, ignoreFunc := range config.Ignore {
if ignoreFunc(r) {
next.ServeHTTP(w, r)
return
}
}
csrfHandler(next).ServeHTTP(w, r)
})
}
// Apply CSRF to all routes if no path or ignores specified
return csrfHandler(next)
}
}
// CSRFToken gets the CSRF token from the request context
func CSRFToken(r *http.Request) string {
return csrf.Token(r)
}
// CSRFTemplateField gets the hidden input field containing the CSRF token
func CSRFTemplateField(r *http.Request) template.HTML {
return csrf.TemplateField(r)
}
// ParseCSRFKey validates and converts a CSRF secret string to the required 32-byte key
// It returns the key as a byte slice and an error if the key is invalid
func ParseCSRFKey(secret string) ([]byte, error) {
if secret == "" {
return nil, fmt.Errorf("csrf secret is required and must be exactly 32 bytes")
}
key := []byte(secret)
if len(key) != 32 {
return nil, fmt.Errorf("csrf secret must be exactly 32 bytes (got %d bytes)", len(key))
}
return key, nil
return protection.Handler(next)
}, nil
}
+28 -7
View File
@@ -6,9 +6,13 @@ package middleware
import (
"net/http"
"github.com/spf13/viper"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
)
// hstsValue is the Strict-Transport-Security policy sent over https: one
// year, subdomains included, no preload.
const hstsValue = "max-age=31536000; includeSubDomains"
// SecurityHeaders adds security and cache-control headers to all responses
func SecureHeaders() Middleware {
return func(next http.Handler) http.Handler {
@@ -25,16 +29,31 @@ func SecureHeaders() Middleware {
// XFrameOptions prevents the page from being displayed in a frame
w.Header().Set("X-Frame-Options", "DENY")
// HSTS (HTTP Strict Transport Security) forces the browser to use HTTPS
w.Header().Set("Strict-Transport-Security", "max-age=3600; includeSubDomains")
// HSTS, only where the console is served over https. RFC 6797
// section 7.2 forbids sending it over non-secure transport
// (browsers ignore it there anyway, section 8.1). The year is
// the standard value: a shorter one re-opens the first-request
// window every time a visitor is away longer than it (section
// 11.2). includeSubDomains covers subdomains of the console's
// own host, which nothing else uses, and closes cookie injection
// from an insecure one (section 11.4); at an apex deployment it
// would reach every subdomain, which production-deployment.md
// says out loud. No preload: that is a shipped-list commitment.
if config.ServesHTTPS() {
w.Header().Set("Strict-Transport-Security", hstsValue)
}
// ReferrerPolicy sets the referrer information passed during navigation
w.Header().Set("Referrer-Policy", "no-referrer")
// CSP controls the resources the user agent is allowed to load for a page
cspPolicy := "default-src 'self'; " +
// Allow HTMX to load from unpkg.com
"script-src 'self' https://unpkg.com/htmx.org@*; " +
// Script comes only from this origin. The hypermedia library is
// vendored into internal/embeds/static/ and served from /static/,
// so the unpkg.com source this directive used to carry authorized
// a CDN nothing loads from — a standing permission for
// third-party script, flagged by the 2026-09 security audit.
"script-src 'self'; " +
"style-src 'self'; " +
"img-src 'self' data:; " +
"font-src 'self'; " +
@@ -44,8 +63,10 @@ func SecureHeaders() Middleware {
"form-action 'self'; " +
"base-uri 'self';"
// Add upgrade-insecure-requests directive only in production
if viper.GetString("env") == "production" {
// upgrade-insecure-requests only where the console is served
// over https (base-url's scheme); on plain HTTP it would
// upgrade every subresource to a scheme nothing listens on.
if config.ServesHTTPS() {
cspPolicy += "upgrade-insecure-requests;"
}
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package tests
import (
"net/http"
"net/http/httptest"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/middleware"
)
func protect(t *testing.T, cfg middleware.CSRFConfig) http.Handler {
t.Helper()
mw, err := middleware.CSRF(cfg)
if err != nil {
t.Fatalf("building protection: %v", err)
}
return mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
}
func post(h http.Handler, path string, headers map[string]string) int {
req := httptest.NewRequest(http.MethodPost, path, nil)
for k, v := range headers {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code
}
// A same-origin form post must pass. This is the case that matters most: the
// app sets Referrer-Policy: no-referrer, which used to null the Origin header
// and break native posts under gorilla/csrf. Sec-Fetch-Site is unaffected by
// referrer policy, so the old HTMX header workaround is no longer needed.
func TestSameOriginPostIsAllowed(t *testing.T) {
h := protect(t, middleware.CSRFConfig{})
if got := post(h, "/anything", map[string]string{"Sec-Fetch-Site": "same-origin"}); got != http.StatusOK {
t.Errorf("same-origin post got %d, want 200", got)
}
}
// The whole point of the migration.
func TestCrossSitePostIsRefused(t *testing.T) {
h := protect(t, middleware.CSRFConfig{})
if got := post(h, "/anything", map[string]string{"Sec-Fetch-Site": "cross-site"}); got != http.StatusForbidden {
t.Errorf("cross-site post got %d, want 403", got)
}
}
// A trusted origin is matched WITH its scheme. Under gorilla/csrf v1.7.3 the
// comparison was host-only, so http://... satisfied a policy written for
// https://... — that is GO-2025-3884 / CVE-2025-47909, and this pins the fix.
func TestTrustedOriginIsSchemeSensitive(t *testing.T) {
h := protect(t, middleware.CSRFConfig{TrustedOrigins: []string{"https://console.example.test"}})
if got := post(h, "/anything", map[string]string{
"Sec-Fetch-Site": "cross-site", "Origin": "https://console.example.test",
}); got != http.StatusOK {
t.Errorf("the trusted https origin got %d, want 200", got)
}
if got := post(h, "/anything", map[string]string{
"Sec-Fetch-Site": "cross-site", "Origin": "http://console.example.test",
}); got != http.StatusForbidden {
t.Errorf("plain http on a trusted https origin got %d, want 403 — this is the CVE", got)
}
}
// An origin without a scheme is a configuration error, not a silent no-op: a
// trusted origin that failed to register would leave a deployment rejecting
// its own posts.
func TestOriginWithoutSchemeIsRejectedAtConstruction(t *testing.T) {
if _, err := middleware.CSRF(middleware.CSRFConfig{TrustedOrigins: []string{"console.example.test"}}); err == nil {
t.Error("a scheme-less trusted origin was accepted; it must be a configuration error")
}
}
// Webhook paths authenticate by provider signature and must bypass entirely.
func TestBypassPatternExemptsAWebhookPath(t *testing.T) {
h := protect(t, middleware.CSRFConfig{BypassPatterns: []string{"/webhooks/stripe"}})
if got := post(h, "/webhooks/stripe", map[string]string{"Sec-Fetch-Site": "cross-site"}); got != http.StatusOK {
t.Errorf("exempt webhook path got %d, want 200", got)
}
if got := post(h, "/not-exempt", map[string]string{"Sec-Fetch-Site": "cross-site"}); got != http.StatusForbidden {
t.Errorf("the exemption leaked to another path: got %d, want 403", got)
}
}
@@ -0,0 +1,161 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package tests
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/spf13/viper"
"git.coopcloud.tech/wiki-cafe/member-console/internal/middleware"
)
func servedCSP(t *testing.T) string {
t.Helper()
h := middleware.SecureHeaders()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
csp := rec.Result().Header.Get("Content-Security-Policy")
if csp == "" {
t.Fatal("no Content-Security-Policy header was set")
}
return csp
}
// The policy must authorize only origins the application actually loads from.
// Front-end assets are vendored into internal/embeds/static/ and served from
// /static/, so no content delivery network belongs in script-src. A dead
// unpkg.com entry lived here until the 2026-09 security audit.
func TestCSPAllowsNoThirdPartyScriptOrigin(t *testing.T) {
csp := servedCSP(t)
var scriptSrc string
for _, d := range strings.Split(csp, ";") {
if d = strings.TrimSpace(d); strings.HasPrefix(d, "script-src") {
scriptSrc = d
}
}
if scriptSrc == "" {
t.Fatalf("policy declares no script-src: %q", csp)
}
if scriptSrc != "script-src 'self'" {
t.Errorf("script-src must be exactly \"script-src 'self'\", got %q", scriptSrc)
}
for _, banned := range []string{"unpkg.com", "cdn.", "https://", "http://", "*"} {
if strings.Contains(scriptSrc, banned) {
t.Errorf("script-src names a third-party or wildcard source %q: %q", banned, scriptSrc)
}
}
}
// Defence in depth around the same header set.
func TestSecurityHeadersPinTheObviousProtections(t *testing.T) {
h := middleware.SecureHeaders()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
res := rec.Result()
for header, want := range map[string]string{
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Referrer-Policy": "no-referrer",
} {
if got := res.Header.Get(header); got != want {
t.Errorf("%s = %q, want %q", header, got, want)
}
}
if !strings.Contains(servedCSP(t), "frame-ancestors 'none'") {
t.Error("policy must set frame-ancestors 'none'")
}
}
// upgrade-insecure-requests follows base-url's scheme, not the env label
// (2026-09 security audit, finding 5): served over https it is on whatever
// the deployment calls itself; over plain http it would upgrade every
// subresource to a scheme nothing listens on, so it is off.
func TestCSPUpgradeDirectiveFollowsTheBaseURLScheme(t *testing.T) {
t.Cleanup(viper.Reset)
for _, tc := range []struct {
baseURL string
env string
want bool
}{
{"https://console.example.coop", "staging", true},
{"https://console.example.coop", "development", true},
{"http://member-console.localhost:9431", "production", false},
} {
viper.Set("base-url", tc.baseURL)
viper.Set("env", tc.env)
got := strings.Contains(servedCSP(t), "upgrade-insecure-requests")
if got != tc.want {
t.Errorf("base-url %q env %q: upgrade-insecure-requests present = %v, want %v", tc.baseURL, tc.env, got, tc.want)
}
}
}
// Strict-Transport-Security is sent only where the console is served over
// https: RFC 6797 section 7.2 forbids it over non-secure transport. The value
// is a year with includeSubDomains; the one-hour value it replaced lapsed
// before most return visits, which re-opened the first plain-HTTP request
// HSTS exists to prevent.
func TestHSTSFollowsTheBaseURLSchemeWithAYearLongPolicy(t *testing.T) {
t.Cleanup(viper.Reset)
serve := func(baseURL string) string {
viper.Set("base-url", baseURL)
h := middleware.SecureHeaders()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
return rec.Result().Header.Get("Strict-Transport-Security")
}
if got, want := serve("https://console.example.coop"), "max-age=31536000; includeSubDomains"; got != want {
t.Errorf("over https: Strict-Transport-Security = %q, want %q", got, want)
}
if got := serve("http://member-console.localhost:9431"); got != "" {
t.Errorf("over http: Strict-Transport-Security = %q, want none (RFC 6797 section 7.2)", got)
}
}
// The size limit and the timeout answer for the handler (a 413, a 503), and
// the timeout handler discards whatever an inner middleware set. So the
// security headers must be applied outside both, which is the order the
// server's stack uses (2026-09 audit candidate "413 responses bypass the
// security-header middleware"). This pins that order's effect.
func TestSecurityHeadersReachRefusalsWrittenByOuterMiddleware(t *testing.T) {
refused := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("the handler must not run")
})
t.Run("413 from the body limit", func(t *testing.T) {
h := middleware.SecureHeaders()(middleware.MaxBodySize(4)(refused))
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("too large"))
req.ContentLength = 9
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want 413", rec.Code)
}
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("the 413 carries no security headers (X-Content-Type-Options = %q)", got)
}
})
t.Run("503 from the timeout", func(t *testing.T) {
slow := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
})
h := middleware.SecureHeaders()(middleware.Timeout(10 * time.Millisecond)(slow))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
if got := rec.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Errorf("the 503 carries no security headers (X-Content-Type-Options = %q)", got)
}
})
}
+6
View File
@@ -25,6 +25,12 @@ import "git.coopcloud.tech/wiki-cafe/member-console/internal/forms"
// routes are declared by the integration that registers them.
func init() {
for _, t := range []forms.ActionTrigger{
// Shell: the account menu's only session control. A POST so a page
// on another origin cannot end a person's session by sending the
// browser to /logout; the handler answers htmx with HX-Redirect to
// the provider's end-session endpoint.
{Label: "Sign out", Method: "POST", Path: "/logout"},
// Operator: the overview's setup banner.
{Label: "Dismiss the getting-started banner", Method: "POST", Path: "/partials/operator/setup/dismiss"},
+44 -7
View File
@@ -73,10 +73,13 @@ type PageHeader struct {
Crumbs []Link
Count string
Action *Link
// NoTrail suppresses the location trail entirely. Set on exactly the
// two surface roots (the operator overview, the member dashboard):
// every other page carries the trail, rooted at the surface (design
// D18 "The location trail on every page, rooted at the surface").
// NoTrail suppresses the location trail entirely. Set on the two
// surface roots (the operator overview, the member dashboard), which
// are what the trail is rooted at, and on the error page, which is
// what came back instead of a location and may be reached without a
// session. Every other page carries the trail, rooted at the surface
// (design D18 "The location trail on every page, rooted at the
// surface").
NoTrail bool
}
@@ -391,18 +394,52 @@ func (OperatorNotFoundData) Header() PageHeader {
return PageHeader{Title: "Page not found"}
}
// ErrorPageAction is the error page's one way out: where the button goes
// and what it says. Every error page has exactly one.
type ErrorPageAction struct {
Label string
Href string
}
// ErrorPageData feeds error.html (render.go), the one page without a
// shell; it still titles itself through the part.
type ErrorPageData struct {
Status int
StatusText string
Message string
// Title replaces "<status> <status text>" as the heading, for a
// failure whose status line says nothing useful to the person reading
// it (a sign-in that expired is a 400).
Title string
// Action is the button. RenderErrorPage and RenderSignInError each
// fill it; a zero value would render a button with no label.
Action ErrorPageAction
}
// Header is the error page's header: the status as the title, the message
// as the lead.
// Header is the error page's header: the status as the title unless the page
// named its own, and the message as the lead.
//
// No trail. The trail is rooted at the surface, and this page is reachable
// without a session, so its root would be a link the visitor may not be able
// to follow. The page is not at a location either -- it is what came back
// instead of one -- and the page's own action already carries the way out.
func (d ErrorPageData) Header() PageHeader {
return PageHeader{Title: fmt.Sprintf("%d %s", d.Status, d.StatusText), Lead: d.Message}
title := d.Title
if title == "" {
title = fmt.Sprintf("%d %s", d.Status, d.StatusText)
}
return PageHeader{Title: title, Lead: d.Message, NoTrail: true}
}
// plain is the page as one line, for the HTMX and render-failure paths that
// answer with text instead.
func (d ErrorPageData) plain() string {
if d.Message != "" {
return d.Message
}
return d.Title
}
// Group 2 of the sweep (People): the pages' values for the parts.
+2 -5
View File
@@ -80,7 +80,6 @@ type DashboardCardView struct {
// composed from their partial routes.
type ProductsPageData struct {
Shell Shell
CSRFToken string
Entitlements template.HTML
Plans template.HTML
Addons template.HTML
@@ -88,9 +87,8 @@ type ProductsPageData struct {
// BillingPageData is the /billing page: the shell plus the invoices region.
type BillingPageData struct {
Shell Shell
CSRFToken string
Invoices template.HTML
Shell Shell
Invoices template.HTML
}
// IndexPageData is the / dashboard page: the member's identity, the
@@ -103,7 +101,6 @@ type IndexPageData struct {
Username string
Email string
KeycloakAccountURL string
CSRFToken string
IsOperator bool
HasMultipleWorkspaces bool
CheckoutStatus string
+90
View File
@@ -7,6 +7,7 @@ import (
"html/template"
"io/fs"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
@@ -113,3 +114,92 @@ func TestRenderErrorPageTemplateFailureFallsBack(t *testing.T) {
t.Errorf("fallback emitted HTML; want plain text")
}
}
// The ordinary error page's one way out is the dashboard, and it says so.
// Pinned because the button became data (ErrorPageAction) when the auth
// failure page needed a different destination; a caller that forgets to fill
// it would render a button with no label and no href.
func TestRenderErrorPageOffersTheDashboard(t *testing.T) {
st := errorPageTemplates(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/no-such-page", nil)
st.RenderErrorPage(rec, req, 404, "The page you requested does not exist.")
body := rec.Body.String()
if !strings.Contains(body, `href="/"`) {
t.Errorf("error page carries no link to the dashboard: %q", body)
}
if !strings.Contains(body, "Back to the dashboard") {
t.Errorf("error page's action has no label: %q", body)
}
}
// A refused sign-in gets the failure named as the heading and a way back into
// the flow, instead of "400 Bad Request" and a dashboard the person cannot
// reach. This is the server half of internal/auth's FailurePage seam.
func TestRenderAuthFailureNamesTheFailureAndTheWayBack(t *testing.T) {
st := errorPageTemplates(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/callback?state=stale", nil)
st.RenderAuthFailure(rec, req, 400, "Sign-in expired", "Sign in again", "/login")
if rec.Code != 400 {
t.Fatalf("status = %d, want 400: the refusal must stay a refusal", rec.Code)
}
body := rec.Body.String()
for _, want := range []string{"Sign-in expired", "Sign in again", `href="/login"`, "navbar-brand"} {
if !strings.Contains(body, want) {
t.Errorf("auth failure page missing %q", want)
}
}
if strings.Contains(body, "400 Bad Request") {
t.Errorf("the status line is still the heading: %q", body)
}
if strings.Contains(body, "Back to the dashboard") {
t.Errorf("the page still offers the dashboard the person cannot reach: %q", body)
}
}
// The HTMX branch answers with text, and it must carry something to show even
// when the page named a heading instead of a message.
func TestRenderAuthFailureHTMXFallsBackToTheHeading(t *testing.T) {
st := errorPageTemplates(t)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/callback?state=stale", nil)
req.Header.Set("HX-Request", "true")
st.RenderAuthFailure(rec, req, 400, "Sign-in expired", "Sign in again", "/login")
if got := strings.TrimSpace(rec.Body.String()); got != "Sign-in expired" {
t.Errorf("HTMX body = %q, want the heading", got)
}
}
// The error page carries no location trail. It is reachable without a session,
// so a trail rooted at the surface would offer a link the visitor may not be
// able to follow, and the page is not at a location to begin with.
func TestErrorPagesCarryNoTrail(t *testing.T) {
st := errorPageTemplates(t)
for _, tc := range []struct {
name string
render func(*httptest.ResponseRecorder, *http.Request)
}{
{"ordinary error", func(rec *httptest.ResponseRecorder, req *http.Request) {
st.RenderErrorPage(rec, req, 404, "The page you requested does not exist.")
}},
{"auth failure", func(rec *httptest.ResponseRecorder, req *http.Request) {
st.RenderAuthFailure(rec, req, 400, "Sign-in expired", "Sign in again", "/login")
}},
} {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
tc.render(rec, httptest.NewRequest("GET", "/whatever", nil))
if body := rec.Body.String(); strings.Contains(body, "breadcrumb") {
t.Errorf("error page still renders a location trail: %q", body)
}
})
}
}
+4 -7
View File
@@ -58,7 +58,7 @@ func TestIndexTemplateNamesNoIntegration(t *testing.T) {
// integration, and with no declared cards it must render no card section
// chrome and no integration script tags.
func TestIndexNoDashboardCards(t *testing.T) {
body := renderIndex(t, IndexPageData{CSRFToken: "tok"})
body := renderIndex(t, IndexPageData{})
if strings.Contains(strings.ToLower(body), "fedwiki") {
t.Errorf("index.html without cards still references fedwiki")
@@ -76,7 +76,6 @@ func TestIndexNoDashboardCards(t *testing.T) {
// script tags.
func TestIndexRendersDeclaredCards(t *testing.T) {
body := renderIndex(t, IndexPageData{
CSRFToken: "tok",
DashboardCards: []DashboardCardView{
{DashboardCard: DashboardCard{
Title: "Stub Sites",
@@ -131,7 +130,6 @@ func TestIndexRendersDeclaredCards(t *testing.T) {
// description renders no lead paragraph.
func TestIndexRendersCardDescription(t *testing.T) {
body := renderIndex(t, IndexPageData{
CSRFToken: "tok",
DashboardCards: []DashboardCardView{
{DashboardCard: DashboardCard{
Title: "FedWiki sites",
@@ -157,7 +155,7 @@ func TestIndexRendersCardDescription(t *testing.T) {
// The member dashboard wraps its content in exactly one <main> (page-anatomy
// "Every page has one main landmark", ACC-40).
func TestIndexHasOneMainLandmark(t *testing.T) {
body := renderIndex(t, IndexPageData{CSRFToken: "tok"})
body := renderIndex(t, IndexPageData{})
if n := strings.Count(body, "<main"); n != 1 {
t.Errorf("index.html must render exactly one <main>, got %d", n)
}
@@ -171,7 +169,7 @@ func TestIndexHasOneMainLandmark(t *testing.T) {
// no org entitlement, one emptyState renders the D25 sentence in place of
// the cards section, and no card chrome renders beside it.
func TestIndexEmptyState(t *testing.T) {
body := renderIndex(t, IndexPageData{CSRFToken: "tok"})
body := renderIndex(t, IndexPageData{})
if !strings.Contains(body, "Nothing to show yet.") {
t.Errorf("empty dashboard missing the D25 headline, got:\n%s", body)
}
@@ -191,7 +189,7 @@ func TestIndexEmptyState(t *testing.T) {
// even with zero declared cards (no configured integration on this
// deployment) — the empty state must not render.
func TestIndexNoEmptyStateWithEntitlement(t *testing.T) {
body := renderIndex(t, IndexPageData{CSRFToken: "tok", HasEntitlement: true})
body := renderIndex(t, IndexPageData{HasEntitlement: true})
if strings.Contains(body, "Nothing to show yet.") {
t.Errorf("dashboard with an org entitlement must not render the empty state, got:\n%s", body)
}
@@ -202,7 +200,6 @@ func TestIndexNoEmptyStateWithEntitlement(t *testing.T) {
// enough to skip the empty state.
func TestIndexNoEmptyStateWithCards(t *testing.T) {
body := renderIndex(t, IndexPageData{
CSRFToken: "tok",
DashboardCards: []DashboardCardView{
{DashboardCard: DashboardCard{Title: "Other Service", PartialPath: "/partials/other/status"}, Body: "<p>other body</p>"},
},
+18 -1
View File
@@ -5,6 +5,7 @@ package server
import (
"fmt"
"math"
"net/http"
"net/url"
"strconv"
@@ -62,7 +63,23 @@ func (p ListParams) Limit() int32 {
}
return int32(operatorListPageSize)
}
func (p ListParams) Offset() int32 { return int32(p.Page-1) * p.Limit() }
// Offset is computed in 64 bits and capped at the int32 the query takes: a
// page number large enough to overflow would otherwise wrap negative and
// Postgres refuses a negative OFFSET (2026-09 audit candidate "Unbounded
// page overflows int32 OFFSET"). The capped offset is past any real list,
// so FetchPage's out-of-range clamp lands the reader on page 1.
func (p ListParams) Offset() int32 {
page := int64(p.Page)
if page < 1 {
page = 1
}
limit := int64(p.Limit())
if page-1 > math.MaxInt32/limit {
return math.MaxInt32
}
return int32((page - 1) * limit)
}
// perPageOptions is the page-size set an operator can pick from on lists
// that offer the picker (embedded lists; the composite's ledger and Tier
+20
View File
@@ -8,6 +8,7 @@ import (
"errors"
"html/template"
"io/fs"
"math"
"net/http/httptest"
"strings"
"testing"
@@ -44,6 +45,25 @@ func TestParseListParamsClamping(t *testing.T) {
}
}
// A page number large enough to overflow the int32 offset used to wrap
// negative, and Postgres refuses a negative OFFSET with an error the reader
// saw as a 500 (2026-09 audit candidate). The offset is capped instead, and
// the capped page is past any real list, which FetchPage already clamps.
func TestOffsetNeverOverflows(t *testing.T) {
for _, page := range []int{1 << 31, 1 << 40, int(^uint(0) >> 1)} {
p := ListParams{Page: page}
if off := p.Offset(); off < 0 || off != math.MaxInt32 {
t.Errorf("page %d: offset = %d, want the int32 cap", page, off)
}
}
if off := (ListParams{Page: 3}).Offset(); off != 100 {
t.Errorf("page 3 at the default size: offset = %d, want 100", off)
}
if off := (ListParams{Page: 0}).Offset(); off != 0 {
t.Errorf("page 0: offset = %d, want 0", off)
}
}
func TestFetchPageClampsPastTheEnd(t *testing.T) {
calls := []int32{}
load := func(limit, offset int32) ([]string, int64, error) {
+10 -6
View File
@@ -91,7 +91,7 @@ func TestShellAccountMenu(t *testing.T) {
}
// Sign out: plain, last, and the only session-ending control.
signOut := `<a class="dropdown-item" href="/logout" hx-boost="false">Sign out</a>`
signOut := `<button type="button" class="dropdown-item" hx-post="/logout" hx-swap="none">Sign out</button>`
if got := strings.Count(body, signOut); got != 1 {
t.Errorf("expected exactly one plain Sign out item, got %d", got)
}
@@ -109,10 +109,14 @@ func TestShellAccountMenu(t *testing.T) {
}
// Nothing session-related outside the menu: exactly one
// /logout link and one account-console link on the page, and
// the old rail session block is gone.
if got := strings.Count(body, `href="/logout"`); got != 1 {
t.Errorf("expected one /logout link on the page, got %d", got)
// /logout control and one account-console link on the page,
// and the old rail session block is gone. Sign out posts; a
// link to /logout would be a GET that ends nothing.
if got := strings.Count(body, `hx-post="/logout"`); got != 1 {
t.Errorf("expected one /logout control on the page, got %d", got)
}
if strings.Contains(body, `href="/logout"`) {
t.Error("Sign out must post, never link to /logout")
}
if got := strings.Count(body, `https://idp.example.test/account`); got != 1 {
t.Errorf("expected one account-console link on the page, got %d", got)
@@ -158,7 +162,7 @@ func TestShellAccountMenuGetHelp(t *testing.T) {
idpIdx := strings.Index(body, `href="https://idp.example.test/account"`)
helpIdx := strings.Index(body, getHelp)
divIdx := strings.Index(body, `<hr class="dropdown-divider">`)
signOutIdx := strings.Index(body, `<a class="dropdown-item" href="/logout" hx-boost="false">Sign out</a>`)
signOutIdx := strings.Index(body, `<button type="button" class="dropdown-item" hx-post="/logout" hx-swap="none">Sign out</button>`)
if !(idpIdx < helpIdx && helpIdx < divIdx && divIdx < signOutIdx) {
t.Error("the menu must read: header, Identity and Access, Get help, divider, Sign out")
}
+36 -1
View File
@@ -791,10 +791,45 @@ func (h *MemberProductsHandler) PostSwitch(w http.ResponseWriter, r *http.Reques
return
}
// The same member gate checkout applies (published, active, public),
// checked here because SwitchPlan verifies the price and the ladder but
// not the product's publication state; without it a member who knows a
// draft tier's price id can move onto it (2026-09 audit finding 12).
if err := h.switchTargetOpenToMembers(r.Context(), priceID); err != nil {
h.renderPlansAfterMove(w, r.Context(), session.OrgID, err, "switch")
return
}
err := fulfillment.SwitchPlan(r.Context(), h.Database, h.Logger, session.OrgID, ladderID, priceID, "member:switch")
h.renderPlansAfterMove(w, r.Context(), session.OrgID, err, "switch")
}
// errPlanNotOpenToMembers is a switch whose target product fails the member
// gate. It renders as the unavailable-plan message, the same one a retired
// price gets, since to the member the two are one fact.
var errPlanNotOpenToMembers = errors.New("switch: target product is not published for members")
// switchTargetOpenToMembers holds a switch's target price to the member
// gate (evaluateMemberGate: published, active, public), the predicate the
// catalog lists by and checkout refuses on. It reads the price and its
// product and nothing else, so a refused switch touches no subscription.
func (h *MemberProductsHandler) switchTargetOpenToMembers(ctx context.Context, priceID string) error {
price, err := h.BillingQ.GetPrice(ctx, priceID)
if err != nil {
return fmt.Errorf("switch: get target price %s: %w", priceID, err)
}
product, err := h.BillingQ.GetProductByID(ctx, price.ProductID)
if err != nil {
return fmt.Errorf("switch: get target product %s: %w", price.ProductID, err)
}
if !evaluateMemberGate(product).OK() {
h.Logger.Info("switch refused: product not published for members",
slog.String("product_id", product.ProductID), slog.String("lifecycle_status", product.LifecycleStatus))
return errPlanNotOpenToMembers
}
return nil
}
// PostCancel handles POST /partials/member/plans/cancel — a paid→free move that
// cancels the member's active subscription on the ladder. timing defaults to
// cancel-at-period-end.
@@ -841,7 +876,7 @@ func (h *MemberProductsHandler) renderPlansAfterMove(w http.ResponseWriter, ctx
data.Error = "There's no active subscription to change on this plan."
case errors.Is(moveErr, fulfillment.ErrSameTier):
data.Error = "You're already on that plan."
case errors.Is(moveErr, fulfillment.ErrPriceInactive):
case errors.Is(moveErr, fulfillment.ErrPriceInactive), errors.Is(moveErr, errPlanNotOpenToMembers):
data.Error = "That plan is no longer available. Refresh the page and choose a current plan."
default:
data.Error = "We couldn't complete that change. Try again in a moment."
@@ -0,0 +1,145 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server_test
import (
"context"
"database/sql"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
"github.com/alexedwards/scs/v2"
"github.com/google/uuid"
)
// TestSwitchRefusesADraftTierThroughTheHandler drives POST
// /partials/member/plans/switch end to end against the database: a draft
// tier that is otherwise fully purchasable (active, public, priced,
// Stripe-mapped) is refused with the unavailable-plan message before any
// subscription is read (2026-09 security audit, finding 12). The control
// posts for a published tier and is refused one precondition later, by
// SwitchPlan's own subscription check, which proves the gate opened.
func TestSwitchRefusesADraftTierThroughTheHandler(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
bq := billing.New(tx)
eq := entitlements.New(tx)
iq := identity.New(tx)
oq := organization.New(tx)
sq := internalstripe.New(tx)
sfx := uuid.New().String()[:8]
es, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{Name: "switch-gate-set-" + sfx})
if err != nil {
t.Fatalf("create entitlement set: %v", err)
}
esID := uuid.NullUUID{UUID: uuid.MustParse(es.SetID), Valid: true}
ladder, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{Name: "Switch Gate Ladder " + sfx, IsActive: true})
if err != nil {
t.Fatalf("create ladder: %v", err)
}
tier := func(name, lifecycle string) billing.Price {
t.Helper()
product, err := bq.CreateProduct(ctx, billing.CreateProductParams{
Name: name + " " + sfx, IsActive: true, IsPublic: true, EntitlementSetID: esID, LifecycleStatus: lifecycle,
})
if err != nil {
t.Fatalf("create %s: %v", name, err)
}
price, err := bq.CreatePrice(ctx, billing.CreatePriceParams{
ProductID: product.ProductID, Currency: "usd", UnitAmount: 1000,
RecurringInterval: sql.NullString{String: "month", Valid: true},
})
if err != nil {
t.Fatalf("create %s price: %v", name, err)
}
if _, err := sq.UpsertPriceMapping(ctx, internalstripe.UpsertPriceMappingParams{
PriceID: price.PriceID, StripePriceID: sql.NullString{String: "price_stripe_" + sfx + "_" + lifecycle, Valid: true}, SyncStatus: "synced",
}); err != nil {
t.Fatalf("%s price mapping: %v", name, err)
}
if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{PlanLadderID: ladder.PlanLadderID, ProductID: product.ProductID}); err != nil {
t.Fatalf("create %s tier: %v", name, err)
}
return price
}
draft := tier("Hidden Tier", "draft")
published := tier("Open Tier", "published")
user, err := iq.CreateUser(ctx, "u-"+uuid.New().String())
if err != nil {
t.Fatalf("create user: %v", err)
}
person, err := iq.CreatePerson(ctx, identity.CreatePersonParams{
UserID: user.UserID, DisplayName: "Switch Gate Member",
PrimaryEmail: "switch-gate-" + sfx + "@example.com", PrimaryEmailVerified: true,
})
if err != nil {
t.Fatalf("create person: %v", err)
}
org, err := oq.CreateOrganization(ctx, organization.CreateOrganizationParams{Name: "Switch Gate Org", OrgType: "personal", OwnerPersonID: person.PersonID})
if err != nil {
t.Fatalf("create org: %v", err)
}
sm := scs.New()
sctx, err := sm.Load(ctx, "")
if err != nil {
t.Fatalf("load session: %v", err)
}
sm.Put(sctx, "authenticated", true)
sm.Put(sctx, "org_id", org.OrgID)
h, err := server.NewMemberProductsHandler(server.MemberProductsConfig{
EntitlementsQ: eq, BillingQ: bq, AuthConfig: &auth.Config{SessionManager: sm}, Logger: discardLogger(), Database: database,
})
if err != nil {
t.Fatalf("new handler: %v", err)
}
post := func(priceID string) string {
t.Helper()
form := url.Values{"ladder_id": {ladder.PlanLadderID}, "price_id": {priceID}}
req := httptest.NewRequest(http.MethodPost, "/partials/member/plans/switch", strings.NewReader(form.Encode())).WithContext(sctx)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.PostSwitch(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("switch to %s: status %d: %s", priceID, rec.Code, rec.Body.String())
}
return rec.Body.String()
}
const unavailable = "That plan is no longer available."
if body := post(draft.PriceID); !strings.Contains(body, unavailable) {
t.Errorf("a switch onto a draft tier must be refused as unavailable; body:\n%s", body)
}
body := post(published.PriceID)
if strings.Contains(body, unavailable) {
t.Errorf("a published tier must pass the gate; body:\n%s", body)
}
if !strings.Contains(body, "no active subscription to change on this plan.") {
t.Errorf("a published tier must be refused by SwitchPlan's subscription check, one step past the gate; body:\n%s", body)
}
}
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server
import (
"context"
"errors"
"io"
"log/slog"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
)
// Finding 12 of the 2026-09 security audit: checkout refused a product that
// failed the member gate, the switch path did not, so a member who knew a
// draft tier's price id could move onto it. The switch now holds its target
// to the same gate before any subscription is touched. The querier stubs
// only the two reads the gate makes; anything further panics on the nil
// interface, which is the proof that a refusal stops there.
func TestSwitchTargetRefusesAProductOutsideTheMemberGate(t *testing.T) {
cases := []struct {
name string
product billing.Product
}{
{"draft product", billing.Product{ProductID: "prod-draft", LifecycleStatus: "draft", IsActive: true, IsPublic: true}},
{"retired product", billing.Product{ProductID: "prod-retired", LifecycleStatus: "retired", IsActive: true, IsPublic: true}},
{"inactive product", billing.Product{ProductID: "prod-inactive", LifecycleStatus: "published", IsActive: false, IsPublic: true}},
{"non-public product", billing.Product{ProductID: "prod-internal", LifecycleStatus: "published", IsActive: true, IsPublic: false}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := &MemberProductsHandler{
BillingQ: fakeCheckoutQuerier{
price: billing.Price{PriceID: "price-1", ProductID: tc.product.ProductID, IsActive: true},
product: tc.product,
},
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
err := h.switchTargetOpenToMembers(context.Background(), "price-1")
if !errors.Is(err, errPlanNotOpenToMembers) {
t.Fatalf("err = %v, want errPlanNotOpenToMembers", err)
}
})
}
}
// The control: a published, active, public target passes, so the gate
// refuses the draft and not every switch.
func TestSwitchTargetAdmitsAPublishedProduct(t *testing.T) {
h := &MemberProductsHandler{
BillingQ: fakeCheckoutQuerier{
price: billing.Price{PriceID: "price-1", ProductID: "prod-1", IsActive: true},
product: billing.Product{ProductID: "prod-1", LifecycleStatus: "published", IsActive: true, IsPublic: true},
},
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
if err := h.switchTargetOpenToMembers(context.Background(), "price-1"); err != nil {
t.Fatalf("a published product must pass the gate, got %v", err)
}
}
-3
View File
@@ -26,7 +26,6 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/instance"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/middleware"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
)
@@ -179,7 +178,6 @@ type OperatorPageData struct {
Username string
Email string
KeycloakAccountURL string
CSRFToken string
// IAPosition is the positional tuple `<group>:<capability>[:<instance>]`
// declared by every operator page per the breadcrumb requirement. The
// landing surface declares `runtime:landing` since it sits in the
@@ -654,7 +652,6 @@ func (h *OperatorHandler) renderLanding(w http.ResponseWriter, r *http.Request,
Username: h.AuthConfig.GetUsername(ctx),
Email: h.AuthConfig.GetUserEmail(ctx),
KeycloakAccountURL: config.IdPAccountURL(),
CSRFToken: middleware.CSRFToken(r),
IAPosition: "runtime:landing",
Overview: h.loadOverview(ctx),
Setup: h.DeriveSetupState(ctx),
+2 -1
View File
@@ -1248,7 +1248,8 @@ func (h *OperatorPartialsHandler) SyncProductToStripe(w http.ResponseWriter, r *
// stripeSyncFailure reports whether the product's Stripe catalog-sync has
// terminally failed (a create_stripe_product / create_stripe_price outbox entry
// reached dead_letter), returning the recorded error. Transient 'failed' rows
// (still auto-retried by the outbox poller) are treated as in-flight, not failed.
// (the entry's Temporal workflow is still retrying them) are treated as
// in-flight, not failed.
// priceID may be empty (no active price yet) — then only the product action matches.
func (h *OperatorPartialsHandler) stripeSyncFailure(ctx context.Context, productID, priceID string) (bool, string) {
var errMsg string
@@ -48,6 +48,80 @@ type StripeIntegrationData struct {
DeliveryQueueHeader SectionHeader
DeliveryQueue DeliveryQueue
DeliveryQueueEntries []DeadLetterEntry
// InboundEventsHeader/InboundEvents/InboundEventEntries carry the
// "Inbound events" section: Stripe's webhook events that failed
// processing through every retry round and were dead-lettered
// (stripe-integration-infrastructure: "Failed events are retried, then
// dead-lettered"). The outbox section above is outbound work; this is
// the inbound counterpart, and the overview's Stripe row counts both.
InboundEventsHeader SectionHeader
InboundEvents InboundEvents
InboundEventEntries []DeadLetterEntry
}
// InboundEvents is the health of Stripe's inbound webhook events: how many
// have exhausted their retries. Received, retrying and processing rows need
// no operator and are not counted; only dead-lettered ones are.
type InboundEvents struct {
DeadLetter int64
Available bool
}
// NeedsAttention reports whether inbound events hold work that will not
// resolve on its own.
func (e InboundEvents) NeedsAttention() bool { return e.DeadLetter > 0 }
// loadInboundEvents counts Stripe's dead-lettered webhook events. Raw SQL
// against core.webhook_events, as loadDeadLetterEntries does for the outbox;
// a nil db or a failed probe leaves Available false.
func loadInboundEvents(ctx context.Context, db *sql.DB, logger *slog.Logger) InboundEvents {
if db == nil {
return InboundEvents{}
}
var n int64
if err := db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM core.webhook_events WHERE provider = 'stripe' AND status = 'dead_letter'`,
).Scan(&n); err != nil {
logger.Warn("inbound events: dead-letter count failed", slog.Any("error", err))
return InboundEvents{}
}
return InboundEvents{DeadLetter: n, Available: true}
}
// loadInboundDeadLetterEntries lists Stripe's dead-lettered webhook events
// for the Inbound events table, newest first, capped like the outbox table.
// OperationType carries the event type (e.g. "invoice.paid").
func loadInboundDeadLetterEntries(ctx context.Context, db *sql.DB, logger *slog.Logger) []DeadLetterEntry {
if db == nil {
return nil
}
rows, err := db.QueryContext(ctx,
`SELECT event_type, COALESCE(error_message, ''), retry_count, updated_at
FROM core.webhook_events
WHERE provider = 'stripe' AND status = 'dead_letter'
ORDER BY updated_at DESC
LIMIT $1`, deadLetterEntryLimit)
if err != nil {
logger.Warn("inbound events: dead-letter entries query failed", slog.Any("error", err))
return nil
}
defer rows.Close()
var entries []DeadLetterEntry
for rows.Next() {
var e DeadLetterEntry
var updatedAt time.Time
if err := rows.Scan(&e.OperationType, &e.ErrorMessage, &e.Attempts, &updatedAt); err != nil {
logger.Warn("inbound events: dead-letter entry scan failed", slog.Any("error", err))
return entries
}
e.UpdatedAt = updatedAt.Format("Jan 2, 2006 3:04 PM")
entries = append(entries, e)
}
if err := rows.Err(); err != nil {
logger.Warn("inbound events: dead-letter entries rows failed", slog.Any("error", err))
}
return entries
}
// DeliveryQueue is the shared integration outbox's health, split by what an
@@ -165,6 +239,7 @@ func (h *OperatorPartialsHandler) GetStripeIntegrationPage(w http.ResponseWriter
ModeLabel: modeLabel,
Description: stripedb.ProviderSource().ProviderManifest().Description,
DeliveryQueueHeader: SectionHeader{Title: "Delivery queue"},
InboundEventsHeader: SectionHeader{Title: "Inbound events"},
}
var iq integration.Querier
@@ -173,6 +248,8 @@ func (h *OperatorPartialsHandler) GetStripeIntegrationPage(w http.ResponseWriter
}
bodyData.DeliveryQueue = loadDeliveryQueue(r.Context(), iq, h.Logger)
bodyData.DeliveryQueueEntries = loadDeadLetterEntries(r.Context(), h.Database, h.Logger)
bodyData.InboundEvents = loadInboundEvents(r.Context(), h.Database, h.Logger)
bodyData.InboundEventEntries = loadInboundDeadLetterEntries(r.Context(), h.Database, h.Logger)
page := h.buildOperatorPageData(r)
page.IAPosition = "integration:integrations:stripe"
@@ -85,6 +85,61 @@ func TestStripeIntegrationTemplate(t *testing.T) {
}
}
// TestStripeIntegrationInboundEvents covers the Inbound events section, the
// delivery queue's inbound mirror: dead-lettered webhook events render with
// alarm styling and their event type as <code>; none renders the quiet line;
// an unavailable probe says so (stripe-integration-infrastructure "Failed
// events are retried, then dead-lettered").
func TestStripeIntegrationInboundEvents(t *testing.T) {
tmpl := parseOperatorPartials(t)
render := func(data StripeIntegrationData) string {
t.Helper()
data.Configured = true
data.ModeLabel = "Test mode"
data.DeliveryQueueHeader = SectionHeader{Title: "Delivery queue"}
data.DeliveryQueue = DeliveryQueue{Available: true}
data.InboundEventsHeader = SectionHeader{Title: "Inbound events"}
var buf bytes.Buffer
if err := tmpl.ExecuteTemplate(&buf, "operator_integration_stripe.html", data); err != nil {
t.Fatalf("render: %v", err)
}
return buf.String()
}
out := render(StripeIntegrationData{
InboundEvents: InboundEvents{DeadLetter: 2, Available: true},
InboundEventEntries: []DeadLetterEntry{
{OperationType: "invoice.paid", ErrorMessage: "resolve invoice mapping: no rows", Attempts: 5, UpdatedAt: "Jan 2, 2026 3:04 PM"},
},
})
for _, want := range []string{
"Inbound events",
"2 events could not be processed after retries and need an operator.",
`<code class="text-nowrap">invoice.paid</code>`,
"resolve invoice mapping: no rows",
} {
if !strings.Contains(out, want) {
t.Errorf("inbound events section missing %q, got:\n%s", want, out)
}
}
if strings.Contains(out, "Inbound events are processing normally") {
t.Error("the quiet line must not render alongside dead-lettered events")
}
out = render(StripeIntegrationData{InboundEvents: InboundEvents{Available: true}})
if !strings.Contains(out, "Inbound events are processing normally.") {
t.Errorf("no dead-lettered events must render the quiet line, got:\n%s", out)
}
if strings.Contains(out, "could not be processed") {
t.Error("no alarm without dead-lettered events")
}
out = render(StripeIntegrationData{})
if !strings.Contains(out, "Inbound event health is unavailable.") {
t.Errorf("an unavailable probe must say so, got:\n%s", out)
}
}
// TestStripeIntegrationNotConfigured covers the Not-configured state, from
// the same configurationReadiness resolution the settings page and the
// Integrations table use.
@@ -142,6 +197,11 @@ func TestGetStripeIntegrationPageDeliveryQueue(t *testing.T) {
`INSERT INTO core.outbox (provider, action_type, status) VALUES ('stripe', 'create_stripe_product', 'pending')`); err != nil {
t.Fatalf("fixture pending outbox row: %v", err)
}
if _, err := database.ExecContext(context.Background(),
`INSERT INTO core.webhook_events (provider, provider_event_id, event_type, payload, status, retry_count, error_message)
VALUES ('stripe', 'evt_page_dead', 'invoice.paid', '{}', 'dead_letter', 5, 'resolve invoice mapping: no rows')`); err != nil {
t.Fatalf("fixture dead-lettered webhook event: %v", err)
}
viper.Reset()
viper.Set("stripe-mode", "live")
@@ -176,6 +236,11 @@ func TestGetStripeIntegrationPageDeliveryQueue(t *testing.T) {
"needs an operator",
"Live mode", // stripe-mode=live
"Not configured", // stripeConfigs' required key is unresolved
// The inbound mirror: the dead-lettered webhook event, by type.
"Inbound events",
`<code class="text-nowrap">invoice.paid</code>`,
"resolve invoice mapping: no rows",
"1 event could not be processed after retries and needs an operator.",
} {
if !strings.Contains(out, want) {
t.Errorf("stripe provider page missing %q, got:\n%s", want, out)
+5 -2
View File
@@ -383,17 +383,20 @@ func (h *OperatorHandler) loadOverviewIntegrationsCard(ctx context.Context) Over
// it's in and the outbox summary (queued / need attention) — never the
// full delivery-queue report, which lives on the Stripe provider page and
// reads the same loadDeliveryQueue call so the two can never disagree
// about what the outbox holds.
// about what the outbox holds. Attention counts dead-lettered work in both
// directions: outbox entries and inbound webhook events, read the way the
// provider page reads them.
func (h *OperatorHandler) loadOverviewStripeFacts(ctx context.Context) OverviewStripeFacts {
mode := "Test mode"
if viper.GetString("stripe-mode") == "live" {
mode = "Live mode"
}
queue := loadDeliveryQueue(ctx, h.IntegrationQ, h.Logger)
inbound := loadInboundEvents(ctx, h.Database, h.Logger)
return OverviewStripeFacts{
ModeLabel: mode,
Queued: queue.Queued(),
Attention: queue.DeadLetter,
Attention: queue.DeadLetter + inbound.DeadLetter,
Available: queue.Available,
}
}
@@ -99,7 +99,6 @@ func landingRegion(t *testing.T, out string) string {
// scenario describes (operator-panel-navigation).
func populatedOverview() OperatorPageData {
return OperatorPageData{
CSRFToken: "csrf",
IAPosition: "runtime:landing",
Overview: OverviewData{
Stats: []OverviewStat{
@@ -238,7 +237,6 @@ func TestOperatorOverviewRendersStatTiles(t *testing.T) {
// .Href) carries no chevron (overview-consistency D3, round 2).
func TestOperatorOverviewTileChevronMarksOnlyLinkedTiles(t *testing.T) {
data := OperatorPageData{
CSRFToken: "csrf",
Overview: OverviewData{Stats: []OverviewStat{
{Label: "Linked", Value: "1", Href: "/operator/persons", Available: true},
{Label: "Unlinked", Value: "2", Available: true},
@@ -258,7 +256,6 @@ func TestOperatorOverviewTileChevronMarksOnlyLinkedTiles(t *testing.T) {
// actively misleading answer to "how many organizations are there".
func TestOperatorOverviewUnavailableStatIsNotZero(t *testing.T) {
data := OperatorPageData{
CSRFToken: "csrf",
Overview: OverviewData{Stats: []OverviewStat{
{Label: "Organizations", Caption: "Active organizations", Href: "/operator/organizations", Available: false},
}},
@@ -426,7 +423,7 @@ func TestOperatorOverviewStripeDeadLetterRaisesAlarm(t *testing.T) {
// The landing surface is the first thing a fresh deployment shows, so every
// region needs a legible empty state rather than a blank panel.
func TestOperatorOverviewEmptyStates(t *testing.T) {
out := landingRegion(t, renderOperator(t, OperatorPageData{CSRFToken: "csrf"}))
out := landingRegion(t, renderOperator(t, OperatorPageData{}))
for _, want := range []string{
"Counts are unavailable.",
@@ -483,7 +480,6 @@ func TestOperatorLandingRegionOrder(t *testing.T) {
// dead link.
func TestOperatorActivityFeedLinksToSubject(t *testing.T) {
data := OperatorPageData{
CSRFToken: "csrf",
Activity: []ActivityEvent{
{EventType: "grant_issued", Timestamp: "t", OrgID: "org-1", OrgName: "Example Org", Summary: "Issued Example Plan", Href: "/operator/organizations/org-1"},
{EventType: "invoice_created", Timestamp: "t", OrgID: "org-1", OrgName: "Example Org", Summary: "Invoice $10.00", Href: "/operator/billing/invoices"},
@@ -615,7 +611,7 @@ func TestOperatorShellIsBrandNeutral(t *testing.T) {
// the toast renders its default body and no auto-show marker — it is
// wired but unshown, same as the error toast beside it.
func TestOperatorShellFlashSuccessToast(t *testing.T) {
out := renderOperator(t, OperatorPageData{CSRFToken: "csrf", FlashSuccess: "Product created."})
out := renderOperator(t, OperatorPageData{FlashSuccess: "Product created."})
if !strings.Contains(out, `id="successToast"`) {
t.Fatalf("shell missing #successToast, got:\n%s", out)
@@ -631,7 +627,7 @@ func TestOperatorShellFlashSuccessToast(t *testing.T) {
t.Errorf("expected no dismissible alert-success banner anywhere in the shell, got:\n%s", out)
}
plain := renderOperator(t, OperatorPageData{CSRFToken: "csrf"})
plain := renderOperator(t, OperatorPageData{})
if strings.Contains(plain, "data-show-on-load") {
t.Errorf("expected no auto-show marker when FlashSuccess is unset, got:\n%s", plain)
}
-2
View File
@@ -13,7 +13,6 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integration"
"git.coopcloud.tech/wiki-cafe/member-console/internal/middleware"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/systemtenant"
"github.com/spf13/viper"
@@ -43,7 +42,6 @@ func BuildOperatorPageData(r *http.Request, authConfig *auth.Config, database *s
Username: authConfig.GetUsername(ctx),
Email: authConfig.GetUserEmail(ctx),
KeycloakAccountURL: config.IdPAccountURL(),
CSRFToken: middleware.CSRFToken(r),
}
}
@@ -124,7 +124,6 @@ func mixedSetupState() SetupState {
func TestSetupChecklistPageRendersAllSteps(t *testing.T) {
out := renderChecklistPage(t, OperatorPageData{
CSRFToken: "csrf",
IAPosition: "runtime:setup",
BodyTemplate: "operator_setup.html",
BodyData: mixedSetupState(),
@@ -199,7 +198,6 @@ func TestSetupChecklistPageRendersWhenEverythingComplete(t *testing.T) {
state.Steps[i].Complete = true
}
out := renderChecklistPage(t, OperatorPageData{
CSRFToken: "csrf",
BodyTemplate: "operator_setup.html",
BodyData: state,
})
@@ -224,7 +222,6 @@ func TestSetupChecklistPageRendersWhenEverythingComplete(t *testing.T) {
// inline event handler, or a style attribute.
func TestSetupChecklistIsCSPClean(t *testing.T) {
out := renderChecklistPage(t, OperatorPageData{
CSRFToken: "csrf",
BodyTemplate: "operator_setup.html",
BodyData: mixedSetupState(),
})
@@ -50,7 +50,7 @@ func sidebarLinkActive(t *testing.T, sidebar, linkText string) bool {
// markup (maintainer 2026-08-23: second-level surfaces are reached
// in-page, not from the sidebar; People joined via ux-operator-scale).
func TestOperatorSidebarFlatTopLevelOrder(t *testing.T) {
out := sidebarRegion(t, renderOperator(t, OperatorPageData{CSRFToken: "csrf"}))
out := sidebarRegion(t, renderOperator(t, OperatorPageData{}))
topLevel := []string{">Overview<", ">People<", ">Organizations<", ">Grants<", ">Billing<", ">Products<", ">Domains<", ">Integrations<"}
positions := make([]int, len(topLevel))
@@ -113,7 +113,6 @@ func TestOperatorSidebarSecondLevelMarksSection(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out := sidebarRegion(t, renderOperator(t, OperatorPageData{
CSRFToken: "csrf",
ActiveCapability: tc.activeCapability,
}))
if !sidebarLinkActive(t, out, tc.sectionText) {
@@ -131,7 +130,7 @@ func TestOperatorSidebarSecondLevelMarksSection(t *testing.T) {
// section active-marking logic accidentally lighting up every top-level
// entry (an `or` chain with a missing `eq` degenerating to always-true).
func TestOperatorSidebarOverviewOnlyActiveOnLanding(t *testing.T) {
out := sidebarRegion(t, renderOperator(t, OperatorPageData{CSRFToken: "csrf"}))
out := sidebarRegion(t, renderOperator(t, OperatorPageData{}))
for _, text := range []string{"People", "Organizations", "Grants", "Billing", "Products", "Domains", "Integrations"} {
if sidebarLinkActive(t, out, text) {
t.Errorf("%q must not be active on the landing surface, sidebar:\n%s", text, out)
@@ -152,7 +151,6 @@ func TestOperatorSidebarOverviewOnlyActiveOnLanding(t *testing.T) {
func TestOperatorShellTopBarAndDrawer(t *testing.T) {
for _, active := range []string{"", "organizations", "billing-invoices"} {
out := renderOperator(t, OperatorPageData{
CSRFToken: "csrf",
Name: "Alice Admin",
Username: "alice",
Email: "alice@example.test",
@@ -163,7 +161,7 @@ func TestOperatorShellTopBarAndDrawer(t *testing.T) {
for _, want := range []string{
`data-bs-toggle="dropdown" aria-expanded="false"><span class="app-account-label">Alice Admin</span></button>`,
`<a class="dropdown-item" href="/logout" hx-boost="false">Sign out</a>`,
`<button type="button" class="dropdown-item" hx-post="/logout" hx-swap="none">Sign out</button>`,
`Identity and <span class="text-nowrap">Access<svg class="external-link-icon"`,
`data-bs-toggle="offcanvas" data-bs-target="#app-rail" aria-controls="app-rail" aria-label="Open navigation"`,
`<a class="navbar-brand" href="/">`,
@@ -222,7 +220,6 @@ func TestOperatorShellAccountMenuGetHelp(t *testing.T) {
t.Cleanup(func() { viper.Set("support-url", "") })
out := renderOperator(t, OperatorPageData{
CSRFToken: "csrf",
Name: "Alice Admin",
Username: "alice",
Email: "alice@example.test",
@@ -238,7 +235,7 @@ func TestOperatorShellAccountMenuGetHelp(t *testing.T) {
idpIdx := strings.Index(topBar, `href="https://idp.example.test/account"`)
helpIdx := strings.Index(topBar, getHelp)
divIdx := strings.Index(topBar, `<hr class="dropdown-divider">`)
signOutIdx := strings.Index(topBar, `<a class="dropdown-item" href="/logout" hx-boost="false">Sign out</a>`)
signOutIdx := strings.Index(topBar, `<button type="button" class="dropdown-item" hx-post="/logout" hx-swap="none">Sign out</button>`)
if !(idpIdx < helpIdx && helpIdx < divIdx && divIdx < signOutIdx) {
t.Error("the menu must read: header, Identity and Access, Get help, divider, Sign out")
}
@@ -62,7 +62,6 @@ func TestOperatorTemplateRendersBothBranches(t *testing.T) {
{
name: "landing",
data: OperatorPageData{
CSRFToken: "csrf",
IAPosition: "runtime:landing",
},
// The lookup region names itself to screen readers with
@@ -76,7 +75,6 @@ func TestOperatorTemplateRendersBothBranches(t *testing.T) {
// that the shell still renders (no landing markup leaks in).
name: "body-template-dispatch",
data: OperatorPageData{
CSRFToken: "csrf",
IAPosition: "runtime:organizations",
ActiveCapability: "organizations",
BodyTemplate: "operator_organizations.html",
@@ -91,7 +89,6 @@ func TestOperatorTemplateRendersBothBranches(t *testing.T) {
// sidebar children (maintainer 2026-08-23).
name: "integration-section-home",
data: OperatorPageData{
CSRFToken: "csrf",
ActiveCapability: "integrations",
},
want: `href="/operator/integrations">Integrations</a>`,
+31 -5
View File
@@ -34,23 +34,49 @@ func NewSafeTemplates(tmpl *template.Template, logger *slog.Logger) *SafeTemplat
// error template itself also falls back to plain text, so this never emits a
// partial page.
func (s *SafeTemplates) RenderErrorPage(w http.ResponseWriter, r *http.Request, status int, msg string) {
s.renderErrorPage(w, r, ErrorPageData{
Status: status,
StatusText: http.StatusText(status),
Message: msg,
Action: ErrorPageAction{Label: "Back to the dashboard", Href: "/"},
})
}
// RenderAuthFailure writes the page a person lands on when an authentication
// flow refuses, satisfying internal/auth's FailurePage.
//
// It differs from RenderErrorPage in the two things that are wrong about the
// ordinary error page here. The heading is the named failure rather than the
// status line, because "400 Bad Request" tells the person nothing about a
// sign-in that expired. And the way out is the flow they were in rather than
// the dashboard, which is the page they could not reach.
func (s *SafeTemplates) RenderAuthFailure(w http.ResponseWriter, r *http.Request, status int, heading, actionLabel, actionHref string) {
s.renderErrorPage(w, r, ErrorPageData{
Status: status,
StatusText: http.StatusText(status),
Title: heading,
Action: ErrorPageAction{Label: actionLabel, Href: actionHref},
})
}
// renderErrorPage is the body both error pages share.
func (s *SafeTemplates) renderErrorPage(w http.ResponseWriter, r *http.Request, data ErrorPageData) {
// The body differs by HX-Request; tell shared caches (chrome-conventions).
w.Header().Add("Vary", "HX-Request")
if r.Header.Get("HX-Request") == "true" {
http.Error(w, msg, status)
http.Error(w, data.plain(), data.Status)
return
}
data := ErrorPageData{Status: status, StatusText: http.StatusText(status), Message: msg}
var buf bytes.Buffer
if err := s.tmpl.ExecuteTemplate(&buf, "error.html", data); err != nil {
s.logger.Error("error page render failed",
slog.Int("status", status),
slog.Int("status", data.Status),
slog.Any("error", err))
http.Error(w, msg, status)
http.Error(w, data.plain(), data.Status)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
w.WriteHeader(data.Status)
w.Write(buf.Bytes())
}
+59 -75
View File
@@ -28,7 +28,6 @@ import (
"git.coopcloud.tech/wiki-cafe/member-console/internal/middleware"
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
"github.com/gorilla/csrf"
"github.com/rs/cors"
"github.com/spf13/viper"
"go.temporal.io/sdk/client"
@@ -38,7 +37,6 @@ import (
type Config struct {
Port string
Env string
CSRFSecret string
Logger *slog.Logger
Database *sql.DB // Raw DB connection
IdentityQ identity.Querier // Identity module queries
@@ -395,7 +393,7 @@ func Start(ctx context.Context, cfg Config) error {
// domains registry, so no integration is involved in the lookup. Core
// therefore seeds its own exemption here — the slice was previously
// assembled only from integration mounts, and it flows to BOTH
// csrfConfig.Ignore and authConfig.Middleware below. /domains/ask is
// the cross-origin bypass patterns and authConfig.Middleware below. /domains/ask is
// unauthenticated by design: the TLS-terminating proxy that calls it
// during a handshake holds no session and no CSRF token.
// The handler itself is registered further below (after safeTmpl exists):
@@ -539,77 +537,53 @@ func Start(ctx context.Context, cfg Config) error {
AllowedMethods: []string{"GET"},
}
// Create empty CSRF configuration with default values
// Cross-origin request protection (net/http.CrossOriginProtection).
// There is no token: the standard library rejects non-safe cross-origin
// requests by reading Sec-Fetch-Site, falling back to comparing Origin's
// hostname with Host. This replaced gorilla/csrf in 2026-09, which carried
// GO-2025-3884 with no fixed release.
var csrfConfig middleware.CSRFConfig
// Get and validate CSRF secret from config
csrfKey, err := middleware.ParseCSRFKey(cfg.CSRFSecret)
if err != nil {
cfg.Logger.Error("invalid csrf-secret",
slog.String("error", err.Error()),
slog.String("hint", "must be exactly 32 bytes and persist across restarts"))
return err
}
csrfConfig.Secret = csrfKey
// Bypass CSRF for the exempt paths collected above: core's own
// /domains/ask (called by a TLS proxy mid-handshake) plus whatever each
// integration declared via RouteMount.CSRFExemptPaths (e.g. the Stripe
// webhook, which verifies its own provider signature instead of a CSRF
// token) — declared by the route, not hardcoded here.
for _, exemptPath := range csrfExemptPaths {
exemptPath := exemptPath
csrfConfig.Ignore = append(csrfConfig.Ignore, func(r *http.Request) bool {
return r.URL.Path == exemptPath
})
}
// Add CSRF error handler for debugging
csrfConfig.ErrorHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cfg.Logger.Error("CSRF validation failed",
slog.String("path", r.URL.Path),
slog.String("method", r.Method),
slog.String("reason", csrf.FailureReason(r).Error()),
slog.String("origin", r.Header.Get("Origin")),
slog.String("referer", r.Header.Get("Referer")))
// Include "CSRF" in response for client-side detection
// This allows the frontend to preserve user input and show a helpful message
http.Error(w, "CSRF token invalid or expired. Refresh the page.", http.StatusForbidden)
})
// Only override specific settings when needed
if cfg.Env == "development" {
// In development, cookies often need to work without HTTPS
csrfConfig.Cookie.Secure = false
}
// Always set cookie path to "/" to avoid multiple CSRF cookies with different paths
// Without this, gorilla/csrf creates separate cookies for different URL paths,
// causing token mismatches (e.g., token from "/" doesn't match cookie from "/partials/fedwiki")
csrfConfig.Cookie.Path = "/"
// The CSRF cookie lives as long as a session does (auth.Setup: one
// week). gorilla/csrf's default is twelve hours, shorter than the
// session, so a page left open overnight outlived its own token: the
// browser dropped the expired cookie, the next full page load in any
// tab minted a new one, and the old page's next HTMX POST failed with
// "CSRF token invalid" although the session was fine (the setup
// banner's close on 2026-09-03). A token now expires no sooner than
// the session it serves.
csrfConfig.Cookie.MaxAge = int((7 * 24 * time.Hour).Seconds())
// Add base URL as trusted origin for CSRF validation
// gorilla/csrf expects just host:port, not the full URL with scheme
// The deployment's own origin, scheme included. gorilla/csrf compared only
// the host here, which is exactly the advisory: a policy written for https
// was equally satisfied over plain http. AddTrustedOrigin rejects an entry
// without a scheme, so this is a full origin or nothing.
baseURL := viper.GetString("base-url")
if baseURL != "" {
// Parse the URL to extract just the host
if parsed, err := url.Parse(baseURL); err == nil && parsed.Host != "" {
csrfConfig.TrustedOrigins = []string{parsed.Host}
cfg.Logger.Info("CSRF trusted origins configured", slog.Any("origins", csrfConfig.TrustedOrigins))
if parsed, err := url.Parse(baseURL); err == nil && parsed.Scheme != "" && parsed.Host != "" {
origin := parsed.Scheme + "://" + parsed.Host
csrfConfig.TrustedOrigins = []string{origin}
cfg.Logger.Info("cross-origin protection trusts", slog.String("origin", origin))
} else {
cfg.Logger.Warn("base-url is not a usable origin; only same-origin requests will be accepted",
slog.String("base-url", baseURL))
}
}
// Exempt the paths collected above: core's own /domains/ask (called by a
// TLS proxy mid-handshake) plus whatever each integration declared via
// RouteMount.CSRFExemptPaths (the Stripe and Discourse webhooks, which
// verify a provider signature instead). Declared by the route, not
// hardcoded here. Each is a deliberate hole in this protection.
csrfConfig.BypassPatterns = append(csrfConfig.BypassPatterns, csrfExemptPaths...)
csrfConfig.DenyHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cfg.Logger.Error("cross-origin request refused",
slog.String("path", r.URL.Path),
slog.String("method", r.Method),
slog.String("sec_fetch_site", r.Header.Get("Sec-Fetch-Site")),
slog.String("origin", r.Header.Get("Origin")))
// The body keeps the literal "CSRF" so client-side code can detect this
// case and preserve the person's input rather than losing the form.
http.Error(w, "CSRF check failed: this request did not come from this site. Refresh the page.", http.StatusForbidden)
})
csrfMiddleware, err := middleware.CSRF(csrfConfig)
if err != nil {
cfg.Logger.Error("invalid cross-origin protection configuration", slog.Any("error", err))
return err
}
// For embedded templates
templateSubFS, err := fs.Sub(embeds.Templates, "templates")
if err != nil {
@@ -657,6 +631,14 @@ func Start(ctx context.Context, cfg Config) error {
tmpl = composeUITemplates(tmpl, cfg.UIMounts)
safeTmpl := NewSafeTemplates(tmpl, cfg.Logger)
// Give the auth package the styled page for a refused sign-in, sign-out
// or registration. It cannot reach the template set on its own (this
// package imports it, not the other way round), so it declares the
// interface and this satisfies it. Assigned here rather than at
// auth.Setup because the template set does not exist until now; the
// handlers registered above only read the field when a request arrives.
authConfig.Failures = safeTmpl
httpRequestRouter.HandleFunc("GET /domains/ask", DomainAskHandler(
domains.NewRegistryAuthorizer(cfg.DomainsQ, cfg.AskFallbackURL, cfg.Logger), cfg.Logger, safeTmpl))
@@ -672,11 +654,16 @@ func Start(ctx context.Context, cfg Config) error {
middleware.Recovery(func(w http.ResponseWriter, r *http.Request) {
safeTmpl.RenderErrorPage(w, r, http.StatusInternalServerError, "An unexpected error occurred. Try again.")
}),
// Headers before the timeout and the body limit: both answer for the
// handler (a 503, a 413) and the timeout handler drops whatever an
// inner middleware set, so headers set inside them would be missing
// from exactly those responses (2026-09 audit candidate "413
// responses bypass the security-header middleware").
middleware.SecureHeaders(), // Set secure headers
middleware.Timeout(32*time.Second), // Set request timeout
middleware.MaxBodySize(1024*1024), // 1MB size limit
middleware.SecureHeaders(), // Set secure headers
middleware.CORS(corsOptions), // CORS configuration
middleware.CSRF(csrfConfig), // CSRF protection
csrfMiddleware, // Cross-origin request protection
middleware.Compress(), // Response compression
authConfig.SessionManager.LoadAndSave, // Session management (must be before auth middleware)
)
@@ -748,7 +735,6 @@ func Start(ctx context.Context, cfg Config) error {
keycloakAccountURL := config.IdPAccountURL()
// Get CSRF token for HTMX requests
csrfToken := middleware.CSRFToken(r)
// Check if user has operator role
isOperator := authConfig.HasRole(r, OperatorRole)
@@ -820,7 +806,7 @@ func Start(ctx context.Context, cfg Config) error {
hasEntitlement = orgHasEntitlement(ctx, cfg.EntitlementsQ, session.OrgID)
}
data := IndexPageData{Shell: memberShell(r, "dashboard"), Name: name, Username: username, Email: email, KeycloakAccountURL: keycloakAccountURL, CSRFToken: csrfToken, IsOperator: isOperator, HasMultipleWorkspaces: hasMultipleWorkspaces, CheckoutStatus: checkoutStatus, DashboardCards: cards, Workspaces: workspaces, PendingDomainClaims: pendingDomainClaims, HasEntitlement: hasEntitlement}
data := IndexPageData{Shell: memberShell(r, "dashboard"), Name: name, Username: username, Email: email, KeycloakAccountURL: keycloakAccountURL, IsOperator: isOperator, HasMultipleWorkspaces: hasMultipleWorkspaces, CheckoutStatus: checkoutStatus, DashboardCards: cards, Workspaces: workspaces, PendingDomainClaims: pendingDomainClaims, HasEntitlement: hasEntitlement}
safeTmpl.Render(w, "index.html", data)
})
@@ -833,7 +819,6 @@ func Start(ctx context.Context, cfg Config) error {
// response; their partial routes stay the swap sources.
data := ProductsPageData{
Shell: memberShell(r, "products"),
CSRFToken: middleware.CSRFToken(r),
Entitlements: IncludeOr(deps.Include, cfg.Logger, r, "/partials/member/entitlements", "your entitlements"),
Plans: IncludeOr(deps.Include, cfg.Logger, r, "/partials/member/plans", "plans"),
Addons: IncludeOr(deps.Include, cfg.Logger, r, "/partials/member/addons", "add-ons"),
@@ -849,9 +834,8 @@ func Start(ctx context.Context, cfg Config) error {
// Server-side composition: the invoices region renders into this
// response; its partial route stays the swap source.
data := BillingPageData{
Shell: memberShell(r, "billing"),
CSRFToken: middleware.CSRFToken(r),
Invoices: IncludeOr(deps.Include, cfg.Logger, r, "/partials/member/invoices", "invoices"),
Shell: memberShell(r, "billing"),
Invoices: IncludeOr(deps.Include, cfg.Logger, r, "/partials/member/invoices", "invoices"),
}
safeTmpl.Render(w, "billing.html", data)
@@ -0,0 +1,120 @@
## Context
`internal/auth` is the only package still on the standard library's `log`, and
its callback failures answer a browser with `http.Error` plain text. Both are
addressed here. The logging half is mechanical. The page half needs a decision,
because `internal/server` imports `internal/auth`, so `internal/auth` cannot
import the server's template set to reach `RenderErrorPage`.
## Goals / Non-Goals
- Goal: every log line in `internal/auth` carries typed attributes, so a value
supplied by an identity provider is a value and not part of the message.
- Goal: a person whose sign-in fails lands on the styled page with a way back
into sign-in.
- Non-Goal: changing which failures refuse. Every refusal keeps its status code.
- Non-Goal: a general error-page templating system. One optional heading and one
optional action is the whole addition.
## Decisions
### D1. The renderer seam is an interface owned by auth
`internal/auth` declares the one method it needs and the server satisfies it:
```go
// in internal/auth
type FailurePage interface {
RenderAuthFailure(w http.ResponseWriter, r *http.Request, status int, heading, actionLabel, actionHref string)
}
```
`Config.Failures` holds it. The server assigns it after building
`SafeTemplates`, which is well after `auth.Setup` and `RegisterHandlers` run;
handlers only read the field when a request arrives.
The action travels through the seam as two strings rather than being fixed by
the renderer, because the four failures do not share one way out: a refused
sign-out leaves the person signed in, where a link to sign in again would be a
lie. Strings rather than a struct keep the server's template helper free of a
dependency on this package's types.
Alternatives rejected:
- **Move the error page into a third package both import.** The page needs the
composed template set, which is assembled from integration mounts inside
`server.New`. Extracting it means extracting that assembly.
- **Pass a `func(http.ResponseWriter, *http.Request, int, string)`.** Same
effect, no name. An interface says what the thing is at the call site.
- **Have the server wrap the auth handlers.** The failures are mid-handler, not
at its edge; there is no status the wrapper could distinguish from a genuine
500 elsewhere.
**A nil `Failures` falls back to `http.Error`.** A `Config` built by hand
(every test in the package) has no template set, and an auth failure must not
become a nil dereference.
### D2. The error page gains an optional heading and an optional action
`ErrorPageData` gets `Title` and `Action`. `Header()` uses `Title` when set and
`"<status> <status text>"` otherwise, so every existing call renders exactly as
before. `error.html`'s hard-coded "Back to the dashboard" button becomes
`{{ .Action.Label }}` / `{{ .Action.Href }}`, and `RenderErrorPage` fills that
same pair as the default.
The dashboard is the wrong destination for a failed sign-in: it is the page the
person could not reach, and following it bounces through `/login` anyway. The
sign-in page names `/login` directly.
### D3. Four headings, no body copy
| Heading | Reached by | Way out |
| --- | --- | --- |
| Sign-in expired | state, code verifier or nonce mismatch; a spent authorization code | Sign in again → `/login` |
| Sign-in failed | token exchange, identity-provider response, or provisioning failure | Sign in again → `/login` |
| Registration failed | the registration flow's own refusals | Start again → `/register` |
| Sign-out failed | logout state mismatch, or the session refusing to be destroyed | Sign out again → `/logout` |
None carries a lead sentence. The heading is the fact and the button is the
action; a sentence restating either is the filler the project's copy rule
refuses.
Sign-in splits in two because whether retrying helps is the one thing the person
cannot work out for themselves. Sign-out links back to sign-out, not to sign-in:
all three of its refusals happen before the session is destroyed, so the person
is still signed in and telling them to sign in again would be false.
Log lines keep the detail. The person is not shown which claim failed to parse.
### D4. Every callback refusal is person-facing
`/callback`, `/logout-callback`, `/login` and `/register` are reached only by a
browser following a redirect. There is no machine caller to keep a plain
response for, so all of their `http.Error` dead ends become the styled page.
`bounceToLogin`'s HTMX 401 is untouched: it is consumed by htmx, not read.
### D5. The identity provider's error response is read
Found while verifying D2 live, in the maintainer's own browser history: the
callback that produced the original report was
```
/callback?error=temporarily_unavailable&error_description=authentication_expired&state=...
```
The handler never looked at `error`. It compared the state, then exchanged an
empty `code`, and the exchange's failure surfaced as an internal error. Nothing
on this side had failed; the identity provider had refused the authorization
request (RFC 6749 §4.1.2.1, OIDC Core §3.1.2.6).
The parameter is now read, after the state check and never before it: an error
response carries the state too, so an unsolicited one must not be able to end a
flow the person is still in.
The code chooses the heading on the one axis the person can act on. Six codes
clear on a retry and get `Sign-in expired`: `temporarily_unavailable`,
`server_error`, `login_required`, `interaction_required`, `consent_required`,
`account_selection_required`. Everything else names a configuration or
authorization problem a second attempt reproduces, and gets `Sign-in failed`.
Both answer 400: the request this console received is the thing that cannot be
used, and no failure occurred here.
@@ -0,0 +1,64 @@
## Why
Two defects in `internal/auth`, both found on 2026-09-08, both about how the auth
package communicates — to the operator reading logs, and to the person in front
of the browser.
**It is the last package on stdlib `log`.** 38 calls, against 865 `slog` calls
everywhere else and a house helper (`internal/logging`) that every other package
uses. `slog.SetDefault` bridges the stdlib logger, so the lines still come out
structured, but they carry no attributes: a message is one interpolated string.
That is not only inconsistent, it is why the security audit found two log
defects here and nowhere else. A `%s` verb invites an unescaped value; a typed
attribute does not. `security-audit-remediation` closed the injection by quoting
every externally-supplied value, which holds today but relies on the next author
remembering.
**A stale sign-in tab produces a bare "Invalid state".** Reproduced live: a
Keycloak authorize page left open for 45 minutes, then submitted, returns
`400 Invalid state` as unstyled `http.Error` text. The refusal is correct and
must stay — it is replay protection doing its job — but the person is told
nothing about what happened or what to do, on a dead-end page with no way back
to sign-in. The same is true of the other bare `http.Error` refusals in the
callback paths.
## What Changes
- `internal/auth` moves to `slog` through `logging.FromContext(ctx)`: every call
site becomes a levelled message with typed attributes instead of an
interpolated string. Errors become `Error`, refusals become `Warn`, routine
progress becomes `Info` or `Debug`.
- The stdlib `log` import leaves the package.
- The callback failures that a person can actually reach — stale or missing
state, a spent authorization code, a failed token exchange — render the styled
error page with copy that says the sign-in attempt expired and offers a link to
start again, instead of bare `http.Error` text.
- Machine-facing failures keep their plain responses; only person-facing
dead ends change. In this package that turns out to be all of them: every
refusal is reached by a browser following a redirect.
- The callback reads the identity provider's `error` parameter, which it had
been ignoring. That is the response the original report came back in, and
ignoring it turned the provider's "authentication expired" into an internal
error on this side.
## Capabilities
### Modified Capabilities
- `oidc-login`: a requirement that a person who reaches a failed callback gets a
recoverable, styled page naming what to do, and that auth logging carries typed
attributes rather than interpolated values.
- `error-pages`: the auth callback dead ends join the styled error surface.
## Impact
- Go: `internal/auth/auth.go` (all 38 log sites and all 20 `http.Error` dead
ends), `internal/auth/valkey.go` (one more log site), and the `FailurePage`
seam the auth package declares for the styled renderer.
- Go: `internal/server/render.go` and `anatomy.go` — the error page's heading and
its single action become data, so a caller can name both. `error.html` reads
them; its `<title>` follows the heading.
- No configuration, migration, or persisted-state change.
- The risk this carried, an import cycle with `internal/server`, did not
materialise: the interface is owned by auth and satisfied by the server, which
already imports auth (`design.md`, D1).
@@ -0,0 +1,68 @@
## MODIFIED Requirements
### Requirement: Navigation error responses render styled pages
The system SHALL render styled, full-page error responses inside the standard page chrome for navigation (non-HTMX) requests that resolve to 404 or 500, and for the authentication flows' refusals whatever their status. The page SHALL show the status and a short human-readable message, and SHALL NOT expose internal details (panic values, stack traces, file paths). The styled 404 SHALL render for unauthenticated visitors too: a request for a path no route matches SHALL receive the 404 page rather than a redirect into the identity provider's sign-in flow, so a mistyped or stale URL is diagnosed as "not found" instead of misread as "signed out". Routes that do exist but require authentication keep their normal redirect-to-login behavior.
#### Scenario: Unknown route renders a styled 404
- **WHEN** a browser issues a GET navigation request for a path no route matches
- **THEN** the response SHALL have status 404
- **AND** the body SHALL be the styled error page rendered inside the standard page chrome
#### Scenario: Anonymous visitor to an unknown route gets the 404, not a login form
- **WHEN** an unauthenticated browser requests a path no route matches
- **THEN** the response SHALL be the styled 404 page with status 404
- **AND** the visitor SHALL NOT be redirected to the identity provider's sign-in page
#### Scenario: Anonymous visitor to a real protected route still reaches login
- **WHEN** an unauthenticated browser requests a path that matches an authenticated route
- **THEN** the normal authentication redirect applies
#### Scenario: Panic renders a styled 500 without leaking internals
- **WHEN** a handler panics while serving a navigation request
- **THEN** the recovery middleware SHALL respond with status 500 and the styled error page
- **AND** the response body SHALL NOT contain the panic value or a stack trace
#### Scenario: A refused sign-in renders the styled page
- **WHEN** a person's sign-in callback is refused because its state is stale
- **THEN** the styled error page SHALL be rendered with the unchanged 400 status
- **AND** it SHALL offer a route back into sign-in
## ADDED Requirements
### Requirement: The error page offers exactly one way out
The styled error page SHALL carry exactly one action, and that action SHALL name
a destination the person can actually reach. The default SHALL be the dashboard.
A page rendered for a refused authentication flow SHALL instead offer that flow,
because the dashboard is the page the person could not reach.
The heading SHALL default to the status and its standard text, and a caller MAY
replace it with a heading that names the failure, for a status line that tells
the person nothing (a sign-in that expired is a 400).
The error page SHALL NOT render a location trail. The trail is rooted at the
surface, the page is reachable without a session, and the page is not at a
location: it is what came back instead of one. The single action is the whole
navigation the page offers.
#### Scenario: An ordinary error page offers the dashboard
- **WHEN** a navigation request resolves to a styled 404 or 500
- **THEN** the page's action SHALL lead to the dashboard
#### Scenario: No location trail on an error page
- **WHEN** either the ordinary error page or an authentication failure page is rendered
- **THEN** the page SHALL carry no location trail
#### Scenario: A refused sign-in offers sign-in
- **WHEN** the styled page is rendered for a refused sign-in
- **THEN** the page's action SHALL lead to sign-in
- **AND** the heading SHALL name the failure rather than the status line
@@ -0,0 +1,72 @@
## ADDED Requirements
### Requirement: A refused authentication flow is recoverable
A browser that reaches a refusal in sign-in, registration or sign-out SHALL
receive the styled error page, carrying a heading that names what failed and one
link back into the flow the person was in. The response SHALL NOT be bare error
text and SHALL NOT leave the person on a dead end with no route onward.
Every refusal in these flows is reached by a browser following a redirect, so
this covers all of them: a stale or missing sign-in state, a spent authorization
code, a failed token exchange, an ID token that fails verification, a nonce
mismatch, a provisioning or database failure during sign-in, a logout state that
does not match, and a session that cannot be destroyed.
The refusal itself SHALL NOT be relaxed. The status code SHALL be unchanged, and
no session SHALL be established by a sign-in that was refused.
Sign-in's heading SHALL distinguish an expired flow, where trying again
succeeds, from a failure of the exchange or of provisioning, where it may not.
Sign-out's link SHALL lead back to sign-out rather than to sign-in, because its
refusals occur before the session is destroyed and the person is still signed
in.
#### Scenario: A stale sign-in tab lands on a recoverable page
- **WHEN** a person submits a sign-in page that has been open long enough for its state to no longer match the session
- **THEN** the response SHALL render the styled error page with the unchanged 400 status
- **AND** the page SHALL carry a link that begins a fresh sign-in
- **AND** no session SHALL be established
#### Scenario: A refused sign-out leads back to sign-out
- **WHEN** the logout callback is refused because its state does not match the session
- **THEN** the page's link SHALL lead to sign-out rather than to sign-in
#### Scenario: The identity provider's own refusal is named
- **WHEN** the callback carries an `error` parameter instead of an authorization code, with a state that matches the session
- **THEN** the console SHALL NOT attempt a token exchange
- **AND** the response SHALL render the styled page naming whether a fresh attempt is likely to succeed
- **AND** the error code and its description SHALL be logged as attributes
#### Scenario: An unsolicited error response cannot end a live flow
- **WHEN** the callback carries an `error` parameter with a state that does not match the session
- **THEN** the state mismatch SHALL be what refuses the request
- **AND** the error parameter SHALL NOT be read
#### Scenario: A deployment without a renderer still refuses
- **WHEN** no error-page renderer is wired into the authentication package
- **THEN** the refusal SHALL still be answered with its status and the heading as plain text
### Requirement: Auth logging carries typed attributes
Log statements in the authentication package SHALL use the application's
structured logger with typed attributes, not the standard library's logger or
interpolated format strings. A value that arrives from a request or from the
identity provider SHALL be carried as an attribute value, so the handler escapes
it and no caller can forge a log record.
#### Scenario: A crafted value cannot forge a record
- **WHEN** a request supplies a `state` containing carriage returns or newlines and the handler logs the refusal
- **THEN** the refusal SHALL occupy exactly one log record
- **AND** the record SHALL NOT contain a forged record from the supplied value
#### Scenario: Levels distinguish failure from routine progress
- **WHEN** the package logs an internal error, a refused request, and a routine step
- **THEN** they SHALL be recorded at error, warning, and informational levels respectively
@@ -0,0 +1,43 @@
# Tasks: auth-diagnostics
Queued 2026-09-08 at the maintainer's request, alongside the audit work.
## 1. Logging
- [x] 1.1 Replace all 38 stdlib `log` calls in `internal/auth` with `logging.FromContext(ctx)` and typed `slog` attributes
- [x] 1.2 Assign honest levels: errors `Error`, refusals `Warn`, routine progress `Info`/`Debug`
- [x] 1.3 Remove the `log` import; confirm no stdlib `log` call remains outside the test fake server
- [x] 1.4 Test: a `state` carrying newlines occupies exactly one log record
- [x] 1.5 `generateRandomString`'s dead `log.Fatalf`-then-`panic` becomes one panic naming the cause
## 2. Person-facing callback failures
- [x] 2.1 Decide the renderer seam in `design.md` — auth must not import `internal/server`
- [x] 2.2 Stale/missing state, spent code, and failed token exchange render the styled error page
- [x] 2.3 Copy says the sign-in attempt expired and links back to start again; no jargon, no "Invalid state"
- [x] 2.4 Machine-facing refusals keep plain responses — nothing to do: every refusal in the package is reached by a browser following a redirect (design.md, D4), and `bounceToLogin`'s HTMX 401 is untouched
- [x] 2.5 Test: a callback with a stale state returns the styled page carrying a sign-in link
- [x] 2.6 The error page's action becomes data, so a refused sign-out leads back to sign-out rather than to a sign-in the person does not need
- [x] 2.7 The page's `<title>` follows the heading instead of the status line
- [x] 2.8 The error page carries no location trail: its root is a link a visitor without a session may not be able to follow, and the page is not at a location
## 3. The identity provider's error response
Found while verifying section 2 live; it is the callback that produced the
original report (design.md, D5).
- [x] 3.1 Read the callback's `error` parameter, after the state check and never before it
- [x] 3.2 Choose the heading on whether a retry clears the code
- [x] 3.3 Test: the transient code, a non-retryable code, and an unsolicited error response with the wrong state
## 4. Verification
- [x] 4.1 `gofmt`, `go build`, `go vet`, `make lint`, `make lint-templates` clean
- [x] 4.2 `make test` green
- [x] 4.3 Live: a sign-in tab whose state no longer matches renders "Sign-in expired" with a working "Sign in again"
- [x] 4.4 Live: the maintainer's own failing URL (`error=temporarily_unavailable&error_description=authentication_expired`) renders the same page, and logs the provider's code and description as attributes
## 5. Review and archive
- [x] 5.1 Maintainer reviews (approved 2026-09-09)
- [x] 5.2 On the maintainer's go, sync the delta specs and archive in the implementation's commit
@@ -0,0 +1,59 @@
## Why
`gorilla/csrf` v1.7.3 carries GO-2025-3884 / CVE-2025-47909: its `TrustedOrigins`
escape hatch compares only the host and ignores the scheme, so a policy written
for `https://host` is equally satisfied by `http://host`. `govulncheck` reports it
as symbol-reachable through `internal/middleware/csrf.go`. **There is no patched
release** and the project is effectively unmaintained; the advisory directs
migration to `net/http.CrossOriginProtection` or `filippo.io/csrf`.
After `security-audit-remediation` this is the last reachable advisory in the
whole dependency graph.
Maintainer's decision, 2026-09-08 (audit design D1, option A): move to the
standard library. The application is server-rendered with same-origin form posts
and HTMX, which is the shape `CrossOriginProtection` was built for, and it
removes a dependency rather than swapping one.
## What Changes
- CSRF protection becomes `net/http.CrossOriginProtection`, which rejects
non-safe cross-origin requests using `Sec-Fetch-Site` (or an Origin/Host
comparison) instead of issuing a token. `AddTrustedOrigin` takes a **full
origin including scheme**, which is precisely what the advisory was about.
- **The CSRF token disappears.** `middleware.CSRFToken`,
`middleware.CSRFTemplateField` and `middleware.ParseCSRFKey` are removed, along
with the `CSRFToken` field on every page-data struct and the
`hx-headers:inherited='{"X-CSRF-Token": ...}'` attribute on all four shell
templates.
- Integration-declared exempt paths move from per-path `Ignore` predicates to
`AddInsecureBypassPattern`. The deny path keeps its 403 and the literal "CSRF"
in the body, which client-side code detects to preserve user input.
- **BREAKING (configuration):** `csrf-secret` and `csrf-secret-file` are retired.
There is no token, so there is no key to derive it from. Boot validation, the
CLI flags, the file-backed secret entry, `internal/embeds/mc-config.yaml` and
`docs/hosting.md` all drop it. A deployment that still sets it is unaffected;
the key is simply no longer read.
- `github.com/gorilla/csrf` leaves `go.mod`.
## Capabilities
### Modified Capabilities
- `http-security-headers`: the cross-origin protection requirement is restated in
terms of origin checking rather than token issuance, and pins that a trusted
origin is matched with its scheme.
- `startup-configuration`: `csrf-secret` is no longer a required key and no longer
fails boot when absent or malformed.
## Impact
- Go: `internal/middleware/csrf.go` (rewritten), `internal/server/server.go`
(wiring), the ~14 handlers that populated `CSRFToken`, `internal/config/validate.go`,
`cmd/start.go`.
- Templates: `index.html`, `operator.html`, `billing.html`, `products.html`, plus
the partial comments that describe the old header pattern.
- Docs: `docs/hosting.md`, `internal/embeds/mc-config.yaml`.
- A welcome side effect: native form posts no longer depend on the HTMX header
workaround. That workaround existed because `Referrer-Policy: no-referrer` made
`Origin` null on native posts; `Sec-Fetch-Site` is unaffected by referrer policy.
@@ -0,0 +1,37 @@
## MODIFIED Requirements
### Requirement: `init` scaffolds a usable starter config
`member-console init <path>` SHALL write a starter `mc-config.yaml` to the target
directory composed of the embedded core configuration template (`embeds.Config`)
plus one generated section per registered integration that declares configuration
(`config.ConfigProvider`), not a placeholder stub. The scaffolded file SHALL be
valid YAML containing every configuration key that `member-console start` requires,
with commented placeholder values the operator fills in. Each generated integration
section SHALL list that integration's `ConfigSpec` keys as commented lines carrying
the key's usage text, its default when one is declared, and a file-based
alternative note for secret keys. The embedded core template SHALL NOT hand-list
keys that an integration's `ConfigSpec` declares. The template's OIDC issuer
example SHALL reference a dedicated application realm placeholder, never an
administration realm such as Keycloak's `master`.
#### Scenario: init writes the embedded starter template
- **WHEN** an operator runs `member-console init ./my-instance`
- **THEN** `./my-instance/mc-config.yaml` SHALL be created from the embedded template
- **AND** it SHALL parse as YAML and contain the required keys (`base-url`, `db-dsn`,
`valkey-addr`, `valkey-password`, `oidc-idp-issuer-url`, `oidc-sp-client-id`)
#### Scenario: Every declaring integration appears in the scaffold
- **WHEN** the scaffolded config is inspected
- **THEN** it SHALL contain one commented section per registered integration that
declares configuration, including every key from that integration's `ConfigSpec`
- **AND** no registered declaring integration SHALL be absent
#### Scenario: Integration sections are generated, not hand-maintained
- **WHEN** a new integration registering a `ConfigSpec` is added to the integration
registry
- **THEN** `init`'s scaffold SHALL include its section with no change to the
embedded template or `init` code
@@ -0,0 +1,42 @@
## ADDED Requirements
### Requirement: Non-safe cross-origin requests are refused
The application SHALL refuse non-safe cross-origin browser requests by inspecting
the request's origin, not by issuing, storing, or validating a token. A trusted
origin SHALL be matched including its scheme, so that a policy naming an `https`
origin is not satisfied by the same host over plain `http`.
Because safe methods are always permitted, no handler SHALL change state in
response to a GET, HEAD, or OPTIONS request.
#### Scenario: A same-origin form post succeeds
- **WHEN** a browser submits a form to the application from one of its own pages
- **THEN** the request SHALL be allowed
- **AND** it SHALL succeed with no token present in the form, in a header, or in a cookie
#### Scenario: A cross-site post is refused
- **WHEN** a non-safe request arrives from another site
- **THEN** the application SHALL refuse it with 403
- **AND** the response body SHALL identify the refusal as a CSRF check failure, so
client-side code can preserve the person's input
#### Scenario: A trusted origin is scheme-sensitive
- **WHEN** an origin is trusted as `https://host` and a request arrives from `http://host`
- **THEN** the request SHALL be refused
#### Scenario: A misconfigured trusted origin fails at startup
- **WHEN** a configured trusted origin carries no scheme
- **THEN** startup SHALL fail with an error naming that origin, rather than silently
dropping it and leaving the deployment to reject its own posts
#### Scenario: A signature-verified endpoint bypasses the check
- **WHEN** a provider webhook that authenticates its caller by signature receives a
cross-origin delivery
- **THEN** cross-origin protection SHALL NOT refuse it
- **AND** the endpoint's own signature verification SHALL still decide the outcome
@@ -0,0 +1,33 @@
## MODIFIED Requirements
### Requirement: Boot-time configuration validation
Boot-time validation SHALL run before database migrations and before any service
starts, SHALL name the offending configuration key and how to set it (its `MC_*`
environment variable or `mc-config.yaml` key), and a single run SHALL report every
problem at once rather than only the first.
The following core configuration SHALL be required unconditionally: `db-dsn` (a
valid PostgreSQL URL), `valkey-addr`, `oidc-idp-issuer-url` (a valid URL),
`oidc-sp-client-id`, and `base-url` (a valid URL). `oidc-sp-client-secret` SHALL
NOT be required (public PKCE clients have none). `csrf-secret` SHALL NOT be
required and SHALL NOT be read: cross-origin protection issues no token, so there
is no key to derive one from.
#### Scenario: Missing required core configuration is reported before migrations
- **WHEN** a required core key is absent at startup
- **THEN** validation SHALL fail before database migrations run
- **AND** the error SHALL name the key and how to set it
#### Scenario: Every problem is reported at once
- **WHEN** several configuration problems are present
- **THEN** the command SHALL report every problem in a single aggregated error, not
just the first
#### Scenario: A deployment that still sets csrf-secret boots normally
- **WHEN** a configuration file or environment still carries `csrf-secret`, of any
length or none
- **THEN** startup SHALL ignore it and SHALL NOT fail on its value
@@ -0,0 +1,49 @@
# Tasks: csrf-standard-library
Closes audit design decision D1 (option A). `gorilla/csrf` carried
GO-2025-3884 with no fixed release; this removes the dependency rather than
swapping it.
## 1. The protection
- [x] 1.1 `internal/middleware/csrf.go` rewritten around `net/http.CrossOriginProtection`; `CSRFConfig` now carries `TrustedOrigins`, `BypassPatterns` and `DenyHandler` only
- [x] 1.2 `CSRF()` returns an error rather than swallowing a bad trusted origin, so a misconfigured deployment fails at boot instead of rejecting its own posts
- [x] 1.3 `internal/server/server.go` builds the trusted origin from `base-url` **with its scheme** (the defect), maps `csrfExemptPaths` onto `AddInsecureBypassPattern`, and keeps the literal "CSRF" in the 403 body for client-side detection
## 2. Remove the token
- [x] 2.1 `CSRFToken`, `CSRFTemplateField` and `ParseCSRFKey` deleted
- [x] 2.2 `CSRFToken` removed from every page-data struct and every handler that populated it
- [x] 2.3 `hx-headers:inherited='{"X-CSRF-Token": ...}'` removed from `index.html`, `operator.html`, `billing.html`, `products.html`; the two partial comments describing the old pattern updated
- [x] 2.4 Render tests that set `CSRFToken` updated
## 3. Retire the secret (BREAKING, configuration)
- [x] 3.1 `csrf-secret` boot validation removed from `internal/config/validate.go`
- [x] 3.2 `--csrf-secret` / `--csrf-secret-file` flags and the file-backed secret entry removed from `cmd/start.go`; `server.Config.CSRFSecret` deleted
- [x] 3.3 `csrf-secret` removed from `internal/embeds/mc-config.yaml`, `test/mc-config.yaml` and `docs/hosting.md`
- [x] 3.4 The `console-init` spec's scaffold key list drops `csrf-secret`. It gains `valkey-password` in the same delta rather than a second one, because `session-store-credentials` makes that key required and the two changes are archived in one commit; two deltas restating one requirement would have let archive order decide the outcome
- [x] 3.4 Config tests updated; the scaffold-parity test no longer expects the key
## 4. Dependency
- [x] 4.1 `github.com/gorilla/csrf` gone from `go.mod` via `go mod tidy`
## 5. Tests
- [x] 5.1 `internal/middleware/tests/cross_origin_test.go`: same-origin allowed, cross-site refused, **trusted origin is scheme-sensitive** (pins the CVE), scheme-less origin fails construction, bypass pattern exempts only its own path
- [x] 5.2 The old `csrf_cookie_test.go` deleted — there is no CSRF cookie now
## 6. Verification
- [x] 6.1 `gofmt`, `go build ./...`, `go vet ./...` clean
- [x] 6.2 `make lint` and `make lint-templates` clean
- [x] 6.3 `make test` green, 0 failures
- [x] 6.4 `govulncheck`: **"No vulnerabilities found."** — 0 affected, down from 26. The single remaining advisory is unreachable and in a transitive compression library
- [x] 6.5 Live: same-origin POST passes (302), cross-site POST refused (403 with the CSRF marker), `/webhooks/stripe` bypasses protection and is then judged by its own signature (400), and no `_gorilla_csrf` cookie is issued
- [x] 6.6 Live browser walkthroughs against the running app: 12 passed, 2 skipped on absent environment, 0 failed. The form-driving ones (grants, org types, plan ladders, integration settings, modals) prove HTMX posts still work without the token
## 7. Review and archive
- [x] 7.1 Maintainer reviews (approved 2026-09-09) the breaking configuration change
- [x] 7.2 On the maintainer's go, sync the delta specs and archive in the implementation's commit
@@ -0,0 +1,66 @@
## Context
`security-audit-remediation` D2 named three shapes and called it a policy
question. The maintainer settled it on 2026-09-09 after the shapes were laid
out with the refresh token, kept in the session since `logout-ends-session`,
as the instrument.
## Goals / Non-Goals
- Goal: a change at the provider reaches the console within a known bound.
- Goal: no new provider requirement and no provider-specific call.
- Non-Goal: instant revocation. That needs push, which no provider offers in a
neutral way today; it can be layered on later for the events it covers.
## Decisions
### D1. Re-derive on an interval, from the refresh token
| | Interval refresh | Every request | Short session, silent re-auth | Push |
| --- | --- | --- | --- | --- |
| Lag | 5 min | none | 5 min | seconds |
| Cost | one call per session per interval | one call per request | a full-page redirect every 5 min | none per request |
| Catches disabled accounts and ended sessions | yes | yes | yes | logout only |
| Needs | refresh tokens, roles in the ID token | same | `prompt=none` | back-channel logout; SSF/CAEP for roles |
Every request is unaffordable for a hypermedia app. Silent re-auth breaks
in-flight HTMX partials every five minutes for every member. Push covers the
wrong event set and is provider-specific. Interval refresh needs nothing the
console does not already need.
### D2. Lazily, in the middleware
The check runs on the first authenticated request after the interval, after the
per-request person check. No background work per session, nothing to schedule,
nothing that outlives a request. A session nobody uses is never refreshed and
never needs to be.
### D3. Three outcomes, weighted differently
A definitive refusal ends the session: `invalid_grant` is what RFC 6749 section
5.2 has the provider say when the grant is invalid, expired or revoked, and
Keycloak says it for a disabled account, an ended session, and a revoked token
alike. No answer keeps the session and backs off a minute: a timeout or a 5xx
says nothing about this person, an outage at the provider must not sign
everyone out, and an operator whose role was revoked cannot cause one. Any
other provider error (`invalid_client`, `unauthorized_client`) is a
configuration problem, not this session's, and is treated as no answer.
### D4. One refresh per session at a time
Two requests crossing the interval together would both present the same
refresh token. With Keycloak's "Revoke Refresh Token" enabled the second is
refused as a replay, which D3 reads as definitive. `singleflight` keyed by
session token serialises them within a process; the second waits and applies
the first's answer. Across processes the race remains and is the same
provider setting's problem; noted, not solved.
### D5. The subject is checked
The refreshed ID token's `sub` must match the session's. It cannot differ on a
sane provider; if it does, the session takes on nobody else's identity.
### D6. Five minutes
Keycloak's default access-token lifetime, and the bound the maintainer chose.
`identityRefreshInterval` is one constant.
@@ -0,0 +1,51 @@
## Why
Roles were copied into the session at sign-in and read from that copy for the
session's week-long life. The identity provider was never asked again, so
removing someone's `operator-member` role, disabling their account, or ending
their session at the provider changed nothing here until the week was up.
Finding 3 of the 2026-09 security audit; decision D2 in
`security-audit-remediation`.
Push from the provider is not available in a provider-neutral way: OpenID
Back-Channel Logout covers logout only, and the Shared Signals events that
would cover a role change are experimental in Keycloak 26.7 (the stack runs
26.4.7) and unsupported elsewhere. Asking the provider is.
## What Changes
- Once every five minutes, on the first authenticated request after the
interval, the session's identity and roles are re-derived from the provider
with a refresh-token grant. The new ID token is verified as sign-in verifies
one; its roles replace the session's; the new ID token and refresh token
replace the stored ones; the clock restarts.
- A definitive refusal (`invalid_grant`: the session ended at the provider, the
account was disabled, the token was revoked) ends the console session on the
spot, and the request bounces to sign-in.
- No answer (timeout, 5xx, a token that fails verification) keeps the session's
last known state and asks again after a minute, the way the per-request
person check already treats a database blip.
- A refreshed token that names a different subject ends the session.
- Requests that cross the interval together share one refresh, so a provider
that retires a refresh token on use does not refuse the second as a replay.
- A session without a refresh token stays on its sign-in snapshot, as before.
## Capabilities
### Modified Capabilities
- `oidc-login`: a requirement that a session's identity and roles are
re-derived from the provider on an interval.
## Impact
- Go: `internal/auth/auth.go` (`refreshIdentity`, `rederive`, the middleware
hook, two session keys); `internal/auth/identity_refresh_test.go` (new).
`golang.org/x/sync/singleflight` becomes a direct dependency.
- One provider call per signed-in session per five minutes, on the request
that crosses the interval. Measured against the test stack's Keycloak the
refresh grant answers in tens of milliseconds.
- Revocation lag is bounded at five minutes; it was a week. An operator's role
removal is not instant, and the constant is one line if the bound is wrong.
- Provider requirements are unchanged: refresh tokens and roles in the ID
token, both already documented.
@@ -0,0 +1,40 @@
## ADDED Requirements
### Requirement: A session's identity is re-derived from the provider on an interval
The console SHALL re-derive a session's identity and roles from the identity
provider with a refresh-token grant once every five minutes, on the first
authenticated request after the interval. The returned ID token SHALL be verified as
a sign-in ID token is, its roles SHALL replace the session's, the returned ID
token and refresh token SHALL replace the stored ones, and the interval SHALL
restart.
A definitive refusal (`invalid_grant`) SHALL end the session, and the request
SHALL be treated as unauthenticated. A refreshed token naming a different
subject SHALL end the session. No answer (a timeout, a server error, a token
that fails verification) SHALL keep the session's last known state, and the
console SHALL NOT ask again for at least one minute.
Requests that cross the interval together SHALL share one refresh. A session
without a refresh token SHALL keep its sign-in snapshot.
#### Scenario: A role removed at the provider is gone within the interval
- **WHEN** an operator's `operator-member` role is removed at the provider
- **THEN** their next request more than five minutes after the session's last re-derivation SHALL carry no operator role
#### Scenario: A session ended at the provider ends here
- **WHEN** the provider answers the refresh with `invalid_grant`
- **THEN** the console session SHALL end and the request SHALL bounce to sign-in
#### Scenario: A provider outage signs nobody out
- **WHEN** the provider gives no usable answer
- **THEN** the request SHALL proceed with the session's last known roles
- **AND** no further refresh SHALL be attempted for that session within one minute
#### Scenario: Requests crossing the interval together
- **WHEN** several requests for one session arrive after the interval at once
- **THEN** exactly one refresh SHALL be made and every request SHALL use its answer
@@ -0,0 +1,39 @@
# Tasks: identity-refresh-interval
Started 2026-09-09 as audit decision D2.
## 1. Re-derivation
- [x] 1.1 `refreshIdentity`: interval and backoff gates, one shared refresh per session, three outcomes
- [x] 1.2 `rederive`: refresh-token grant, ID token verified as at sign-in, roles read as at sign-in
- [x] 1.3 Middleware hook after the person check; a definitive refusal ends the session and bounces
- [x] 1.4 Sign-in stamps `identity_refreshed_at`
- [x] 1.5 `providerCallTimeout` shared by the sign-out and re-derivation calls
## 2. Tests (`internal/auth/identity_refresh_test.go`)
- [x] 2.1 Inside the interval: no call
- [x] 2.2 A role removed at the provider is gone after the interval
- [x] 2.3 A role granted arrives; tokens and clock replaced; no second call inside the new interval
- [x] 2.4 `invalid_grant` ends the session and bounces to `/login`
- [x] 2.5 No answer keeps the session and backs off
- [x] 2.6 A different subject ends the session
- [x] 2.7 No refresh token: no call, snapshot kept
- [x] 2.8 Concurrent requests share one refresh
## 3. Verification
- [x] 3.1 `gofmt`, `go build`, `go vet`, `make lint`, `make lint-templates` clean (2026-09-09, whole tree)
- [x] 3.2 `make test` green. The first full run on this build had one walkthrough failure (`TestPlanLaddersWalkthrough`, 16 s) that coincided with the live checks driving the same Chrome instance; the log shows no refused operator request in that window and the role removal came after the walkthrough's last request. Re-run alone and in a quiet full run, it passes in 5 s
- [x] 3.3 Live (2026-09-09): alice signed in 00:49:17 with `Operator panel` in her sidebar; her `operator-member` client role was unassigned in the Keycloak admin console at 00:49:56; her next request at 00:55:07 answered in 13.9 ms with the refresh call included, the sidebar no longer carries `Operator panel`, and the log reads `roles changed at the identity provider; session updated before=[operator-member] after=[]`
- [x] 3.4 Live (2026-09-09): carlos signed in 00:49:14; his account was disabled in the Keycloak admin console at 00:49:56; his next request at 00:55:08 answered 302 to `/login` in 6.3 ms, the browser landed on Keycloak's sign-in form, and the log reads `the identity provider refused the session's refresh token; ending the session error="oauth2: invalid_grant User disabled"`
## 4. Docs
- [x] 4.1 `docs/identity-provider-setup.md`: refresh tokens also serve re-derivation; roles must be in the ID token on refresh
- [x] 4.2 `internal/auth/README.md`: session keys and the re-derivation step
## 5. Review and archive
- [x] 5.1 Maintainer reviews (approved 2026-09-09)
- [x] 5.2 On the maintainer's go, sync the delta spec and archive in the implementation's commit
@@ -0,0 +1,125 @@
## Context
`security-audit-remediation` D5 offered two shapes: destroy the session locally
and carry the logout state somewhere that survives, or keep the round trip
authoritative and add a bounded fallback. Discussion on 2026-09-08 settled on
the first, and went one step further: remove the reason the round trip was
abandonable in the first place.
## Goals / Non-Goals
- Goal: Sign out always ends the console session, on the request that says so.
- Goal: The identity provider ends its session without prompting whenever the
console can give it a valid hint.
- Non-Goal: Forcing the provider's session to end when the person walks away
from the provider's own confirmation page. That page appears only when no
valid hint could be had, and what the provider does with its own session is
the provider's.
- Non-Goal: Using the refresh token for anything but the sign-out hint. D2
(role revocation lag) may reuse it; that is D2's design to make.
## Decisions
### D1. The logout state goes, rather than moving into a cookie
The state existed to let the callback verify that the redirect it received
belonged to a logout the console had started, before the callback destroyed
the session. With the session destroyed at `/logout`, the callback does one
thing, redirect to `/login`, and an unsolicited visit that triggers it harms
nobody. A short-lived signed cookie could have preserved verification; that is
machinery bought to protect a redirect to a sign-in page.
### D2. The refresh token is kept, for the hint
The provider skips its confirmation prompt when the request carries a valid
`id_token_hint`. The stored ID token is valid for minutes; sessions last a week.
The only way to have a valid hint later is to mint one, and the standard way to
mint one is a refresh-token grant (OAuth 2.0 section 6; OIDC Core section 12).
The stored token is used while it is still valid, so the common case makes no
call. Past that, one call bounded to five seconds. Its failure, or a provider
whose refresh response carries no ID token (OIDC Core section 12.2 makes that
optional), is logged at warning and costs the hint only. The sign-out proceeds
and the provider prompts, exactly as it did before this change.
### D3. What holding a refresh token means for the store, and revocation
A refresh token can mint access tokens for as long as the provider's session
lives; it is a stronger credential than the ID token the store already held.
It never reaches the browser (the cookie carries a session id only) and it is
deleted with the session at sign-out. The store it sits in required a password
as of `session-store-credentials`, which landed first for this reason among
others.
It is also revoked at the provider on sign-out (RFC 7009), once the session is
destroyed and after any refresh. Every token that was live is revoked: the
stored one and, when a refresh handed back a different one, that one too. RFC
7009 obliges a provider to invalidate the token named and its access tokens,
not other refresh tokens of the same grant, so revoking only the newest would
leave the older one working on a provider that rotates without retiring. The provider's own logout invalidates the
session's tokens as well, so in the common path revocation is redundant; what it
buys is the case where that logout does not happen, and the assurance that a
copy of the store taken before sign-out holds a token that no longer works.
Best effort, bounded to five seconds, logged at warning on failure. A provider
that publishes no `revocation_endpoint` is warned about once at startup and the
step is skipped.
### D4. The endpoints are discovered, with no fallback
`end_session_endpoint` is part of OpenID Connect RP-Initiated Logout 1.0
(section 2.1) and every provider that supports RP-initiated logout publishes
it; `revocation_endpoint` is RFC 8414's name for the RFC 7009 endpoint. Both
are read from the discovery document once, in `Setup`, and stored on the
`Config`. A provider that publishes no `end_session_endpoint` cannot end its
session on the person's behalf, so `Setup` refuses to start and says why, rather
than guess a path. The Keycloak path the handler used to hard-code is gone. A
`Config` built without an endpoint (only possible by hand) refuses the sign-out
with a logged error rather than redirecting to nowhere.
### D5. The callback destroys once more, and never refuses
With nothing to verify, the callback could simply redirect. Destroying first is
cheap and covers two shapes: a browser still carrying the cookie for the session
`/logout` deleted gets it expired, and a store that refused the first delete
gets a second chance. A failure there is logged at warning and not shown; the
person asked to sign out and is on their way to the sign-in page either way.
### D6. Sign out posts, from a declared trigger, and GET /logout does nothing
`/logout` was a GET, so a page on any origin could end a person's session by
sending the browser there (an image tag is enough); the cross-origin
protection never sees a GET. Sign-out changes state and is a POST now.
Where the control lives was a real choice. The design system's rule that Sign
out is plain text and last still holds; what changes is the element. Three
homes were possible: a native `<form method="post">` in the top bar, which the
anatomy lint forbids outside the form parts and would need a recorded
exception; a new form family for a one-button form, which is a form with no
fields; or the action-trigger registry, which exists for exactly this shape
("a single-intent control with nothing to fill in"). The trigger is the
declared home, so Sign out is an `hx-post` button declared in
`action_triggers.go`, and the route test that walks the router finds `POST
/logout` accounted for. No lint exception, no new family.
Two consequences. First, fetch cannot follow a redirect to the provider's
origin, so the handler answers an htmx request with `HX-Redirect` naming the
end-session URL (the billing checkout precedent); any other POST gets a 303.
Second, the sign-out failure page's action was a link to `/logout`, now a GET
that ends nothing; it leads back to the console, where the account menu is.
A GET at `/logout` redirects to `/` rather than answering 405: whoever arrives
that way followed a link or a bookmark and should land somewhere, not on a
bare status line. `LogoutHandler` itself still refuses anything but a POST,
for a caller that wires it without `RegisterHandlers`.
The backslash fix rides here because it is the audit's other auth candidate
and three lines. `validReturnTo` refuses any backslash rather than modelling
the WHATWG parser: no console path contains one.
## Risks
- A provider that issues no refresh token behaves as before: prompt past the ID
token's lifetime. Documented as a prerequisite.
- The refresh call adds up to five seconds to a sign-out whose stored ID token
has expired, which is most of them on Keycloak's defaults. A back-channel POST
to the provider is normally far quicker.
@@ -0,0 +1,74 @@
## Why
Clicking Sign out did not sign you out. `/logout` wrote a logout state into the
session and redirected to the identity provider; the session was destroyed only
in `/logout-callback`, after the provider had sent the browser back and the
state had matched. A person who never completed that round trip kept a working
console session for the rest of its week.
The round trip is abandonable by design. The console attaches `id_token_hint`
only while the stored ID token is valid, and Keycloak ID tokens live about five
minutes, so nearly every real sign-out reached Keycloak without a hint, and
Keycloak answers a hintless request with "Do you want to log out?". Closing the
tab there was the whole failure. Finding 11 of the 2026-09 security audit;
decision D5 in `security-audit-remediation`.
## What Changes
- `/logout` destroys the console session before redirecting. Sign-out is
unconditional; the provider's round trip no longer decides it.
- The logout state and its check are removed. Once the session is gone at
`/logout`, the callback's only action is a redirect to `/login`, and there is
nothing a forged visit could do.
- The refresh token the identity provider issued at sign-in is kept in the
server-side session. At sign-out, when the stored ID token has expired, a
fresh one is minted from it in one bounded call and sent as the hint, so the
provider ends its session without prompting. A refresh that fails, or a
provider that issues no refresh token, costs the hint and nothing else.
- The logout endpoint is the provider's discovered `end_session_endpoint`,
read once at startup. A provider that publishes none fails startup; the
Keycloak path the handler used to hard-code is gone, with no fallback.
- The refresh token is revoked at the provider's discovered
`revocation_endpoint` (RFC 7009) once the session is gone, so no copy of it
can mint tokens after sign-out. Best effort; a provider without the endpoint
is warned about once at startup.
- `/logout-callback` destroys the session once more (expiring a cookie the
browser may still carry) and redirects to `/login`. It never refuses.
- Sign-out is a POST. The account menu's Sign out is a button that posts
(`hx-post`), declared as an action trigger; the handler answers htmx with
`HX-Redirect` to the provider. A GET at `/logout` redirects to `/` and ends
nothing, so a link on another origin cannot sign a person out (the audit's
"Logout CSRF" candidate, confirmed: the old GET was exempt from the
cross-origin protection by its method).
- `validReturnTo` refuses any backslash. Browsers parse `\` as `/` in an
http(s) URL, so `/\evil.example` was a protocol-relative redirect to the
browser and a same-origin path to `net/url` (the audit's "Open redirect
via backslash" candidate, confirmed by reading; the fix does not depend on
which browsers do it).
## Capabilities
### Modified Capabilities
- `oidc-login`: a requirement that sign-out ends the console session before the
provider is asked, that a valid hint is sent where one can be had, and that
the callback never gates the sign-out; a requirement that sign-out is a
POST; a scenario on the return-to requirement refusing backslashes.
## Impact
- Go: `internal/auth/auth.go` (`LogoutHandler`, `LogoutCallbackHandler`, two
helpers, one new session key, one removed; `POST /logout` and a `GET
/logout` redirect; `validReturnTo`); `internal/auth/logout_test.go` (new);
`internal/server/action_triggers.go` (the Sign out trigger) and the two
render tests that pin the account menu.
- Template: `partials/shell_topbar.html`, Sign out becomes a posting button.
`docs/design-system.md` account menu section updated to match.
- Sessions now hold a refresh token. It never leaves the server-side store, and
that store is password-protected as of `session-store-credentials`, which is
the precondition this relies on. See `design.md`, D3.
- No configuration, migration or provider-side change for Keycloak: refresh
tokens, `end_session_endpoint` and `revocation_endpoint` are its defaults.
Documented as prerequisites for other providers in
`docs/identity-provider-setup.md`; a provider without `end_session_endpoint`
now fails startup.
@@ -0,0 +1,119 @@
## ADDED Requirements
### Requirement: Sign-out ends the session before the identity provider is asked
`/logout` SHALL destroy the console session before redirecting the browser to the
identity provider's end-session endpoint. The sign-out SHALL NOT depend on the
provider redirecting back: a person who leaves the provider's page is signed out
of the console regardless.
The redirect SHALL carry `id_token_hint` whenever a valid ID token can be had:
the stored token while it is still valid, otherwise a fresh one minted from the
stored refresh token in one call bounded in time. A refresh that fails, a refresh
response without an ID token, or a session without a refresh token SHALL omit the
hint and SHALL NOT delay or prevent the sign-out.
The refresh token SHALL be held only in the server-side session store and SHALL
be deleted with the session.
The end-session endpoint SHALL be the provider's discovered
`end_session_endpoint`, read at startup. Startup SHALL fail when the provider
publishes none.
Once the session is destroyed, every refresh token that was live SHALL be
revoked at the provider's discovered `revocation_endpoint` when one is
published: the stored token, and the one a refresh handed back when it differs.
Each is one call bounded in time whose failure SHALL NOT prevent the sign-out.
`/logout-callback` SHALL redirect to `/login` unconditionally. It SHALL NOT
verify a state, because the session it once protected has already ended.
#### Scenario: Leaving the provider's page still signs out of the console
- **WHEN** a signed-in person requests `/logout` and never completes the provider's round trip
- **THEN** a request carrying their previous session cookie SHALL be treated as unauthenticated
- **AND** the response to `/logout` SHALL have expired the session cookie
#### Scenario: An aged session mints a fresh hint
- **WHEN** a person whose stored ID token has expired requests `/logout` and the session holds a refresh token
- **THEN** the console SHALL obtain a fresh ID token with a `refresh_token` grant
- **AND** the redirect SHALL carry it as `id_token_hint`
#### Scenario: A still-valid stored token is the hint
- **WHEN** the stored ID token is still valid at `/logout`
- **THEN** it SHALL be the hint and the token endpoint SHALL NOT be called
#### Scenario: The hint never gates the sign-out
- **WHEN** the refresh fails, or the session holds no refresh token
- **THEN** the redirect SHALL carry no `id_token_hint`
- **AND** the session SHALL still have ended
#### Scenario: The refresh token is revoked at the provider
- **WHEN** a person requests `/logout` and the session holds a refresh token
- **THEN** the console SHALL send that token, and the one a refresh rotated it to when there is one, to the provider's revocation endpoint, authenticated as the client
- **AND** a failed revocation SHALL NOT prevent the sign-out
#### Scenario: A provider without an end-session endpoint cannot start
- **WHEN** the provider's discovery document publishes no `end_session_endpoint`
- **THEN** startup SHALL fail naming the missing endpoint
#### Scenario: The callback never refuses
- **WHEN** `/logout-callback` is requested with no session, with a stale cookie, or with any query
- **THEN** the response SHALL be a redirect to `/login`
### Requirement: Sign-out is a POST
`/logout` SHALL end a session only on a POST. The console's sign-out control
SHALL be a button that posts, declared as an action trigger, so the
cross-origin protection covers it. A GET at `/logout` SHALL redirect to `/`
and SHALL NOT change the session. The handler SHALL answer an htmx request
with `HX-Redirect` naming the provider's end-session URL, because fetch
cannot follow a redirect to another origin; any other POST SHALL receive a
303 to that URL.
#### Scenario: A link cannot sign a person out
- **WHEN** a signed-in browser is sent to `/logout` by a GET, from any origin
- **THEN** the response SHALL be a redirect to `/` and the session SHALL remain valid
#### Scenario: The account menu signs out
- **WHEN** the account menu's Sign out posts to `/logout` with `HX-Request`
- **THEN** the session SHALL be destroyed and the response SHALL be 200 with `HX-Redirect` naming the end-session URL, hint included where one can be had
#### Scenario: Any other POST is redirected
- **WHEN** `/logout` receives a POST without `HX-Request`
- **THEN** the session SHALL be destroyed and the response SHALL be a 303 to the end-session URL
## MODIFIED Requirements
### Requirement: Sign-in returns to the requested page
When an unauthenticated request for a page is redirected to sign-in, the console SHALL remember the requested URL and land the person there after the identity provider's callback. Only a same-origin relative path (path plus query, starting with a single `/`, never `//` or a scheme, and containing no backslash, which browsers parse as a slash) from a GET request is remembered; anything else, or nothing, lands on `/`. The remembered URL SHALL be stored server-side with the login state the idempotent `/login` already keeps, so the freshness window carries it, and SHALL be validated again at the callback before the redirect. An HTMX-initiated request that is redirected to sign-in remembers the same-origin page it came from, when known.
#### Scenario: An operator lands where they were going
- **WHEN** a signed-out person opens `/operator/persons` and completes sign-in
- **THEN** the callback redirects to `/operator/persons`
#### Scenario: A foreign destination is ignored
- **WHEN** the remembered URL is not a same-origin relative path
- **THEN** the callback redirects to `/`
#### Scenario: A backslash is not a path separator to the console
- **WHEN** the requested page is `/\evil.example/steal`, `/\/evil.example` or any value containing a backslash
- **THEN** it is discarded and the callback redirects to `/`, because a browser reads the backslash as a slash and would leave the origin
#### Scenario: Direct sign-in lands on the home surface
- **WHEN** a person opens `/login` with nothing remembered
- **THEN** the callback redirects to `/`

Some files were not shown because too many files have changed in this diff Show More