Member identity list and step-up-gated unlink; a link never moves an identity #14

Merged
heimdall merged 5 commits from heimdall/identity-unlink into main 2026-11-13 19:22:35 +00:00
Member

What changed

  • Routes. GET /v1/me/identities (bearer of any app; ids, provider, providerUserId, username/displayName, linkedAt, scopes; never token columns, profile or email; no-store) and POST /v1/me/identities/:identityId/unlink (bearer plus step-up proof; 401 sign_in_required, 403 step_up_required, 404 identity_not_found for a missing or another account's id, 409 last_identity, 200 with the remaining list). Audit event identity_unlinked (provider, identityId only). When the account then holds no identity of that provider, revocation receipts are written for its live grants of that provider, through one revokeLiveGrants helper now shared with the grants revoke route.
  • Claim. Access claim step_up_at (epoch seconds), set only when the callback's signed state has step_up and mode sign_in; a refresh never carries it. STEP_UP_PROOF_TTL_SECONDS = 600 in src/claims.ts. One bearer verifier: resolveBearer returns {accountId, claims}; resolveLinkAccount and resolveAccessAccount are thin wrappers.
  • Store write rule. upsertLinkedIdentity no longer moves an identity: the conflict arm drops account_id = EXCLUDED.account_id and gains WHERE linked_identities.account_id = EXCLUDED.account_id; another account's identity throws IdentityHeldElsewhereError (memory store the same). links/confirm maps it to 409 identity_linked_elsewhere; the callback maps it to link_initiator_mismatch. unlinkLinkedIdentity is the only delete: one transaction locks the account row, refuses the last identity, deletes WHERE id AND account_id.
  • DELETE privilege. STORE_TABLE_PRIVILEGES.linked_identities gains DELETE.

Deploy: grant before start

A build of this branch needs, run as the owner role BEFORE it starts:

GRANT DELETE ON linked_identities TO heimdall;

Without it startup refuses with SCHEMA_PRIVILEGES. The grant is a root-list item at deploy, in the shape of root-list-heimdall-oauth-state-grant-20261113.md. Nothing is deployed or granted by this PR.

Tests

  • npx vitest run (no Postgres): 682 passed, 202 skipped.
  • bash scripts/test-live-pg.sh (throwaway cluster, 127.0.0.1:54329): 868 passed, 16 skipped (the skips are the two pg-mem cases that need real Postgres, plus environment-gated files). Includes concurrent unlink of the last two identities from two pools over 10 rounds, the upsert guard on real Postgres, and the startup refusal for a role without DELETE on linked_identities.
  • Soul follow-ups pinned: the 600 s edge (exactly 600 s passes, 601 refused; the >= mutant fails), a one-identity bearer naming a foreign id gets 404 not 409, and a forged signed link-mode state with step_up yields no step_up_at (dropping the mode === "sign_in" guard fails it).
  • Mutation: restoring the old account_id = EXCLUDED.account_id arm fails the live-PG test.
  • New tests/identity-unlink.test.ts (HTTP, real step-up sign-ins and refresh); additions to store, link-binding, google-link and step-up tests. pnpm build could not run in the sandbox (pnpm tool dir not writable); the same script, tsc -p tsconfig.json, passes.

Known limits

  • Existing bearers keep the unlinked identity's claims until they expire.
  • Discord and Patreon identities cannot give proof (no re-authentication parameter); an account holding only those cannot unlink.
  • A stolen fresh bearer inside the 600 s window can unlink more than one identity.
  • Sign-ins through a provider whose identity is not linked mint a new account.

Ledger: one line appended to state/evidence.jsonl (the repo has no CHANGELOG), as the step-up change did.

🤖 Generated with Claude Code

## What changed - **Routes.** `GET /v1/me/identities` (bearer of any app; ids, provider, providerUserId, username/displayName, linkedAt, scopes; never token columns, profile or email; no-store) and `POST /v1/me/identities/:identityId/unlink` (bearer plus step-up proof; 401 sign_in_required, 403 step_up_required, 404 identity_not_found for a missing or another account's id, 409 last_identity, 200 with the remaining list). Audit event `identity_unlinked` (provider, identityId only). When the account then holds no identity of that provider, revocation receipts are written for its live grants of that provider, through one `revokeLiveGrants` helper now shared with the grants revoke route. - **Claim.** Access claim `step_up_at` (epoch seconds), set only when the callback's signed state has `step_up` and mode `sign_in`; a refresh never carries it. `STEP_UP_PROOF_TTL_SECONDS = 600` in `src/claims.ts`. One bearer verifier: `resolveBearer` returns `{accountId, claims}`; `resolveLinkAccount` and `resolveAccessAccount` are thin wrappers. - **Store write rule.** `upsertLinkedIdentity` no longer moves an identity: the conflict arm drops `account_id = EXCLUDED.account_id` and gains `WHERE linked_identities.account_id = EXCLUDED.account_id`; another account's identity throws `IdentityHeldElsewhereError` (memory store the same). links/confirm maps it to 409 `identity_linked_elsewhere`; the callback maps it to `link_initiator_mismatch`. `unlinkLinkedIdentity` is the only delete: one transaction locks the account row, refuses the last identity, deletes `WHERE id AND account_id`. - **DELETE privilege.** `STORE_TABLE_PRIVILEGES.linked_identities` gains DELETE. ## Deploy: grant before start A build of this branch needs, run as the owner role BEFORE it starts: GRANT DELETE ON linked_identities TO heimdall; Without it startup refuses with `SCHEMA_PRIVILEGES`. The grant is a root-list item at deploy, in the shape of root-list-heimdall-oauth-state-grant-20261113.md. Nothing is deployed or granted by this PR. ## Tests - `npx vitest run` (no Postgres): 682 passed, 202 skipped. - `bash scripts/test-live-pg.sh` (throwaway cluster, 127.0.0.1:54329): 868 passed, 16 skipped (the skips are the two pg-mem cases that need real Postgres, plus environment-gated files). Includes concurrent unlink of the last two identities from two pools over 10 rounds, the upsert guard on real Postgres, and the startup refusal for a role without DELETE on linked_identities. - Soul follow-ups pinned: the 600 s edge (exactly 600 s passes, 601 refused; the `>=` mutant fails), a one-identity bearer naming a foreign id gets 404 not 409, and a forged signed link-mode state with `step_up` yields no `step_up_at` (dropping the `mode === "sign_in"` guard fails it). - Mutation: restoring the old `account_id = EXCLUDED.account_id` arm fails the live-PG test. - New `tests/identity-unlink.test.ts` (HTTP, real step-up sign-ins and refresh); additions to store, link-binding, google-link and step-up tests. `pnpm build` could not run in the sandbox (pnpm tool dir not writable); the same script, `tsc -p tsconfig.json`, passes. ## Known limits - Existing bearers keep the unlinked identity's claims until they expire. - Discord and Patreon identities cannot give proof (no re-authentication parameter); an account holding only those cannot unlink. - A stolen fresh bearer inside the 600 s window can unlink more than one identity. - Sign-ins through a provider whose identity is not linked mint a new account. Ledger: one line appended to `state/evidence.jsonl` (the repo has no CHANGELOG), as the step-up change did. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
STORE_TABLE_PRIVILEGES.linked_identities gains DELETE; startup refuses a role without it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
resolveBearer is the one bearer verifier; the link race maps to link_initiator_mismatch and links/confirm 409.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
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
GameCult/Heimdall!14
No description provided.