Put the OIDC set-up where it can be read

The .env.example had grown a page of prose around seven variables, which is
the wrong place for it: that file is read while editing, one line at a time.
It now names the variables and points at the readme.

The readme carries the whole thing instead, worked through with PocketID: the
client at the provider, the settings, the two lines the boot log can print,
and a table of what each failure looks like from the outside — including the
one that catches everyone, a server container that cannot reach an issuer the
browser reaches perfectly well.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-18 16:51:15 +02:00
1 parent 43a98b0972
commit 26a48af620
2 files changed
+87 -52

No files matched your search

+4 -16
View File
@@ -32,24 +32,12 @@ INKNOTE_ADMIN_PASSWORD=
# VERSION_COALESCE_MINUTES=5 # VERSION_COALESCE_MINUTES=5
# ALLOWED_ORIGINS= # only if the UI is on another host # ALLOWED_ORIGINS= # only if the UI is on another host
# ── Signing in through an identity provider (OpenID Connect) ── # ── Signing in through an identity provider (OIDC) ── optional, off when empty
# Optional. Leave the four empty and the app is password-only; set all four and # Set-up and what it does: readme.md → Signing in through an identity provider
# the sign-in screen offers the provider as well. The password login stays in
# either case — an unreachable provider must not lock anybody out of their own
# notes. Somebody who signs in this way and has no account here yet gets one
# (never an administrator: that is granted in the app, on purpose).
#
# In PocketID: a new OIDC client, redirect URI exactly
# https://<your-inknote-host>/api/auth/oidc/callback
# The *server container* has to reach the issuer itself, not just your browser.
#
# The first sign-in binds an identity to an account of the same name here, so
# this trusts the provider's usernames. Right for a provider whose names are set
# by its own administrator; wrong for one where people pick their own.
# INKNOTE_PUBLIC_URL=https://notes.example.com # INKNOTE_PUBLIC_URL=https://notes.example.com
# INKNOTE_OIDC_ISSUER=https://id.example.com # INKNOTE_OIDC_ISSUER=https://id.example.com
# INKNOTE_OIDC_CLIENT_ID= # INKNOTE_OIDC_CLIENT_ID=
# INKNOTE_OIDC_CLIENT_SECRET= # INKNOTE_OIDC_CLIENT_SECRET=
# INKNOTE_OIDC_LABEL=PocketID # what the button says: "Continue with …" # INKNOTE_OIDC_LABEL=PocketID # the button says "Continue with …"
# INKNOTE_OIDC_SCOPES=openid profile email # INKNOTE_OIDC_SCOPES=openid profile email
# INKNOTE_OIDC_REDIRECT_URI= # only if it is not <public url>/api/auth/oidc/callback # INKNOTE_OIDC_REDIRECT_URI= # only if not <public url>/api/auth/oidc/callback
+83 -36
View File
@@ -224,56 +224,103 @@ deleted person's session stops working immediately: tokens are signed rather tha
### Signing in through an identity provider ### Signing in through an identity provider
Optional, and off unless four settings are present (`.env.example` lists them): Optional, and off unless four settings are present. With them, the sign-in screen
the issuer, a client id and secret, and the public URL this app is reached at. offers the provider beside the password form; without them nothing about it
The provider is only ever named on the sign-in screen — nothing else about it is appears anywhere.
told to somebody who is not signed in.
**It is a second way to the same token.** The password login mints a signed JWT #### Setting it up, with PocketID
and every route, including the WebSocket, knows only that; the provider route
**1. A client at the provider.** In PocketID that is a new OIDC client; three
things about it matter, whatever the fields happen to be called:
- the **callback URL** is `https://notes.example.com/api/auth/oidc/callback`,
with `notes.example.com` replaced by the address you reach InkNote at. The path
is fixed and is compared character for character — a mismatch is refused by the
provider before InkNote hears about it at all.
- it is **not** a public client: InkNote is a server and keeps the secret there.
- PKCE may be required; InkNote sends a challenge either way.
PocketID shows the client id and secret once. Copy both.
**2. The settings in `.env`** — the first four are what switch it on, the fifth
is only the name on the button. Then `docker compose up -d`, which recreates the
container with them:
```bash
INKNOTE_PUBLIC_URL=https://notes.example.com # where you reach InkNote
INKNOTE_OIDC_ISSUER=https://id.example.com # where PocketID lives, no trailing path
INKNOTE_OIDC_CLIENT_ID=<from PocketID>
INKNOTE_OIDC_CLIENT_SECRET=<from PocketID>
INKNOTE_OIDC_LABEL=PocketID # the button reads "Continue with PocketID"
```
Two more exist and are rarely needed: `INKNOTE_OIDC_SCOPES` (default
`openid profile email`) and `INKNOTE_OIDC_REDIRECT_URI`, for the case where the
callback is not `<public url>/api/auth/oidc/callback`.
**3. Read the boot log.** It says which of the two happened, and there is no
third:
```
✅ OpenID Connect enabled — https://id.example.com (redirect https://notes.example.com/api/auth/oidc/callback)
⚠️ OpenID Connect is half configured and stays off — missing: INKNOTE_OIDC_CLIENT_SECRET
```
Half a configuration is a typo rather than a decision, so it is named rather than
quietly ignored. An issuer on plain `http://` is called out too — the client
secret and the authorization code travel to it.
**What goes wrong, and what it looks like**
| Symptom | Cause |
| --- | --- |
| Nothing on the sign-in screen | one of the four is unset — see the boot log |
| Back at the sign-in screen saying the provider could not be reached | the **server container** cannot resolve or reach the issuer. Your browser reaching it proves nothing: behind a reverse proxy those are different networks. `docker compose exec inknote-server sh -c 'wget -qO- $INKNOTE_OIDC_ISSUER/.well-known/openid-configuration'` |
| The provider refuses before InkNote is involved | the callback URL there is not exactly `<public url>/api/auth/oidc/callback` |
| "That sign-in took too long" | more than ten minutes between pressing the button and finishing, or the server restarted in between — pending sign-ins are held in memory on purpose |
#### What it actually does
**It is a second way to the same token.** The password login mints a signed JWT,
and every route — the WebSocket included — knows only that; the provider route
ends at the same `jwt.sign`. Nothing downstream had to learn what OIDC is, which ends at the same `jwt.sign`. Nothing downstream had to learn what OIDC is, which
is why the whole thing is one file ([`backend/oidc.js`](backend/oidc.js)) and no is why the whole thing is one file ([`backend/oidc.js`](backend/oidc.js)) with no
new dependency: discovery is one document, the exchange is one form post, and new dependency: discovery is one document, the exchange is one form post, and
checking an ID token is a signature and five claims — Node imports a JWK checking an ID token is a signature and five claims — Node imports a JWK
directly. directly.
**Authorization Code with PKCE, exchanged on the server.** The client secret **Authorization Code with PKCE, exchanged on the server.** The client secret
never reaches the browser, and neither does the session token: the callback never reaches the browser, and neither does the session token: the callback
redirects home with a **one-time ticket**, valid for a minute and consumed on sends the browser home with a **one-time ticket**, good for a minute and consumed
first use, which the app swaps for its token. A token in a redirect URL would on first use, which the app swaps for its token. A token in a redirect URL would
land in the history, in the proxy's log and in the referrer of the next request. land in the history, in the proxy's log and in the referrer of the next request.
**Accounts.** The provider's `sub` is stored on the account, and that is the **Accounts.** The provider's `sub` is stored on the account and is the link from
link from then on — either side may be renamed without breaking it. On a first then on, so either side may be renamed without breaking it. On a first sign-in
sign-in the subject is bound to an account of the same name if there is one, and the subject is bound to an account of the same name if there is one, and
otherwise **a new account is made**. Never an administrator: rights are granted otherwise **a new account is made**. Never an administrator: those rights are
in this app, and a group at the provider must not be a way around that. granted in this app, and a group at the provider must not be a way around that.
That first binding trusts the provider's usernames, and it is worth being That first binding trusts the provider's usernames, which is worth being explicit
explicit about what that means: whoever can set `preferred_username` over there about: whoever can set `preferred_username` over there decides which account here
can decide which account here they land in the first time. With a provider whose they land in the first time. Where the names are set by the provider's own
names are set by *its* administrator — PocketID, Authentik, Keycloak as they are administrator — PocketID, Authentik, Keycloak as they are normally run — that is
normally run — that is exactly the intent. Do not point this at one where people exactly the intent. Do not point this at one where people choose their own name
choose their own name freely. freely.
**Both doors stay open.** An account created by the provider has no password **Both doors stay open.** An account created by the provider has no password here
here at all — the interface stops offering to change one, and the login route at all: the interface stops offering to change one, and the login route refuses
refuses it as it refuses a wrong one, at the same speed. An administrator can it exactly as it refuses a wrong password, at the same speed. An administrator
still *set* a password for such an account, which is deliberate: it is the way can still *set* one for such an account, and that is deliberate — it is the way
back in the day the provider is unreachable. back in on the day the provider is unreachable.
Two things about the setting-up, both easy to get wrong: **The refresh token is deliberately unused.** The provider is asked once, at
- the redirect URI at the provider must be exactly
`<public url>/api/auth/oidc/callback`;
- the **server** has to reach the issuer itself — for the token exchange and the
key set — not merely your browser. Behind a reverse proxy those are different
networks.
The refresh token is deliberately not used. The provider is asked once, at
sign-in; InkNote's own token carries the session from then on, and nothing here sign-in; InkNote's own token carries the session from then on, and nothing here
needs the provider to be reachable afterwards. That is what keeps the app needs the provider to be reachable afterwards. That is what keeps the app working
working offline, which is most of the point of it. offline, which is most of the point of it.
On a phone the redirect opens the system browser and returns to the installed
app, so a passkey at the provider works as it does anywhere else.
### Notebooks ### Notebooks