# 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.