Skip to content

fix(auth): preserve first-party deletion on refresh - #331

Open
realmeylisdev wants to merge 4 commits into
mainfrom
fix/323-nip98-account-deletion
Open

fix(auth): preserve first-party deletion on refresh#331
realmeylisdev wants to merge 4 commits into
mainfrom
fix/323-nip98-account-deletion

Conversation

@realmeylisdev

@realmeylisdev realmeylisdev commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Preserve first-party account deletion authorization across OAuth refresh-token rotation.
  • Add an oauth_authorizations.is_first_party flag so the refresh grant can mint replacement UCANs with first_party: true only for authorizations that originally came from a first-party flow.
  • Gate /api/headless/authorize so a third-party OAuth bearer token cannot mint a first-party authorization.
  • Preserve first-party status through silent OAuth re-authentication when replacing an existing first-party authorization.
  • Keep DELETE /user/account on the existing UCAN authorization path; it no longer gains a NIP-98 bypass.
  • Stop truncating account-deletion pubkeys in structured logs.
  • Add integration coverage for refresh rotation, headless authorize gating, and silent re-auth first-party preservation.

Motivation

Authorization for account deletion depends on a UCAN fact (first_party) that was previously present only on the original headless-flow access token. Clients refresh immediately before deletion, so a returning first-party user could lose the fact and be denied after the client had already published an irreversible NIP-62 vanish.

The safer fix is to persist first-party status on the OAuth authorization and preserve it during refresh-token rotation while keeping third-party OAuth apps outside the account-deletion boundary.

Related Issue

Review notes

  • This remains an authorization change in a security-critical path and should get a human security/platform review.
  • Existing authorizations created before this migration have is_first_party = false; users with old sessions may need to re-authenticate once to get a first-party authorization carrying the new persisted flag. There is no safe server-side backfill, because client_id and redirect_uri are caller-supplied at /api/headless/login.
  • first_party now persists for the life of eligible first-party authorizations rather than for a single access token, so deletion authority is renewable via refresh where it previously expired with the access token.
  • Deploy ordering is safe with the current mobile fallback to bearer-token deletion, and cloudbuild.yaml runs migrations before the deploy step.

Testing

  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets --all-features -- -D warnings -A deprecated
  • cargo check -p keycast_api --tests --features integration-tests
  • cargo test -p keycast_api server_signed_ucan_includes_first_party_only_when_requested
  • Local DB-backed targeted tests were attempted but could not run because Docker/Postgres is unavailable here (PoolTimedOut):
    • cargo test -p keycast_api --features integration-tests --test headless_auth_test headless_authorize
    • cargo test -p keycast_api --features integration-tests --test oauth_refresh_first_party_test
    • cargo test -p keycast_core --features integration-tests --lib repositories::oauth_authorization
  • GitHub CI DB-backed checks passed on commit 40ae627.

Visuals

  • UI/web change with screenshots/video attached
  • No visual change
  • Visuals and text avoid sensitive external brand or partner names unless explicitly approved

@NotThatKindOfDrLiz

Copy link
Copy Markdown
Member

@/private/tmp/pr331-comment.md

@NotThatKindOfDrLiz
NotThatKindOfDrLiz force-pushed the fix/323-nip98-account-deletion branch from 54584c0 to 50d9e74 Compare August 3, 2026 22:19
@NotThatKindOfDrLiz NotThatKindOfDrLiz changed the title fix(auth): accept NIP-98 proof-of-key for account deletion fix(auth): preserve first-party deletion on refresh Aug 3, 2026
@NotThatKindOfDrLiz
NotThatKindOfDrLiz requested a review from a team August 3, 2026 22:25
@NotThatKindOfDrLiz

Copy link
Copy Markdown
Member

@realmeylisdev I took over the branch and replaced the NIP-98 deletion path with the safer fix we discussed:

  • DELETE /user/account stays on the existing UCAN authorization path.
  • OAuth code exchange now stores whether the authorization came from the first-party headless flow.
  • Refresh-token rotation preserves first_party: true only for those persisted first-party authorizations.
  • Third-party apps, including apps with signing access, do not gain deletion authority.
  • Account-deletion structured logs now emit the full pubkey instead of a truncated prefix.

Verification:

  • CI is green on 50d9e74.
  • Local: cargo fmt --all -- --check
  • Local: cargo clippy --workspace --all-targets --all-features -- -D warnings -A deprecated
  • Local: cargo check --workspace --all-targets --all-features
  • Local: cargo test -p keycast_api server_signed_ucan_includes_first_party_only_when_requested

Local DB-backed tests could not run here because Docker/Postgres was unavailable (PoolTimedOut), but the GitHub test job passed with the repo test stack.

Please review the takeover changes and merge when you are comfortable with them.

