kanji-buddy

Auth & access

Invite-only, with two ways in and one rule underneath: the user id comes from the credential, never from the request.

There is no sign-up

Accounts are minted by hand (npm run create:user) or by the landing page on behalf of someone it has already authenticated. No registration form, no password reset, no email verification — none of which is laziness so much as the natural consequence of an app for two people.

Two credentials, one lookup

lib/auth.ts
const token = extractBearer(request) ?? extractCookie(request, SESSION_COOKIE);
CredentialWho uses itExpires
Authorization: Bearer …Scripts, curl, a future mobile appNever
kb_session cookieBrowsers arriving from the portal30 days

Both resolve to the same row in api_tokens. The header wins when both are present, so an explicit token is never overridden by a stale cookie left in the browser.

Only hashes are stored. The column is the sha-256 of the token; the plaintext is shown once at mint time and never again. A database dump therefore leaks no working credentials.

Portal single sign-on

1. sign in at mblrc.me — the portal checks you're allowed in 2. portal ──POST /api/v1/internal/sessions──▶ kanji-buddy X-Service-Secret: … { external_id, username, email } 3. kanji-buddy finds or creates the user, mints a token, returns it 4. portal sets it as a cookie on .mblrc.me 5. every subdomain now presents it

Why mint a token instead of verifying a JWT

A signed token stays valid until it expires — there is no way to take it back. Access here is conditional on the portal still allowing it, so revocation had to be immediate. A session is a row; a flag on that row stops it working on the very next request. That single requirement is what ruled JWTs out.

The link between the two systems is users.external_id, holding the portal’s immutable user id — not an email. An email would break the link the day someone changed theirs (they would return as a new person with an empty account) and would hand the old account to whoever inherited the address if one were reassigned. The email is stored too, but only so the nudge job has somewhere to send; it identifies nobody.

The email field is optional in the request, deliberately: the portal may deploy later than this, and an omitted field must leave a stored address untouched rather than wiping it. Logging in also never re-enables notifications for someone who unsubscribed.

The shared secret

internal/ endpoints are guarded by a secret in a header, compared with timingSafeEqual after a length check — a plain === leaks the prefix through timing. If the secret is unset the endpoint returns 503, not 401: a misconfiguration must not be indistinguishable from a bad credential, and must never fall open.

The cron endpoint's awkward corner

Vercel Cron cannot set custom headers — it sends Authorization: Bearer $CRON_SECRET, the same header user tokens arrive on. So app/api/v1/internal/nudges/route.ts compares against CRON_SECRET itself and never calls authenticate(), which would hash that secret and look it up in api_tokens. Containment runs both ways: a cron secret on a normal route hashes to nothing and 401s; a real API token here fails the comparison and 401s. Both directions are covered by tests.

Tenant isolation

The user id is derived from the credential and never read from the request, so it cannot be spoofed. Every user-owned query then filters on it.

That was true before it was tested, and the distinction mattered. An audit found the rule was enforced by convention — one removable line per route, repeated by hand, with nothing failing if it went missing. Worse, the suite would not have caught its removal: only one test file seeded a second user, so everywhere else “all rows” and “my rows” were identical.

tests/tenant-isolation.test.ts now seeds two users and asserts one cannot read or mutate the other’s data across every resource and every write path. It was verified by mutation: deleting the filter from the assignments route makes those tests fail, where previously the whole suite still passed.

Unsubscribe links

An HMAC over unsubscribe:v1:<userId>, signed with a secret kept separate from the portal’s so that leaking one doesn’t compromise the other. Deliberately no expiry — Gmail may fire List-Unsubscribe months after delivery, and a dead opt-out link is worse than a long-lived one. Replay is harmless: the operation is idempotent and the only capability the token grants is “stop emailing this one person”.

Related: Email nudges, where those links are sent.