Skip to main content

The OAuth identity scope

Orunbase's OAuth authorization server normally mints full-authority tokens: approving an MCP client (Claude, Cursor, VS Code) lets it read and act as you across all your workspaces, exactly like a CLI login. That is the right shape for a coding agent. It is the wrong shape for a surface that only needs to know who you are.

The identity scope is the narrow alternative. A client that requests it gets a token that answers one question — who is this? — and is refused everywhere else on the platform.

What a scoped token can do

One endpoint:

MethodPathReturns
GET/v1/identity/me{ "subjectId": "usr_…", "displayName": "…" }

Plus one deliberately-added family: /v1/openproduct/me/*, the contributor's own surface on openproduct.live (authored profile, applications, saved products) — every route in it keyed by the same subject id and nothing else. That family is the "second endpoint" the reuse note below anticipates, added through exactly the design conversation it prescribes (the openproduct-home epic's design). That is the complete list. The response shape is a pinned contract (IDENTITY_ME_KEYS): the subject id is the stable join key a surface can correlate on, and the display name is what a page renders. No email, no workspace list, no roles — an identity-scoped token cannot enumerate what you have access to, let alone touch it.

Unscoped tokens can call /v1/identity/me too; it is a useful read in its own right.

The enforcement guarantee

The refusal is a default-deny at the API edge, in the single code path every authenticated route already goes through. A scoped token presented anywhere but GET /v1/identity/me receives the platform's standard 404 not_found — the same resource-hiding shape as a route that does not exist, so a scoped token cannot probe which endpoints are real.

Because the deny is the default, a route added to the platform next year is refused to scoped tokens without anyone remembering to refuse it. There is no per-route opt-out to forget.

Three more properties hold, each pinned by test:

  • Absence means full authority. A token minted without a scope behaves exactly as before the scope existed. Existing MCP clients and CLI logins are untouched.
  • Refresh keeps the scope. The scope is recorded on the grant (the consent you approved), and every refresh re-reads it from there — never from the presented token. A scoped session cannot rotate its way to full authority.
  • Narrow, never widen. A refresh may request a scope at or below what the grant carries (RFC 6749 §6). Requesting more is refused with a named reason — never silently downgraded, and never silently granted. Narrowing an unscoped grant persists one-way on the grant.

The consent screen for an identity-scoped request states the grant in plain words — the client will see your name and account id — and the exclusions: it will not see your workspaces, projects, code, secrets, or spend, and it cannot act as you anywhere else.

Scoped connections appear in Account → Sessions & devices labeled identity only, and revoke exactly like any other connection. Scoped grant issuance and scope changes are recorded as distinct security events.

For a new surface: obtaining a scoped grant

The scope is named identity, not after any one product, so the next satellite surface does not re-derive this design. Three steps:

  1. Register a static client. Add an entry to OAUTH_PUBLIC_CLIENTS in @saas/contracts (an ordinary code-reviewed PR): a stable client_id, a display name, and your exact https redirect URI. First-party surfaces use the static list, not dynamic registration — DCR rows expire and render as "Unverified app" on consent.
  2. Request scope=identity on the standard authorization-code + PKCE (S256) flow. The authorization server advertises the scope in its RFC 8414 metadata; an unknown scope value is refused, never ignored.
  3. Expect refusal everywhere but /v1/identity/me. Build your surface against that one read. If you find yourself needing a second endpoint, that is a platform design conversation — not a bigger scope request.