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
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user