You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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-Keyheader (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:
@tolgee/webandtolgee/browser-extension): the user pastes a key that the extension stores and the SDK replays asX-API-Key./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/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:
SecurityFilterChainfor the authz-server endpoints (/oauth2/authorize,/oauth2/token,.well-known/*), coexisting with the existing stateless chain.JwtService) as the access-token format soAuthenticationFilter's existing Bearer path validates OAuth tokens. Tokens are audience-bound (RFC 8707): a token minted for MCP carriesaud = <MCP canonical URI>and is rejected by the REST API, and vice versa.Scopeenum wire values (translations.suggest,translation-suggestions.manage,translation-comments.add, and so on). Effective access isexpand(token.scopes)intersected withexpand(user's live project scopes), evaluated per request. This is already exactly whatSecurityService.getCurrentPermittedScopesdoes for PAKs. The token only narrows; losing a project or leaving the org kills access immediately.client_idand redirect URIs (seeded rows, giving the extension a realchrome-extension://anchor and branded consent). Unknown third-party MCP clients: Client ID Metadata Documents (CIMD), where the client'sclient_idis 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 customRegisteredClientRepository, 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/webpinned on customer sites), not a one-liner. It needs:@tolgee/web'sclient.ts(today it is unconditionallyX-API-Key);TOLGEE_READY(absence means an old SDK), so we carry both theX-API-Keyand Bearer paths indefinitely;sessionStorageis XSS-reachable).Scope and consent decisions (load-bearing, named now rather than left to the spike):
scope; the project set lives in a custom claim, since the client doesn't know project IDs before it is authorized.insufficient_scopestep-up flow.Other clients:
/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.http://127.0.0.1:<port>) plus PKCE. Keeps--api-keyfor CI and back-compat.Cross-cutting:
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.4. Basic Drawings
5. Rabbit Holes
Scopeenum as OAuth scopes and reuse the existinggetCurrentPermittedScopesintersection, 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").RegisteredClientRepositorythat resolves the URL-formclient_id, fetches the metadata doc, validatesclient_idagainstredirect_uris, advertisesclient_id_metadata_document_supportedin AS metadata, invalidates consents when a client'sredirect_urischange, 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.@tolgee/web; an old SDK reading an OAuth token asX-API-Keygets a 401. A capability flag onTOLGEE_READY, both paths carried for a long time.JwtServiceemits one fixed audience today; OAuth needs a per-resourceaudand rejection of wrong-audience tokens.6. No-Goes
client_credentialsgrant. PAK already covers headless machine-to-machine.7. Success Criteria
--api-keystill works for CI./mcp/developervia redirect and consent, nothing in plaintext JSON.client_id, no pasted key, no pre-registration on our side), and a client presenting aclient_idwhose metadata doesn't list itsredirect_uriis rejected.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: