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

+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
Optional, and off unless four settings are present (`.env.example` lists them):
the issuer, a client id and secret, and the public URL this app is reached at.
The provider is only ever named on the sign-in screen — nothing else about it is
told to somebody who is not signed in.
Optional, and off unless four settings are present. With them, the sign-in screen
offers the provider beside the password form; without them nothing about it
appears anywhere.
**It is a second way to the same token.** The password login mints a signed JWT
and every route, including the WebSocket, knows only that; the provider route
#### Setting it up, with PocketID
**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
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
checking an ID token is a signature and five claims — Node imports a JWK
directly.
**Authorization Code with PKCE, exchanged on the server.** The client secret
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
first use, which the app swaps for its token. A token in a redirect URL would
sends the browser home with a **one-time ticket**, good for a minute and consumed
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.
**Accounts.** The provider's `sub` is stored on the account, and that is the
link from then on — either side may be renamed without breaking it. On a first
sign-in 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
in this app, and a group at the provider must not be a way around that.
**Accounts.** The provider's `sub` is stored on the account and is the link from
then on, so either side may be renamed without breaking it. On a first sign-in
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: those rights are
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
explicit about what that means: whoever can set `preferred_username` over there
can decide which account here they land in the first time. With a provider whose
names are set by *its* administrator — PocketID, Authentik, Keycloak as they are
normally run — that is exactly the intent. Do not point this at one where people
choose their own name freely.
That first binding trusts the provider's usernames, which is worth being explicit
about: whoever can set `preferred_username` over there decides which account here
they land in the first time. Where the names are set by the provider's own
administrator — PocketID, Authentik, Keycloak as they are normally run — that is
exactly the intent. Do not point this at one where people choose their own name
freely.
**Both doors stay open.** An account created by the provider has no password
here at all — the interface stops offering to change one, and the login route
refuses it as it refuses a wrong one, at the same speed. An administrator can
still *set* a password for such an account, which is deliberate: it is the way
back in the day the provider is unreachable.
**Both doors stay open.** An account created by the provider has no password here
at all: the interface stops offering to change one, and the login route refuses
it exactly as it refuses a wrong password, at the same speed. An administrator
can still *set* one for such an account, and that is deliberate — it is the way
back in on the day the provider is unreachable.
Two things about the setting-up, both easy to get wrong:
- 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
**The refresh token is deliberately unused.** The provider is asked once, at
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
working offline, which is most of the point of it.
needs the provider to be reachable afterwards. That is what keeps the app working
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