Skip to content

OAuth 2.1 authorization server for token-free client auth (MCP & browser extension) #3826

Description

@bdshadow

1. Problem

Every non-webapp client that talks to a Tolgee server authenticates with a static, long-lived secret, a Project API Key (PAK) or Personal Access Token (PAT) sent as the X-API-Key header (AuthenticationFilter.kt). The user generates that key in the UI, copies it, and pastes it into the client.

The reason this matters most is community translation. With public projects, a contributor is a logged-in non-member who gets an implicit "community floor" of permissions (view, suggest, comment) on a public project, and there is no anonymous access. To translate in-context, that external person's browser extension needs a credential, and we cannot hand a stranger a project API key (a PAK is bounded to a member's own project access). OAuth is what lets an outside contributor authorize the extension with exactly their community-level access. This is the critical path.

The same paste-a-static-key friction hits our other headless clients:

  • Browser extension and SDK (in-context editing, @tolgee/web and tolgee/browser-extension): the user pastes a key that the extension stores and the SDK replays as X-API-Key.
  • MCP server (/mcp/developer, in the official MCP registry): users paste a PAK/PAT into MCP client config, landing in plaintext JSON on disk, out of step with the MCP OAuth 2.1 spec. Secondary priority, a power-user convenience rather than the driver.
  • Tolgee CLI (tolgee/tolgee-cli): same paste-a-token model; users expect the seamless "open browser, approve, logged in" flow.

2. Appetite

2w


3. Solution

Make the Tolgee platform an OAuth 2.1 authorization server using spring-security-oauth2-authorization-server, exposing Authorization Code + PKCE so a client redirects the user to Tolgee, the user consents to a listed set of access, and the client receives a short-lived access token (plus refresh token) instead of a pasted key. All our clients are public clients (no secret); PKCE replaces the secret.

