hsa-app/deploy/AUTH.md
Jean-Michel Tremblay e94a17160b
All checks were successful
Build and Test / build-and-test (push) Successful in 39s
Add deploy docs (CICD + Authelia/OIDC); changelog 0.1.0
deploy/CICD.md documents which git push/tag triggers which pipeline
steps; deploy/AUTH.md documents the Authelia OIDC integration contract
with a sequence diagram of the login flow. Cross-link from README and
INSTALL. The 0.1.0 release also ships the previously-committed security
hardening (server-side session expiry + nosniff on served files).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:20:08 -04:00

5.6 KiB

Authentication — Authelia (OIDC)

The app is an OIDC Relying Party: it runs the standard authorization-code + PKCE flow against Authelia directly. It is not behind Authelia's forward-auth / access_control (note hsa.maisym.com is intentionally absent from those rules). So Authelia authenticates the user (password + WebAuthn); the app authorizes them via the hsa-users group claim.

Flow — every redirect & call, and what it carries

Two cookies are in play: hsa_login (short-lived, holds the PKCE verifier + state + nonce during the login round-trip) and hsa_session (the 12h authenticated session). Front-channel = via the browser (302 redirects); back-channel = direct server-to-server calls the browser never sees.

sequenceDiagram
    autonumber
    actor Browser
    participant App as App (hsa.maisym.com)
    participant Authelia as Authelia (auth.maisym.com)

    Note over App,Authelia: at startup, the App discovers Authelia via<br/>GET /.well-known/openid-configuration and JWKS<br/>(learns its endpoints and signing keys)

    Browser->>App: GET / (protected page, no hsa_session)
    App-->>Browser: 302 to /login

    Browser->>App: GET /login
    Note right of App: make PKCE verifier, state, nonce<br/>seal them into the hsa_login cookie (10m)
    App-->>Browser: 302 to Authelia /authorize, Set-Cookie hsa_login<br/>params client_id, redirect_uri, response_type=code,<br/>scope openid profile email groups, state, nonce,<br/>code_challenge S256 of verifier, method S256

    Browser->>Authelia: GET /authorize with those params
    Note over Browser,Authelia: password and WebAuthn (2FA)
    Authelia-->>Browser: 302 to /callback with code and state

    Browser->>App: GET /callback (code, state, Cookie hsa_login)
    Note right of App: decode hsa_login and check<br/>returned state equals stored state (CSRF)
    App->>Authelia: POST /token back-channel<br/>code, code_verifier, redirect_uri,<br/>grant_type authorization_code,<br/>Authorization Basic client_id and client_secret
    Authelia-->>App: access_token and id_token (JWT)
    Note right of App: verify id_token signature via JWKS<br/>and check nonce equals stored nonce
    App->>Authelia: GET /userinfo back-channel<br/>Authorization Bearer access_token
    Authelia-->>App: email, preferred_username, groups
    Note right of App: require hsa-users in groups, else 403<br/>seal Session subject, groups, issuedAt<br/>into hsa_session cookie (12h, AES-256-GCM)
    App-->>Browser: 302 to /, Set-Cookie hsa_session, clear hsa_login

    Browser->>App: GET / (Cookie hsa_session)
    Note right of App: decrypt and verify hsa_session,<br/>reject if older than the 12h TTL
    App-->>Browser: 200, the app

The contract — what must agree on both sides

Authelia configuration.yml App .env Notes
issuer https://auth.maisym.com ISSUER_URL app discovers ISSUER_URL/.well-known/openid-configuration
client_id: hsa-tracker OIDC_CLIENT_ID exact match
client_secret (argon2id hash) OIDC_CLIENT_SECRET (plaintext) Authelia stores the hash; the app holds the plaintext that hashes to it
redirect_uris REDIRECT_URL must match character-for-character (https://hsa.maisym.com/callback)
scopes: [openid, profile, email, groups] the groups scope delivers the claim authz depends on
require_pkce: true, pkce_challenge_method: S256 the app always uses PKCE S256
token_endpoint_auth_method: client_secret_basic matches the Go OAuth2 client default
userinfo_signed_response_alg: 'none' the app reads UserInfo as plain JSON
REQUIRED_GROUP=hsa-users the group the app demands; defined per-user in users_database.yml

The Authelia client block (this deployment)

In identity_providers.oidc.clients (secret redacted):

- client_id: 'hsa-tracker'
  client_name: 'HSA Receipt Tracker'
  client_secret: '$argon2id$v=19$m=65536,t=3,p=4$argon2xxxxxxxxxxx'   # hash; plaintext lives in the app .env
  public: false
  authorization_policy: 'two_factor'          # users must pass WebAuthn
  require_pkce: true
  pkce_challenge_method: 'S256'
  redirect_uris:
    - 'https://hsa.maisym.com/callback'
    - 'http://localhost:8080/callback'         # local dev
  scopes: ['openid', 'profile', 'email', 'groups']
  response_types: ['code']
  grant_types: ['authorization_code']
  token_endpoint_auth_method: 'client_secret_basic'
  userinfo_signed_response_alg: 'none'

The three things that actually bite

  1. client_secret is a hash on the Authelia side, plaintext on the app side. To rotate: authelia crypto hash generate argon2 --password '<plaintext>', put the resulting $argon2id$... hash in configuration.yml and the plaintext in the app's OIDC_CLIENT_SECRET. A mismatch fails the token exchange.
  2. redirect_uri must match exactly (scheme, host, path, no trailing slash) or Authelia refuses the callback.
  3. The groups scope is load-bearing. Drop it and the app gets no groups claim → no hsa-users403 for everyone.

Who can log in / how to revoke

Access requires an Authelia account that passes 2FA and is in hsa-users (users_database.yml):

users:
  jm:    { groups: ['admins', 'hsa-users'], ... }   # password: '$argon2id$...argon2xxxxxxxxxxx'
  lynna: { groups: ['hsa-users'], ... }             # password: '$argon2id$...argon2xxxxxxxxxxx'

To revoke someone: remove them from the hsa-users group (or set disabled: true). An already-issued app session still lasts up to its 12h server-side TTL.