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

23 KiB

Keycloak

Dashy supports using a Keycloak (V17+) authentication server.

Keycloak is a Java-based open source, high-performance, secure authentication system, supported by RedHat. It can be deployed with Docker, and enables you to secure multiple self-hosted applications with single-sign-on using standard protocols (OpenID Connect, OAuth 2.0, SAML 2.0 and social login).

Contents

1. Deploy Keycloak

If you've not already done so, spin up a Keycloak instance. You can do this by following the Keycloak Docs, or use the following Docker examples:

docker run -d \
  -p 9100:8080 \
  --name keycloak \
  -e KEYCLOAK_ADMIN=kc-admin \
  -e KEYCLOAK_ADMIN_PASSWORD=KeycloakAdmin2026! \
  quay.io/keycloak/keycloak:25.0 start-dev
Example docker-compose.yml
KEYCLOAK_ADMIN=kc-admin
KEYCLOAK_ADMIN_PASSWORD=KeycloakAdmin2026!
name: dashy-keycloak
services:
  keycloak:
    image: quay.io/keycloak/keycloak:25.0
    command:
      - start-dev
      - --http-port=9100
      - --hostname-strict=false
      - --health-enabled=true
    restart: unless-stopped
    ports:
      - "9100:9100"
      - "4000:8080"
    volumes:
      - keycloak-data:/opt/keycloak/data
    environment:
      KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN}
      KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
      KC_HTTP_ENABLED: "true"
      KC_HOSTNAME_STRICT: "false"
      KC_HEALTH_ENABLED: "true"
    healthcheck:
      test: ["CMD-SHELL", "timeout 2 bash -c '</dev/tcp/127.0.0.1/9100'"]
      start_period: 30s
      interval: 10s
      timeout: 5s
      retries: 15

  dashy:
    image: lissy93/dashy:4.1.0
    network_mode: service:keycloak
    restart: unless-stopped
    depends_on:
      keycloak:
        condition: service_healthy
    environment:
      NODE_ENV: production
      HOST: 0.0.0.0
      PORT: 8080
    volumes:
      - ./user-data:/app/user-data
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1"]
      start_period: 30s
      interval: 10s
      timeout: 5s
      retries: 15

volumes:
  keycloak-data:

You should now be able to access the Keycloak web interface at http://127.0.0.1:9100, log in with your admin credentials above, and create a new password when prompted.


2. Configure Keycloak

Create the Keycloak realm

  1. Open http://localhost:9100.
  2. Click Administration Console.
  3. Log in as kc-admin.
  4. Open the realm selector in the top-left.
  5. Click Create realm.
  6. Set Realm name to dashy.
  7. Click Create.

Allow Dashy's local origin

  1. In the dashy realm, open Realm settings.
  2. Open Security defenses.
  3. Open Headers.
  4. Clear X-Frame-Options.
  5. Set Content-Security-Policy to:
frame-src 'self' http://localhost:4000 http://127.0.0.1:4000; frame-ancestors 'self' http://localhost:4000 http://127.0.0.1:4000; object-src 'none';
  1. Click Save.

Create the Dashy client

  1. In the dashy realm, open Clients.
  2. Click Create client.
  3. Set Client type to OpenID Connect.
  4. Set Client ID to dashy.
  5. Click Next.
  6. Turn Client authentication off. Dashy is a SPA using PKCE, so it must be a public client.
  7. Leave Standard flow on.
  8. Leave Direct access grants on or off; Dashy does not require it.
  9. Click Next.
  10. Set Valid redirect URIs to:
    • http://localhost:4000/*
    • http://127.0.0.1:4000/*
  11. Set Valid post logout redirect URIs to:
    • http://localhost:4000/*
    • http://127.0.0.1:4000/*
  12. Set Web origins to:
    • http://localhost:4000
    • http://127.0.0.1:4000
  13. Click Save.

Add the realm-role mapper