@NotThatKindOfDrLiz NotThatKindOfDrLiz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved after takeover. The PR now preserves first-party deletion authority through refresh-token rotation without adding a NIP-98 deletion bypass, and CI is green on the current head.

The refresh grant is the path issue #323 actually fixes, but nothing
exercised it. The two tests added with the fix cover the UCAN builder and
the repository column independently, so both still pass if the grant stops
reading `oauth_auth.is_first_party`.

Drive the real `/oauth/token` route with `grant_type=refresh_token` against
a seeded authorization and assert the `first_party` fact on the rotated
access token: present for a first-party authorization, absent for a
third-party one. Verified the positive case fails when the grant is reverted
to a hardcoded `false`.
@realmeylisdev

Copy link
Copy Markdown
Contributor Author

@NotThatKindOfDrLiz Reviewed the takeover — the persisted-flag approach is the right shape and I'm happy with it. I traced every site it touches: both explicit-column selects into OAuthAuthorization were updated (the signer's two use SELECT *), all three create call sites set the flag and the compiler enforces it, and the other generate_server_signed_ucan call sites still pass false. The migration sorts newest, is additive, and cloudbuild.yaml runs run-migrations before the deploy step.

I pushed one test-only commit on top (b78b5c8), so CI will re-run against a new head.

Why: the refresh grant is the path this PR actually fixes, but nothing exercised it. server_signed_ucan_includes_first_party_only_when_requested covers the UCAN builder and test_create_and_find_preserves_first_party_flag covers the column — both still pass if handle_refresh_token_grant_inner stops reading oauth_auth.is_first_party.

api/tests/oauth_refresh_first_party_test.rs drives the real /oauth/token route with grant_type=refresh_token against a seeded authorization and asserts the first_party fact on the rotated access token: present for a first-party authorization, absent for a third-party one. I confirmed the positive case fails when the grant is reverted to a hardcoded false, then restored the fix and re-ran.

Docker was available here, so I also ran the DB-backed tests that were blocked on your side:

  • cargo test -p keycast_core --features integration-tests --lib repositories::oauth_authorization — 5 passed, including test_create_and_find_preserves_first_party_flag
  • cargo test -p keycast_api --features integration-tests --test oauth_refresh_first_party_test — 2 passed
  • cargo fmt --all -- --check and cargo clippy --workspace --all-targets --all-features -- -D warnings -A deprecated clean

Two things I'd like your read on before I merge:

  1. Pre-migration authorizations all get is_first_party = false, and I don't see a safe server-side backfill — client_id and redirect_uri are caller-supplied at /api/headless/login with no allowlist, so there's no reliable signal for which existing rows were first-party. Existing mobile sessions lose deletion authority until the user logs out and back in. Does the bearer-token fallback cover that gap in practice, or do we want a re-auth prompt?

  2. first_party now means "presented email+password to /api/headless/*" for the life of the authorization rather than for one access token. headless_login does no registered-client validation and authorize_get documents open OAuth, so a stolen refresh token can renew deletion authority for as long as the authorization lives, where previously it could never obtain it. That widening is inherent to the fix rather than a flaw in it, but I'd rather it be an explicit accept than a side effect.

@NotThatKindOfDrLiz

Copy link
Copy Markdown
Member

@realmeylisdev I took over the remaining auth-boundary issues and pushed two commits on top of the branch:

  • 9bd2874 gates /api/headless/authorize so a third-party server-signed OAuth bearer cannot mint a first-party authorization, and preserves first-party status through handle/origin silent re-auth replacement paths.
  • 40ae627 fixes the new re-auth integration test setup so the authorization-code exchange has a seeded secret pool.

Verification:

  • Local: cargo fmt --all -- --check
  • Local: cargo clippy --workspace --all-targets --all-features -- -D warnings -A deprecated
  • Local: cargo check -p keycast_api --tests --features integration-tests
  • Local: cargo test -p keycast_api server_signed_ucan_includes_first_party_only_when_requested
  • Local DB-backed targeted tests were attempted here, but Docker/Postgres is unavailable and they timed out at pool setup.
  • GitHub CI is green on 40ae627 (test, Web Tests, semantic PR, CLA).

Please review the takeover changes and merge when you are ready.

@NotThatKindOfDrLiz NotThatKindOfDrLiz left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final pass is clean after the takeover commits.

The first-party authorization boundary now matches the PR description: third-party OAuth bearers cannot use /api/headless/authorize to mint durable deletion authority, refresh rotation preserves only persisted first-party authorizations, and silent re-auth replacement keeps the first-party flag from the authorization it replaces. GitHub CI is green on the current head.

@NotThatKindOfDrLiz

Copy link
Copy Markdown
Member

@realmeylisdev one more merge-gate reminder: because this is a security-sensitive Keycast auth change, the PR should ultimately be approved for merge by @dcadenas before it lands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(auth): account deletion 403s for every user whose token has been refreshed (first_party fact not preserved)

2 participants