A second way in: signing in through an identity provider

OpenID Connect, off unless four settings are present. It is a second way to
the same token the password login mints — every route and the WebSocket
still know only a signed JWT — which is why it fits in one file and needs no
new dependency: Node imports a JWK itself, so checking an ID token is a
signature and five claims.

Authorization Code with PKCE, exchanged on the server so the client secret
stays there. The browser comes home with a one-time ticket rather than the
token, because a token in a redirect URL sits in the history, in the proxy's
log and in the referrer of the next request.

Somebody the app has not seen gets an account; an account that already
exists under that name is linked to the identity rather than duplicated, by
subject from then on. Never an administrator. An account made this way has
no password here, and the interface stops offering one — but an admin can
still set one, which is the way back in if the provider is ever gone.

The suite signs in at a provider of its own that signs real RS256 tokens,
and holds down the four ways the journey must fail as well as the way it
must work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-18 16:38:24 +02:00
1 parent c86c63922a
commit 43a98b0972
17 files changed
+1271 -44

No files matched your search

+19
View File
@@ -36,6 +36,25 @@ services:
- MAX_VERSIONS_PER_NOTE=${MAX_VERSIONS_PER_NOTE:-}
- VERSION_RETENTION_DAYS=${VERSION_RETENTION_DAYS:-}
- VERSION_COALESCE_MINUTES=${VERSION_COALESCE_MINUTES:-}
# Signing in through an identity provider — see .env.example. Unset means
# the app is password-only and the sign-in screen shows nothing about it.
# Signing in through an identity provider. In development this points at
# the throwaway one the OIDC suite starts (e2e/fakeIdp.mjs) — which only
# exists while that suite runs, so the button on the sign-in screen is
# there the rest of the time and simply fails. That is the price of
# testing the real redirect against a real signature.
- INKNOTE_PUBLIC_URL=${INKNOTE_PUBLIC_URL:-http://localhost:3000}
- INKNOTE_OIDC_ISSUER=${INKNOTE_OIDC_ISSUER:-http://idp.test:3011}
- INKNOTE_OIDC_CLIENT_ID=${INKNOTE_OIDC_CLIENT_ID:-inknote-test}
- INKNOTE_OIDC_CLIENT_SECRET=${INKNOTE_OIDC_CLIENT_SECRET:-test-secret}
- INKNOTE_OIDC_LABEL=${INKNOTE_OIDC_LABEL:-Test IdP}
- INKNOTE_OIDC_SCOPES=${INKNOTE_OIDC_SCOPES:-}
- INKNOTE_OIDC_REDIRECT_URI=${INKNOTE_OIDC_REDIRECT_URI:-}
# The provider runs on the Docker host (inside the test container, which
# shares the host's network). Same name on both sides, or the issuer in the
# token would not match the one configured here.
extra_hosts:
- "idp.test:host-gateway"
# Bind mounts, not named volumes: the data sits in a plain directory on the
# Docker host where the host's own backup picks it up.
user: "${INKNOTE_UID:-1000}:${INKNOTE_GID:-1000}"