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:
1 parent
c86c63922a
commit
43a98b0972
17 files changed
+1271
-44
No files matched your search
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user