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": "…" } |
That is the complete list on this edge. (The /v1/openproduct/me/* family
was deliberately added here for a time — the "second endpoint" the reuse note
below anticipates — and left with openproduct's independence: its own gateway
now resolves the same scoped token by calling this endpoint, which is the
reuse note's pattern working exactly as written.) 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.
One display-layer exception: a static client marked firstParty in
OAUTH_PUBLIC_CLIENTS asking for exactly scope=identity is approved
automatically for a signed-in user — a brief "Connecting…" note (which still
states the narrow grant) replaces the Approve/Deny interruption. This is
consent-page behavior only; the server's authorize path is unchanged and
re-checks everything, and any wider scope — or any non-first-party client —
still renders the full consent screen.
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.
The second scope: apps
That design conversation happened once (saas-orun-work, decision WA-D5), and it produced exactly one more scope. It is not a taxonomy, and there is no per-route scope negotiation.
apps reaches the apps plane, where a person describes an app and the
Work Agent builds it. It was made for orun.work when orun.work was a
separate app. orun.work is now the Orunbase console on its own host and signs
in as the console does, so today an apps token is what an account API key
carries, and what another app gets when it asks for scope=apps. The token
reaches:
| Method | Path |
|---|---|
| any | /v1/organizations/{account}/app-chats… (the apps plane) |
| any | /v1/organizations/{account}/apps… (the apps plane, later) |
GET | /v1/accounts (which accounts the person can choose) |
GET | /v1/identity/me |
Everything else answers the same 404 not_found as for identity. The table
lives in one place, api-edge's scoped-token check, and a scope missing from it
reaches nothing.
Why a second scope. When orun.work was a separate origin that built apps, it needed some standing authority. The alternatives were worse: handing off to the console for every write (a chat cannot bounce to another origin per message), or an unscoped token on the most exposed surface.
How the apps plane acts for the person. Building an app means creating a
workspace, linking a repository and starting builds, none of which an apps
token can do. So when a person prompts a turn, the apps plane exchanges their
apps token at an internal identity door for an on-behalf-of token:
- it carries the person's own authority, unscoped, and
via: apps; - it lives at most 15 minutes and never past the
appstoken it came from; - only app-agent-worker can ask for one, over its service binding;
- api-edge refuses it unless it arrives over the platform's own entrypoint
(
PlatformEdge), which only a service binding can reach, so a leaked on-behalf-of token is not a credential on the internet; - api-edge stamps
x-actor-via: appson whatever it forwards, so what the person does through orun.work can be attributed as "via orun.work".