Skip to content

Latest commit

 

History

History
488 lines (378 loc) · 23.3 KB

File metadata and controls

488 lines (378 loc) · 23.3 KB

Authelia OIDC

Dashy supports using Authelia as its OIDC provider.

Authelia is an open source authentication and authorization server for self-hosted services. It speaks OIDC, supports MFA, and is configured via a single YAML file. Users live in a flat file, LDAP, or your own SQL store. It's a popular pairing with reverse proxies for forward-auth across many apps, and the same instance can act as your Dashy OIDC provider.

Contents

1. Deploy Authelia

Authelia v4.38+ requires HTTPS for the session cookie URLs. In production, terminate TLS at a reverse proxy on a real domain. For local testing the example below uses a self-signed cert and lvh.me, a wildcard DNS name that resolves to 127.0.0.1 everywhere, so no /etc/hosts edits are needed.

Example docker-compose.yml
name: dashy-authelia

services:
  authelia:
    image: authelia/authelia:4.38
    restart: unless-stopped
    ports:
      - "9091:9091"
    volumes:
      - ./config:/config
    environment:
      TZ: UTC
    healthcheck:
      test: ["CMD-SHELL", "wget --no-check-certificate -qO- https://127.0.0.1:9091/api/health >/dev/null 2>&1"]
      start_period: 20s
      interval: 10s
      timeout: 5s
      retries: 10

The config/ directory holds Authelia's configuration.yml, the user database, the OIDC signing key, and (for local testing) the TLS cert. Generate them in step 2.


2. Configure Authelia

Generate secrets and keys

Authelia needs four secrets: a session secret, a storage encryption key, an OIDC HMAC secret, and an RSA private key for signing id_tokens. For local testing also generate a self-signed TLS cert.

mkdir -p config
SESSION_SECRET=$(openssl rand -hex 64)
STORAGE_KEY=$(openssl rand -hex 32)
HMAC_SECRET=$(openssl rand -hex 64)

openssl genrsa -out config/oidc-private.pem 2048

# Local testing only. In production use a real cert from your reverse proxy.
openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout config/tls.key -out config/tls.crt \
  -days 365 -subj "/CN=authelia.lvh.me" \
  -addext "subjectAltName=DNS:authelia.lvh.me,DNS:*.lvh.me"

Keep the three shell variables around: you'll paste them into configuration.yml below.

Users database

Hash a password with Authelia's CLI:

docker run --rm authelia/authelia:4.38 \
  authelia crypto hash generate argon2 --password 'YourPasswordHere'

Copy the Digest: value into config/users_database.yml:

users:
  authelia-admin:
    disabled: false
    displayname: "Authelia Admin"
    password: "$argon2id$v=19$m=65536,t=3,p=4$..."
    email: admin@example.com
    groups:
      - admins
  authelia-user:
    disabled: false
    displayname: "Authelia User"
    password: "$argon2id$v=19$m=65536,t=3,p=4$..."
    email: user@example.com
    groups:
      - users

The admins group name is what Dashy's adminGroup will match.

Main configuration

config/configuration.yml:

theme: light
server:
  address: 'tcp://:9091'
  tls:
    certificate: /config/tls.crt
    key: /config/tls.key
log:
  level: info
totp:
  issuer: authelia.lvh.me
authentication_backend:
  password_reset:
    disable: true
  file:
    path: /config/users_database.yml
    password:
      algorithm: argon2id
access_control:
  default_policy: one_factor
session:
  secret: <SESSION_SECRET>
  cookies:
    - domain: 'lvh.me'
      authelia_url: 'https://authelia.lvh.me:9091'
regulation:
  max_retries: 3
  find_time: 2m
  ban_time: 5m
storage:
  encryption_key: <STORAGE_KEY>
  local:
    path: /config/db.sqlite3
notifier:
  filesystem:
    filename: /config/notifications.txt
identity_providers:
  oidc:
    hmac_secret: <HMAC_SECRET>
    cors:
      endpoints:
        - authorization
        - token
        - revocation
        - introspection
        - userinfo
      allowed_origins:
        - http://localhost:4000
    jwks:
      - key_id: dashy
        algorithm: RS256
        use: sig
        key: |
          -----BEGIN PRIVATE KEY-----
          (contents of config/oidc-private.pem)
          -----END PRIVATE KEY-----
    clients:
      - client_id: dashy
        client_name: Dashy
        public: true
        authorization_policy: one_factor
        require_pkce: true
        pkce_challenge_method: S256
        redirect_uris:
          - http://localhost:4000
          - http://localhost:4000/
        scopes:
          - openid
          - profile
          - email
          - groups
        userinfo_signed_response_alg: none

