OAuth2 shim so the feedback board signs in against accounts.nectenda.com #107

Closed
opened 2026-09-23 10:46:22 +01:00 by cruelacid · 1 comment
Owner

Why

Depends on the Fider board, and deferred with it. Filed so the design is not
re-derived.

Without this, board sign-in is Fider's built-in email and GitHub. That is not
nothing — the primary market lives on GitHub — but a signed-in plugin user
clicking through to the board and having to authenticate again is friction on the
exact people whose feedback is most wanted.

The constraint that shapes everything

packages/identity is an OAuth consumer, not a provider. openid-client is
a relying-party library; JWTs are Ed25519 via a ~100-line
packages/ops-common/src/eddsa-jwt.ts; routing is raw node:http. There is no
/authorize, no /token, no JWKS, no client registry, and docs/identity.md:213
states "Everything it exposes is listed here… Anything not listed answers 404."

Critically it has no browser session, by design. routes.ts:447: "tokens
are bearer, never cookies, so a wildcard origin grants nothing a token does
not"
— which is why Access-Control-Allow-Origin: * is safe there. An OAuth
/authorize requires the browser itself to be authenticated, so building one
in-place means inventing a session cookie on the host that holds every user's
wrapped key material, then narrowing that CORS policy.

A security-model change to the crown jewel, driven by a feedback board. Hence
a shim instead: a separate service that speaks OAuth2 to Fider and authenticates
people by driving the existing handshake. packages/identity diff: zero lines.

Better Auth was considered and rejected

Replacing the hand-rolled consumer with Better Auth, and using its OIDC provider
plugin to become a provider, was evaluated. Against it:

  1. Better Auth's own OIDC provider docs say the plugin is "in active
    development and may not be suitable for production use."
    Founding the IdP on
    that, for a product selling cryptographic checkability, inverts the priority.
  2. It replaces an enumerable 404-by-default surface with a framework's. That
    enumerability is the product's second pillar.
  3. It assumes the cookie-session web-app model this service deliberately rejects.
  4. The migration is a rewrite of email codes, passkeys, three IdP integrations,
    the bespoke Obsidian handshake, Ed25519 JWTs with env-distributed shard keys,
    and rotating refresh tokens with reuse detection — plus migrating live
    accounts including user_key_history. Blast radius: every user's ability to
    sign in and decrypt.

Design findings — do not re-derive these

It belongs on the identity host, not the ops box

The ops box has no deployer, deliberately — deploy/hosts.txt says it "gets
its compose file and Caddyfile and no deployer, because there is no first-party
image there to follow."

And it is mechanically blocked: deploy/inventory.mjs filters
repositron.nerchure.com refs out of parsePins, and an existing test
(deploy/test/console.test.ts:236-246) asserts every name in UPGRADEABLE[kind]
appears in that host's pins. Adding feedback-bridge to UPGRADEABLE.ops
fails CI.
On the ops box it would have no deployer, no inventory row, no
console button and no version visible anywhere.

The recorded reason identity does not share a box is that ops "runs two
third-party internet-facing web applications with their own dependency trees" — a
first-party service from the same pipeline is not that. Adjacency to Fider buys
nothing either: /authorize is a browser redirect and must be a public URL
regardless.

The browser leg cannot work the obvious way

packages/identity/static/auth.html ends with "Obsidian has picked up the
sign-in. You can close this tab"
, and auth.js has no redirect-out branch —
every path terminates. The identity page never sends the browser anywhere, so the
shim cannot regain control of the tab it handed away.

It must keep its own tab and poll itself, via <meta http-equiv="refresh"> — not
a script; this origin mints session-equivalent codes and script-src 'none' is
cheap to keep — bounded at about two minutes with a clear expiry message rather
than refreshing forever.

Two safety properties that are load-bearing

  • deviceId: 'feedback-bridge', a constant. sessions.ts revokes any
    existing session with the same device id on sign-in. A distinct constant
    means a board sign-in supersedes only the previous board sign-in and can
    never sign someone out of their vault.
  • Log the session out immediately. /auth/poll returns a full sign-in result
    — access, identity and refresh tokens and a live session row — when the shim
    needs only id, email and display name. POST /auth/logout takes the refresh
    token and always answers 200, so nothing is left behind and no mysterious
    "Feedback board" device appears in the user's plugin.

