All checks were successful
Build and Test / build-and-test (push) Successful in 39s
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>
110 lines
5.6 KiB
Markdown
110 lines
5.6 KiB
Markdown
# 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<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):
|
|
|
|
```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 '<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-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.
|