Dashy uses adminRole: dashy-admin in user-data/conf.yml. For server-side admin checks to work, Keycloak must include realm roles in the ID token

  1. Open Clients
  2. Click the dashy client
  3. Open Client scopes
  4. Click the dedicated scope row, usually named dashy-dedicated
  5. Click Add mapper
  6. Click By configuration
  7. Select User Realm Role
  8. Set Name to dashy-realm-roles
  9. Set Token Claim Name to realm_access.roles
  10. Turn Multivalued on
  11. Turn Add to ID token on
  12. Turn Add to access token on
  13. Turn Add to userinfo on
  14. Click Save

Create the Dashy admin role

  1. In the dashy realm, open Realm roles
  2. Click Create role
  3. Set Role name to dashy-admin
  4. Click Save

(Alternative) Use a group for admin

To grant admin via group membership instead of a realm role, add a second mapper of type Group Membership with claim name groups, create the group under Groups, assign your admin users to it, then set adminGroup: <group-name> (in place of adminRole) in your Dashy config later.

Create test users

Create an admin user:

  1. Open Users
  2. Click Add user
  3. Set Username to keycloak-admin
  4. Set Email verified to on
  5. Set First name to Keycloak
  6. Set Last name to Admin
  7. Click Create
  8. Open the Credentials tab
  9. Click Set password
  10. Set a password, turn Temporary off, and save it
  11. Open the Role mapping tab
  12. Click Assign role
  13. Filter by realm roles, select dashy-admin, and assign it

Create a normal user:

  1. Open Users
  2. Click Add user
  3. Set Username to keycloak-user
  4. Set Email verified to on
  5. Set First name to Keycloak
  6. Set Last name to User
  7. Click Create
  8. Open the Credentials tab
  9. Set a non-temporary password
  10. Do not assign dashy-admin

Summary

Keycloak should now be configured, and ready to go!

The Keycloak UI is not super intuitive, so if you're struggling to find where to configure any of the above options, below is a full start-to-end walkthrough video:

https://github.com/user-attachments/assets/12b6a596-1ec6-453a-9ff7-d4e2c3aa69f7

If you need to, you can make a backup of your Keycloak config, with their built-in backup tool. Something like:

docker compose run --rm --no-deps \
  -v ./backup:/backup \
  keycloak export \
  --realm dashy \
  --dir /backup \
  --users realm_file \
  --optimized

Here's an example of a configured dashy-realm.json backup.


3. Enabling Keycloak in Dashy

Finally, you need to tell Dashy to use the new Keycloak setup, and only allow access from authorized Keycloak users. This is done in the appConfig.auth section of your main /user-data/conf.yml file.

As an example, you can view this conf.yml, which is fully-configured with Keycloak auth using the info from the above steps.

appConfig:
  ...
  disableConfigurationForNonAdmin: true
  auth:
    enableKeycloak: true
    keycloak:
      serverUrl: http://localhost:9100
      realm: dashy
      clientId: dashy
      adminRole: dashy-admin

Where:

  • disableConfigurationForNonAdmin - Prevent read/write config access to non-admin Keycloak users
  • auth.enableKeycloak - Set the auth mode to Keycloak
  • serverUrl - The host (no paths) to your Keycloak instance, accessible from the Dashy container
  • realm - The name (case sensitive) of the Keycloak realm to use
  • clientId - Client ID that you created for Dashy
  • adminRole - The role name that grants admin
  • adminGroup - (Alternative to adminRole) Group name that grants admin
  • idpHint - (Optional) Alias of an external IdP federated through Keycloak; skips the Keycloak login page and redirects straight to that provider

Note that a restart is required for these changes to take effect.

If Keycloak runs on a different host or behind a reverse proxy, make sure serverUrl is reachable from inside the Dashy container, and that the realm's redirect URIs and Web Origins match Dashy's public URL.

Everything should now be fully configured and working 🎉
Now, when you load Dashy, you'll be redirected to your Keycloak login page, after logging in you will then land back on Dashy's homepage with full access! All Dashy's client, server and asset endpoints will be blocked, and return a 403 until you are authenticated.


