Files
dashy/docs/authentication/authentik.md
2026-05-23 15:03:52 +01:00

22 KiB

Authentik OIDC

Dashy supports using Authentik as its OIDC provider.

Authentik is an open source identity provider that speaks OIDC, OAuth 2.0, SAML 2.0 and LDAP. It runs in Docker, has a polished admin UI, and supports MFA, social login, and per-application group policies, which makes it a good fit for self-hosted setups where you want a single login across many services.

Contents

1. Deploy Authentik

If you've not already done so, spin up an Authentik instance, following the official docs. The compose file below is a minimal local setup.

A .env file alongside the compose file (generate fresh secrets with openssl rand -hex 32):

AUTHENTIK_TAG=2024.12
PG_PASS=replace-me-with-random-hex
AUTHENTIK_SECRET_KEY=replace-me-with-random-hex
AUTHENTIK_BOOTSTRAP_PASSWORD=change-me-now
AUTHENTIK_BOOTSTRAP_EMAIL=you@example.com
AUTHENTIK_BOOTSTRAP_TOKEN=replace-me-with-random-hex
Example docker-compose.yml
name: authentik

services:
  postgresql:
    image: docker.io/library/postgres:16-alpine
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
      start_period: 20s
      interval: 10s
      retries: 5
      timeout: 5s
    volumes:
      - ./data/postgres:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: ${PG_PASS}
      POSTGRES_USER: authentik
      POSTGRES_DB: authentik

  redis:
    image: docker.io/library/redis:7-alpine
    command: --save 60 1 --loglevel warning
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
      start_period: 20s
      interval: 10s
      retries: 5
      timeout: 3s
    volumes:
      - ./data/redis:/data

  server:
    image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG}
    restart: unless-stopped
    command: server
    environment: &authentik-env
      AUTHENTIK_REDIS__HOST: redis
      AUTHENTIK_POSTGRESQL__HOST: postgresql
      AUTHENTIK_POSTGRESQL__USER: authentik
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
      AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
      AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN}
      AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL}
      AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
    ports:
      - "9000:9000"
      - "9443:9443"
    depends_on:
      postgresql: {condition: service_healthy}
      redis: {condition: service_healthy}

  worker:
    image: ghcr.io/goauthentik/server:${AUTHENTIK_TAG}
    restart: unless-stopped
    command: worker
    environment: *authentik-env
    depends_on:
      postgresql: {condition: service_healthy}
      redis: {condition: service_healthy}

Bring it up:

docker compose up -d

First boot runs database migrations and takes a minute or two. Once the server container is healthy, open http://localhost:9000 and sign in as akadmin with the bootstrap password.


2. Configure Authentik

Create the groups scope

Authentik doesn't expose group membership in the id_token by default. Dashy needs it for the adminGroup check and for the showForKeycloakUsers / hideForKeycloakUsers visibility rules.

  1. Go to Customisation > Property Mappings
  2. Click Create > Scope Mapping
  3. Set Name to groups
  4. Set Scope name to groups
  5. Set Expression to:
return {"groups": [g.name for g in request.user.ak_groups.all()]}
  1. Click Finish

Create the OIDC provider

  1. Go to Applications > Providers
  2. Click Create, pick OAuth2/OpenID Provider, click Next
  3. Set Name to Dashy
  4. Set Authorization flow to default-provider-authorization-implicit-consent (use default-provider-authorization-explicit-consent if you want users to confirm sign-in each time)
  5. Set Invalidation flow to default-provider-invalidation-flow (required on Authentik 2023.10 and newer)
  6. Under Protocol settings:
    • Client type: Public
    • Client ID: dashy, or leave the auto-generated value and copy it for later
    • Redirect URIs with matching mode Strict, one URL per line. Register both the bare URL and the trailing-slash version:
      • https://dashy.example.com
      • https://dashy.example.com/
    • Signing Key: the built-in authentik Self-signed Certificate is fine
  7. Expand Advanced protocol settings:
    • Add openid, profile, email, and the groups scope you just created to Scopes
    • Turn Include claims in id_token on
  8. Click Finish

Create the application

  1. Go to Applications > Applications
  2. Click Create
  3. Set Name to Dashy
  4. Set Slug to dashy (this becomes part of the issuer URL: <host>/application/o/<slug>/)
  5. Set Provider to the Dashy provider you just made
  6. Click Create