A signed-in plugin user gets a button that lands them in Fider already
authenticated, using only endpoints that exist:

  1. Plugin POSTs to the shim with Authorization: Bearer <accessToken> it already
    holds.
  2. Shim forwards that bearer verbatim to GET {IDENTITY_URL}/api/me
    (routes.ts:809, guarded by authenticate() — signature, aud: nectenda-identity, typ: 'access', and liveSession(sid)). The shim
    validates nothing itself and holds no verification key; a second
    implementation of that check is a second place for it to be wrong.
  3. Shim mints a short-lived single-use ticket, returns a URL.
  4. Plugin opens it in the system browser; shim consumes the ticket and lands the
    user in Fider.

The access token never appears in a URL — only an opaque ticket — and remote
sign-out is honoured for free
, because /api/me checks liveSession.

Package and storage

packages/feedback-bridge, private and UNLICENSED like ops-common, reusing
its logger, rate limiter, clientIp, EnvReader, readiness, shutdown sequencer,
background jobs, error reporting, SQLite and JSON body helpers. Not mirrored —
build-mirror.mjs stages packages/plugin and packages/shared by explicit
path, so a new package is proprietary by default.

SQLite, not in-memory. The deployer restarts this process whenever :stable
moves, about a minute after any merge, and in-flight sign-ins must survive that.
Three tables, every row expiring in minutes: auth_requests (10 min), codes
(60 s), tickets (120 s).

Identifiers are 32 random bytes, base64url — deliberately not JWTs.
Single-use is the whole security property, and a stateless token cannot be
single-use without a ledger, so the ledger is the design rather than an addition
to it. Claim rows with the db.transaction(…).immediate() conditional-UPDATE
idiom flows.ts:claimFlow already uses; compare secrets with timingSafeEqual
as requireAdmin does.

Open-redirect discipline

redirect_uri matched as an exact string against an env allowlist — no
prefix logic, no normalisation, no wildcards — and on failure the shim renders an
error page and never redirects. state is Fider's, echoed back unmodified
and never interpreted.

Fider side

Configure as a custom OAuth2 provider (Display Name, Client ID, Client Secret,
Authorize URL, Token URL, Profile API URL, scopes, JSON paths for id/name/email)
marked "Trusted Source", so anyone it authenticates is admitted without an
invite.

Fider cannot disable its built-in email sign-in — still an open upstream
feature request — so both paths exist regardless. For this audience that is
arguably right: someone can file "we need X for our engagement workflow" without
tying it to their company account.

Config and CI

Every variable through the package's own src/config.ts using EnvReader, with
envNamesRead() and a --env-names argv branch as the first statement of
main(). Copy packages/identity/src/compose-env.test.ts wholesale —
including its third test, the deliberate inversion that feeds the real
OPS_HOST/METRICS_HOST mistake and asserts it is caught.

No signing key: the tokens are opaque random strings, so there is nothing to
rotate and nothing that survives a DELETE.

A third Dockerfile target modelled on the identity stage, then three places
in ci.yml — the two-platform build, the publish step, and the promote job,
which starts the image and waits for /api/health to report the literal sha.
That last is not a formality; it is what catches a native module with no
prebuild, and this image carries better-sqlite3.

Privacy

The shim holds nothing beyond ten minutes and its SQLite must not be swept
into restic — the rows are worthless within minutes and the file holds emails.

The one-tab version, if two tabs reads as broken

Needs a returnTo on the identity flow: a nullable column in auth_flows,
acceptance in POST /auth/flow against a server-side exact-match allowlist,
exposure on the flow GET, and a redirect instead of the terminal state in
auth.js. Small, but it is a change to packages/identity and an open-redirect
surface on the host holding key material. Recorded so it is not rediscovered.

Verification when this is picked up

  • Every guard exercised from both sides: a redirect_uri that matches and
    one that does not; a code claimed once and the same code twice; an expired
    ticket and a live one.
  • Both legs in a real browser and real Obsidian, not curl. Then the two
    cases that matter most: the plugin's device list contains no leftover "Feedback
    board" session, and the user's vault session is still live.
  • Remotely sign the device out and confirm the ticket endpoint is refused.
  • Confirm no access token appears in any URL by reading the browser history and
    the shim's logs for the token prefix — not by inspecting the code.
## Why Depends on the Fider board, and deferred with it. Filed so the design is not re-derived. Without this, board sign-in is Fider's built-in email and GitHub. That is not nothing — the primary market lives on GitHub — but a signed-in plugin user clicking through to the board and having to authenticate again is friction on the exact people whose feedback is most wanted. ## The constraint that shapes everything `packages/identity` is an OAuth **consumer**, not a provider. `openid-client` is a relying-party library; JWTs are Ed25519 via a ~100-line `packages/ops-common/src/eddsa-jwt.ts`; routing is raw `node:http`. There is no `/authorize`, no `/token`, no JWKS, no client registry, and `docs/identity.md:213` states *"Everything it exposes is listed here… Anything not listed answers 404."* Critically it has **no browser session, by design**. `routes.ts:447`: *"tokens are bearer, never cookies, so a wildcard origin grants nothing a token does not"* — which is why `Access-Control-Allow-Origin: *` is safe there. An OAuth `/authorize` requires the browser itself to be authenticated, so building one in-place means inventing a session cookie on the host that holds every user's wrapped key material, then narrowing that CORS policy. **A security-model change to the crown jewel, driven by a feedback board.** Hence a shim instead: a separate service that speaks OAuth2 to Fider and authenticates people by driving the *existing* handshake. `packages/identity` diff: zero lines. ## Better Auth was considered and rejected Replacing the hand-rolled consumer with Better Auth, and using its OIDC provider plugin to become a provider, was evaluated. Against it: 1. Better Auth's own OIDC provider docs say the plugin is **"in active development and may not be suitable for production use."** Founding the IdP on that, for a product selling cryptographic checkability, inverts the priority. 2. It replaces an enumerable 404-by-default surface with a framework's. That enumerability **is** the product's second pillar. 3. It assumes the cookie-session web-app model this service deliberately rejects. 4. The migration is a rewrite of email codes, passkeys, three IdP integrations, the bespoke Obsidian handshake, Ed25519 JWTs with env-distributed shard keys, and rotating refresh tokens with reuse detection — plus migrating live accounts including `user_key_history`. Blast radius: every user's ability to sign in and decrypt. ## Design findings — do not re-derive these ### It belongs on the identity host, not the ops box The ops box has **no deployer, deliberately** — `deploy/hosts.txt` says it "gets its compose file and Caddyfile and no deployer, because there is no first-party image there to follow." And it is mechanically blocked: `deploy/inventory.mjs` filters `repositron.nerchure.com` refs out of `parsePins`, and an existing test (`deploy/test/console.test.ts:236-246`) asserts every name in `UPGRADEABLE[kind]` appears in that host's pins. **Adding `feedback-bridge` to `UPGRADEABLE.ops` fails CI.** On the ops box it would have no deployer, no inventory row, no console button and no version visible anywhere. The recorded reason identity does not share a box is that ops "runs two third-party internet-facing web applications with their own dependency trees" — a first-party service from the same pipeline is not that. Adjacency to Fider buys nothing either: `/authorize` is a browser redirect and must be a public URL regardless. ### The browser leg cannot work the obvious way `packages/identity/static/auth.html` ends with *"Obsidian has picked up the sign-in. You can close this tab"*, and `auth.js` has **no redirect-out branch** — every path terminates. The identity page never sends the browser anywhere, so the shim cannot regain control of the tab it handed away. It must keep its own tab and poll itself, via `<meta http-equiv="refresh">` — not a script; this origin mints session-equivalent codes and `script-src 'none'` is cheap to keep — bounded at about two minutes with a clear expiry message rather than refreshing forever. ### Two safety properties that are load-bearing - **`deviceId: 'feedback-bridge'`, a constant.** `sessions.ts` revokes any existing session with the *same* device id on sign-in. A distinct constant means a board sign-in supersedes only the previous board sign-in and **can never sign someone out of their vault.** - **Log the session out immediately.** `/auth/poll` returns a full sign-in result — access, identity and refresh tokens and a live session row — when the shim needs only id, email and display name. `POST /auth/logout` takes the refresh token and always answers 200, so nothing is left behind and no mysterious "Feedback board" device appears in the user's plugin. ### The deep link from the plugin A signed-in plugin user gets a button that lands them in Fider already authenticated, using only endpoints that exist: 1. Plugin POSTs to the shim with `Authorization: Bearer <accessToken>` it already holds. 2. Shim forwards that bearer verbatim to `GET {IDENTITY_URL}/api/me` (`routes.ts:809`, guarded by `authenticate()` — signature, `aud: nectenda-identity`, `typ: 'access'`, **and `liveSession(sid)`**). The shim **validates nothing itself and holds no verification key**; a second implementation of that check is a second place for it to be wrong. 3. Shim mints a short-lived single-use ticket, returns a URL. 4. Plugin opens it in the system browser; shim consumes the ticket and lands the user in Fider. The access token never appears in a URL — only an opaque ticket — and **remote sign-out is honoured for free**, because `/api/me` checks `liveSession`. ### Package and storage `packages/feedback-bridge`, private and `UNLICENSED` like `ops-common`, reusing its logger, rate limiter, `clientIp`, `EnvReader`, readiness, shutdown sequencer, background jobs, error reporting, SQLite and JSON body helpers. Not mirrored — `build-mirror.mjs` stages `packages/plugin` and `packages/shared` by explicit path, so a new package is proprietary by default. **SQLite, not in-memory.** The deployer restarts this process whenever `:stable` moves, about a minute after any merge, and in-flight sign-ins must survive that. Three tables, every row expiring in minutes: `auth_requests` (10 min), `codes` (60 s), `tickets` (120 s). Identifiers are 32 random bytes, base64url — **deliberately not JWTs.** Single-use is the whole security property, and a stateless token cannot be single-use without a ledger, so the ledger is the design rather than an addition to it. Claim rows with the `db.transaction(…).immediate()` conditional-UPDATE idiom `flows.ts:claimFlow` already uses; compare secrets with `timingSafeEqual` as `requireAdmin` does. ### Open-redirect discipline `redirect_uri` matched as an **exact string** against an env allowlist — no prefix logic, no normalisation, no wildcards — and on failure the shim renders an error page and **never redirects**. `state` is Fider's, echoed back unmodified and never interpreted. ### Fider side Configure as a custom OAuth2 provider (Display Name, Client ID, Client Secret, Authorize URL, Token URL, Profile API URL, scopes, JSON paths for id/name/email) marked **"Trusted Source"**, so anyone it authenticates is admitted without an invite. **Fider cannot disable its built-in email sign-in** — still an open upstream feature request — so both paths exist regardless. For this audience that is arguably right: someone can file "we need X for our engagement workflow" without tying it to their company account. ### Config and CI Every variable through the package's own `src/config.ts` using `EnvReader`, with `envNamesRead()` and a `--env-names` argv branch as the first statement of `main()`. Copy `packages/identity/src/compose-env.test.ts` wholesale — **including its third test**, the deliberate inversion that feeds the real `OPS_HOST`/`METRICS_HOST` mistake and asserts it is caught. **No signing key**: the tokens are opaque random strings, so there is nothing to rotate and nothing that survives a `DELETE`. A third `Dockerfile` target modelled on the `identity` stage, then three places in `ci.yml` — the two-platform build, the publish step, and **the promote job**, which starts the image and waits for `/api/health` to report the literal sha. That last is not a formality; it is what catches a native module with no prebuild, and this image carries `better-sqlite3`. ### Privacy The shim holds nothing beyond ten minutes and its SQLite must **not** be swept into restic — the rows are worthless within minutes and the file holds emails. ## The one-tab version, if two tabs reads as broken Needs a `returnTo` on the identity flow: a nullable column in `auth_flows`, acceptance in `POST /auth/flow` **against a server-side exact-match allowlist**, exposure on the flow GET, and a redirect instead of the terminal state in `auth.js`. Small, but it is a change to `packages/identity` and an open-redirect surface on the host holding key material. Recorded so it is not rediscovered. ## Verification when this is picked up - Every guard exercised **from both sides**: a `redirect_uri` that matches and one that does not; a code claimed once and the same code twice; an expired ticket and a live one. - **Both legs in a real browser and real Obsidian**, not `curl`. Then the two cases that matter most: the plugin's device list contains no leftover "Feedback board" session, and the user's vault session is still live. - Remotely sign the device out and confirm the ticket endpoint is refused. - Confirm no access token appears in any URL by reading the browser history and the shim's logs for the token prefix — not by inspecting the code.
cruelacid added this to the Marketing project 2026-09-23 10:48:13 +01:00
Author
Owner

Moved to the Vikunja board as NEC-83: https://projectron.nerchure.com/tasks/83

Moved to the Vikunja board as **NEC-83**: https://projectron.nerchure.com/tasks/83
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Nectenda/nectenda#107
No description provided.