1"use strict";(globalThis.webpackChunkdashy=globalThis.webpackChunkdashy||[]).push([[8559],{29535(e,n,s){s.r(n),s.d(n,{assets:()=>a,contentTitle:()=>t,default:()=>h,frontMatter:()=>l,metadata:()=>i,toc:()=>c});const i=JSON.parse('{"id":"authentication/keycloak","title":"Keycloak","description":"Dashy supports using a Keycloak (V17+) authentication server.","source":"@site/docs/authentication/keycloak.md","sourceDirName":"authentication","slug":"/authentication/keycloak","permalink":"/docs/authentication/keycloak","draft":false,"unlisted":false,"editUrl":"https://github.com/Lissy93/dashy/edit/master/docs/authentication/keycloak.md","tags":[],"version":"current","lastUpdatedBy":"Liss-Bot","lastUpdatedAt":1784660686000,"frontMatter":{},"sidebar":"dashySidebar","previous":{"title":"Header Authentication","permalink":"/docs/authentication/header-auth"},"next":{"title":"OIDC","permalink":"/docs/authentication/oidc"}}');var r=s(74848),o=s(28453);const l={},t="Keycloak",a={},c=[{value:"Contents",id:"contents",level:3},{value:"1. Deploy Keycloak",id:"1-deploy-keycloak",level:2},{value:"2. Configure Keycloak",id:"2-configure-keycloak",level:2},{value:"Create the Keycloak realm",id:"create-the-keycloak-realm",level:3},{value:"Allow Dashy's local origin",id:"allow-dashys-local-origin",level:3},{value:"Create the Dashy client",id:"create-the-dashy-client",level:3},{value:"Add the realm-role mapper",id:"add-the-realm-role-mapper",level:3},{value:"Create the Dashy admin role",id:"create-the-dashy-admin-role",level:3},{value:"(Alternative) Use a group for admin",id:"alternative-use-a-group-for-admin",level:3},{value:"Create test users",id:"create-test-users",level:3},{value:"Summary",id:"summary",level:3},{value:"3. Enabling Keycloak in Dashy",id:"3-enabling-keycloak-in-dashy",level:2},{value:"4. Groups and Roles",id:"4-groups-and-roles",level:2},{value:"Troubleshooting common Keycloak Issues",id:"troubleshooting-common-keycloak-issues",level:2},{value:"Client Authentication Issue",id:"client-authentication-issue",level:4},{value:"Double URL",id:"double-url",level:4},{value:"Problems with multiple Dashy Pages",id:"problems-with-multiple-dashy-pages",level:4},{value:"403 on login-status-iframe.html/init",id:"403-on-login-status-iframehtmlinit",level:4},{value:"CSP error for /3p-cookies/step1.html or "Authentication failed (Keycloak)"",id:"csp-error-for-3p-cookiesstep1html-or-authentication-failed-keycloak",level:4},{value:"Dashy server can't reach Keycloak",id:"dashy-server-cant-reach-keycloak",level:4},{value:"Logged in but config saves return 403",id:"logged-in-but-config-saves-return-403",level:4},{value:"Issuer mismatch behind a reverse proxy",id:"issuer-mismatch-behind-a-reverse-proxy",level:4},{value:"Audience mismatch on token verification",id:"audience-mismatch-on-token-verification",level:4},{value:"Self-signed Keycloak certificate rejected",id:"self-signed-keycloak-certificate-rejected",level:4},{value:"Token expired / clock skew",id:"token-expired--clock-skew",level:4},{value:"Mixed content blocked by the browser",id:"mixed-content-blocked-by-the-browser",level:4},{value:"Numeric Client ID truncated",id:"numeric-client-id-truncated",level:4},{value:"Logout lands on a broken page",id:"logout-lands-on-a-broken-page",level:4},{value:"check-sso hangs in strict browsers",id:"check-sso-hangs-in-strict-browsers",level:4},{value:"Config change to auth.keycloak not picked up",id:"config-change-to-authkeycloak-not-picked-up",level:4},{value:"How it Works",id:"how-it-works",level:2},{value:"Client side",id:"client-side",level:3},{value:"Server side",id:"server-side",level:3},{value:"Why the mapper matters",id:"why-the-mapper-matters",level:3},{value:"Visual Overview",id:"visual-overview",level:3}];function d(e){const n={a:"a",blockquote:"blockquote",br:"br",code:"code",details:"details",em:"em",h1:"h1",h2:"h2",h3:"h3",h4:"h4",header:"header",hr:"hr",li:"li",mermaid:"mermaid",ol:"ol",p:"p",pre:"pre",strong:"strong",summary:"summary",ul:"ul",...(0,o.R)(),...e.components};return(0,r.jsxs)(r.Fragment,{children:[(0,r.jsx)(n.header,{children:(0,r.jsx)(n.h1,{id:"keycloak",children:"Keycloak"})}),"\n",(0,r.jsxs)(n.p,{children:["Dashy supports using a ",(0,r.jsx)(n.a,{href:"https://www.keycloak.org/",children:"Keycloak"})," (V17+) authentication server."]}),"\n",(0,r.jsxs)(n.p,{children:[(0,r.jsx)(n.a,{href:"https://www.keycloak.org/about.html",children:"Keycloak"})," is a Java-based ",(0,r.jsx)(n.a,{href:"https://github.com/keycloak/keycloak",children:"open source"}),", high-performance, secure authentication system, supported by ",(0,r.jsx)(n.a,{href:"https://www.redhat.com/en",children:"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 s
1ocial login)."]}),"\n",(0,r.jsx)(n.h3,{id:"contents",children:"Contents"}),"\n",(0,r.jsxs)(n.ul,{children:["\n",(0,r.jsx)(n.li,{children:(0,r.jsx)(n.a,{href:"#1-deploy-keycloak",children:"1. Deploy Keycloak"})}),"\n",(0,r.jsx)(n.li,{children:(0,r.jsx)(n.a,{href:"#2-configure-keycloak",children:"2. Configure Keycloak"})}),"\n",(0,r.jsx)(n.li,{children:(0,r.jsx)(n.a,{href:"#3-enabling-keycloak-in-dashy",children:"3. Enabling Keycloak in Dashy"})}),"\n",(0,r.jsx)(n.li,{children:(0,r.jsx)(n.a,{href:"#4-groups-and-roles",children:"4. Groups and Roles"})}),"\n",(0,r.jsx)(n.li,{children:(0,r.jsx)(n.a,{href:"#troubleshooting-common-keycloak-issues",children:"Troubleshooting"})}),"\n",(0,r.jsx)(n.li,{children:(0,r.jsx)(n.a,{href:"#how-it-works",children:"How it Works"})}),"\n"]}),"\n",(0,r.jsx)(n.h2,{id:"1-deploy-keycloak",children:"1. Deploy Keycloak"}),"\n",(0,r.jsxs)(n.p,{children:["If you've not already done so, spin up a Keycloak instance.\nYou can do this by following the ",(0,r.jsx)(n.a,{href:"https://www.keycloak.org/guides.html#getting-started",children:"Keycloak Docs"}),", or use the following Docker examples:"]}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-bash",children:"docker run -d \\\n -p 9100:8080 \\\n --name keycloak \\\n -e KEYCLOAK_ADMIN=kc-admin \\\n -e KEYCLOAK_ADMIN_PASSWORD=KeycloakAdmin2026! \\\n quay.io/keycloak/keycloak:25.0 start-dev\n"})}),"\n",(0,r.jsxs)(n.details,{children:["\n ",(0,r.jsxs)(n.summary,{children:["Example ",(0,r.jsx)(n.code,{children:"docker-compose.yml"})]}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-env",children:"KEYCLOAK_ADMIN=kc-admin\nKEYCLOAK_ADMIN_PASSWORD=KeycloakAdmin2026!\n"})}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-yaml",children:'name: dashy-keycloak\nservices:\n keycloak:\n image: quay.io/keycloak/keycloak:25.0\n command:\n - start-dev\n - --http-port=9100\n - --hostname-strict=false\n - --health-enabled=true\n restart: unless-stopped\n ports:\n - "9100:9100"\n - "4000:8080"\n volumes:\n - keycloak-data:/opt/keycloak/data\n environment:\n KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN}\n KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}\n KC_HTTP_ENABLED: "true"\n KC_HOSTNAME_STRICT: "false"\n KC_HEALTH_ENABLED: "true"\n healthcheck:\n test: ["CMD-SHELL", "timeout 2 bash -c \'</dev/tcp/127.0.0.1/9100\'"]\n start_period: 30s\n interval: 10s\n timeout: 5s\n retries: 15\n\n dashy:\n image: lissy93/dashy:4.1.0\n network_mode: service:keycloak\n restart: unless-stopped\n depends_on:\n keycloak:\n condition: service_healthy\n environment:\n NODE_ENV: production\n HOST: 0.0.0.0\n PORT: 8080\n volumes:\n - ./user-data:/app/user-data\n healthcheck:\n test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/healthz >/dev/null 2>&1"]\n start_period: 30s\n interval: 10s\n timeout: 5s\n retries: 15\n\nvolumes:\n keycloak-data:\n'})}),"\n"]}),"\n",(0,r.jsxs)(n.p,{children:["You should now be able to access the Keycloak web interface at ",(0,r.jsx)(n.code,{children:"http://127.0.0.1:9100"}),", log in with your admin credentials above, and create a new password when prompted."]}),"\n",(0,r.jsx)(n.hr,{}),"\n",(0,r.jsx)(n.h2,{id:"2-configure-keycloak",children:"2. Configure Keycloak"}),"\n",(0,r.jsx)(n.h3,{id:"create-the-keycloak-realm",children:"Create the Keycloak realm"}),"\n",(0,r.jsxs)(n.ol,{children:["\n",(0,r.jsxs)(n.li,{children:["Open ",(0,r.jsx)(n.code,{children:"http://localhost:9100"}),"."]}),"\n",(0,r.jsxs)(n.li,{children:["Click ",(0,r.jsx)(n.strong,{children:"Administration Console"}),"."]}),"\n",(0,r.jsxs)(n.li,{children:["Log in as ",(0,r.jsx)(n.code,{children:"kc-admin"}),"."]}),"\n",(0,r.jsx)(n.li,{children:"Open the realm selector in the top-left."}),"\n",(0,r.jsxs)(n.li,{children:["Click ",(0,r.jsx)(n.strong,{children:"Create realm"}),"."]}),"\n",(0,r.jsxs)(n.li,{children:["Set ",(0,r.jsx)(n.strong,{children:"Realm name"})," to ",(0,r.jsx)(n.code,{children:"dashy"}),"."]}),"\n",(0,r.jsxs)(n.li,{children:["Click ",(0,r.jsx)(n.strong,{children:"Create"}),"."]}),"\n"]}),"\n",(0,r.jsx)(n.h3,{id:"allow-dashys-local-origin",children:"Allow Dashy's local origin"}),"\n",(0,r.jsxs)(n.ol,{children:["\n",(0,r.jsxs)(n.li,{children:["In the ",(0,r.jsx)(n.code,{children:"dashy"})," realm, open ",(0,r.jsx)(n.strong,{children:"Realm settings"}),"."]}),"\n",(0,r.jsxs)(n.li,{children:["Open ",(0,r.jsx)(n.strong,{children:"Security defenses"}),"."]}),"\n",(0,r.jsxs)(n.li,{children:["Open ",(0,r.jsx)(n.strong,{children:"Headers"}),"."]}),"\n",(0,r.jsxs)(n.li,{children:["Clear ",(0,r.jsx)(n.strong,{children:"X-Frame-Options"}),"."]}),"\n",(0,r.jsxs)(n.li,{children:["Set ",(0,r.jsx)(n.strong,{children:"Content-Security-Policy"})," to:"]}),"\n"]}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-text",children:"frame-sr
1c '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';\n"})}),"\n",(0,r.jsxs)(n.ol,{start:"6",children:["\n",(0,r.jsxs)(n.li,{children:["Click ",(0,r.jsx)(n.strong,{children:"Save"}),"."]}),"\n"]}),"\n",(0,r.jsxs)(n.blockquote,{children:["\n",(0,r.jsxs)(n.p,{children:["If Dashy and Keycloak are served from the same origin in production, you can skip this step. Clearing X-Frame-Options and allow-listing origins is only needed when Dashy frames Keycloak's ",(0,r.jsx)(n.code,{children:"check-sso"})," iframe across origins, as in this localhost setup."]}),"\n"]}),"\n",(0,r.jsx)(n.h3,{id:"create-the-dashy-client",children:"Create the Dashy client"}),"\n",(0,r.jsxs)(n.ol,{children:["\n",(0,r.jsxs)(n.li,{children:["In the ",(0,r.jsx)(n.code,{children:"dashy"})," realm, open ",(0,r.jsx)(n.strong,{children:"Clients"}),"."]}),"\n",(0,r.jsxs)(n.li,{children:["Click ",(0,r.jsx)(n.strong,{children:"Create client"}),"."]}),"\n",(0,r.jsxs)(n.li,{children:["Set ",(0,r.jsx)(n.strong,{children:"Client type"})," to ",(0,r.jsx)(n.code,{children:"OpenID Connect"}),"."]}),"\n",(0,r.jsxs)(n.li,{children:["Set ",(0,r.jsx)(n.strong,{children:"Client ID"})," to ",(0,r.jsx)(n.code,{children:"dashy"}),"."]}),"\n",(0,r.jsxs)(n.li,{children:["Click ",(0,r.jsx)(n.strong,{children:"Next"}),"."]}),"\n",(0,r.jsxs)(n.li,{children:["Turn ",(0,r.jsx)(n.strong,{children:"Client authentication"})," off. Dashy is a SPA using PKCE, so it must be a public client."]}),"\n",(0,r.jsxs)(n.li,{children:["Leave ",(0,r.jsx)(n.strong,{children:"Standard flow"})," on."]}),"\n",(0,r.jsxs)(n.li,{children:["Leave ",(0,r.jsx)(n.strong,{children:"Direct access grants"})," on or off; Dashy does not require it."]}),"\n",(0,r.jsxs)(n.li,{children:["Click ",(0,r.jsx)(n.strong,{children:"Next"}),"."]}),"\n",(0,r.jsxs)(n.li,{children:["Set ",(0,r.jsx)(n.strong,{children:"Valid redirect URIs"})," to:\n",(0,r.jsxs)(n.ul,{children:["\n",(0,r.jsx)(n.li,{children:(0,r.jsx)(n.code,{children:"http://localhost:4000/*"})}),"\n",(0,r.jsx)(n.li,{children:(0,r.jsx)(n.code,{children:"http://127.0.0.1:4000/*"})}),"\n"]}),"\n"]}),"\n",(0,r.jsxs)(n.li,{children:["Set ",(0,r.jsx)(n.strong,{children:"Valid post logout redirect URIs"})," to:\n",(0,r.jsxs)(n.ul,{children:["\n",(0,r.jsx)(n.li,{children:(0,r.jsx)(n.code,{children:"http://localhost:4000/*"})}),"\n",(0,r.jsx)(n.li,{children:(0,r.jsx)(n.code,{children:"http://127.0.0.1:4000/*"})}),"\n"]}),"\n"]}),"\n",(0,r.jsxs)(n.li,{children:["Set ",(0,r.jsx)(n.strong,{children:"Web origins"})," to:\n",(0,r.jsxs)(n.ul,{children:["\n",(0,r.jsx)(n.li,{children:(0,r.jsx)(n.code,{children:"http://localhost:4000"})}),"\n",(0,r.jsx)(n.li,{children:(0,r.jsx)(n.code,{children:"http://127.0.0.1:4000"})}),"\n"]}),"\n"]}),"\n",(0,r.jsxs)(n.li,{children:["Click ",(0,r.jsx)(n.strong,{children:"Save"}),"."]}),"\n"]}),"\n",(0,r.jsx)(n.h3,{id:"add-the-realm-role-mapper",children:"Add the realm-role mapper"}),"\n",(0,r.jsxs)(n.p,{children:["Dashy uses ",(0,r.jsx)(n.code,{children:"adminRole: dashy-admin"})," in ",(0,r.jsx)(n.code,{children:"user-data/conf.yml"}),". For server-side admin checks to work, Keycloak must include realm roles in the ID token"]}),"\n",(0,r.jsxs)(n.ol,{children:["\n",(0,r.jsxs)(n.li,{children:["Open ",(0,r.jsx)(n.strong,{children:"Clients"})]}),"\n",(0,r.jsxs)(n.li,{children:["Click the ",(0,r.jsx)(n.code,{children:"dashy"})," client"]}),"\n",(0,r.jsxs)(n.li,{children:["Open ",(0,r.jsx)(n.strong,{children:"Client scopes"})]}),"\n",(0,r.jsxs)(n.li,{children:["Click the dedicated scope row, usually named ",(0,r.jsx)(n.code,{children:"dashy-dedicated"})]}),"\n",(0,r.jsxs)(n.li,{children:["Click ",(0,r.jsx)(n.strong,{children:"Add mapper"})]}),"\n",(0,r.jsxs)(n.li,{children:["Click ",(0,r.jsx)(n.strong,{children:"By configuration"})]}),"\n",(0,r.jsxs)(n.li,{children:["Select ",(0,r.jsx)(n.strong,{children:"User Realm Role"})]}),"\n",(0,r.jsxs)(n.li,{children:["Set ",(0,r.jsx)(n.strong,{children:"Name"})," to ",(0,r.jsx)(n.code,{children:"dashy-realm-roles"})]}),"\n",(0,r.jsxs)(n.li,{children:["Set ",(0,r.jsx)(n.strong,{children:"Token Claim Name"})," to ",(0,r.jsx)(n.code,{children:"realm_access.roles"})]}),"\n",(0,r.jsxs)(n.li,{children:["Turn ",(0,r.jsx)(n.strong,{children:"Multivalued"})," on"]}),"\n",(0,r.jsxs)(n.li,{children:["Turn ",(0,r.jsx)(n.strong,{children:"Add to ID token"})," on"]}),"\n",(0,r.jsxs)(n.li,{children:["Turn ",(0,r.jsx)(n.strong,{children:"Add to access token"})," on"]}),"\n",(0,r.jsxs)(n.li,{children:["Turn ",(0,r.jsx)(n.strong,{children:"Add to userinfo"})," on"]}),"\n",(0,r.jsxs)(n.li,{children:["Click ",(0,r.jsx)(n.strong,{children:"Save"})]}),"\n"]}),"\n",(0,r.jsx)(n.h3,{id:"create-the-dashy-admin-role",children:"Create the Dashy admin role"}),"\n",(0,r.jsxs)(n.ol,{children:["\n",(0,r.jsxs)(n.li,{children:["In the ",(0,r.jsx)(n.code,{children:"dashy"})," realm, open ",(0,r.jsx)(n.strong,{children:"Realm roles"})]}),"\n",(0,r.jsxs)(n.li,{children:["Click ",(0,r.jsx)(n.strong,{children:"Create role"})]}),"\n",(0,r.jsxs)(n.li,{children:["Set ",(0,r.jsx)(n.strong,{children:"Role name"})," to ",(0,r.jsx)(n.code,{children:"dashy-admin"})]}),"\n",(0,r.jsxs)(n.li,{children:["Click ",(0,r.jsx)(n.strong,{children:"Save"})]}),"\n"]}),"\n",(0,r.jsx)(n.h3,{id:"alternative-use-a-group-for-admin",children:"(Alternative) Use a group for admin"}),"\n",(0,r.jsxs)(n.p,{children:["To grant admin via group membership instead of a realm role, add a second mapper of type ",(0,r.jsx)(n.em,{children:"Group Membership"})," with claim name ",(0,r.jsx)(n.code,{children:"groups"}),", create the group under ",(0,r.jsx)(n.em,{children:"Groups"}),", assign your admin users to it, then set ",(0,r.jsx)(n.code,{children:"adminGroup: <group-name>"})," (in place of ",(0,r.jsx)(n.code,{children:"adminRole"}),") in your Dashy config later."]}),"\n",(0,r.jsx)(n.h3,{id:"create-test-users",children:"Create test users"}),"\n",(0,r.jsxs)(n.blockquote,{children:["\n",(0,r.jsxs)(n.p,{children:["On Keycloak 25 and newer, ",(0,r.jsx)(n.strong,{children:"First name"})," and ",(0,r.jsx)(n.strong,{children:"Last name"}),' are required by the default user-profile schema. Skip them and the user can authenticate, but login then fails with "Account is not fully set up". The steps below set both.']}),"\n"]}),"\n",(0,r.jsx)(n.p,{children:"Create an admin user:"}),"\n",(0,r.jsxs)(n.ol,{children:["\n",(0,r.jsxs)(n.li,{children:["Open ",(0,r.jsx)(n.strong,{children:"Users"})]}),"\n",(0,r.jsxs)(n.li,{children:["Click ",(0,r.jsx)(n.strong,{children:"Add user"})]}),"\n",(0,r.jsxs)(n.li,{children:["Set ",(0,r.jsx)(n.strong,{children:"Username"})," to ",(0,r.jsx)(n.code,{children:"keycloak-admin"})]}),"\n",(0,r.jsxs)(n.li,{children:["Set ",(0,r.jsx)(n.strong,{children:"Email verified"})," to on"]}),"\n",(0,r.jsxs)(n.li,{children:["Set ",(0,r.jsx)(n.strong,{children:"First name"})," to ",(0,r.jsx)(n.code,{children:"Keycloak"})]}),"\n",(0,r.jsxs)(n.li,{children:["Set ",(0,r.jsx)(n.strong,{children:"Last name"})," to ",(0,r.jsx)(n.code,{children:"Admin"})]}),"\n",(0,r.jsxs)(n.li,{children:["Click ",(0,r.jsx)(n.strong,{children:"Create"})]}),"\n",(0,r.jsxs)(n.li,{children:["Open the ",(0,r.jsx)(n.strong,{children:"Credentials"})," tab"]}),"\n",(0,r.jsxs)(n.li,{children:["Click ",(0,r.jsx)(n.strong,{children:"Set password"})]}),"\n",(0,r.jsxs)(n.li,{children:["Set a password, turn ",(0,r.jsx)(n.strong,{children:"Temporary"})," off, and save it"]}),"\n",(0,r.jsxs)(n.li,{children:["Open the ",(0,r.jsx)(n.strong,{children:"Role mapping"})," tab"]}),"\n",(0,r.jsxs)(n.li,{children:["Click ",(0,r.jsx)(n.strong,{children:"Assign role"})]}),"\n",(0,r.jsxs)(n.li,{children:["Filter by realm roles, select ",(0,r.jsx)(n.code,{children:"dashy-admin"}),", and assign it"]}),"\n"]}),"\n",(0,r.jsx)(n.p,{children:"Create a normal user:"}),"\n",(0,r.jsxs)(n.ol,{children:["\n",(0,r.jsxs)(n.li,{children:["Open ",(0,r.jsx)(n.strong,{children:"Users"})]}),"\n",(0,r.jsxs)(n.li,{children:["Click ",(0,r.jsx)(n.strong,{children:"Add user"})]}),"\n",(0,r.jsxs)(n.li,{children:["Set ",(0,r.jsx)(n.strong,{children:"Username"})," to ",(0,r.jsx)(n.code,{children:"keycloak-user"})]}),"\n",(0,r.jsxs)(n.li,{children:["Set ",(0,r.jsx)(n.strong,{children:"Email verified"})," to on"]}),"\n",(0,r.jsxs)(n.li,{children:["Set ",(0,r.jsx)(n.strong,{children:"First name"})," to ",(0,r.jsx)(n.code,{children:"Keycloak"})]}),"\n",(0,r.jsxs)(n.li,{children:["Set ",(0,r.jsx)(n.strong,{children:"Last name"})," to ",(0,r.jsx)(n.code,{children:"User"})]}),"\n",(0,r.jsxs)(n.li,{children:["Click ",(0,r.jsx)(n.strong,{children:"Create"})]}),"\n",(0,r.jsxs)(n.li,{children:["Open the ",(0,r.jsx)(n.strong,{children:"Credentials"})," tab"]}),"\n",(0,r.jsx)(n.li,{children:"Set a non-temporary password"}),"\n",(0,r.jsxs)(n.li,{children:["Do not assign ",(0,r.jsx)(n.code,{children:"dashy-admin"})]}),"\n"]}),"\n",(0,r.jsx)(n.h3,{id:"summary",children:"Summary"}),"\n",(0,r.jsx)(n.p,{children:"Keycloak should now be configured, and ready to go!"}),"\n",(0,r.jsx)(n.p,{children:"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:"}),"\n",(0,r.jsx)(n.p,{children:(0,r.jsx)(n.a,{href:"https://github.com/user-attachments/assets/12b6a596-1ec6-453a-9ff7-d4e2c3aa69f7",children:"https://github.com/user-attachments/assets/12b6a596-1ec6-453a-9ff7-d4e2c3aa69f7"})}),"\n",(0,r.jsx)(n.p,{children:"If you need to, you can make a backup of your Keycloak config, with their built-in backup tool. Something like:"}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-bash",children:"docker
1compose run --rm --no-deps \\\n -v ./backup:/backup \\\n keycloak export \\\n --realm dashy \\\n --dir /backup \\\n --users realm_file \\\n --optimized\n"})}),"\n",(0,r.jsxs)(n.p,{children:["Here's an example of a configured ",(0,r.jsx)(n.a,{href:"https://github.com/user-attachments/files/27861822/dashy-realm.json",children:(0,r.jsx)(n.code,{children:"dashy-realm.json"})})," backup."]}),"\n",(0,r.jsx)(n.hr,{}),"\n",(0,r.jsx)(n.h2,{id:"3-enabling-keycloak-in-dashy",children:"3. Enabling Keycloak in Dashy"}),"\n",(0,r.jsxs)(n.p,{children:["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 ",(0,r.jsx)(n.code,{children:"appConfig.auth"})," section of your main ",(0,r.jsx)(n.code,{children:"/user-data/conf.yml"})," file."]}),"\n",(0,r.jsxs)(n.p,{children:["As an example, you can view this ",(0,r.jsx)(n.a,{href:"https://github.com/user-attachments/files/27861628/conf.yml",children:(0,r.jsx)(n.code,{children:"conf.yml"})}),", which is fully-configured with Keycloak auth using the info from the above steps."]}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-yaml",children:"appConfig:\n ...\n disableConfigurationForNonAdmin: true\n auth:\n enableKeycloak: true\n keycloak:\n serverUrl: http://localhost:9100\n realm: dashy\n clientId: dashy\n adminRole: dashy-admin\n"})}),"\n",(0,r.jsx)(n.p,{children:"Where:"}),"\n",(0,r.jsxs)(n.ul,{children:["\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.code,{children:"disableConfigurationForNonAdmin"})," - Prevent read/write config access to non-admin Keycloak users"]}),"\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.code,{children:"auth.enableKeycloak"})," - Set the auth mode to Keycloak"]}),"\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.code,{children:"serverUrl"})," - The host (no paths) to your Keycloak instance, accessible from the Dashy container"]}),"\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.code,{children:"realm"})," - The name (case sensitive) of the Keycloak realm to use"]}),"\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.code,{children:"clientId"})," - Client ID that you created for Dashy"]}),"\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.code,{children:"adminRole"})," - The role name that grants admin"]}),"\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.code,{children:"adminGroup"})," - (Alternative to ",(0,r.jsx)(n.code,{children:"adminRole"}),") Group name that grants admin"]}),"\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.code,{children:"idpHint"})," - (Optional) Alias of an external IdP federated through Keycloak; skips the Keycloak login page and redirects straight to that provider"]}),"\n"]}),"\n",(0,r.jsx)(n.p,{children:"Note that a restart is required for these changes to take effect."}),"\n",(0,r.jsxs)(n.p,{children:["If Keycloak runs on a different host or behind a reverse proxy, make sure ",(0,r.jsx)(n.code,{children:"serverUrl"})," is reachable from inside the Dashy container, and that the realm's redirect URIs and Web Origins match Dashy's public URL."]}),"\n",(0,r.jsxs)(n.p,{children:["Everything should now be fully configured and working \ud83c\udf89",(0,r.jsx)(n.br,{}),"\nNow, 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! Until you're authenticated, Dashy's config and API endpoints return a 401 (a write attempt by a non-admin returns a 403)."]}),"\n",(0,r.jsx)(n.hr,{}),"\n",(0,r.jsx)(n.h2,{id:"4-groups-and-roles",children:"4. Groups and Roles"}),"\n",(0,r.jsx)(n.p,{children:"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."}),"\n",(0,r.jsxs)(n.p,{children:["For example, to make any given section only visible to admins, simply add the following to the section's ",(0,r.jsx)(n.code,{children:"displayData"})," section:"]}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-yaml",children:"displayData:\n showForRoles:\n - dashy-admin # ID of the admin role you created earlier\n"})}),"\n",(0,r.jsxs)(n.p,{children:["Keycloak server administration and configuration is a deep topic; please refer to the ",(0,r.jsx)(n.a,{href:"https://www.keycloak.org/docs/latest/server_admin/index.html#assigning-permissions-and-access-using-roles-and-groups",children:"server admin guide"})," to see details about creating and assigning roles and groups."]}),"\n",(0,r.jsxs)(n.p,{children:["Once you have groups or roles assigned to users you can configure access under each section or item, using ",(0,r.jsx)(n.code,{children:"showForGroups"}),", ",(0,r.jsx)(n.code,{children:"hideForGroups"}),", ",(0,r.jsx)(n.code,{children:"showForRoles"})," and ",(0,r.jsx)(n.code,{children:"hideForRoles"})," within ",(0,r.jsx)(n.code,{children:"displayData"}),"."]}),"\n",(0,r.jsx)(n.p,{children:"Each accepts a list of group or role names that limit access. If a users data matches one or more items in these lists they will be allowed or excluded as defined."}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-yaml",children:"sections:\n - name: DeveloperResources\n displayData:\n showForRoles: ['canViewDevResources']\n hideForGroups: ['ProductTeam']\n items:\n - title: Not Visible for developers\n displayData:\n hideForGroups: ['DevelopmentTeam']\n"})}),"\n",(0,r.jsx)(n.hr,{}),"\n",(0,r.jsx)(n.h2,{id:"troubleshooting-common-keycloak-issues",children:"Troubleshooting common Keycloak Issues"}),"\n",(0,r.jsx)(n.h4,{id:"client-authentication-issue",children:"Client Authentication Issue"}),"\n",(0,r.jsxs)(n.p,{children:["Problem: Redirect loop, if client authentication is enabled.",(0,r.jsx)(n.br,{}),'\nSolution: Switch off "Client authentication" in the dashy client\'s "Advanced" settings.']}),"\n",(0,r.jsx)(n.h4,{id:"double-url",children:"Double URL"}),"\n",(0,r.jsxs)(n.p,{children:['Problem: If you get redirected to "',(0,r.jsx)(n.a,{href:"https://dashy.my.domain/#iss=https://keycloak.my.domain/realms/dashy",children:"https://dashy.my.domain/#iss=https://keycloak.my.domain/realms/dashy"}),'"',(0,r.jsx)(n.br,{}),'\nSolution: Turn on "Exclude Issuer From Authentication Response" in the dashy client\'s "Advanced" -> "OpenID Connect Compatibility Modes".']}),"\n",(0,r.jsx)(n.h4,{id:"problems-with-multiple-dashy-pages",children:"Problems with multiple Dashy Pages"}),"\n",(0,r.jsxs)(n.p,{children:['Problem: Refreshing or logging out of dashy results in an "invali
1d_redirect_uri" error.',(0,r.jsx)(n.br,{}),'\nSolution: In the dashy client\'s "Access settings", set "Root URL" to ',(0,r.jsx)(n.a,{href:"https://dashy.my.domain/",children:"https://dashy.my.domain/"}),", and make sure the valid redirect URIs end in /*."]}),"\n",(0,r.jsx)(n.h4,{id:"403-on-login-status-iframehtmlinit",children:"403 on login-status-iframe.html/init"}),"\n",(0,r.jsxs)(n.p,{children:["Problem: Browser console shows a 403 from Keycloak when the SPA loads.",(0,r.jsx)(n.br,{}),'\nSolution: Open the dashy client\'s "Web origins" and remove any trailing ',(0,r.jsx)(n.code,{children:"/*"}),". Web Origins must be bare origins (e.g. ",(0,r.jsx)(n.a,{href:"http://localhost:4000",children:"http://localhost:4000"}),"), not ",(0,r.jsx)(n.a,{href:"http://localhost:4000/",children:"http://localhost:4000/"}),"*."]}),"\n",(0,r.jsx)(n.h4,{id:"csp-error-for-3p-cookiesstep1html-or-authentication-failed-keycloak",children:'CSP error for /3p-cookies/step1.html or "Authentication failed (Keycloak)"'}),"\n",(0,r.jsxs)(n.p,{children:["Problem: The hidden Keycloak iframe is blocked by frame-ancestors.",(0,r.jsx)(n.br,{}),"\nSolution: 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."]}),"\n",(0,r.jsx)(n.h4,{id:"dashy-server-cant-reach-keycloak",children:"Dashy server can't reach Keycloak"}),"\n",(0,r.jsxs)(n.p,{children:["Problem: SPA loads fine but every authenticated API call returns 401, and the Dashy server logs show ",(0,r.jsx)(n.code,{children:"[auth-oidc] token verification failed"})," or fetch errors for ",(0,r.jsx)(n.code,{children:".well-known/openid-configuration"}),".",(0,r.jsx)(n.br,{}),"\nSolution: ",(0,r.jsx)(n.code,{children:"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. ",(0,r.jsx)(n.code,{children:"http://keycloak:8080"}),"). Test with ",(0,r.jsx)(n.code,{children:'docker exec <dashy-container> wget -qO- "$SERVER_URL/realms/dashy/.well-known/openid-configuration"'}),"."]}),"\n",(0,r.jsx)(n.h4,{id:"logged-in-but-config-saves-return-403",children:"Logged in but config saves return 403"}),"\n",(0,r.jsxs)(n.p,{children:["Problem: User authenticates fine, but saving the dashboard returns 403.",(0,r.jsx)(n.br,{}),"\nSolution: The id_token doesn't carry the admin claim. Confirm the ",(0,r.jsx)(n.em,{children:"Add to ID token"})," toggle on the Step 2 mapper is on. Paste the token (from localStorage, key ",(0,r.jsx)(n.code,{children:"ID_TOKEN"}),") into ",(0,r.jsx)(n.a,{href:"https://jwt.io",children:"jwt.io"})," and look for ",(0,r.jsx)(n.code,{children:"realm_access.roles"})," (or ",(0,r.jsx)(n.code,{children:"groups"})," if you're using ",(0,r.jsx)(n.code,{children:"adminGroup"}),")."]}),"\n",(0,r.jsx)(n.h4,{id:"issuer-mismatch-behind-a-reverse-proxy",children:"Issuer mismatch behind a reverse proxy"}),"\n",(0,r.jsxs)(n.p,{children:["Problem: Server logs show ",(0,r.jsx)(n.code,{children:'unexpected "iss" claim value'}),". The browser reaches Keycloak over HTTPS but Keycloak advertises an HTTP issuer in its discovery document.",(0,r.jsx)(n.br,{}),"\nSolution: Set ",(0,r.jsx)(n.code,{children:"KC_HOSTNAME=<public-host>"})," on Keycloak so the issuer matches the public URL, and ensure your reverse proxy forwards ",(0,r.jsx)(n.code,{children:"X-Forwarded-Proto: https"}),"."]}),"\n",(0,r.jsx)(n.h4,{id:"audience-mismatch-on-token-verification",children:"Audience mismatch on token verification"}),"\n",(0,r.jsxs)(n.p,{children:["Problem: Server logs show ",(0,r.jsx)(n.code,{children:'unexpected "aud" claim value'}),". Every auth'd API call returns 401.",(0,r.jsx)(n.br,{}),"\nSolution: ",(0,r.jsx)(n.code,{children:"clientId"})," in ",(0,r.jsx)(n.code,{children:"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."]}),"\n",(0,r.jsx)(n.h4,{id:"self-signed-keycloak-certificate-rejected",children:"Self-signed Keycloak certificate rejected"}),"\n",(0,r.jsxs)(n.p,{children:["Problem: Dashy server logs show TLS errors (",(0,r.jsx)(n.code,{children:"self-signed certificate"}),", ",(0,r.jsx)(n.code,{children:"UNABLE_TO_VERIFY_LEAF_SIGNATURE"}),") when fetching JWKS or discovery.",(0,r.jsx)(n.br,{}),"\nSolution: Use a real certificate on Keycloak (Let's Encrypt, or your homelab CA), or mount your CA bundle into the Dashy container and set ",(0,r.jsx)(n.code,{children:"NODE_EXTRA_CA_CERTS=/path/to/ca.pem"}),"."]}),"\n",(0,r.jsx)(n.h4,{id:"token-expired--clock-skew",children:"Token expired / clock skew"}),"\n",(0,r.jsxs)(n.p,{children:["Problem: 401s with ",(0,r.jsx)(n.code,{children:'"exp" claim timestamp check failed'})," or ",(0,r.jsx)(n.code,{children:'"iat" claim timestamp check failed'}),", even just after login.",(0,r.jsx)(n.br,{}),"\nSolution: 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."]}),"\n",(0,r.jsx)(n.h4,{id:"mixed-content-blocked-by-the-browser",children:"Mixed content blocked by the browser"}),"\n",(0,r.jsxs)(n.p,{children:["Problem: Dashy served over HTTPS, Keycloak over HTTP. The browser blocks the token or JWKS endpoint with a mixed-content error.",(0,r.jsx)(n.br,{}),"\nSolution: Terminate both behind HTTPS. For local testing, use HTTP on both, but never mix schemes in the same flow."]}),"\n",(0,r.jsx)(n.h4,{id:"numeric-client-id-truncated",children:"Numeric Client ID truncated"}),"\n",(0,r.jsxs)(n.p,{children:["Problem: Token verification fails with audie
1nce mismatch when ",(0,r.jsx)(n.code,{children:"clientId"})," in ",(0,r.jsx)(n.code,{children:"conf.yml"})," is a long numeric string.",(0,r.jsx)(n.br,{}),"\nSolution: Wrap numeric Client IDs in quotes (e.g. ",(0,r.jsx)(n.code,{children:'clientId: "12345678901234567"'}),"). Without quotes YAML parses the value as a JS number and loses precision past about 15 digits."]}),"\n",(0,r.jsx)(n.h4,{id:"logout-lands-on-a-broken-page",children:"Logout lands on a broken page"}),"\n",(0,r.jsxs)(n.p,{children:["Problem: Clicking Logout in Dashy ends on a Keycloak error or 404 instead of returning to Dashy.",(0,r.jsx)(n.br,{}),"\nSolution: Add Dashy's URL to ",(0,r.jsx)(n.strong,{children:"Valid post logout redirect URIs"})," on the ",(0,r.jsx)(n.code,{children:"dashy"})," client. Keycloak v18+ requires registered, exact-match post-logout URIs."]}),"\n",(0,r.jsx)(n.h4,{id:"check-sso-hangs-in-strict-browsers",children:"check-sso hangs in strict browsers"}),"\n",(0,r.jsxs)(n.p,{children:["Problem: First load spins indefinitely. Browser console reports blocked third-party cookies. Common in Safari, Brave, or Firefox with strict tracking protection.",(0,r.jsx)(n.br,{}),"\nSolution: Serve Dashy and Keycloak under the same registrable domain (e.g. ",(0,r.jsx)(n.code,{children:"dashy.example.com"})," and ",(0,r.jsx)(n.code,{children:"auth.example.com"}),") so the session cookie is first-party."]}),"\n",(0,r.jsx)(n.h4,{id:"config-change-to-authkeycloak-not-picked-up",children:"Config change to auth.keycloak not picked up"}
1),"\n",(0,r.jsxs)(n.p,{children:["Problem: Updated ",(0,r.jsx)(n.code,{children:"serverUrl"}),", ",(0,r.jsx)(n.code,{children:"realm"}),", ",(0,r.jsx)(n.code,{children:"clientId"}),", ",(0,r.jsx)(n.code,{children:"adminRole"}),", or ",(0,r.jsx)(n.code,{children:"adminGroup"})," in ",(0,r.jsx)(n.code,{children:"conf.yml"}),", but Dashy still authenticates against the old values.",(0,r.jsx)(n.br,{}),"\nSolution: The server reads the auth config only at boot. Restart the Dashy container after any change to fields under ",(0,r.jsx)(n.code,{children:"auth.keycloak"}),"."]}),"\n",(0,r.jsx)(n.hr,{}),"\n",(0,r.jsx)(n.h2,{id:"how-it-works",children:"How it Works"}),"\n",(0,r.jsx)(n.p,{children:"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."}),"\n",(0,r.jsxs)(n.p,{children:["The same OIDC pipeline backs both Keycloak and generic OIDC providers. The only Keycloak-specific code is the SPA adapter, which uses ",(0,r.jsx)(n.code,{children:"keycloak-js"})," so it can take advantage of ",(0,r.jsx)(n.code,{children:"check-sso"})," and silent token renewal."]}),"\n",(0,r.jsx)(n.h3,{id:"client-side",children:"Client side"}),"\n",(0,r.jsxs)(n.p,{children:["Boot starts in ",(0,r.jsx)(n.a,{href:"https://github.com/lissy93/dashy/blob/4.1.5/src/main.js",children:(0,r.jsx)(n.code,{children:"src/main.js"})}),". After the initial ",(0,r.jsx)(n.code,{children:"/conf.yml"})," fetch parses the auth block, ",(0,r.jsx)(n.code,{children:"isKeycloakEnabled()"})," decides whether to lazily import ",(0,r.jsx)(n.code,{children:"keycloak-js"})," and call ",(0,r.jsx)(n.code,{children:"initKeycloakAuth()"}),"."]}),"\n",(0,r.jsxs)(n.p,{children:[(0,r.jsx)(n.a,{href:"https://github.com/lissy93/dashy/blob/4.1.5/src/utils/auth/KeycloakAuth.js",children:(0,r.jsx)(n.code,{children:"src/utils/auth/KeycloakAuth.js"})})," wraps the adapter. On load it calls ",(0,r.jsx)(n.code,{children:"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, ",(0,r.jsx)(n.code,{children:"storeKeycloakInfo()"})," persists the raw ",(0,r.jsx)(n.code,{children:"id_token"}),", the user's ",(0,r.jsx)(n.code,{children:"groups"})," and ",(0,r.jsx)(n.code,{children:"roles"}),", ",(0,r.jsx)(n.code,{children:"preferred_username"}),", and a derived ",(0,r.jsx)(n.code,{children:"isAdmin"})," flag to localStorage, then hard-redirects to ",(0,r.jsx)(n.code,{children:"/"})," so the SPA boots a second time with the Bearer token in place."]}),"\n",(0,r.jsxs)(n.p,{children:[(0,r.jsx)(n.a,{href:"https://github.com/lissy93/dashy/blob/4.1.5/src/utils/auth/getApiAuthHeader.js",children:(0,r.jsx)(n.code,{children:"src/utils/auth/getApiAuthHeader.js"})})," builds the Authorization header for every internal API call. It does a client-side ",(0,r.jsx)(n.code,{children:"exp"})," check and returns ",(0,r.jsx)(n.code,{children:"null"})," for missing or expired tokens, so the next request triggers a fresh login rather than a 401."]}),"\n",(0,r.jsxs)(n.p,{children:["The localStorage keys (",(0,r.jsx)(n.code,{children:"ID_TOKEN"}),", ",(0,r.jsx)(n.code,{children:"KEYCLOAK_INFO"}),", ",(0,r.jsx)(n.code,{children:"USERNAME"}),", ",(0,r.jsx)(n.code,{children:"ISADMIN"}),") live in ",(0,r.jsx)(n.a,{href:"https://github.com/lissy93/dashy/blob/4.1.5/src/utils/config/defaults.js",children:(0,r.jsx)(n.code,{children:"src/utils/config/defaults.js"})}),". ",(0,r.jsx)(n.a,{href:"https://github.com/lissy93/dashy/blob/4.1.5/src/utils/IsVisibleToUser.js",children:(0,r.jsx)(n.code,{children:"src/utils/IsVisibleToUser.js"})})," reads ",(0,r.jsx)(n.code,{children:"KEYCLOAK_INFO"})," when evaluating ",(0,r.jsx)(n.code,{children:"show"}),"/",(0,r.jsx)(n.code,{children:"hideForGroups"})," and ",(0,r.jsx)(n.code,{children:"show"}),"/",(0,r.jsx)(n.code,{children:"hideForRoles"})," rules."]}),"\n",(0,r.jsx)(n.h3,{id:"server-side",children:"Server side"}),"\n",(0,r.jsxs)(n.p,{children:[(0,r.jsx)(n.a,{href:"https://github.com/lissy93/dashy/blob/4.1.5/services/auth-oidc.js",children:(0,r.jsx)(n.code,{children:"services/auth-oidc.js"})})," contains the entire server-side auth surface, in five small pieces:"]}),"\n",(0,r.jsxs)(n.ul,{children:["\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.code,{children:"loadOidcSettings()"})," reads ",(0,r.jsx)(n.code,{children:"auth.keycloak"})," (or ",(0,r.jsx)(n.code,{children:"auth.oidc"}),") at boot and returns a normalised ",(0,r.jsx)(n.code,{children:"{ issuer, clientId, adminGroup, adminRole }"}),". For Keycloak the issuer is ",(0,r.jsx)(n.code,{children:"<serverUrl>/realms/<realm>"}),"."]}),"\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.code,{children:"createOidcMiddleware()"})," returns a Connect middleware. Permissive on no-token requests so the SPA c
1an bootstrap; otherwise it verifies the Bearer token against the realm's JWKS using ",(0,r.jsx)(n.a,{href:"https://github.com/panva/jose",children:(0,r.jsx)(n.code,{children:"jose"})}),". Checks cover signature, issuer (against the canonical value from the discovery doc), audience (must equal ",(0,r.jsx)(n.code,{children:"clientId"}),"), and expiry, with a 30-second clock-skew tolerance. Sets ",(0,r.jsx)(n.code,{children:"req.auth = { user, isAdmin, claims }"})," on success, ",(0,r.jsx)(n.code,{children:"401"})," on failure."]}),"\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.code,{children:"getIssuerContext()"})," lazily fetches ",(0,r.jsx)(n.code,{children:".well-known/openid-configuration"})," on first use and wraps ",(0,r.jsx)(n.code,{children:"jwks_uri"})," in ",(0,r.jsx)(n.code,{children:"createRemoteJWKSet"}),", which handles JWKS caching and on-demand key rotation. The result is memoised per-issuer for the life of the process."]}),"\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.code,{children:"deriveIsAdmin()"})," checks the token's ",(0,r.jsx)(n.code,{children:"groups"})," claim against ",(0,r.jsx)(n.code,{children:"adminGroup"}),", and the union of ",(0,r.jsx)(n.code,{children:"realm_access.roles"})," and ",(0,r.jsx)(n.code,{children:"resource_access.<clientId>.roles"})," against ",(0,r.jsx)(n.code,{children:"adminRole"}),". Either match returns true."]}),"\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.code,{children:"maybeBootstrapConfig()"})," is the stripped-response helper. When auth is configured, guest access is off, and an unauthenticated request hits the root ",(0,r.jsx)(n.code,{children:"/conf.yml"}),", it returns a minimal copy with only ",(0,r.jsx)(n.code,{children:"appConfig.auth"}),", ",(0,r.jsx)(n.code,{children:"appConfig.enableServiceWorker"}),", and a ",(0,r.jsx)(n.code,{children:"pageInfo.title"})," of ",(0,r.jsx)(n.code,{children:"Login | <your title>"}),". Sections, items, hostnames and any other secrets never leave the server."]}),"\n"]}),"\n",(0,r.jsxs)(n.p,{children:[(0,r.jsx)(n.a,{href:"https://github.com/lissy93/dashy/blob/4.1.5/services/app.js",children:(0,r.jsx)(n.code,{children:"services/app.js"})})," wires it all together. The middleware mounts as ",(0,r.jsx)(n.code,{children:"protectConfig"})," in front of every YAML route and config-mutating route. The ",(0,r.jsx)(n.code,{children:"/*.yml"})," handler sets ",(0,r.jsx)(n.code,{children:"Cache-Control: private, no-store"})," and ",(0,r.jsx)(n.code,{children:"Vary: Authorization"})," whenever auth is configured (so intermediate caches can never mix auth states), then calls ",(0,r.jsx)(n.code,{children:"maybeBootstrapConfig"}),"; a stripped result is sent as-is, otherwise ",(0,r.jsx)(n.code,{children:"res.sendFile"})," serves the full file. ",(0,r.jsx)(n.code,{children:"POST /config-manager/save"})," is additionally guarded by ",(0,r.jsx)(n.code,{children:"requireAdmin"}),", which returns ",(0,r.jsx)(n.code,{children:"401"})," if ",(0,r.jsx)(n.code,{children:"req.auth"})," is unset and ",(0,r.jsx)(n.code,{children:"403"})," if ",(0,r.jsx)(n.code,{children:"req.auth.isAdmin"})," is false."]}),"\n",(0,r.jsx)(n.h3,{id:"why-the-mapper-matters",children:"Why the mapper matters"}),"\n",(0,r.jsxs)(n.p,{children:["The server's admin check reads from the ",(0,r.jsx)(n.code,{children:"id_token"})," only. Keycloak's default mapper adds realm roles to the access token but not the id token, so without the Step 2 ",(0,r.jsx)(n.em,{children:"Add to ID token"})," toggle, ",(0,r.jsx)(n.code,{children:"realm_access.roles"})," is absent and every user is treated as non-admin. The same applies to ",(0,r.jsx)(n.code,{children:"groups"})," if you use ",(0,r.jsx)(n.code,{children:"adminGroup"}),' instead. This is the single most common cause of "logged in fine, but can\'t save changes".']}),"\n",(0,r.jsx)(n.h3,{id:"visual-overview",children:"Visual Overview"}),"\n",(0,r.jsxs)(n.details,{children:["\n",(0,r.jsx)(n.summary,{children:"End-to-end authentication flow"}),"\n",(0,r.jsx)(n.mermaid,{value:"sequenceDiagram\n autonumber\n actor User\n participant Browser as Browser (Dashy SPA)\n participant Dashy as Dashy Server\n participant KC as Keycloak\n\n Note over Browser,Dashy: 1. Bootstrap (no token yet)\n User->>Browser: Open Dashy\n Browser->>Dashy: GET /conf.yml\n Dashy--\x3e>Browser: 200 stripped conf<br />(auth block + minimal pageInfo)\n Browser->>Browser: Parse auth.keycloak\n\n Note over Browser,KC: 2. OIDC login (Authorization Code + PKCE)\n Browser->>KC: 302 /realms/dashy/protocol/openid-connect/auth<br />client_id, code_challenge, redirect_uri\n User->>KC: Submit credentials\n KC--\x3e>Browser: 302 back with ?code=AUTH_CODE\n Browser->>KC: POST /token (code + code_verifier)\n KC--\x3e>Browser: id_token + access_token + refresh_token\n Browser->>Browser: Store tokens in localStorage\n\n Note over Browser,Dashy: 3. Authenticated read\n Browser->>Dashy: GET /conf.yml<br />Authorization: Bearer id_token\n Dashy->>KC: Fetch discovery + JWKS<br />(lazy on first call, then cached)\n KC--\x3e>Dashy: openid-configuration + JWKS\n Dashy->>Dashy: Verify signature / issuer / audience / expiry\n Dashy--\x3e>Browser: 200 full conf.yml\n\n Note over Browser,Dashy: 4. Write request (admin only)\n Browser->>Dashy: POST /config-manager/save<br />Authorization: Bearer id_token\n Dashy->>Dashy: Verify token, derive isAdmin from<br />adminRole / adminGroup claims\n alt isAdmin\n Dashy--\x3e>Browser: 200 saved\n else not admin\n Dashy--\x3e>Browser: 403 Forbidden\n end"}),"\n"]}),"\n",(0,r.jsxs)(n.details,{children:["\n",(0,r.jsx)(n.summary,{children:"Server-side request handling"}),"\n",(0,r.jsx)(n.mermaid,{value:'flowchart TD\n Req([Incoming request to Dashy server])\n Req --\x3e Bearer{Authorization:<br />Bearer present?}\n\n Bearer -- No --\x3e NoAuth[req.auth unset<br />pass through]:::neutral\n Bearer -- Yes --\x3e Verify[/Verify JWT against cached JWKS:<br />
1signature \xb7 issuer \xb7 audience \xb7 expiry/]\n Verify --\x3e Valid{Valid?}\n Valid -- No --\x3e R401Bad[/401 Unauthorized/]:::err\n Valid -- Yes --\x3e SetAuth[req.auth = user + isAdmin<br />derived from claims]:::ok\n\n NoAuth --\x3e Route{Endpoint}\n SetAuth --\x3e Route\n\n Route -- "GET /conf.yml" --\x3e ConfGate{req.auth?}\n ConfGate -- No --\x3e Strip[/200 stripped conf<br />auth + minimal pageInfo/]:::ok\n ConfGate -- Yes --\x3e Full[/200 full conf.yml/]:::ok\n\n Route -- "POST /config-manager/save" --\x3e SaveGate{req.auth<br />and isAdmin?}\n SaveGate -- No req.auth --\x3e R401Save[/401 Unauthorized/]:::err\n SaveGate -- Not admin --\x3e R403[/403 Forbidden/]:::err\n SaveGate -- Admin --\x3e Saved[/200 saved/]:::ok\n\n classDef ok fill:#bbf7d0,stroke:#16a34a,color:#14532d\n classDef err fill:#fecaca,stroke:#dc2626,color:#7f1d1d\n classDef neutral fill:#dbeafe,stroke:#2563eb,color:#1e3a8a'}),"\n"]})]})}function h(e={}){const{wrapper:n}={...(0,o.R)(),...e.components};return n?(0,r.jsx)(n,{...e,children:(0,r.jsx)(d,{...e})}):d(e)}},28453(e,n,s){s.d(n,{R:()=>l,x:()=>t});var i=s(96540);const r={},o=i.createContext(r);function l(e){const n=i.useContext(o);return i.useMemo(function(){return"function"==typeof e?e(n):{...n,...e}},[n,e])}function t(e){let n;return n=e.disableParentContext?"function"==typeof e.components?e.components(r):e.components||r:l(e.components),i.createElement(o.Provider,{value:n},e.children)}}}]);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.