Skip to content

feat(docker): one-command Keycloak OIDC demo stack - #947

Merged
cevheri merged 2 commits into
libredb:mainfrom
Lingikaushikreddy:feat/oidc-demo-stack
Sep 17, 2026
Merged

cevheri merged 2 commits into
libredb:mainfrom
Lingikaushikreddy:feat/oidc-demo-stack

Conversation

@Lingikaushikreddy

Copy link
Copy Markdown
Contributor

Description

A one-command Docker Compose stack that starts LibreDB Studio, a preconfigured Keycloak and a TLS proxy on a single origin, so SSO login and role mapping can be tried without reading the provider docs first. Follows the design in #941, with Caddy tls internal as agreed on the issue.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Code refactoring
  • Performance improvement
  • Test addition or update

Related Issue

Closes #941

Changes Made

  • docker-compose.oidc-demo.yml (new, self-contained, with the Caddyfile and realm inline as compose configs):
    • proxy: caddy:2.10-alpine on :8443, tls internal, network alias demo.127.0.0.1.nip.io. /auth* goes to Keycloak and everything else to Studio. Its healthcheck waits for root.crt.
    • ca-export: one-shot install -m 0644 of only root.crt into its own volume (details below).
    • keycloak: quay.io/keycloak/keycloak:26.4.7, start-dev --import-realm --http-relative-path=/auth --proxy-headers=xforwarded --hostname=https://demo.127.0.0.1.nip.io:8443/auth, with a readiness healthcheck on the management port.
    • libredb-studio: ghcr.io/libredb/libredb-studio:latest, NODE_EXTRA_CA_CERTS=/ca/root.crt, and depends_on both ca-export: service_completed_successfully and keycloak: service_healthy. There is no JWT_SECRET, no .env, and no port published except the proxy's.
    • realm libredb: confidential client libredb-studio, redirect URI, web origin and post.logout.redirect.uris all on the single origin, realm roles admin / user, and users admin/admin and user/user.
  • docs/OIDC.md: new "Try it locally with Keycloak" section before Provider-Specific Setup, plus a TOC entry. It covers the prerequisites (Compose version, network, and the nip.io check with what a filtered resolver prints), the single certificate warning and the 12-hour lifetime, the three-step walkthrough, the Keycloak logout confirmation, and that this is not production.
  • README.md: one line under Authentication & SSO.
  • Nothing under src/. docker-compose.yml and docker-compose.example.yml are unchanged.

Decisions worth a look

CA readability, and not sharing the key. Measured on a fresh Caddy volume: Caddy writes /data/caddy/pki/authorities/local/root.crt as root:root 0600 inside 0700 directories, next to root.key. Mounted directly, uid 1001 in Studio gets Permission denied, and as @yusuf-gundogdu found, Node then skips NODE_EXTRA_CA_CERTS silently. Mounting Caddy's data volume would also hand Studio the CA private key. So ca-export copies only the public certificate at 0644 into a separate demo-ca volume, and Studio mounts only that one, read-only. Inside Studio: -rw-r--r-- root root /ca/root.crt.

Startup ordering, observed on a cold down -v / up (container start times, UTC):

proxy          started 15:09:22.467   (healthy once root.crt exists, about 1s)
keycloak       started 15:09:22.465   Realm 'libredb' imported 15:09:33.362
ca-export      started 15:09:24.079   finished 15:09:24.155  exit 0
libredb-studio started 15:09:38.248   (after ca-export completed and keycloak healthy)

So Studio never starts before the certificate it trusts exists.

Roles in the ID token. The realm-roles mapper with Add to ID token is defined on the libredb-studio client (oidc-usermodel-realm-role-mapper, claim realm_access.roles), not by editing the realm's built-in roles client scope. Importing clientScopes in a realm file replaces all of Keycloak's default scopes, so overriding just roles would also require redeclaring openid, profile, email and the rest. Happy to switch if you'd rather mirror the admin-console path in docs/OIDC.md exactly.

