Authentication
The framework supports two ways for a user to sign in: a local username + password (with the user store on disk or in the database) and OpenID Connect (any standards-compliant IdP). Both produce the same artefact — a short-lived JWT access token + a longer-lived refresh token — which the frontend stores in memory and the backend verifies on every API call.
The decision between local and OIDC, plus the JWT lifetimes and the OIDC claim mapping, are set under Settings → Framework → Authentication. The two can coexist: OIDC enabled for SSO users, the local backend kept for break-glass administration.
At a glance
Two storage variants:
• TOML file on the host
• Database tables (ly2_users)
Argon2id password hashes.
Configured under Authentication → OIDC.
Maps the IdP's groups claim to Liberty roles 1:1.
Refresh token rotated on use (default 7 days).
Lifetimes editable per install.
Picking a local backend
In Settings → Framework → Authentication, the Backend dropdown offers two values:
| Backend | Storage | Pro | Con |
|---|---|---|---|
| Local — TOML (default) | config/auth.toml on the host. | Zero infrastructure — works without an external DB. | Doesn't survive container rebuilds when the file isn't mounted; doesn't share state across replicas. Not appropriate past ~100 users. |
| Local — Database | ly2_users / ly2_roles / ly2_permissions tables on the default pool. | Survives container rebuilds when the DB is external. Shared across replicas — sign-in on one node works on every node. Scales to thousands of users. | Requires the default pool to be reachable at startup. One more thing to back up. |
The first time the framework is run on a fresh install, ./start.sh init-db bootstraps whichever backend is configured and seeds an admin user (the password is printed once on stdout).
Switching the backend is a restart-required change — the form highlights the field with a Restart required badge.
Managing local users — Settings → Users
The Users tab is the canonical editor for the local backend (both variants). The page lists every user with their roles, active flag and last sign-in:
| Action | How |
|---|---|
| Create a user | + New user — username, display name, roles (multi-select from the configured roles), password. |
| Reset password | Click a user, Reset password — prompts for the new password (twice). |
| Add / remove roles | Click a user, edit the Roles multi-select. |
| Deactivate | Click a user, toggle Active off. Soft-deactivates the user (preserves history). |
| Revoke sessions | Click a user, Revoke all sessions — invalidates every active access + refresh token. |
The same operations are available via the CLI for shell scripting.
Password policy
Passwords are hashed with argon2id. The Argon2 tuning is editable under Settings → Framework → Authentication — defaults are Time cost = 2, Memory cost = 64 MiB, Parallelism = 1, tuned for a ~50 ms server cost on a modern CPU. Raise Memory cost on installs with stricter threat models.
A pluggable validator rejects weak passwords. The bundled validator enforces:
- 10+ characters.
- At least one digit, one upper-case, one lower-case.
- Not equal to the username.
To override, set Password validator under Settings → Framework → Authentication to a callable from your apps repo (e.g. myapp.security:validate). See Apps & Plugins → Plugins for the function signature.
OIDC SSO
In Settings → Framework → Authentication, flip the OIDC enabled toggle on. The OIDC sub-form appears:
| Field | Description |
|---|---|
| Issuer URL | URL of the OIDC provider. The framework fetches ${issuer}/.well-known/openid-configuration at startup to discover the endpoints. |
| Client ID / Client secret | OAuth2 client credentials registered with the IdP. The secret is always encrypted in storage — the 🔒 icon is locked on. |
| Redirect URI | The framework's callback URL. Must be registered on the IdP side. |
| Email claim | JWT claim used as the local username. Default email. |
| Groups claim | JWT claim mapped to Liberty roles 1:1. Default groups. A group named editor on the IdP grants the Liberty role editor. |
| Auto-provision | When on, a Liberty user is created on first sign-in. When off, the user has to exist already — convenient when group membership is broader than the Liberty allow-list. |
A Test sign-in button at the bottom of the OIDC sub-form opens the IdP redirect in a new tab and reports the result. Use it before saving — a wrong issuer URL is much easier to spot here than from the login page.
IdP-specific issuer formats
| IdP | Issuer URL template |
|---|---|
| Authentik | https://auth.example.com/application/o/liberty/ (trailing slash matters) |
| Keycloak | https://kc.example.com/realms/<realm> (and enable the groups mapper on the Liberty client) |
| Azure AD / Entra | https://login.microsoftonline.com/<tenant>/v2.0 (set Groups claims in token config) |
| Okta | https://<your-domain>.okta.com/oauth2/default |
| Google Workspace | https://accounts.google.com (no group claim — use HD claim for domain restriction, manage roles in Liberty's Users tab) |
The sign-in page
With OIDC on, the Sign in with SSO button appears next to the username/password form on the login page. The local form stays available for break-glass admins.
Token lifecycle
The lifetimes are editable in Settings → Framework → Authentication:
| Token | Default | Editor field | Storage | Use |
|---|---|---|---|---|
| Access token | 15 min | Access token lifetime | Frontend localStorage (key liberty_access) | Sent as Authorization: Bearer … on every API call. |
| Refresh token | 7 days | Refresh token lifetime | httpOnly cookie (liberty_refresh), SameSite=Strict | Exchanged for a new access token when the previous one expires. |
The frontend's API client transparently refreshes the access token when it gets a 401 with WWW-Authenticate: Bearer error="invalid_token". The failing request replays once with the new token.
Revocation
| Trigger | Effect |
|---|---|
| User logs out | The refresh token is dropped from the server-side store; the access token expires within the access TTL. |
| Operator clicks Revoke all sessions on the user | Same as logout for every active session of the user. |
| Operator marks the user Inactive | Subsequent token refreshes fail. |
| Restart of the framework with a new JWT signing key | Every active access token becomes invalid immediately. Refresh tokens fail too — every user has to sign in again. |
The JWT signing key is referenced by env-var name in the Authentication form (default ${LIBERTY_JWT_SECRET}). Rotating the key is a restart-required action.
Permissions
The Users tab is gated by users:read (view) / users:write (edit). The Roles tab follows the same model — see Roles & permissions. The Authentication sub-form on the Framework tab is gated by settings:framework.
Tips & best practices
- Set the JWT signing key in production. An ephemeral key works for development but invalidates every session on each restart.
- Use the database backend past a handful of users. TOML breaks down under concurrent edits; the DB backend handles it cleanly.
- Keep at least one local admin even on OIDC installs. Break-glass access when the IdP is down is invaluable.
- Mirror the IdP groups to Liberty roles 1:1. Cleanest contract — name your Liberty roles to match the IdP group names.
- Rotate the JWT signing key on a schedule. Annual cadence is reasonable; the disruption is one re-sign-in per user.
- Use Test sign-in on the OIDC form before saving. Catches wrong issuer URLs / unregistered redirect URIs early.
Under the hood
The Authentication settings live in config/app.toml under the [auth] and [auth.oidc] sections. Local user entries live in config/auth.toml (TOML backend) or the ly2_users tables (Database backend). Operators do not edit these files by hand — the Settings UI is the canonical interface. Advanced operators can still use the Raw TOML tab or the CLI for scripted setup.
What's next
- Roles & permissions — what a role can do, the permission codes, the assignment matrix.
- License key — RS256-signed feature gates.
- Apps & Plugins → Plugins — custom password validator and other auth hooks.