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:
| Method | Path | Returns |
|---|---|---|
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.
Consent, visibility, revocation
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:
- Register a static client. Add an entry to
OAUTH_PUBLIC_CLIENTSin@saas/contracts(an ordinary code-reviewed PR): a stableclient_id, a display name, and your exacthttpsredirect URI. First-party surfaces use the static list, not dynamic registration — DCR rows expire and render as "Unverified app" on consent. - Request
scope=identityon 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. - 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.