Logout shows one Keycloak confirmation. buildLogoutUrl sends client_id and post_logout_redirect_uri but no id_token_hint. With an active Keycloak session, Keycloak 26.4.7 answers that with a "Do you want to log out?" page (HTTP 200, button #kc-logout) instead of redirecting straight back. After one click it redirects to /login and the Keycloak session is cleared: the next SSO click asks for credentials. That meets the acceptance criterion without touching src/, and I documented it as step 2 of the walkthrough. Skipping the page would need the ID token kept in the session to send id_token_hint, which is a product change, so I'm raising it here rather than doing it. Happy to open a separate issue if you want it.

Testing

Run from an empty directory containing only docker-compose.oidc-demo.yml (no checkout, no .env), on Docker 29.1.3 with Compose v2.40.3. The browser checks used headless Chrome (Playwright, channel: 'chrome').

Cold start (docker compose -f docker-compose.oidc-demo.yml up -d, returned after 17s):

Container libredb-oidc-demo-proxy-1  Started
Container libredb-oidc-demo-keycloak-1  Started
Container libredb-oidc-demo-proxy-1  Healthy
Container libredb-oidc-demo-ca-export-1  Started
Container libredb-oidc-demo-ca-export-1  Exited
Container libredb-oidc-demo-keycloak-1  Healthy
Container libredb-oidc-demo-libredb-studio-1  Started

Keycloak: Keycloak 26.4.7 on JVM (powered by Quarkus 3.27.1) started, Realm 'libredb' imported.

Observed values

What Observed
Discovery issuer https://demo.127.0.0.1.nip.io:8443/auth/realms/libredb (same as OIDC_ISSUER; openid-client accepted the ID token on both logins)
authorization_endpoint https://demo.127.0.0.1.nip.io:8443/auth/realms/libredb/protocol/openid-connect/auth
redirect_uri Studio sent to Keycloak https://demo.127.0.0.1.nip.io:8443/api/auth/oidc/callback (port preserved through X-Forwarded-Host; matches the realm)
Logout request .../realms/libredb/protocol/openid-connect/logout?post_logout_redirect_uri=https%3A%2F%2Fdemo.127.0.0.1.nip.io%3A8443%2Flogin&client_id=libredb-studio
Name inside Studio getent hosts demo.127.0.0.1.nip.io gives 172.24.0.3 (the proxy container, via the alias)
Discovery from Studio as uid 1001 with NODE_EXTRA_CA_CERTS: issuer JSON returned. Unset: UNABLE_TO_GET_ISSUER_CERT_LOCALLY
Certificate issuer Caddy Local Authority - ECC Intermediate, SAN DNS:demo.127.0.0.1.nip.io, valid 15:09:22 to 03:09:22 (12h)
Browser warning a strict browser gets net::ERR_CERT_AUTHORITY_INVALID on https://demo.127.0.0.1.nip.io:8443/login. During the full flow no other https origin is requested, so it warns once.

The walkthrough, as run

admin login: askedForCredentials=true  landedOn=/admin/overview
             /api/auth/me -> {"role":"admin","username":"admin@example.com"}
logout:      POST /api/auth/logout -> redirectUrl to Keycloak end_session
             Keycloak "Do you want to log out?" shown -> click Logout -> /login
             /api/auth/me -> 401
user login:  askedForCredentials=true  (no auto-login)  landedOn=/
             /api/auth/me -> {"role":"user","username":"user@example.com"}
             GET /admin as user -> ends on /

Reproducibility: docker compose down -v removed both volumes and the network. up -d a second time regenerated the Caddy CA, re-exported the certificate, re-imported the realm, and gave the same results for every row above.

nip.io check, for the docs: getent hosts demo.127.0.0.1.nip.io prints 127.0.0.1 demo.127.0.0.1.nip.io (exit 0). With a resolver that doesn't answer (--dns 127.0.0.1), it prints nothing and exits 2. macOS: dscacheutil -q host -a name demo.127.0.0.1.nip.io.

Repo checks (on this branch): bun run format, bun run readme:check, bun run security:check, bun run chart:check with CHART_SYNC_STRICT=1, channels:showcase:check, and 11 related test files (README, env documentation, packaging prune, distribution, chart version, LoginPage and others): 497 pass. helm-chart-agent.test.ts didn't run locally because Helm isn't installed.

Test Environment

  • LibreDB Studio Version: ghcr.io/libredb/libredb-studio:latest (0.16.0)
  • Browser: Chrome (headless, Playwright)
  • OS: macOS 15 (Apple Silicon), Docker Desktop 29.1.3
  • Node.js/Bun Version: Node 26.8.2 in the image
  • Database Type: bundled SQLite samples

Checklist

  • My code follows the project's code style guidelines
  • I have performed a self-review of my code
  • I have updated the documentation accordingly
  • My changes generate no new warnings
  • New and existing unit tests pass locally with my changes
  • The required CI test job passes the 100% line-coverage gate (no code under src/ changed)
  • If I changed src/lib/db/providers/, I updated the matching docs/providers/ documentation and tests/integration/db/ tests in the same PR (provider triad): not applicable

Additional Notes

The translated READMEs didn't get the new line. readme:check only compares engines and install commands, but I can add it to them if you'd like.

Add docker-compose.oidc-demo.yml, which starts the published Studio image,
Keycloak 26.4.7 with an imported realm, and a Caddy TLS proxy on one origin,
https://demo.127.0.0.1.nip.io:8443, so SSO login and role mapping can be tried
without an identity provider or a .env file.

Document the demo in docs/OIDC.md before the provider setup, including the
nip.io prerequisite, the one certificate warning and the Keycloak logout
confirmation, and add one line to README.md.

Closes libredb#941

@yusuf-gundogdu yusuf-gundogdu left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does what the issue asked, and I checked it by running the stack rather than by reading the file.

One docker compose -f docker-compose.oidc-demo.yml up brought all three services healthy, with no .env, no hosts file edit and no second command. The browser warned once, on the one origin, and not again.

The values I observed, rather than a statement that they matched:

issuer          https://demo.127.0.0.1.nip.io:8443/auth/realms/libredb
redirect_uri    https://demo.127.0.0.1.nip.io:8443/api/auth/oidc/callback
post_logout     https://demo.127.0.0.1.nip.io:8443/login  (with client_id)

Role mapping is the part that actually had to work, and it does. Signing in as admin returned {"role":"admin"} from /api/auth/me and landed on /admin/overview. Signing in as user returned {"role":"user"} and the admin route redirected away. Logout cleared the Keycloak session: the next SSO click asked for credentials instead of signing me straight back in. docker compose down -v followed by up rebuilt the CA and reimported the realm with no leftover state.

Everything I flagged on the issue is handled, and the reasons are written into the file where the next person will find them rather than left implicit. The comment above reverse_proxy names all three places the port has to survive in X-Forwarded-Host. --proxy-headers=xforwarded is there with the 403 it prevents spelled out. The realm registers post.logout.redirect.uris as its own attribute. And the ca-export service exists precisely because Node ignores an unreadable NODE_EXTRA_CA_CERTS in silence, which is the failure that would have cost someone an afternoon.

Two things I suspected and was wrong about, recorded so nobody re-raises them. I expected the Keycloak health check to miss, since --http-relative-path=/auth also applies to the management port, but /auth/health/ready on 9000 is correct and the container reported healthy. And the em dash in the file header is house style here, not a slip: docker-compose.example.yml, which the issue named as the model to follow, carries fourteen of them.

src/ is untouched, both existing compose files are unchanged, and the walkthrough sits before the provider sections in docs/OIDC.md as the issue asked. Approving. Using Caddy's tls internal instead of hand-rolling certificates was your own call and it is the better one, since it removes a whole init script from the surface we have to maintain.

@Lingikaushikreddy

Lingikaushikreddy commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Thanks a lot @yusuf-gundogdu, for running the whole stack yourself and for the notes on #941 before I started. The port and the silent NODE_EXTRA_CA_CERTS failure would each have cost me hours, so the credit for those parts of the file is really yours.

Good to know the health check path and the em dash are right as they are.

@cevheri whenever you get a chance. The one open point from my side is still the Keycloak logout confirmation, which happens because no id_token_hint is sent. I left src/ alone as the issue asked, and I'm happy to open a separate issue for it if you think it's worth tracking.

…dashboard

handleLogout POSTed /api/auth/logout and pushed /login without reading the
response, discarding the redirectUrl the route returns in OIDC mode. The local
cookie was cleared, so the app looked signed out while the session at the
identity provider stayed alive. src/hooks/use-auth.ts, the other caller of this
route, has always handled it correctly.

Invisible until now because src/lib/oidc.ts sends prompt=login on every
authorize request, so the provider asks for credentials whether or not the SSO
session survived. It falsified README.md and docs/OIDC.md, which both say
logout clears the identity provider session.

The branch had no coverage: the existing test mocked {success:true} only, so
the 100% line gate never saw it, because the missing code was not a line.
Found while reviewing libredb#947, whose walkthrough logs out from the admin dashboard.
@codecov

codecov Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@cevheri

cevheri commented Sep 17, 2026

Copy link
Copy Markdown
Member

Ran your stack rather than read it, and found a bug of ours sitting under your step 2. Your file was never the problem and is unchanged.

The Logout button on /admin/overview, where step 1 lands, dropped the redirectUrl that /api/auth/logout returns in OIDC mode, so Keycloak was never contacted and step 3 could not be typed. The editor header has always handled it correctly. prompt=login on every authorize request is what hid this from all three of us: Keycloak asks for credentials whether or not the session survived, so the check we were relying on passes either way.

#941 told you not to touch src/, so I pushed the fix to your branch myself, failing test first. Your walkthrough is now true as written. The demo pins :latest, so the fix only reaches it at the next release.

On your open point, the prompt is correct behaviour rather than a defect, and docs/OIDC.md already records why we cannot send id_token_hint. Do not open that issue yourself though: our gate wants one opened by a maintainer, so yours would sit held rather than worked. I will open it, and I would like you to take it. What is worth tracking there is the Okta row, not the prompt.

The stack reproduced exactly on Linux, unmodified, from one command. Merging, and thank you.

@cevheri
cevheri merged commit 0c29d75 into libredb:main Sep 17, 2026
35 of 36 checks passed
@cevheri cevheri added enhancement New feature or request dependencies Dependency version updates labels Sep 17, 2026
@Lingikaushikreddy

Copy link
Copy Markdown
Contributor Author

Thanks @cevheri, and good catch on the admin header. That one was under my nose: step 1 lands on /admin/overview, so the button I clicked in the walkthrough was exactly the broken one, and prompt=login made the retry look right. I should have compared it against the editor header instead of trusting the credential prompt.

Understood on the logout point, and thanks for explaining why id_token_hint cannot be sent. I won't open anything. Please tag me when you open the Okta one and I'll take it.

Thanks to you and @yusuf-gundogdu for running the stack yourselves. Good to know it reproduced unchanged on Linux.

@Lingikaushikreddy

Copy link
Copy Markdown
Contributor Author

Following your point about prompt=login, I went back over what I merged and found three things I'd like to correct. All in my own files, nothing in src/.

  1. Step 3 of the walkthrough argues from the wrong evidence. It says Keycloak "asks for credentials again rather than signing you straight back in", but src/lib/oidc.ts:122 sends prompt=login on every authorize request, so it asks either way. The step is fine as an instruction, the reason behind it is not, and it is the same assumption that hid the dashboard bug. I'd reword it to say the prompt is forced and is not evidence that the session ended.

  2. The proxy publishes 8443 on every interface. Measured from this machine's LAN address: curl --resolve demo.127.0.0.1.nip.io:8443:<lan-ip> https://demo.127.0.0.1.nip.io:8443/login returns 200, so anyone on the same network reaches the demo, and through /auth/admin/ the Keycloak console. 127.0.0.1:8443:8443 keeps it on the machine that started it and changes nothing in the walkthrough.

  3. The header lists the committed client secret and user passwords but not kcadmin / kcadmin, which is the account that can change the realm. Worth naming in the same breath.

Happy to send these as one small PR. Given your gate, would you like to open an issue for it first, or is a PR straight onto the file I just added acceptable here?

@cevheri

cevheri commented Sep 17, 2026

Copy link
Copy Markdown
Member

can you create an issue, after that I will assign it to you

@Lingikaushikreddy

Copy link
Copy Markdown
Contributor Author

Opened as #948. Waiting for the assignment before I start on it.

Thanks for merging #940 too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Dependency version updates enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

One-command Keycloak demo stack so OIDC can be tried without reading the docs

3 participants