A second way in: signing in through an identity provider

OpenID Connect, off unless four settings are present. It is a second way to
the same token the password login mints — every route and the WebSocket
still know only a signed JWT — which is why it fits in one file and needs no
new dependency: Node imports a JWK itself, so checking an ID token is a
signature and five claims.

Authorization Code with PKCE, exchanged on the server so the client secret
stays there. The browser comes home with a one-time ticket rather than the
token, because a token in a redirect URL sits in the history, in the proxy's
log and in the referrer of the next request.

Somebody the app has not seen gets an account; an account that already
exists under that name is linked to the identity rather than duplicated, by
subject from then on. Never an administrator. An account made this way has
no password here, and the interface stops offering one — but an admin can
still set one, which is the way back in if the provider is ever gone.

The suite signs in at a provider of its own that signs real RS256 tokens,
and holds down the four ways the journey must fail as well as the way it
must work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-18 16:38:24 +02:00
1 parent c86c63922a
commit 43a98b0972
17 files changed
+1271 -44

No files matched your search

+67 -7
View File
@@ -28,8 +28,9 @@ A self-hosted note-taking application with rich text editing, freehand drawing,
- **Conflict-Aware Sync** — A write from a device that missed another's changes keeps the state it replaces as a version, rather than burying it
- **WebSocket Real-Time Push** — Changes appear on other devices instantly
- **Encrypted at Rest** — AES-256-GCM for note content *and* uploaded files on the server
- **JWT Authentication** — Username + password accounts with bcrypt password hashing. No public
sign-up: an administrator creates the accounts
- **JWT Authentication** — Username + password accounts with bcrypt password hashing, and
optionally an identity provider (OpenID Connect). No public sign-up: accounts come from an
administrator or from the provider
- **Vector Drawings** — Strokes are stored as vectors in one canonical page space, so a drawing looks identical on every screen and stays sharp at any pixel density
### Architecture
@@ -180,9 +181,13 @@ otherwise the app is reachable on the host's address without TLS.
| GET | `/api/auth/me` | — | `{id, username, displayName, isAdmin}` |
| PUT | `/api/auth/username` | `{newUsername, password}` | updated user |
| PUT | `/api/auth/password` | `{currentPassword, newPassword}` | `{success}` |
| GET | `/api/auth/methods` | — *(no token needed)* | `{password, oidc:{enabled,label}}` |
| GET | `/api/auth/oidc/start` | — | redirect to the provider |
| GET | `/api/auth/oidc/callback` | *(the provider's `code` and `state`)* | redirect home with a one-time ticket |
| POST | `/api/auth/oidc/claim` | `{ticket}` | `{token, user}` |
**There is no registration endpoint.** This is a private instance: accounts are created by an
administrator, and nothing on the login screen suggests otherwise.
administrator or by the identity provider, and nothing on the login screen suggests otherwise.
Usernames are 3–32 characters of letters, digits, `.`, `_` or `-`, and match case-insensitively.
Servers created before this carried e-mail logins; on first start they migrate automatically,
@@ -217,6 +222,59 @@ Deleting an account destroys its notes, notebooks, drawings and version history
deleted person's session stops working immediately: tokens are signed rather than stored, so
`authMiddleware` checks on every request that the account still exists.
### 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.
**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
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
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
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.
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.
**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.
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
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.
### Notebooks
| Method | Endpoint | Description |
@@ -253,6 +311,7 @@ Connect to `ws://localhost:4000/ws`, then send:
Receive real-time events: `note_created`, `note_updated`, `note_deleted`, `notebook_created`, etc.
---
## Sync Engine (Client)
@@ -458,16 +517,17 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
| Suite | Checks | What it holds down |
| --- | --- | --- |
| `admin` | 15 | no sign-up anywhere; `/api/admin/*` refused for non-admins even when called directly |
| `editor` | 37 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload |
| `oidc` | 26 | the whole redirect journey against a provider that signs real tokens — and the four ways it must fail: a refusal, a replayed ticket, a token signed with an unpublished key, a token addressed to another client |
| `editor` | 40 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload |
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
| `drawing` | 18 | strokes survive the poller, match across four viewport widths, and stay on the page they were drawn on |
| `drawing` | 22 | strokes survive the poller, match across four viewport widths, and stay on the page they were drawn on |
| `pdf` | 91 | import, page proportions, lazy loading, and reading with the network off |
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
| `export` | 23 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
| `organise` | 75 | a hand-made order survives a search; a new note lands on top and a deleted one takes only itself; one line goes under another and stays there |
| `chapters` | 60 | a filed note stays filed across a sync; a chapter is dragged among its neighbours *and* takes what is dropped in it; its own order is set in its own dialog |
| `chapters` | 65 | a filed note stays filed across a sync; a chapter is dragged among its neighbours *and* takes what is dropped in it; its own order is set in its own dialog |
| `offline` | 17 | a cold page load with the network off still opens notes and searches them — and a note made, written and drawn on offline arrives whole once there is a connection |
| `tables` | 11 | a long table is broken between its rows, with none left lying across the gap, no page furniture reaches the stored note, and the column edge being dragged is drawn on the cell rather than in it |
| `sync` | 7 | a PDF written on one device — document, drawing and pinned notes — reaches a second one, which can find it by its contents without opening it |