Core mechanics:

  • A dedicated SecurityFilterChain for the authz-server endpoints (/oauth2/authorize, /oauth2/token, .well-known/*), coexisting with the existing stateless chain.
  • Reuse the JWT stack (JwtService) as the access-token format so AuthenticationFilter's existing Bearer path validates OAuth tokens. Tokens are audience-bound (RFC 8707): a token minted for MCP carries aud = <MCP canonical URI> and is rejected by the REST API, and vice versa.
  • The scope model is the existing Scope enum wire values (translations.suggest, translation-suggestions.manage, translation-comments.add, and so on). Effective access is expand(token.scopes) intersected with expand(user's live project scopes), evaluated per request. This is already exactly what SecurityService.getCurrentPermittedScopes does for PAKs. The token only narrows; losing a project or leaving the org kills access immediately.
  • Client registration. Our own clients (extension, CLI): pre-registered public clients with a fixed client_id and redirect URIs (seeded rows, giving the extension a real chrome-extension:// anchor and branded consent). Unknown third-party MCP clients: Client ID Metadata Documents (CIMD), where the client's client_id is an HTTPS URL we fetch and validate. CIMD is spec-preferred and supported by the MCP clients we target (Claude Code, Claude Desktop, VS Code Copilot). Implemented as a custom RegisteredClientRepository, since it is not in SAS. DCR is not implemented in v1 (see No-Goes).

Extension and SDK (the critical path, and a cross-repo change). This is three repos (platform, tolgee/browser-extension, and @tolgee/web pinned on customer sites), not a one-liner. It needs:

  • a PAK-vs-OAuth discriminator so the SDK picks the right header;
  • a Bearer branch in @tolgee/web's client.ts (today it is unconditionally X-API-Key);
  • token lifecycle the SDK has never had: 401, ask the extension, refresh, retry;
  • capability negotiation via TOLGEE_READY (absence means an old SDK), so we carry both the X-API-Key and Bearer paths indefinitely;
  • the refresh token stays in extension storage; the page/SDK only ever receives short-lived access tokens (page sessionStorage is XSS-reachable).

Scope and consent decisions (load-bearing, named now rather than left to the spike):

  • Capabilities live in scope; the project set lives in a custom claim, since the client doesn't know project IDs before it is authorized.
  • "All projects" is a sentinel (including future projects), never an expanded list, otherwise tokens blow past header limits for big orgs and users re-authorize on every new project. GitHub's wording is "all repositories, including future ones".
  • A client that knows the project (Figma, the extension) may hint one; consent pre-selects and marks it required, other accessible projects optional. The server validates the user actually holds those scopes on the hint, so it can't leak IDs or nudge access.
  • Re-consent / step-up is first-class, not just first login; it maps onto the MCP spec's insufficient_scope step-up flow.
  • Effective access is evaluated per request (token intersected with live permissions, per the core mechanics above).
  • Org-level operations don't fit a per-project selector: v1 covers project scopes plus "all projects"; org-level ops get their own consent scope or are a v1 no-go.

Other clients:

  • MCP: Protected Resource Metadata (RFC 9728) on /mcp/developer, RFC 8707 audience, RFC 9207 issuer, so spec-compliant clients discover and run the flow, with CIMD for the ones we don't pre-register.
  • CLI: seamless login via loopback redirect (http://127.0.0.1:<port>) plus PKCE. Keeps --api-key for CI and back-compat.

Cross-cutting:

  • Connected apps: persist authorizations and revoke a single app, largely free from SAS's OAuth2AuthorizationService. The durable grant/refresh/consent store lives in Postgres (built-in JDBC); Redis (optional) is used only for ephemeral TTL data (authorization codes, a revocation denylist), never the system of record.
  • Signing keys: today's JWT uses a symmetric HMAC secret auto-generated at boot. OAuth needs an asymmetric keypair plus a JWKS endpoint (external verification), persisted so restarts/replicas don't invalidate tokens, with a rotation-with-overlap story.
  • Token lifetimes: short access TTL; refresh rotation with reuse detection plus a grace window tolerating near-simultaneous refresh; a defined grant-invalidation trigger.
  • PAK/PAT stay for CI, scripts, and machine-to-machine (see No-Goes).

4. Basic Drawings


5. Rabbit Holes

  • Scope-to-permission mapping: expose the Scope enum as OAuth scopes and reuse the existing getCurrentPermittedScopes intersection, including the public-project community floor (derived server-side per request, independent of token scopes). The consent UI reuses the permissions-dialog content plus a project selector ("all projects").
  • CIMD implementation (in scope): not in Spring Authorization Server, so we build a custom RegisteredClientRepository that resolves the URL-form client_id, fetches the metadata doc, validates client_id against redirect_uris, advertises client_id_metadata_document_supported in AS metadata, invalidates consents when a client's redirect_uris change, and hardens the outbound fetch against SSRF (allow-list, no internal IPs, size and timeout limits) with caching. This is the security-critical surface of the cycle.
  • Extension version negotiation: customer sites pin @tolgee/web; an old SDK reading an OAuth token as X-API-Key gets a 401. A capability flag on TOLGEE_READY, both paths carried for a long time.
  • Refresh-token rotation races: clients refresh proactively before expiry, so rotation must tolerate near-simultaneous attempts without tripping reuse-detection.
  • Audience binding (RFC 8707): JwtService emits one fixed audience today; OAuth needs a per-resource aud and rejection of wrong-audience tokens.
  • Self-hosted keys and discovery: persisted asymmetric keys, JWKS, issuer and canonical URIs, zero-config on a fresh instance, correct behind reverse proxies (external URL is not the internal URL).
  • Two filter chains on Security 7: the authz-server chain coexisting with the stateless chain on the freshly-migrated chore: migrate to Spring Boot 4 #3804 baseline.

6. No-Goes

  • Not full/unified session management. Connected-apps plus per-OAuth-grant revocation is in; revoking the webapp's own stateless JWT sessions, "log out everywhere", or a unified view across JWT/PAK/PAT/OAuth is a separate subsystem and a follow-up.
  • DCR (RFC 7591) deferred. CIMD covers the MCP clients we target; DCR is a fast-follow only if we hit a DCR-only client. Avoids building two registration paths.
  • Figma plugin deferred. Its iframe can't do a redirect (null origin); the right answer is the device authorization grant (RFC 8628), and a large Figma refactor is landing soon. Postponed to a later version to avoid rework; rationale captured so it isn't re-litigated.
  • Device grant deferred, unless a headless/SSH/container CLI case forces it earlier.
  • Not removing PAK/PAT. They stay for CI, scripts, and machine-to-machine. Additive.
  • Not the client_credentials grant. PAK already covers headless machine-to-machine.
  • Not a general-purpose IdP. No "Login with Tolgee" for third parties, and no change to Tolgee's SSO-client role.
  • No Redis-backed system of record for grants. Postgres stays authoritative.
  • Org-level operations out of v1 unless the scope model lands cleanly.

7. Success Criteria

  • An external community contributor authorizes the browser extension on a public project via redirect and consent, and suggests a translation, with no key pasted. This is the headline.
  • The extension logs in via redirect and consent for members too; no manual key entry.
  • The CLI logs in via the browser (open, approve, done); --api-key still works for CI.
  • A real MCP client (Claude Desktop, Cursor) connects to /mcp/developer via redirect and consent, nothing in plaintext JSON.
  • An unknown third-party MCP client authorizes via CIMD (URL-form client_id, no pasted key, no pre-registration on our side), and a client presenting a client_id whose metadata doesn't list its redirect_uri is rejected.
  • Issued tokens carry exactly the consented scope, and effective access is the token intersected with live permissions evaluated per request (losing a project or leaving the org revokes immediately).
  • Audience binding holds: an MCP token is rejected by the REST API and vice versa.
  • A user can see connected apps and revoke one without affecting others.
  • Works on a fresh self-hosted instance with no config, including persisted signing keys (restarts/replicas don't log everyone out).
  • PAK/PAT auth continues to work unchanged.

Scopes

MUST:

  • OAuth server core — Click connect in an app, log in in the browser, and get a short-lived token instead of a pasted key. Demoable with curl/Postman against the endpoint that already accepts Bearer.

  • Scope model & token shape — The token carries exactly what was approved: which actions, on which project. ("All projects" sentinel exists but is for owner clients, not community.)

  • Live permission enforcement — The moment someone loses access to a project, their token stops working there.

  • Audience binding — A token issued for one thing can't be used against another (foreign-audience token is rejected).

  • Consent screen — When an app asks to connect, the person sees a plain summary of what it will be able to do and clicks Allow or Deny. Project is pre-filled from context, not picked.

  • Connected apps & revocation — See the apps you've connected and disconnect any of them for real.

  • Stay signed in — Sign in once and stay signed in; the token renews silently.

  • SDK — OAuth prep (@tolgee/web) — The SDK can speak OAuth, so any app or plugin on it can log in without a key. (Project comes from page config, not decoded from the key.)

  • Chrome plugin — path prep — The plugin's sign-in path is wired and verified end to end. (The page's known project survives the token swap.)

  • MCP client (+ CIMD) — An AI tool (Claude, VS Code, Cursor) connects by logging in, with no key in a config file, and works across your projects.

  • Tolgee CLI — Sign in with one command via the browser and the CLI just works; key option stays for CI.

OPTIONAL:

  • Figma plugin

Metadata

Metadata

Assignees

Labels

pitchThe pitch according to the Shape-up approach

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions