OAuth2 shim so the feedback board signs in against accounts.nectenda.com #107
Labels
No labels
area:docs
area:identity
area:ops
area:plugin
area:server
channel:community
channel:direct
channel:owned
channel:press
channel:social
e2ee-constrained
gate:at-ga
gate:pre-ga
marketing
parity
relay:absent
relay:planned
relay:requested
relay:supported
risk:additive
risk:contract
risk:none
usability
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Nectenda/nectenda#107
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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/identityis an OAuth consumer, not a provider.openid-clientisa relying-party library; JWTs are Ed25519 via a ~100-line
packages/ops-common/src/eddsa-jwt.ts; routing is rawnode:http. There is no/authorize, no/token, no JWKS, no client registry, anddocs/identity.md:213states "Everything it exposes is listed here… Anything not listed answers 404."
Critically it has no browser session, by design.
routes.ts:447: "tokensare 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/authorizerequires the browser itself to be authenticated, so building onein-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/identitydiff: 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:
development and may not be suitable for production use." Founding the IdP on
that, for a product selling cryptographic checkability, inverts the priority.
enumerability is the product's second pillar.
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 tosign 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.txtsays it "getsits compose file and Caddyfile and no deployer, because there is no first-party
image there to follow."
And it is mechanically blocked:
deploy/inventory.mjsfiltersrepositron.nerchure.comrefs out ofparsePins, and an existing test(
deploy/test/console.test.ts:236-246) asserts every name inUPGRADEABLE[kind]appears in that host's pins. Adding
feedback-bridgetoUPGRADEABLE.opsfails 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:
/authorizeis a browser redirect and must be a public URLregardless.
The browser leg cannot work the obvious way
packages/identity/static/auth.htmlends with "Obsidian has picked up thesign-in. You can close this tab", and
auth.jshas 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">— nota script; this origin mints session-equivalent codes and
script-src 'none'ischeap 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.tsrevokes anyexisting 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.
/auth/pollreturns 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/logouttakes the refreshtoken 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:
Authorization: Bearer <accessToken>it alreadyholds.
GET {IDENTITY_URL}/api/me(
routes.ts:809, guarded byauthenticate()— signature,aud: nectenda-identity,typ: 'access', andliveSession(sid)). The shimvalidates nothing itself and holds no verification key; a second
implementation of that check is a second place for it to be wrong.
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/mechecksliveSession.Package and storage
packages/feedback-bridge, private andUNLICENSEDlikeops-common, reusingits logger, rate limiter,
clientIp,EnvReader, readiness, shutdown sequencer,background jobs, error reporting, SQLite and JSON body helpers. Not mirrored —
build-mirror.mjsstagespackages/pluginandpackages/sharedby explicitpath, so a new package is proprietary by default.
SQLite, not in-memory. The deployer restarts this process whenever
:stablemoves, 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-UPDATEidiom
flows.ts:claimFlowalready uses; compare secrets withtimingSafeEqualas
requireAdmindoes.Open-redirect discipline
redirect_urimatched as an exact string against an env allowlist — noprefix logic, no normalisation, no wildcards — and on failure the shim renders an
error page and never redirects.
stateis Fider's, echoed back unmodifiedand 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.tsusingEnvReader, withenvNamesRead()and a--env-namesargv branch as the first statement ofmain(). Copypackages/identity/src/compose-env.test.tswholesale —including its third test, the deliberate inversion that feeds the real
OPS_HOST/METRICS_HOSTmistake 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
Dockerfiletarget modelled on theidentitystage, then three placesin
ci.yml— the two-platform build, the publish step, and the promote job,which starts the image and waits for
/api/healthto 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
returnToon the identity flow: a nullable column inauth_flows,acceptance in
POST /auth/flowagainst 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 topackages/identityand an open-redirectsurface on the host holding key material. Recorded so it is not rediscovered.
Verification when this is picked up
redirect_urithat matches andone that does not; a code claimed once and the same code twice; an expired
ticket and a live one.
curl. Then the twocases that matter most: the plugin's device list contains no leftover "Feedback
board" session, and the user's vault session is still live.
the shim's logs for the token prefix — not by inspecting the code.
Moved to the Vikunja board as NEC-83: https://projectron.nerchure.com/tasks/83