ADR-0015: MCP sign-in: whity-core is the resource server, the authorization server is configuration
- Status: Accepted (2026-10-28)
- Deciders: project owner
- Tasks: WHIT-698 (decision), WHIT-709 (implementation)
- Related: ADR-0006 (MCP server architecture), WHIT-667 (scope freeze), WHIT-697 (connect wizard)
Context
Section titled “Context”The apps that non-technical users connect most easily are Claude (web, desktop and mobile custom connectors) and ChatGPT. Both expect a remote MCP server to be either open or protected by OAuth: the user pastes the server URL and then signs in. Neither takes a pasted bearer token in its interface. The static MCP tokens behind the “Connect an AI app” wizard (WHIT-697) therefore cover developer tools (Claude Code, Cursor, VS Code, Codex, Gemini CLI, n8n) but not these two.
The MCP authorization spec makes the server an OAuth 2.1 protected resource:
- On an unauthenticated call it answers 401 with
WWW-Authenticatenaming its Protected Resource Metadata (RFC 9728). - The metadata names the authorization server the client signs in with.
- The server accepts that authorization server’s access tokens, issued for this resource.
Someone has to be that authorization server: issuing tokens, showing the sign-in and consent screens, registering clients through Client ID Metadata Documents or dynamic client registration. WHIT-667 froze identity machinery out of core (“identity and money belong outside core”).
Decision
Section titled “Decision”- The authorization server is Whity-Auth, a separate, paid, hosted service rebuilt from Nuqta. whity-core stays free and open source, and durable external services that cost money to run, Whity-Auth here and Almolakhas Pay for payments, are the paid layer.
- whity-core is only the resource server.
- It serves Protected Resource Metadata at
/.well-known/oauth-protected-resourceand at the path-suffixed/.well-known/oauth-protected-resource/mcp, which clients try first. - It answers an unauthenticated
/mcpcall with401andWWW-Authenticate: Bearer resource_metadata="…", scope="…". - It accepts the authorization server’s access tokens (part B; see Amendment 1).
- It serves Protected Resource Metadata at
- The authorization server is configuration (
MCP_OAUTH_ISSUER), not a dependency. Unset means OAuth is off and nothing changes. A self-hoster can point it at any OAuth 2.1 server. Core never needs the paid service to work, and stays inside the WHIT-667 freeze. - Static MCP tokens stay. They’re the right tool for apps that accept a pasted token, and they keep working alongside OAuth.
- The metadata’s
resourceisAPP_URL+/mcp, the same address the wizard hands out. WithAPP_URLunset the resource is unknown and OAuth stays off rather than guessing. When the bundled admin serves the public origin, the web tier forwards the metadata documents just as it forwards/mcp.
Consequences
Section titled “Consequences”- An unauthenticated
/mcpcall is now HTTP 401 for every client, OAuth or not, as each MCP authorization revision requires. It used to be 200 with a JSON-RPC error. A client relying on the old status gets a 401 with the same JSON-RPC body. - With part B (Amendment 1), Claude and ChatGPT can complete sign-in. A person needs a Whity account linked to the authorization server through single sign-on first.
Open (part B, as it stood before Amendment 1)
Section titled “Open (part B, as it stood before Amendment 1)”-
Identity mapping. An access token names a subject at the authorization server, and core has to map it to a Whity profile and tenant. Candidates:
- the existing linked-identity table (issuer + subject), as SSO uses;
- a profile and tenant claim that Whity-Auth mints;
- a verified-email match.
The tenant needs a source too: a claim, the profile’s only tenant, or a per-tenant MCP URL.
-
Signature verification. The JWKS fetch-and-cache with RS256/ES256 can be done by an approved JWT library (the dependency policy requires architect approval, a licence review and a security audit) or in-house on
openssl_verify. -
Scope. The authorization server’s consent screen should offer the wizard’s plain-language presets, mapped onto the
McpScopevocabulary.
Amendment 1 (2026-10-28): accepting access tokens (part B)
Section titled “Amendment 1 (2026-10-28): accepting access tokens (part B)”The owner decided the open questions:
- Identity: the token’s
(iss, sub)through the existing linked-identity table, in the global-trust namespace only. The issuer is operator configuration, which is what global trust means (migration 047); a tenant’s own identity provider could claim any issuer, so its links never answer. Core does no linking or provisioning on this path. A person links once by signing in to Whity with the same provider, and a token for an unlinked subject is refused with a message saying so. - Tenant: a claim if present, else the profile’s only tenant, else refused. The claim is
tenant_idby default (MCP_OAUTH_TENANT_CLAIM). An active membership is required either way, so suspension applies at once. The system tenant (id 0) is refused outright. It needs no membership row, so an outside server naming it would otherwise grant cross-tenant authority to any linked account. - Signatures: firebase/php-jwt, already a core dependency for SSO ID tokens, so there is no new library. The JWKS comes from
MCP_OAUTH_JWKS_URIor the issuer’s RFC 8414 or OIDC metadata, cached through the SSO path’sJwksProvider. Symmetric keys are discarded, and each key’s own algorithm binds the token’s, so a token cannot choosenoneor HMAC. - Audience:
audmust include the resource (APP_URL/mcp), so a token issued for another service is not accepted here. - Errors: an invalid token gets 401 and the client signs in again. A genuine token that can’t be used gets 403 and a plain message, because signing in again would only produce the same token.
Scope presets on the authorization server’s consent screen (Open item 3) belong to Whity-Auth, not core.
Alternatives considered
Section titled “Alternatives considered”- whity-core as its own authorization server. This needs login, consent, token and client-registration endpoints in core. It’s identity machinery the scope freeze excludes, and every self-hoster would have to run and secure it.
- Nuqta as the authorization server. Nuqta serves Almolakhas and its own clients under a separate brand, so it can’t be the provider every whity-core deployment points at. Whity-Auth reuses its code instead.
- A third-party hosted provider (e.g. WorkOS AuthKit) as the default. Fastest to ship, but it makes a vendor the default dependency of a free, open-source core. It stays possible as configuration.