Now open the Dashy provider again (Applications > Providers > Dashy) and copy the OpenID Configuration Issuer URL shown on the page (e.g. https://auth.example.com/application/o/dashy/). The provider only displays a valid URL once it's bound to an application. You'll need this for Dashy's endpoint setting later.

Create the admin group

  1. Go to Directory > Groups
  2. Click Create
  3. Set Name to dashy-admins
  4. Click Create
  5. Open the new group, click Users, and add any users who should have admin rights in Dashy

Create test users

If you want separate accounts beyond akadmin:

  1. Go to Directory > Users
  2. Click Create, fill in Username, Name and Email, click Create
  3. On the new user's page, click Set password, set a password, click Update
  4. Add the user to dashy-admins for admin access, or leave them out for a non-admin

Summary

Authentik should now be configured, and ready to go!


3. Enabling Authentik in Dashy

Finally, you need to tell Dashy to use Authentik. This goes in the appConfig.auth section of your main /user-data/conf.yml.

appConfig:
  ...
  disableConfigurationForNonAdmin: true
  auth:
    enableOidc: true
    oidc:
      clientId: dashy
      endpoint: https://auth.example.com/application/o/dashy/
      adminGroup: dashy-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 - The Client ID from the Authentik provider (exact, case-sensitive)
  • endpoint - The OpenID Configuration Issuer URL from the provider page. Use the bare issuer, not the discovery URL; Dashy appends /.well-known/openid-configuration itself
  • adminGroup - Name of the Authentik group that grants admin in Dashy (matches the dashy-admins group above)
  • scope - Space-separated list of scopes to request. Must include groups when adminGroup is set, otherwise the id_token won't carry the claim

Restart Dashy for these changes to take effect.

If Authentik runs on a different host or behind a reverse proxy, make sure endpoint is reachable from inside the Dashy container, and that the issuer URL the provider advertises matches endpoint exactly.

Everything should now be fully configured and working 🎉 When you load Dashy, you'll be redirected to Authentik's login page. After signing in you will 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 in Dashy. The property name is hideForKeycloakUsers / showForKeycloakUsers (the name is historical; it works for any OIDC provider, including Authentik).

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

displayData:
  showForKeycloakUsers:
    groups:
      - dashy-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: ['dashy-admins']
      hideForKeycloakUsers:
        groups: ['guests']
    items:
      - title: Hidden from interns
        displayData:
          hideForKeycloakUsers:
            groups: ['interns']

Troubleshooting common Authentik Issues

Migrations still running on first boot

Problem: Authentik returns 502 or never reaches the login page right after docker compose up.
Solution: First boot runs database migrations and can take a minute or two. Tail the logs with docker compose logs -f server and wait for the uvicorn startup line before opening the UI.

Redirect loop after login

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

invalid_redirect_uri

Problem: Authentik shows "invalid redirect URI" after submitting credentials.
Solution: The URL Dashy is being served from doesn't exactly match what's registered on the provider. Register both the bare URL and the trailing-slash variant (e.g. https://dashy.example.com and https://dashy.example.com/), keep matching mode on Strict, and make sure the scheme matches (http vs https).

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 groups scope mapping isn't attached to the provider's Scopes or Include claims in id_token is off. If the claim is there but the user isn't in it, add them to the dashy-admins group.

Issuer mismatch behind a reverse proxy

Problem: Server logs show unexpected "iss" claim value. The browser reaches Authentik over HTTPS, but Authentik advertises an HTTP issuer in its discovery document.
Solution: Set AUTHENTIK_LISTEN__TRUSTED_PROXY_CIDRS on the Authentik server and worker containers to include your proxy's IP range (e.g. 172.16.0.0/12 for default Docker bridges), and make sure the proxy forwards X-Forwarded-Proto: https. Once Authentik trusts the proxy, its discovery document will advertise the public HTTPS URL.

Audience mismatch on token verification

Problem: Server logs show unexpected "aud" claim value. Every auth'd API call returns 401.
Solution: clientId in conf.yml must exactly match the provider's Client ID field. If you let Authentik auto-generate one, copy the exact value (including case) from the provider page.

Self-signed Authentik certificate rejected

Problem: Dashy server logs show TLS errors (self-signed certificate, UNABLE_TO_VERIFY_LEAF_SIGNATURE) when fetching the discovery doc or JWKS.
Solution: Use a real certificate on the Authentik HTTPS endpoint (Let's Encrypt or your homelab CA), or mount your CA bundle into the Dashy container and set NODE_EXTRA_CA_CERTS=/path/to/ca.pem. Authentik's built-in authentik Self-signed Certificate is only used to sign tokens; the TLS cert is whatever's terminating HTTPS in front of Authentik.

"OIDC signinCallback returned no user"

Problem: Login submits, Authentik redirects back, then Dashy shows the error toast OIDC signinCallback returned no user.
Solution: The id_token came back without a usable username claim. Confirm profile and email are in the provider's Scopes, that Include claims in id_token is on, and that the user has an email or username set in Authentik.

Problem: Clicking Logout sends the user to Authentik's end-session endpoint, which prompts for confirmation and never returns.
Solution: This is the default behaviour of default-provider-invalidation-flow. To skip the prompt, change the provider's Invalidation flow to one without a consent stage, or accept the extra click.

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 Authentik

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 Authentik, Keycloak, and any other generic OIDC provider. The only Authentik-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 Authentik. 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 Authentik.

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 realm_access.roles / resource_access.<clientId>.roles arrays against adminRole. Authentik 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 AK as Authentik

    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,AK: 2. OIDC login (Authorization Code + PKCE)
    Browser->>AK: 302 /application/o/authorize/<br/>client_id, code_challenge, redirect_uri
    User->>AK: Submit credentials
    AK-->>Browser: 302 back with ?code=AUTH_CODE
    Browser->>AK: POST /application/o/token/<br/>(code + code_verifier)
    AK-->>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->>AK: Fetch discovery + JWKS<br/>(lazy on first call, then cached)
    AK-->>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
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