From 26a48af620502af42cb644face2fb30600d590c9 Mon Sep 17 00:00:00 2001 From: marc Date: Tue, 18 Aug 2026 16:51:15 +0200 Subject: [PATCH] Put the OIDC set-up where it can be read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 20 ++------- readme.md | 119 +++++++++++++++++++++++++++++++++++---------------- 2 files changed, 87 insertions(+), 52 deletions(-) diff --git a/.env.example b/.env.example index 825842c..374600a 100644 --- a/.env.example +++ b/.env.example @@ -32,24 +32,12 @@ INKNOTE_ADMIN_PASSWORD= # VERSION_COALESCE_MINUTES=5 # ALLOWED_ORIGINS= # only if the UI is on another host -# ── Signing in through an identity provider (OpenID Connect) ── -# Optional. Leave the four empty and the app is password-only; set all four and -# 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:///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. +# ── Signing in through an identity provider (OIDC) ── optional, off when empty +# Set-up and what it does: readme.md → Signing in through an identity provider # INKNOTE_PUBLIC_URL=https://notes.example.com # INKNOTE_OIDC_ISSUER=https://id.example.com # INKNOTE_OIDC_CLIENT_ID= # 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_REDIRECT_URI= # only if it is not /api/auth/oidc/callback +# INKNOTE_OIDC_REDIRECT_URI= # only if not /api/auth/oidc/callback diff --git a/readme.md b/readme.md index b999f97..a2de6d9 100644 --- a/readme.md +++ b/readme.md @@ -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= +INKNOTE_OIDC_CLIENT_SECRET= +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 `/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 `/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 - `/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