Skip to content

Latest commit

 

History

History
435 lines (313 loc) · 25.3 KB

File metadata and controls

435 lines (313 loc) · 25.3 KB

OIDC Authentication

Bindery v0.24.0 adds native OpenID Connect (Authorization Code + PKCE). Users sign in via Google, GitHub, Authelia, Authentik, Keycloak, or any OIDC-compliant provider. Local password login continues to work alongside OIDC.

How it works

Bindery acts as an OIDC Relying Party. On login, it redirects to the provider, exchanges the code for tokens, validates the ID token, and maps the user by (issuer, sub) — not by email or username, which can change. On first login the user is auto-provisioned as a Bindery account.

Sessions are issued using the same HMAC-signed cookie as password login. OIDC sits in front of session issuance — the rest of Bindery is unaware of the auth path.

Prerequisites

  • Bindery is reachable at a stable base URL (needed for the callback URL).
  • You have an OIDC application configured at your IdP with the redirect URI: <BINDERY_OIDC_REDIRECT_BASE_URL>/api/v1/auth/oidc/<provider-id>/callback.

Environment variables

Variable Default Description
BINDERY_OIDC_REDIRECT_BASE_URL (see resolution below) Public base URL Bindery is reachable at (e.g. https://bindery.example.com). Used as the prefix for OIDC callback URLs. Required for path-prefix deploys; otherwise optional when behind a trusted proxy that sets X-Forwarded-Proto and X-Forwarded-Host.
BINDERY_TRUSTED_PROXY (unset) Comma-separated CIDRs of reverse proxies whose X-Forwarded-* headers Bindery may trust. Required for the auto-derive fallback to kick in.
BINDERY_LOCAL_AUTH_ENABLED true Set to false to disable password-based login entirely. POST /auth/login returns 403 and the admin user-create API is also blocked. Use when all users must authenticate via OIDC.
BINDERY_OIDC_AUTO_PROVISION true Set to false to require that OIDC users already exist in Bindery's database. First-time OIDC logins from unknown (issuer, sub) pairs return 403 instead of creating an account.
BINDERY_OIDC_EMAIL_LINK false Set to true to link an unknown OIDC identity to an existing Bindery account if the email address matches. Runs before the auto-provision check. Useful for migrating from local accounts to OIDC without losing history.
BINDERY_OIDC_DEFAULT_ROLE user Role assigned to a freshly auto-provisioned OIDC user. Valid values: user, admin. Any other value falls back to user. Set to admin for single-admin homelab deployments to skip the manual promotion step.
BINDERY_OIDC_ADMIN_GROUP (unset) When set, makes the IdP authoritative for the admin role. On every login, the user is promoted to admin if this group is present in the group claim and demoted to user if absent. See Group-based role mapping.
BINDERY_OIDC_GROUP_CLAIM groups ID-token claim path Bindery reads the user's groups from for BINDERY_OIDC_ADMIN_GROUP. Override for IdPs that put groups under a non-standard claim.

Redirect URL construction

The callback URL registered in your IdP must exactly match what Bindery sends during the authorization redirect. Bindery constructs it as:

<base-url>/api/v1/auth/oidc/<provider-id>/callback

Redirect base URL resolution

Bindery picks the <base-url> per request, in this order:

  1. BINDERY_OIDC_REDIRECT_BASE_URL — if set, this wins unconditionally. Use this for path-prefix deploys (https://example.com/bindery) or any case where forwarded headers don't reflect the public URL the IdP will see.
  2. X-Forwarded-Proto + X-Forwarded-Host — if BINDERY_TRUSTED_PROXY is configured and the immediate peer is in that CIDR list, Bindery uses the forwarded headers. Untrusted peers can't influence the URL — their headers are ignored.
  3. r.Host — direct-access fallback. The scheme is inferred from the TLS connection or X-Forwarded-Proto. Suitable for development; not what you want behind a proxy.

For typical reverse-proxy deploys (Traefik, nginx, Caddy, Kubernetes Ingress) where BINDERY_TRUSTED_PROXY already needs to be set for proxy-auth or X-Forwarded-For trust, you can leave BINDERY_OIDC_REDIRECT_BASE_URL unset and the redirect URL will track the public hostname automatically.

Path-prefix example (Bindery mounted at /bindery/):

BINDERY_OIDC_REDIRECT_BASE_URL=https://example.com/bindery
# → callback: https://example.com/bindery/api/v1/auth/oidc/<id>/callback

The auto-derive path doesn't know about path prefixes — when Bindery is mounted under a sub-path, set the env var explicitly.

Pinning across the flow

The base URL Bindery resolves at /login is stored in the (HttpOnly, short-lived) flow cookie and replayed at /callback, so the redirect_uri in the token exchange always matches the value the IdP saw at the authorize request. If the proxy chain changes between the two hops (extremely unusual), the callback would still use the originally-resolved URL.

For example, with BINDERY_OIDC_REDIRECT_BASE_URL=https://bindery.example.com and provider id google:

https://bindery.example.com/api/v1/auth/oidc/google/callback

Adding a provider

Providers are configured in Settings → Security → OIDC Providers (admin only). Each provider has:

Field Description
id Short identifier used in callback URL path (e.g. google, authelia).
name Display name shown on the login button.
issuer OIDC discovery URL base (e.g. https://accounts.google.com).
client_id From your IdP app registration.
client_secret From your IdP app registration. Stored in the settings table — treat the database as sensitive.
scopes Space-separated scopes (e.g. openid email profile).
allowed_groups Optional. Comma-separated IdP groups/roles that are allowed to log in. Empty = allow all authenticated users.

Providers can also be set directly via the settings table key auth.oidc.providers (JSON array) for scripted deploys.

Auth policy controls

Three env vars govern who can log in and how accounts are created. All default to backwards-compatible values — existing deployments are unaffected.

SSO-only mode (disable local password login)

BINDERY_LOCAL_AUTH_ENABLED=false

With this set, POST /auth/login returns 403 Forbidden and the OIDC providers are the only way in. The admin user-create API is also blocked. The login page hides the username/password form; if no OIDC providers are configured it shows "Contact your administrator for access".

Before setting this: ensure at least one OIDC provider is configured and working, and that you can authenticate through it. There is no break-glass override — if you lock yourself out, you must restart with BINDERY_LOCAL_AUTH_ENABLED=true to recover.

Preventing account self-registration via OIDC

BINDERY_OIDC_AUTO_PROVISION=false

By default, any user who authenticates via a configured OIDC provider gets a Bindery account created for them automatically. Setting this to false requires the admin to pre-create accounts (via POST /api/v1/auth/users) before users can log in for the first time. First-time logins from an unknown (issuer, sub) pair receive 403 access denied: account not provisioned.

Useful for closed deployments where you control the user list explicitly.

Linking OIDC identities to existing local accounts

BINDERY_OIDC_EMAIL_LINK=true

When BINDERY_OIDC_EMAIL_LINK=true, Bindery tries to match an unrecognised OIDC login to an existing account by email address before deciding whether to auto-provision or deny. If the email in the ID token matches a local account's email, the OIDC identity is permanently linked to that account (oidc_issuer / oidc_sub updated) and the user is logged in.

This is a one-time migration path: once linked, the (issuer, sub) pair is stored and subsequent logins bypass the email check. If the email doesn't match any account, the normal auto-provision / deny logic continues.

Security note: email linking trusts that the IdP has verified the email address. Only enable this with IdPs that verify email — don't use it with IdPs that allow users to self-assign arbitrary email claims.

OIDC role mapping

By default every auto-provisioned OIDC user gets the user role and must be promoted to admin manually (via a local admin session or PUT /api/v1/auth/users/{id}/role). Three opt-in env vars remove that friction.

Default role for new OIDC users

BINDERY_OIDC_DEFAULT_ROLE=admin

Sets the role assigned at auto-provision time. Valid values are user (default) and admin; any other value silently falls back to user (a startup warning is logged). Use admin for single-admin homelab deployments so the first — and only — operator is an admin immediately.

This only affects new accounts at creation time. It does not change the role of users who already exist.

Group-based role mapping

BINDERY_OIDC_ADMIN_GROUP=bindery-admin
BINDERY_OIDC_GROUP_CLAIM=groups

When BINDERY_OIDC_ADMIN_GROUP is set, the IdP becomes authoritative for the admin role. On every login Bindery reads the configured group claim from the ID token and:

  • promotes the user to admin if the group is present, or
  • demotes the user to user if the group is absent.

BINDERY_OIDC_GROUP_CLAIM (default groups) selects the claim path; override it for IdPs that emit groups under a different name.

The group claim's value shape varies between IdPs — Bindery handles both forms:

  • a JSON array of strings (["bindery-admin","staff"]) — Authentik, Keycloak with a groups mapper;
  • a single string with several groups separated by spaces and/or commas ("bindery-admin staff" or "bindery-admin,staff") — some Keycloak/Authelia role-string configs.

Group name matching is exact and case-sensitive.

Important: while BINDERY_OIDC_ADMIN_GROUP is set it overrides any role set manually via PUT /api/v1/auth/users/{id}/role for OIDC users. A manual promotion or demotion is reverted on the user's next login to whatever the IdP group membership dictates. This is intentional — manage roles in the IdP, not in Bindery, when group mapping is enabled. The last-admin demotion guard is also bypassed for this IdP-driven sync, so removing a user from the admin group always takes effect.

Avoiding the SSO-only lockout trap

If BINDERY_LOCAL_AUTH_ENABLED=false and Bindery has zero admin users at the time an OIDC user is being provisioned, that user is automatically promoted to admin. This mirrors the local-auth PromoteFirstUser behaviour and removes the trap where disabling local auth before promoting any admin leaves the instance with no way in.

The fallback is guarded by a one-shot flag (auth.oidc.first_admin_promoted in the settings table): once it fires, deleting every admin later will not silently re-promote the next OIDC login. The fallback also does nothing when local auth is still enabled (you have a recovery path) or when BINDERY_OIDC_DEFAULT_ROLE=admin (every new user is already an admin).

Scenario: fully managed SSO deployment

BINDERY_LOCAL_AUTH_ENABLED=false
BINDERY_OIDC_AUTO_PROVISION=false

Only users pre-created by an admin can log in, exclusively via OIDC. No local password logins, no self-registration. Pair this with allowed_groups on the provider to further restrict who can authenticate.

client_secret write-only semantics

client_secret is never returned by GET /api/v1/settings/auth/oidc/providers — it is write-only. The PUT endpoint uses secret-preservation semantics so the Settings UI (which only fetches public config) cannot accidentally blank a secret it never had access to:

client_secret in PUT body Effect
Non-empty string Secret updated to the new value
Empty string "" or field absent Existing secret preserved unchanged
Empty string on a new provider (POST) Rejected with 400 Bad Request

To rotate a secret without touching other fields:

curl -X PUT http://bindery:8787/api/v1/settings/auth/oidc/providers/google \
  -H "X-Api-Key: <admin-key>" \
  -H "Content-Type: application/json" \
  -d '{"id": "google", "client_secret": "<new-secret>"}'

To update scopes or groups without disturbing the secret, omit client_secret from the body entirely.

Google

  1. Go to Google Cloud Console → APIs & Services → Credentials → Create OAuth 2.0 Client.
  2. Application type: Web application.
  3. Authorized redirect URIs: https://bindery.example.com/api/v1/auth/oidc/google/callback.
  4. Add provider in Bindery:
{
  "id": "google",
  "name": "Google",
  "issuer": "https://accounts.google.com",
  "client_id": "<your-client-id>.apps.googleusercontent.com",
  "client_secret": "<your-client-secret>",
  "scopes": "openid email profile"
}

GitHub (via Dex)

GitHub's OAuth2 implementation does not expose an OIDC discovery endpoint, so Bindery cannot use it directly. The recommended bridge is Dex with its GitHub connector.

1. Register a GitHub OAuth App at github.com → Settings → Developer settings → OAuth Apps. Set the callback URL to your Dex instance: https://dex.example.com/callback.

2. Configure Dex:

# dex/config.yaml
issuer: https://dex.example.com

connectors:
  - type: github
    id: github
    name: GitHub
    config:
      clientID: <github-oauth-app-client-id>
      clientSecret: <github-oauth-app-client-secret>
      redirectURI: https://dex.example.com/callback
      orgs:
        - name: your-github-org   # optional: restrict to org members

staticClients:
  - id: bindery
    secret: <dex-client-secret>
    name: Bindery
    redirectURIs:
      - https://bindery.example.com/api/v1/auth/oidc/github/callback

3. Add provider in Bindery:

{
  "id": "github",
  "name": "GitHub",
  "issuer": "https://dex.example.com",
  "client_id": "bindery",
  "client_secret": "<dex-client-secret>",
  "scopes": "openid email profile"
}

The sub claim in Dex's tokens is the GitHub user ID (numeric, stable — does not change on username rename).

Authelia

Authelia exposes a native OIDC provider in v4.34+. Register a client:

# authelia/configuration.yml
identity_providers:
  oidc:
    clients:
      - id: bindery
        secret: '$plaintext$<your-secret>'
        authorization_policy: one_factor
        redirect_uris:
          - https://bindery.example.com/api/v1/auth/oidc/authelia/callback
        scopes:
          - openid
          - email
          - profile
          - groups

Bindery provider config:

{
  "id": "authelia",
  "name": "Authelia",
  "issuer": "https://auth.example.com",
  "client_id": "bindery",
  "client_secret": "<your-secret>",
  "scopes": "openid email profile groups",
  "allowed_groups": "bindery-users"
}

Authentik

  1. In Authentik admin, create a Provider → OAuth2/OpenID Provider.
  2. Set redirect URI: https://bindery.example.com/api/v1/auth/oidc/authentik/callback.
  3. Create an Application linked to the provider.
{
  "id": "authentik",
  "name": "Authentik",
  "issuer": "https://auth.example.com/application/o/<app-slug>/",
  "client_id": "<your-client-id>",
  "client_secret": "<your-client-secret>",
  "scopes": "openid email profile"
}

Keycloak

  1. In the Keycloak admin console, select your realm and go to Clients → Create client.
  2. Client type: OpenID Connect. Client ID: bindery.
  3. Enable Client authentication (confidential client).
  4. Under Valid redirect URIs, add: https://bindery.example.com/api/v1/auth/oidc/keycloak/callback.
  5. Save, then go to the Credentials tab and copy the client secret.
  6. To restrict login to a specific group, create a Keycloak group (e.g. bindery-users), assign users to it, and map it to the token via Client scopes → groups mapper.

Add provider in Bindery:

{
  "id": "keycloak",
  "name": "Keycloak",
  "issuer": "https://keycloak.example.com/realms/<your-realm>",
  "client_id": "bindery",
  "client_secret": "<your-secret>",
  "scopes": "openid email profile groups",
  "allowed_groups": "/bindery-users"
}

The issuer must include the realm path. Keycloak groups in the token are prefixed with / (e.g. /bindery-users) — match this exactly in allowed_groups.

JWKS caching

Bindery caches the IdP's JWKS (public keys) to avoid a round-trip to the identity provider on every token validation. The cache is refreshed automatically on cache miss or key rotation. This means:

  • Token validation is fast and does not hit the IdP per-request.
  • If you rotate IdP signing keys, Bindery will re-fetch on the next cache miss (typically within minutes).

Provider load status and on-demand recovery

Bindery performs OIDC discovery (<issuer>/.well-known/openid-configuration) when providers are loaded — at startup and whenever an admin saves the provider list. If discovery fails for a provider (e.g. the IdP is briefly unreachable while Bindery's pod is starting), the provider is recorded as failed instead of being dropped silently. Two consequences:

  1. GET /api/v1/auth/oidc/providers returns a status block per provider:

    [
      {
        "id": "authentik",
        "name": "Authentik",
        "issuer": "https://auth.example.com/application/o/bindery/",
        "client_id": "...",
        "scopes": ["openid", "email", "profile"],
        "status": { "state": "ok" }
      },
      {
        "id": "google",
        "name": "Google",
        "issuer": "https://accounts.google.com",
        "client_id": "...",
        "scopes": ["openid", "email", "profile"],
        "status": {
          "state": "failed",
          "last_error": "oidc discovery for \"https://accounts.google.com\": context canceled",
          "last_attempt": "2026-05-05T22:25:42Z"
        }
      }
    ]
  2. The first login attempt for a failed provider triggers an on-demand re-discovery, rate-limited to once per 30 seconds per provider. If discovery now succeeds, the provider transitions to ok and the login flow continues normally. If it still fails, the user gets the same unknown oidc provider error as before, and the failed entry's last_error and last_attempt are updated for the admin to inspect.

The login page only renders Sign in with X buttons for providers in ok state — failed providers are intentionally hidden so users don't click into a flow that's guaranteed to error.

Multi-provider note: sub re-use

Two different IdPs can emit the same sub value for different users. Bindery's user mapping key is (issuer, sub), not sub alone — so collisions across providers are impossible by design.

Session and logout caveat

OIDC logout from the IdP does not immediately log the user out of Bindery. This is a known limitation.

Bindery issues its own HMAC-signed session cookie when OIDC login succeeds. That cookie is independent of the IdP session — revoking the IdP session, signing out of the IdP, or disabling the user in the IdP has no effect on the Bindery cookie until it expires naturally (~12 hours for a short-lived session, up to 30 days if "Remember me" is checked).

Mitigations:

  • Shorten cookie lifetime — reduce the session TTL in Settings → General → Security → Session lifetime so stale sessions expire sooner.
  • Force global logout — rotate the session secret in Settings → General → Security → Rotate session secret. This invalidates every active Bindery session immediately for all users. Use for security incidents.
  • Per-user logout (not yet available) — a sessions table with per-session revocation is planned for a future release. Until then, global secret rotation is the only way to evict a specific user.

Client secret storage

OIDC client secrets are stored as plaintext in the settings table — the same posture as indexer API keys and download client passwords. This is an accepted trade-off for a self-hosted application: all sensitive values are in one place, and protecting the database file protects everything.

What this means in practice:

  • Anyone with read access to bindery.db can extract OIDC client secrets.
  • Protect the database file: chmod 600 /config/bindery.db, ensure the volume mount is not world-readable, and restrict kubectl exec / docker exec access to the container.
  • The Bindery backup API (POST /api/v1/backup) creates a copy of the database — treat backup files with the same care.

An env-var reference pattern (reading secrets from environment variables rather than storing them in the DB) is planned for a future release.

Helm / Kubernetes

# values.yaml
env:
  BINDERY_OIDC_REDIRECT_BASE_URL: "https://bindery.example.com"

Provider configuration lives in the database (Settings UI or settings table), not in the Helm chart, to avoid storing client secrets in values files.

Rollback

Migration 018_oidc.sql is additive-only (nullable columns). Rolling back the binary is safe — the columns remain in place and are ignored by older versions.

See also

Troubleshooting

Symptom Cause Fix
Browser loops between Bindery login and IdP without completing BINDERY_OIDC_REDIRECT_BASE_URL mismatch — IdP receives a callback URL that doesn't match its registration Set BINDERY_OIDC_REDIRECT_BASE_URL to the exact public URL your proxy exposes (including scheme, domain, and any path prefix). The value must match the redirect URI registered in your IdP character-for-character. See Redirect URL construction.
redirect_uri_mismatch error from IdP on callback Callback URL sent by Bindery does not match the URI registered in the IdP Same cause as redirect loop. Confirm BINDERY_OIDC_REDIRECT_BASE_URL is set and correct. Copy the exact URL from the IdP error message and register it.
state mismatch or nonce mismatch error on callback State/nonce cookie set during login redirect was lost or altered before the callback arrived Two common causes: (1) reverse proxy strips Set-Cookie response headers — ensure Set-Cookie passes through unchanged; (2) login and callback served on different domains or subdomains — the session cookie won't be sent cross-domain. Both must use the same origin. With path-prefix deployments, verify the cookie Path attribute is not restricted.
invalid_client or unauthorized_client from IdP on callback Client ID / secret mismatch, or redirect URI not registered Verify the redirect URI in your IdP exactly matches <BINDERY_OIDC_REDIRECT_BASE_URL>/api/v1/auth/oidc/<id>/callback. Check client ID and secret.
Login button does not appear on login page Provider not configured, or OIDC settings not saved Check Settings → Security → OIDC Providers. Verify the provider record has issuer, client_id, client_secret set.
issuer mismatch in token validation Bindery's configured issuer does not match the iss claim in the ID token For Keycloak, issuer includes the realm: https://keycloak.example.com/realms/<realm>. For Authentik, it includes the app slug. Use BINDERY_LOG_LEVEL=debug to log the received iss value.
Two different real users map to the same Bindery account Two providers emitting the same sub for different people This cannot happen — Bindery keys on (issuer, sub), not sub alone. If it does occur, file a bug.
OIDC logout from IdP doesn't log out of Bindery Session cookie is independent of IdP session; cookie TTL is ~12h or up to 30d Rotate session secret in Settings → General → Security to force global logout. Shorten session TTL to reduce window. Per-user revocation is planned for a future release.
connection refused or timeout fetching discovery URL IdP not reachable from Bindery container/pod at startup Check network policy / firewall. Bindery must reach <issuer>/.well-known/openid-configuration. Test with curl from inside the container.
JWKS fetch fails after IdP key rotation Stale cached JWKS Restart Bindery to force re-fetch. The cache auto-refreshes on miss in subsequent versions.
allowed_groups filter blocks all users Group claim name or format differs from config value Enable BINDERY_LOG_LEVEL=debug to log decoded ID token claims. Keycloak groups are prefixed with / (e.g. /bindery-users); match this exactly.
Login page shows "Contact your administrator for access" with no login form BINDERY_LOCAL_AUTH_ENABLED=false and no OIDC providers configured Either configure an OIDC provider or restart with BINDERY_LOCAL_AUTH_ENABLED=true to restore local login.
OIDC login returns 403 "account not provisioned" BINDERY_OIDC_AUTO_PROVISION=false and no account pre-created for this OIDC identity Admin must create an account for this user first (POST /api/v1/auth/users), then ask the user to log in again. Or set BINDERY_OIDC_AUTO_PROVISION=true to allow self-registration.
First OIDC login creates a duplicate account instead of linking to existing local account BINDERY_OIDC_EMAIL_LINK=false (default) Set BINDERY_OIDC_EMAIL_LINK=true to link by email on first login. After enabling, the next login attempt will link if the email matches. Existing duplicate account must be removed manually if already created.