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:
+7
-2
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, "-", "_"))
|
||||
|
||||
@@ -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;
|
||||
@@ -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. ---
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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, ¬Found) {
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>"},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>`,
|
||||
|
||||
@@ -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
@@ -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
|
||||
+42
@@ -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
|
||||
+33
@@ -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.
|
||||
+40
@@ -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
Reference in New Issue
Block a user