Key things to note:

  • public: true and require_pkce: true are required: Dashy is a SPA, so it must be a public client with PKCE
  • Register the redirect URI both with and without a trailing slash, since browsers may strip or add one
  • The groups scope is required for the adminGroup check
  • userinfo_signed_response_alg: none keeps the userinfo response as JSON, which is what oidc-client-ts (Dashy's OIDC client) expects
  • The cors block is required: Dashy is a SPA and makes cross-origin calls from the browser to Authelia's token and userinfo endpoints, and without these origins allow-listed the browser blocks the token exchange after login. allowed_origins must list Dashy's exact origin (scheme + host + port)

For production, swap server.tls out for a reverse proxy doing HTTPS, change session.cookies to your real domain (e.g. domain: example.com, authelia_url: https://auth.example.com), and pick a real user backend (LDAP or SQL).

Bring Authelia up:

docker compose up -d

Once it's healthy, https://authelia.lvh.me:9091/.well-known/openid-configuration should return JSON with issuer: https://authelia.lvh.me:9091. That issuer URL is what Dashy needs.

Summary

Authelia is now configured with an OIDC client for Dashy.


3. Enabling Authelia in Dashy

In /user-data/conf.yml:

appConfig:
  ...
  disableConfigurationForNonAdmin: true
  auth:
    enableOidc: true
    oidc:
      clientId: dashy
      endpoint: https://authelia.lvh.me:9091
      adminGroup: admins
      scope: openid profile email groups

Where:

  • disableConfigurationForNonAdmin - Prevent read/write config access to non-admin users
  • auth.enableOidc - Set the auth mode to OIDC
  • clientId - Must match the client_id field of the Authelia client
  • endpoint - The Authelia base URL (the OIDC issuer). Dashy appends /.well-known/openid-configuration itself
  • adminGroup - Name of the Authelia group that grants admin in Dashy
  • scope - Must include groups for the adminGroup check to work

Restart Dashy for these changes to take effect.

If Authelia uses a self-signed cert, Dashy's server has to trust it before it can fetch the discovery doc or JWKS. Start Dashy with NODE_EXTRA_CA_CERTS=/path/to/tls.crt, or mount your CA bundle into the Dashy container and point that env var at it. With a real cert this isn't needed. Your browser also needs to trust the cert (visit https://authelia.lvh.me:9091 once and accept the warning, or import the cert into the browser's trust store).

Everything should now be fully configured and working 🎉 When you load Dashy, you'll be redirected to Authelia's login page. After signing in, you'll land back on Dashy's homepage with full access, and all of Dashy's client, server and asset endpoints will be locked behind authentication.


4. Groups and Visibility

Once group membership is in the id_token, you can use it to hide or show pages, sections and items. The property name is hideForKeycloakUsers / showForKeycloakUsers (the name is historical; it works for any OIDC provider, including Authelia).

To make an Admin section visible only to members of admins:

displayData:
  showForKeycloakUsers:
    groups:
      - admins

Both showForKeycloakUsers and hideForKeycloakUsers accept lists of groups and roles. If a user matches an entry they're allowed or excluded as defined.

sections:
  - name: Internal Tools
    displayData:
      showForKeycloakUsers:
        groups: ['admins']
      hideForKeycloakUsers:
        groups: ['users']
    items:
      - title: Hidden from interns
        displayData:
          hideForKeycloakUsers:
            groups: ['interns']

5. Silent token renewal (optional)

By default, when your token expires Dashy sends you back through Authelia's login to get a new one. Set enableSilentRenew: true to have Dashy refresh the session quietly in the background instead, using a refresh token:

    oidc:
      clientId: dashy
      endpoint: https://authelia.lvh.me:9091
      adminGroup: admins
      scope: openid profile email groups
      enableSilentRenew: true

Dashy adds the offline_access scope to its request automatically, but Authelia only issues a refresh token if the client is allowed to. Add offline_access to the client's scopes and refresh_token to its grant_types:

    clients:
      - client_id: dashy
        scopes:
          - openid
          - profile
          - email
          - groups
          - offline_access
        grant_types:
          - authorization_code
          - refresh_token

It's off by default, and if a refresh ever fails Dashy falls back to the normal sign-in. See silent token renewal for the full notes and caveats.


Troubleshooting common Authelia Issues

Config validation fails on session cookies

Problem: Authelia refuses to start, complaining option 'authelia_url' does not have a secure scheme or option 'domain' is not a valid cookie domain.
Solution: v4.38+ requires HTTPS for authelia_url and a cookie domain with at least one period. Use a real domain (example.com, auth.example.com) or lvh.me for local testing, and enable TLS either on Authelia or in front of it.

Dashy server can't fetch Authelia's discovery doc

Problem: Dashy server logs show TLS errors (self-signed certificate, UNABLE_TO_VERIFY_LEAF_SIGNATURE) when fetching .well-known/openid-configuration or the JWKS.
Solution: Authelia is on a self-signed cert. Start Dashy with NODE_EXTRA_CA_CERTS=/path/to/tls.crt, or use a real certificate. The signing cert for OIDC tokens is separate from the TLS cert: it's the TLS cert that needs to be trusted by Node.

"Critical Configuration Load Error" with a CORS message on first load

Problem: Browser shows Cross-Origin Request Blocked ... at https://authelia.lvh.me:9091/.well-known/openid-configuration. (Reason: CORS request did not succeed). Status code: (null) before any redirect to Authelia, and Dashy renders a Critical Configuration Load Error.
Solution: Misleading error: this is the self-signed cert, not CORS. When the browser sees an untrusted HTTPS cert during a JS fetch it blocks the request silently with no chance for you to click through. Open https://authelia.lvh.me:9091 directly in the same browser, click through the warning, then reload Dashy. For a less hacky setup, use mkcert to generate a locally-trusted cert, or terminate TLS at a reverse proxy with a real Let's Encrypt cert.

CORS error on the token endpoint after login

Problem: Login at Authelia succeeds, but the redirect back to Dashy fails with a CORS error on a POST to /api/oidc/token.
Solution: Authelia's CORS allow-list doesn't include Dashy's origin. Add the identity_providers.oidc.cors block from step 2 with token (and ideally authorization, userinfo, revocation, introspection) under endpoints, and Dashy's origin under allowed_origins. Restart Authelia.

Redirect loop after login

Problem: Browser bounces between Dashy and Authelia repeatedly.
Solution: endpoint in conf.yml probably includes .well-known/openid-configuration. Drop everything from .well-known onwards; Dashy appends it itself.

invalid_request: redirect URI mismatch

Problem: Authelia returns invalid_request saying the redirect URI is not registered.
Solution: Authelia matches redirect URIs exactly. Register both the bare URL and the trailing-slash variant (http://localhost:4000 and http://localhost:4000/) on the client.

Logged in but config saves return 403

Problem: User authenticates fine, but saving the dashboard returns 403.
Solution: The id_token isn't carrying the group claim. Paste the token (from localStorage, key ID_TOKEN) into jwt.io and look for groups. If it's missing, the client's scopes list doesn't include groups. If the claim is there but the user isn't in it, add them to the admins group in users_database.yml (or whatever backend you're using) and restart Authelia.

Issuer mismatch behind a reverse proxy

Problem: Server logs show unexpected "iss" claim value. The browser reaches Authelia at https://auth.example.com but Authelia advertises a different issuer.
Solution: Make sure your reverse proxy forwards X-Forwarded-Proto: https and X-Forwarded-Host, and that session.cookies[].authelia_url matches the public URL exactly.

Locked out after a few wrong passwords

Problem: Login fails with "User has been banned" even with the correct password.
Solution: The regulation block bans an account after max_retries failed attempts within find_time. Default ban is 5 minutes. Wait it out or docker compose restart authelia to clear the in-memory ban.

Two-factor prompt appears unexpectedly

Problem: After password entry Authelia asks for a TOTP code or other 2FA, but you wanted password-only for Dashy.
Solution: access_control.default_policy is set to two_factor. Change it to one_factor, or add a specific rule with policy: one_factor matching the Dashy redirect.

Username claim is a UUID, not the username

Problem: Dashy logs show users with random-looking subject IDs.
Solution: Authelia's sub claim is an opaque UUID per user. Dashy's OIDC client falls through preferred_username, email, then sub, so as long as the profile and email scopes are requested the username should be human-readable. Confirm both scopes are in the client's scopes list.

Token expired / clock skew

Problem: 401s with "exp" claim timestamp check failed or "iat" claim timestamp check failed, even just after login.
Solution: Dashy allows 30 seconds of drift. Sync clocks on both hosts with NTP. Container clocks follow their host, so it's almost always the host that's drifted.

Numeric Client ID truncated

Problem: Audience mismatch when clientId in conf.yml is a long numeric string.
Solution: Wrap numeric Client IDs in quotes (e.g. clientId: "12345678901234567"). Without quotes YAML parses the value as a JS number and loses precision past around 15 digits.

Dashy server can't reach Authelia

Problem: Auth'd API calls return 401 and Dashy logs show fetch errors for .well-known/openid-configuration.
Solution: endpoint must be reachable from inside the Dashy container, not just from the browser. If both run in Docker, put them on the same network. Test with docker exec <dashy-container> wget -qO- "$ENDPOINT/.well-known/openid-configuration".

Config change to auth.oidc not picked up

Problem: Updated clientId, endpoint, adminGroup or scope in conf.yml, but Dashy still uses the old values.
Solution: The server reads the auth config only at boot. Restart the Dashy container after any change to fields under auth.oidc.


How it Works

If you're a developer or contributor looking to understand or make changes to Dashy's OIDC implementation, the following outlines how it's wired together.

The same OIDC pipeline backs Authelia, Authentik, Keycloak, and any other generic OIDC provider. The only Authelia-specific code is your configuration; everything else is shared.

Client side

Boot starts in src/main.js. After the initial /conf.yml fetch parses the auth block, isOidcEnabled() decides whether to lazily import oidc-client-ts and call initOidcAuth().

src/utils/auth/OidcAuth.js wraps oidc-client-ts. On load it inspects the URL: if it sees a ?code= callback it runs userManager.signinCallback() to exchange the code (and PKCE verifier) for tokens, persists the user info, and hard-redirects to /. Otherwise it calls userManager.getUser(); if there's no usable session it falls through to userManager.signinRedirect() to send the browser to Authelia. A short-lived sessionStorage guard prevents the redirect loop that would otherwise occur if the IdP returns without a usable user.

persistUserInfo() writes the raw id_token, the user's groups and roles, a derived isAdmin flag, and a username (falling back through preferred_username, email, and sub) to localStorage. The keys (ID_TOKEN, KEYCLOAK_INFO, USERNAME, ISADMIN) live in src/utils/config/defaults.js; the KEYCLOAK_INFO name is historical and reused for all OIDC providers, including Authelia.

src/utils/auth/getApiAuthHeader.js builds the Authorization header for every internal API call. It does a client-side exp check and returns null for missing or expired tokens, so the next request triggers a fresh login rather than a 401.

src/utils/IsVisibleToUser.js reads KEYCLOAK_INFO when evaluating showForKeycloakUsers and hideForKeycloakUsers rules.

Server side

services/auth-oidc.js contains the entire server-side auth surface, in five small pieces:

  • loadOidcSettings() reads auth.oidc (or auth.keycloak) at boot and returns a normalised { issuer, clientId, adminGroup, adminRole }. For generic OIDC providers the issuer is whatever you set as endpoint in conf.yml, verbatim
  • createOidcMiddleware() returns a Connect middleware. Permissive on no-token requests so the SPA can bootstrap; otherwise it verifies the Bearer token against the issuer's JWKS using jose. Checks cover signature, issuer (against the canonical value from the discovery doc), audience (must equal clientId), and expiry, with a 30-second clock-skew tolerance. Sets req.auth = { user, isAdmin, claims } on success, 401 on failure
  • getIssuerContext() lazily fetches .well-known/openid-configuration on first use and wraps jwks_uri in createRemoteJWKSet, which handles JWKS caching and on-demand key rotation. The result is memoised per-issuer for the life of the process
  • deriveIsAdmin() checks the token's groups claim against adminGroup, and the top-level roles claim against adminRole (for Keycloak it also folds in the nested realm_access.roles / resource_access.<clientId>.roles arrays). Authelia only emits groups, so the group path is what's used in practice
  • maybeBootstrapConfig() is the stripped-response helper. When auth is configured, guest access is off, and an unauthenticated request hits the root /conf.yml, it returns a minimal copy with only appConfig.auth, appConfig.enableServiceWorker, and a pageInfo.title of Login | <your title>. Sections, items, hostnames and any other secrets never leave the server

services/app.js wires it all together. The middleware mounts as protectConfig in front of every YAML route and config-mutating route. The /*.yml handler sets Cache-Control: private, no-store and Vary: Authorization whenever auth is configured (so intermediate caches can never mix auth states), then calls maybeBootstrapConfig; a stripped result is sent as-is, otherwise res.sendFile serves the full file. POST /config-manager/save is additionally guarded by requireAdmin, which returns 401 if req.auth is unset and 403 if req.auth.isAdmin is false.

Visual Overview

End-to-end authentication flow
sequenceDiagram
    autonumber
    actor User
    participant Browser as Browser (Dashy SPA)
    participant Dashy as Dashy Server
    participant Auth as Authelia

    Note over Browser,Dashy: 1. Bootstrap (no token yet)
    User->>Browser: Open Dashy
    Browser->>Dashy: GET /conf.yml
    Dashy-->>Browser: 200 stripped conf<br/>(auth block + minimal pageInfo)
    Browser->>Browser: Parse auth.oidc

    Note over Browser,Auth: 2. OIDC login (Authorization Code + PKCE)
    Browser->>Auth: 302 /api/oidc/authorization<br/>client_id, code_challenge, redirect_uri
    User->>Auth: Submit credentials
    Auth-->>Browser: 302 back with ?code=AUTH_CODE
    Browser->>Auth: POST /api/oidc/token<br/>(code + code_verifier)
    Auth-->>Browser: id_token + access_token
    Browser->>Browser: Store tokens in localStorage<br/>hard reload to /

    Note over Browser,Dashy: 3. Authenticated read
    Browser->>Dashy: GET /conf.yml<br/>Authorization: Bearer id_token
    Dashy->>Auth: Fetch discovery + JWKS<br/>(lazy on first call, then cached)
    Auth-->>Dashy: openid-configuration + JWKS
    Dashy->>Dashy: Verify signature / issuer / audience / expiry
    Dashy-->>Browser: 200 full conf.yml

    Note over Browser,Dashy: 4. Write request (admin only)
    Browser->>Dashy: POST /config-manager/save<br/>Authorization: Bearer id_token
    Dashy->>Dashy: Verify token, derive isAdmin from<br/>adminGroup claim
    alt isAdmin
        Dashy-->>Browser: 200 saved
    else not admin
        Dashy-->>Browser: 403 Forbidden
    end
Loading
Server-side request handling
flowchart TD
    Req([Incoming request to Dashy server])
    Req --> Bearer{Authorization:<br/>Bearer present?}

    Bearer -- No --> NoAuth[req.auth unset<br/>pass through]:::neutral
    Bearer -- Yes --> Verify[/Verify JWT against cached JWKS:<br/>signature · issuer · audience · expiry/]
    Verify --> Valid{Valid?}
    Valid -- No --> R401Bad[/401 Unauthorized/]:::err
    Valid -- Yes --> SetAuth[req.auth = user + isAdmin<br/>derived from claims]:::ok

    NoAuth --> Route{Endpoint}
    SetAuth --> Route

    Route -- "GET /conf.yml" --> ConfGate{req.auth?}
    ConfGate -- No --> Strip[/200 stripped conf<br/>auth + minimal pageInfo/]:::ok
    ConfGate -- Yes --> Full[/200 full conf.yml/]:::ok

    Route -- "POST /config-manager/save" --> SaveGate{req.auth<br/>and isAdmin?}
    SaveGate -- No req.auth --> R401Save[/401 Unauthorized/]:::err
    SaveGate -- Not admin --> R403[/403 Forbidden/]:::err
    SaveGate -- Admin --> Saved[/200 saved/]:::ok

    classDef ok fill:#bbf7d0,stroke:#16a34a,color:#14532d
    classDef err fill:#fecaca,stroke:#dc2626,color:#7f1d1d
    classDef neutral fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
Loading