From e94a17160bd26ad3949320d9ed7ca6fb979ec49d Mon Sep 17 00:00:00 2001 From: Jean-Michel Tremblay Date: Sat, 20 Jun 2026 21:20:08 -0400 Subject: [PATCH] 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 --- CHANGELOG.md | 13 ++++++ README.md | 10 ++++- deploy/AUTH.md | 110 ++++++++++++++++++++++++++++++++++++++++++++++ deploy/CICD.md | 77 ++++++++++++++++++++++++++++++++ deploy/INSTALL.md | 3 ++ 5 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 deploy/AUTH.md create mode 100644 deploy/CICD.md diff --git a/CHANGELOG.md b/CHANGELOG.md index affeef2..23072ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project are documented here. Versions are git tags; release tags `X.Y.Z` are built and deployed automatically (pre-release tags such as `0.0.0a1` are built and staged only). +## [0.1.0] - 2026-06-20 + +### Security +- Enforce the 12h session lifetime server-side (reject sessions older than the TTL + even if the sealed cookie is intact), so a leaked cookie can't be replayed forever. +- Send `X-Content-Type-Options: nosniff` when serving user-uploaded receipt and + attachment bytes. + +### Docs +- Add deploy docs: `deploy/CICD.md` (which git push/tag triggers which pipeline + steps) and `deploy/AUTH.md` (the Authelia/OIDC integration contract + a sequence + diagram of the login flow). Cross-linked from the README and `deploy/INSTALL.md`. + ## [0.0.4] - 2026-06-20 ### Changed diff --git a/README.md b/README.md index 72922e1..a41ac31 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,15 @@ substantiation, with optional AI auto-fill of the amount/date/category/patient. It's a single static Go binary (`CGO_ENABLED=0`, pure-Go SQLite). Configure via environment (see [.env.example](.env.example)); `./scripts/build.sh` builds it and -`./scripts/run.sh` runs it locally. Deployment notes: [deploy/INSTALL.md](deploy/INSTALL.md). +`./scripts/run.sh` runs it locally. + +Deployment: +- **[deploy/CICD.md](deploy/CICD.md)** — what each git push/tag triggers (branch = + build+test; release tag `X.Y.Z` = build+deploy; pre-release tag = build+stage). +- **[deploy/INSTALL.md](deploy/INSTALL.md)** — one-time host setup, on-disk layout, + manual deploy, and rollback. +- **[deploy/AUTH.md](deploy/AUTH.md)** — the Authelia (OIDC) integration: which + config fields must agree with which app env vars, and how access is granted/revoked. ## AI classifier correction notes diff --git a/deploy/AUTH.md b/deploy/AUTH.md new file mode 100644 index 0000000..5b76326 --- /dev/null +++ b/deploy/AUTH.md @@ -0,0 +1,110 @@ +# 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. + +```mermaid +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
GET /.well-known/openid-configuration and JWKS
(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
seal them into the hsa_login cookie (10m) + App-->>Browser: 302 to Authelia /authorize, Set-Cookie hsa_login
params client_id, redirect_uri, response_type=code,
scope openid profile email groups, state, nonce,
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
returned state equals stored state (CSRF) + App->>Authelia: POST /token back-channel
code, code_verifier, redirect_uri,
grant_type authorization_code,
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
and check nonce equals stored nonce + App->>Authelia: GET /userinfo back-channel
Authorization Bearer access_token + Authelia-->>App: email, preferred_username, groups + Note right of App: require hsa-users in groups, else 403
seal Session subject, groups, issuedAt
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,
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): + +```yaml +- 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 ''`, 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-users` → **403 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`): + +```yaml +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. diff --git a/deploy/CICD.md b/deploy/CICD.md new file mode 100644 index 0000000..34acee5 --- /dev/null +++ b/deploy/CICD.md @@ -0,0 +1,77 @@ +# CI/CD — what git actions trigger what + +The pipeline is a single Forgejo Actions workflow, +[.forgejo/workflows/build.yml](../.forgejo/workflows/build.yml), triggered on every +push (`on: [push]`, which covers both branch and tag pushes). This document explains +which git action produces which outcome. For one-time host setup, the on-disk +layout, and manual deploy/rollback, see [INSTALL.md](INSTALL.md). + +## Trigger behavior at a glance + +| You push… | Build + test | Stage binary to host | Activate (go live) | +|---|:---:|:---:|:---:| +| any branch (no tag) | ✅ | — | — | +| a **release** tag `X.Y.Z` (e.g. `1.4.0`) | ✅ | ✅ | ✅ | +| a **pre-release** tag (anything else, e.g. `0.0.0a3`) | ✅ | ✅ | — | + +- **Branch push** → builds and runs the full test suite, uploads the `hsa` binary as + a run artifact. Nothing touches the server. This is your PR / pre-merge gate. +- **Release tag** `X.Y.Z` → builds, tests, copies the binary to the host, and makes + it live (symlink swap + service restart). +- **Pre-release tag** → builds, tests, and copies the binary to the host, but does + **not** activate it. Use this to park a build on the server (QA, manual + activation) without flipping production. + +The release-vs-pre-release decision is purely the tag name: the **Activate** step +runs only when the tag matches the regex `^[0-9]+\.[0-9]+\.[0-9]+$`. Note this means +a `v`-prefixed tag (`v1.4.0`) would build + stage but **not** activate — tag with +**bare numbers** (`1.4.0`). + +## What each step does + +1. **Checkout** — `git clone` of the pushed branch/tag (the runner doesn't use a + checkout action). +2. **Test** — `go test ./...` inside a `golang:1.26` container. +3. **Build** — static binary: `CGO_ENABLED=0 go build -buildvcs=false -ldflags "-s -w"` + (pure-Go SQLite, so no C toolchain; `-buildvcs=false` avoids the container's + dubious-ownership git error). +4. **Upload binary** — the `hsa` binary as a downloadable run artifact. +5. **Stage release** *(tags only)* — `scp` the binary to + `~/hsa-app/releases/hsa-app-V<tag>/hsa` on the host and `chmod +x` it. +6. **Activate release** *(release tags `X.Y.Z` only)* — `ln -sfn` the + `~/hsa-app/hsa` symlink to the new release and `systemctl --user restart hsa_app`. + Pre-release tags log "staged but not activated" and stop here. + +## Cutting a release + +```bash +# 1. land your change on main (push branch -> CI builds/tests -> merge) +# 2. tag and push: +git tag -a 1.4.0 -m "1.4.0 — <summary>" +git push origin 1.4.0 +``` + +The tag push runs the whole pipeline through Activate. Watch it under the repo's +**Actions** tab; the **Activate release** step is the one that goes live. Roll back +by activating an older still-staged release (see [INSTALL.md](INSTALL.md)). + +## Runner requirements + +- One Forgejo `act_runner` registered with the **`shell`** label (`runs-on: shell`). + The job runs directly on the host, which therefore must have the **docker CLI** + available (the Test/Build steps run inside `golang:1.26` via `docker run`). +- The deploy steps `ssh`/`scp` from the runner host to the app host (they may be the + same machine). The app must run as a **user systemd service** named `hsa_app`, + reachable via `systemctl --user` — which requires `loginctl enable-linger` for the + deploy user (see [INSTALL.md](INSTALL.md)). + +## Required repo configuration (Settings → Actions) + +| Name | Kind | Purpose | +|---|---|---| +| `FORGEJO_SSH` | **Secret** | private SSH key authorized on the app host | +| `HSA_APP_HOST` | **Variable** | app host name / IP | +| `HSA_APP_USER` | **Variable** | SSH user that owns `~/hsa-app` and the user service | + +If these are missing, branch pushes still build/test, but the Stage/Activate steps +fail on tags. diff --git a/deploy/INSTALL.md b/deploy/INSTALL.md index cf3dd2a..e571450 100644 --- a/deploy/INSTALL.md +++ b/deploy/INSTALL.md @@ -13,6 +13,9 @@ The symlink decouples "what's on disk" from "what's running," so activating a staged pre-release or rolling back is just a symlink repoint + restart (see below). +For the pipeline side — which git pushes/tags trigger which steps, the runner +requirements, and the required repo secrets/variables — see [CICD.md](CICD.md). + ## Layout on the host Mutable state (DB, env, config) lives at the top of `~/hsa-app/` and survives