Skip to content

fix: request the identity:handle OAuth scope - #600

Merged
tompscanlan merged 2 commits into
mainfrom
fix/atproto-identity-handle-scope
Aug 19, 2026
Merged

tompscanlan merged 2 commits into
mainfrom
fix/atproto-identity-handle-scope

Conversation

@tompscanlan

@tompscanlan tompscanlan commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

The problem

Changing an AT Protocol handle fails in production. The PDS returns:

Missing required scope "identity:handle"

DEFAULT_ATPROTO_OAUTH_SCOPES in src/utils/bluesky.ts never included that
scope, so com.atproto.identity.updateHandle is refused.

Who it affects

Only users whose session comes from OAuth.

A custodial session is built from stored credentials and has full account
access, so the handle change works for them. After a user takes ownership of
their own PDS account, the same call returns a 403. Taking ownership of your
account should not cost you the ability to rename it.

This matters now because the take-ownership on-ramp is about to move a large
group of custodial users onto OAuth sessions.

The change

Two commits.

The first adds identity:handle to the default scope list, plus a unit test.
Nothing sets ATPROTO_OAUTH_SCOPES in the infrastructure repo or in any env
file, so the code default is what every environment uses. There is no config
change to make alongside this.

The second stops us reporting that scope as missing for users who cannot use
it. See below.

Existing sessions

A token does not gain a scope that is added later, so a session authorized
before this ships is measured against the new list and reported as out of
date.

That is correct for users on our PDS. They reconnect, and the handle change
starts working.

It is wrong for users on an external PDS. updateHandle rejects any identity
that is not on our PDS, so identity:handle can never be used for those
accounts. Those users would get the "New permissions are available, reconnect"
banner next to the banner that tells them account changes must be made through
their own PDS provider. Reconnecting would grant us a handle rename permission
we never use.

So the second commit measures each identity against the scopes it actually
needs, instead of one global list. getRequiredScopesForIdentity builds that
list, and both places that report a mismatch compare against it:

  • /auth/me computes the mismatch live from the session token.
  • The identity endpoint reads the mismatch flag stored at login.

Users on our PDS still get the banner, which is the case this scope was added
for. Users on an external PDS do not, unless they are missing a scope that
does apply to them.

Still open

We request identity:handle from everyone at authorization time, so an
external user is still asked for it on the consent screen. Narrowing the
authorization request means resolving the PDS before client.authorize, which
is a larger change. That is a separate PR, and it can call
getRequiredScopesForIdentity as well.

Testing

  • 147 unit tests pass across the four affected spec files.
  • I checked the new tests discriminate. With the identity:handle condition
    removed from getRequiredScopesForIdentity, eight tests fail, and all of
    them are the external PDS cases.
  • eslint clean on all changed files.
  • tsc --noEmit reports the same 461 errors before and after this branch. The
    diff is empty. This repo does not type check clean on main today.

Note for review

This was reported once before and closed against a change that fixed
something else, so the fix it asked for was never made. The unit test is
there to stop that happening a third time.

Changing an AT Protocol handle failed in production with "Missing required
scope identity:handle". The scope was never added to the default OAuth scope
list, so the PDS refused com.atproto.identity.updateHandle.

This only bites users whose session comes from OAuth. A custodial session is
built from stored credentials and has full account access, so the handle
change worked for them. Once a user takes ownership of their own PDS account,
the same call returns a 403. Taking ownership of your account should not cost
you the ability to rename it.

Existing sessions do not gain a newly added scope. The API already compares
granted scopes against the configured list and reports the difference on
/auth/me, and the profile page already shows a Reconnect banner, so affected
users are prompted to re-authorize.

The unit test on the default scope list is a guard. This was reported once
before and closed against a change that fixed something else.
Adding identity:handle to the default scope list means every existing OAuth
session is now missing a scope. The stale scope check measured every
non-custodial identity against the same global list, so users hosted on an
external PDS get the "New permissions are available, reconnect" banner too.

That prompt is wrong for them. updateHandle rejects any identity that is not
on our PDS, so identity:handle can never be used for those accounts. The
banner also contradicts the one right next to it, which tells the same user
that account changes must be made through their own PDS provider. Following
the prompt grants us a handle rename permission we never use.

The fix is the yardstick, not the result. getRequiredScopesForIdentity builds
the scope list a given identity is expected to hold, and both places that
report a mismatch now compare against that instead of the global list. Users
on our PDS are unaffected, so people who take ownership of their account still
get prompted to reconnect, which is the case the scope was added for. A scope
that turns on some other condition adds a field and a case in one place.

The scope itself is unchanged. It stays in the default list and in the OAuth
client metadata. Narrowing what we request at authorization time is a
separate change, and it can call the same function.
@tompscanlan
tompscanlan merged commit 8c131a2 into main Aug 19, 2026
4 checks passed
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.

1 participant