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:
1 parent
43a98b0972
commit
26a48af620
2 files changed
+87
-52
No files matched your search
+4
-16
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user