4. Groups and Roles

Keycloak allows you to assign users roles and groups. Dashy supports using these roles, to configure who can access various sections or items in Dashy's frontend.

For example, to make any given section only visible to admins, simply add the following to the section's displayData section:

displayData:
  showForKeycloakUsers:
    roles:
      - dashy-admin # ID of the admin role you created earlier

Keycloak server administration and configuration is a deep topic; please refer to the server admin guide to see details about creating and assigning roles and groups.

Once you have groups or roles assigned to users you can configure access under each section or item displayData.showForKeycloakUser and displayData.hideForKeycloakUser.

Both show and hide configurations accept a list of groups and roles that limit access. If a users data matches one or more items in these lists they will be allowed or excluded as defined.

sections:
  - name: DeveloperResources
    displayData:
      showForKeycloakUsers:
        roles: ['canViewDevResources']
      hideForKeycloakUsers:
        groups: ['ProductTeam']
    items:
      - title: Not Visible for developers
        displayData:
          hideForKeycloakUsers:
            groups: ['DevelopmentTeam']

Troubleshooting common Keycloak Issues

Client Authentication Issue

Problem: Redirect loop, if client authentication is enabled.
Solution: Switch off "Client authentication" in the dashy client's "Advanced" settings.

Double URL

Problem: If you get redirected to "https://dashy.my.domain/#iss=https://keycloak.my.domain/realms/dashy"
Solution: Turn on "Exclude Issuer From Authentication Response" in the dashy client's "Advanced" -> "OpenID Connect Compatibility Modes".

Problems with multiple Dashy Pages

Problem: Refreshing or logging out of dashy results in an "invalid_redirect_uri" error.
Solution: In the dashy client's "Access settings", set "Root URL" to https://dashy.my.domain/, and make sure the valid redirect URIs end in /*.

403 on login-status-iframe.html/init

Problem: Browser console shows a 403 from Keycloak when the SPA loads.
Solution: Open the dashy client's "Web origins" and remove any trailing /*. Web Origins must be bare origins (e.g. http://localhost:4000), not http://localhost:4000/*.

CSP error for /3p-cookies/step1.html or "Authentication failed (Keycloak)"

Problem: The hidden Keycloak iframe is blocked by frame-ancestors.
Solution: In the dashy realm (not master), open Realm settings -> Security defenses -> Headers. Clear X-Frame-Options and set the Content-Security-Policy as described earlier in this section.

Dashy server can't reach Keycloak

Problem: SPA loads fine but every authenticated API call returns 401, and the Dashy server logs show [auth-oidc] token verification failed or fetch errors for .well-known/openid-configuration.
Solution: serverUrl must be reachable from inside the Dashy container, not just from the browser. In Docker, put both services on the same network and use the service name (e.g. http://keycloak:8080). Test with docker exec <dashy-container> wget -qO- "$SERVER_URL/realms/dashy/.well-known/openid-configuration".

Logged in but config saves return 403

Problem: User authenticates fine, but saving the dashboard returns 403.
Solution: The id_token doesn't carry the admin claim. Confirm the Add to ID token toggle on the Step 2 mapper is on. Paste the token (from localStorage, key ID_TOKEN) into jwt.io and look for realm_access.roles (or groups if you're using adminGroup).

Issuer mismatch behind a reverse proxy

Problem: Server logs show unexpected "iss" claim value. The browser reaches Keycloak over HTTPS but Keycloak advertises an HTTP issuer in its discovery document.
Solution: Set KC_HOSTNAME=<public-host> on Keycloak so the issuer matches the public URL, and ensure your reverse proxy forwards X-Forwarded-Proto: https.

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 Keycloak client's Client ID. Check for trailing whitespace, case mismatches, or accidentally using the client's internal UUID instead of the Client ID string.

Self-signed Keycloak certificate rejected

