Files
harmony/docs/guides/web-auth-security.md
2026-05-20 08:02:09 -04:00

6.2 KiB

Web Authentication and CSRF Security Guidelines

This document describes the security baseline currently implemented for Harmony browser-facing dashboards that use Axum, HTMX, cookie sessions, and Zitadel/OIDC.

It is intentionally limited to controls that are present in the code today. Items such as role-based authorization and server-side session storage are not documented here until implemented.

Current Baseline

Harmony dashboards currently use:

  1. Zitadel/OIDC Authorization Code + PKCE for login.
  2. OIDC nonce binding on login callback.
  3. JWT session cookie verification on protected routes.
  4. CSRF protection on mutating protected routes.
  5. Security headers, including Content Security Policy.
  6. No application-level CORS policy for the dashboard.

OIDC Login Flow

On /login, the application creates a short-lived login attempt containing:

  • state
  • pkce_code_verifier
  • nonce

The login attempt is stored in an encrypted private cookie with a short max age.

The authorize URL sent to Zitadel includes:

  • client_id
  • redirect_uri
  • response_type=code
  • requested scope
  • PKCE code_challenge
  • code_challenge_method=S256
  • state
  • nonce

On /auth/callback, the application:

  1. Reads the encrypted login-attempt cookie.
  2. Validates the returned state.
  3. Exchanges the authorization code using the stored PKCE verifier.
  4. Verifies the returned ID token JWT using Zitadel JWKS.
  5. Validates issuer and configured trusted audiences.
  6. Validates that the ID token nonce matches the stored login-attempt nonce.
  7. Deletes the login-attempt cookie.
  8. Stores the verified ID token in the application session cookie.

state, PKCE, and nonce serve different purposes:

  • state binds the callback redirect to the browser login attempt.
  • PKCE binds the authorization code exchange to the client that started login.
  • nonce binds the returned ID token to the browser login attempt.

The current dashboard session cookie stores the raw Zitadel ID token.

The session cookie is configured with:

  • HttpOnly
  • SameSite=Lax
  • Path=/
  • Secure when BASE_URL starts with https://
  • Max-Age derived from the ID token exp claim when available

On every protected request, the application verifies the session cookie JWT against cached Zitadel JWKS before allowing access.

Local logout removes the application session cookie and redirects to Zitadel end-session with the ID token as id_token_hint.

Clearing only the application cookies does not necessarily log the user out of Zitadel. If the Zitadel SSO session cookie still exists on the Zitadel origin, the user may be immediately re-authenticated when redirected through /login.

CSRF Protection

CSRF protection is applied to protected mutating routes.

Mutating methods are:

  • POST
  • PUT
  • PATCH
  • DELETE

For these methods, the application requires:

  1. A custom request header:

    x-csrf-token: 1
    
  2. A same-origin Origin header, or if Origin is absent, a same-origin Referer header.

The expected origin is derived from configured BASE_URL.

Requests are rejected when:

  • the custom CSRF header is missing;
  • Origin is present and does not match BASE_URL origin;
  • Origin is absent and Referer is missing or does not match BASE_URL origin.

The CSRF header value is static. The protection relies on browser behavior: cross-origin HTML forms cannot set custom headers, and cross-origin JavaScript cannot send credentialed custom-header requests unless the server opts into CORS.

HTMX Integration

The dashboard serves a static JavaScript file at /static/app.js.

That file adds the CSRF header to HTMX requests:

document.body.addEventListener('htmx:configRequest', (event) => {
  event.detail.headers['x-csrf-token'] = '1';
});

This keeps CSRF wiring centralized instead of adding hidden fields or per-form tokens to every HTMX action.

Security Headers

The dashboard adds security headers globally.

Current production-oriented Content Security Policy:

Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'

In live-reload development mode, script-src also allows inline scripts for the dev reload helper.

The current CSP means:

  • scripts load from the application origin;
  • styles load from the application origin, with inline styles currently allowed;
  • images load from the application origin or data: URLs;
  • HTMX, fetch, and SSE connections go to the application origin;
  • the dashboard cannot be framed;
  • injected <base> tags cannot redirect relative links away from the app origin;
  • forms can submit only to the app origin;
  • plugin/object content is disabled.

The application also sets:

X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Permissions-Policy: geolocation=(), microphone=(), camera=()

When BASE_URL is HTTPS, it also sets:

Strict-Transport-Security: max-age=31536000; includeSubDomains

CORS Policy

The dashboard does not currently configure a permissive application CORS policy.

This is important because the CSRF design depends on untrusted origins being unable to send credentialed requests with the custom x-csrf-token header.

Do not add credentialed CORS for arbitrary or reflected origins without revisiting the CSRF design.

Current Implementation Checklist

The current Harmony dashboard baseline is:

  • Login uses Authorization Code + PKCE.
  • Login attempt stores state, PKCE verifier, and nonce.
  • Login-attempt cookie has a short max age.
  • Callback validates state.
  • Callback validates ID token nonce.
  • JWT validation checks signature, issuer, expiration, and configured trusted audience.
  • Protected routes require a verified session cookie.
  • Mutating protected routes require CSRF header and same-origin Origin/Referer.
  • Session cookies are HttpOnly, SameSite=Lax, and Secure when BASE_URL is HTTPS.
  • Static HTMX helper adds the CSRF header.
  • Security headers are configured.
  • No permissive dashboard CORS policy is configured.