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": "…" }

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.

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:

  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.

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:

MethodPath
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 apps token 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: apps on whatever it forwards, so what the person does through orun.work can be attributed as "via orun.work".