Problem: Dashy server logs show TLS errors (self-signed certificate, UNABLE_TO_VERIFY_LEAF_SIGNATURE) when fetching JWKS or discovery.
Solution: Use a real certificate on Keycloak (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.

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.

Mixed content blocked by the browser

Problem: Dashy served over HTTPS, Keycloak over HTTP. The browser blocks the token or JWKS endpoint with a mixed-content error.
Solution: Terminate both behind HTTPS. For local testing, use HTTP on both, but never mix schemes in the same flow.

Numeric Client ID truncated

Problem: Token verification fails with 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 about 15 digits.

Logout lands on a broken page

Problem: Clicking Logout in Dashy ends on a Keycloak error or 404 instead of returning to Dashy.
Solution: Add Dashy's URL to Valid post logout redirect URIs on the dashy client. Keycloak v18+ requires registered, exact-match post-logout URIs.

check-sso hangs in strict browsers

Problem: First load spins indefinitely. Browser console reports blocked third-party cookies. Common in Safari, Brave, or Firefox with strict tracking protection.
Solution: Serve Dashy and Keycloak under the same registrable domain (e.g. dashy.example.com and auth.example.com) so the session cookie is first-party.

Config change to auth.keycloak not picked up

Problem: Updated serverUrl, realm, clientId, adminRole, or adminGroup in conf.yml, but Dashy still authenticates against the old values.
Solution: The server reads the auth config only at boot. Restart the Dashy container after any change to fields under auth.keycloak.


How it Works

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

The same OIDC pipeline backs both Keycloak and generic OIDC providers. The only Keycloak-specific code is the SPA adapter, which uses keycloak-js so it can take advantage of check-sso and silent token renewal.

Client side

Boot starts in src/main.js. After the initial /conf.yml fetch parses the auth block, isKeycloakEnabled() decides whether to lazily import keycloak-js and call initKeycloakAuth().

src/utils/auth/KeycloakAuth.js wraps the adapter. On load it calls keycloakClient.init({ onLoad: 'check-sso', responseMode: 'query' }). If a Keycloak session already exists the user is silently authenticated; otherwise the SPA redirects to the login page with PKCE. On return, storeKeycloakInfo() persists the raw id_token, the user's groups and roles, preferred_username, and a derived isAdmin flag to localStorage, then hard-redirects to / so the SPA boots a second time with the Bearer token in place.

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.

The localStorage keys (ID_TOKEN, KEYCLOAK_INFO, USERNAME, ISADMIN) live in src/utils/config/defaults.js. 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.keycloak (or auth.oidc) at boot and returns a normalised { issuer, clientId, adminGroup, adminRole }. For Keycloak the issuer is <serverUrl>/realms/<realm>.
  • createOidcMiddleware() returns a Connect middleware. Permissive on no-token requests so the SPA can bootstrap; otherwise it verifies the Bearer token against the realm'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 union of realm_access.roles and resource_access.<clientId>.roles against adminRole. Either match returns true.
  • 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.

Why the mapper matters

The server's admin check reads from the id_token only. Keycloak's default mapper adds realm roles to the access token but not the id token, so without the Step 2 Add to ID token toggle, realm_access.roles is absent and every user is treated as non-admin. The same applies to groups if you use adminGroup instead. This is the single most common cause of "logged in fine, but can't save changes".

Visual Overview

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

    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.keycloak

    Note over Browser,KC: 2. OIDC login (Authorization Code + PKCE)
    Browser->>KC: 302 /realms/dashy/protocol/openid-connect/auth<br/>client_id, code_challenge, redirect_uri
    User->>KC: Submit credentials
    KC-->>Browser: 302 back with ?code=AUTH_CODE
    Browser->>KC: POST /token (code + code_verifier)
    KC-->>Browser: id_token + access_token + refresh_token
    Browser->>Browser: Store tokens in localStorage

    Note over Browser,Dashy: 3. Authenticated read
    Browser->>Dashy: GET /conf.yml<br/>Authorization: Bearer id_token
    Dashy->>KC: Fetch discovery + JWKS<br/>(lazy on first call, then cached)
    KC-->>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/>adminRole / adminGroup claims
    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