feat: OAuth 2.1 authorization server (round 1) - #3849
Draft
bdshadow wants to merge 30 commits into
Draft
Conversation
Adds spring-security-oauth2-authorization-server (managed by the Spring Boot 4 BOM at 7.1.0) to the version catalog and to :data as an api dependency, so :app and :api can reference the SAS types they use.
Contributor
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Persisted RSA keypair via FileStorage (separate from the HMAC session secret) feeding a JWKSource, JWT encoder and RS256 decoder. Adds the Spring Authorization Server JDBC schema (registered clients, authorizations, consents) as a Liquibase changeset, included in schema.xml and excluded from diffChangeLog so it is not dropped.
bdshadow
force-pushed
the
bdshadow/oauth-authorization-server
branch
from
August 7, 2026 13:50
b208945 to
b38bbaf
Compare
Highest-precedence SecurityFilterChain for the /oauth2 and .well-known endpoints, coexisting with the stateless main chain. Issuer derived from the backend/front-end URL. Access tokens are self-contained RS256 JWTs; a token customizer sets sub (user id), the scope claim, a tg.prj project-set claim (from a client project hint) and a per-resource audience. Seeds the browser-extension and CLI public clients (PKCE required) and advertises CIMD support in the server metadata.
AuthenticationFilter distinguishes an RS256 authorization-server token from the legacy HS512 session token by the JOSE alg header and validates each accordingly. A resolved token carries its granted scopes and project set, which SecurityService.getCurrentPermittedScopes now intersects with the user's live permissions (a third term after the API key), plus a project-set gate. Audience is enforced so a token minted for another resource is rejected. PAK/PAT/legacy-JWT auth is unchanged.
A URL-form client_id is resolved by fetching and validating a Client ID Metadata Document instead of pre-registration. The fetch is hardened against SSRF (https only, host allow-list, reuse of UrlSecurity's internal-address guard, no redirects, size and timeout caps); the document must self-identify and keep redirect URIs same-origin, and must be a public PKCE client. CimdRegisteredClientRepository is primary and delegates to the JDBC store for fixed clients; a redirect-uri change rotates the client id so consent is re-asked.
Endpoints to list the OAuth apps a user has authorized and to disconnect one (deleting its grants and consent). Serves RFC 9728 protected resource metadata for the MCP endpoint. Honors X-Forwarded-* so issuer and .well-known URLs are correct behind a reverse proxy.
The JDBC authorization store does not self-prune, so a scheduled job deletes authorizations whose credentials have all been expired past the retention window. Keying on the newest expiry guarantees a still-valid refresh token is never removed.
The web app turns the logged-in JWT into a short-lived session (session-bootstrap) so a top-level navigation to /oauth2/authorize carries an authenticated principal, then renders a consent page showing the requesting app, the capabilities it asks for and the bound project, with Allow/Deny posting back to the authorization endpoint. consent-info exposes the app name and project (only when the user has access to it).
Plain-language guide to the flow with a diagram, and a glossary (PKCE, JWK/JWKS, kid, issuer, scope vs project set) plus a CIMD-versus-DCR comparison.
Spring Authorization Server withholds refresh tokens from public (NONE-auth)
clients on the authorization-code grant, and cannot authenticate a public
client on the refresh-token grant at all — so Tolgee's browser-extension/CLI
clients could not maintain a session.
- PublicClientRefreshTokenGenerator: SAS's refresh-token generator without the
public-client skip, wired via a custom OAuth2TokenGenerator bean (JWT access
tokens keep the Tolgee customizer).
- PublicClientRefreshAuthentication{Converter,Provider}: authenticate a bare
client_id for a registered public client, gated strictly to
grant_type=refresh_token so PKCE stays the only way to redeem a code.
Rotation + reuse detection (reuseRefreshTokens(false)) already configured on the
first-party clients. Tests cover refresh issuance + rotation and that a wrong
PKCE verifier still fails the code exchange.
The consent screen showed the requested scopes as a read-only list (all-or- nothing Allow). Render them as checkboxes (default checked) and submit only the selected subset; disable Allow when nothing is selected. Spring Authorization Server already honors a partial-scope consent POST, so the granted token carries exactly the approved scopes.
…Auth testing The OAuth browser flow needs the SPA and API on one origin. Forward the backend-owned paths to VITE_DEV_PROXY_TARGET while keeping /oauth2/consent and /oauth2/bootstrap as SPA routes, so a split local dev setup can run the flow. Inert unless VITE_APP_API_URL is empty.
…resh note Document the full local flow (single-origin vite proxy + env vars, mkcert HTTPS required by launchWebAuthFlow, registering the unpacked extension's redirect, connecting). Also update the refresh limitation: public clients now do get rotating refresh tokens; only the grace-window/reuse-detection provider remains a follow-up.
The auth-code consent flow needs an HttpSession to carry the authenticated principal from /oauth2/session-bootstrap into /oauth2/authorize. Storing it in JDBC (Spring Session) keeps it shared across replicas so the app stays otherwise stateless; the schema is managed by Liquibase, not Spring Session.
The consent screen now offers a project selector next to the scopes. consent-info returns the user's accessible projects; select-project binds the pending authorization to the chosen project (or leaves it unscoped for "all projects"), which the token customizer stamps into tg.prj — a consent choice overriding any client hint. A hinted project (e.g. the public project a community contributor is editing) is pre-selected but stays changeable, so one token can also cover several community projects via "all projects", bounded by live per-project permissions.
bdshadow
force-pushed
the
bdshadow/oauth-authorization-server
branch
from
August 9, 2026 21:43
b38bbaf to
9ea938e
Compare
Lets a signed-in user see the OAuth apps they have authorized and disconnect any of them (Account -> Connected apps, under Personal Access Tokens). Disconnecting revokes the client's grants and consent, so re-connecting shows the consent screen again and the project can be re-selected. The revoke endpoint now returns 200 instead of 204 to match the other delete endpoints and the webapp's typed mutation helper. Regenerated the API schema, which also picks up the OAuth consent-info/select-project/ session-bootstrap endpoints added earlier.
An OAuth token narrowed to a single project (tg.prj) carries that project
the same way a PAK does, so implicit-project endpoints (no {projectId} in
the path, used by the in-context SDK) can now resolve it instead of failing
with project_not_selected. All-projects and multi-project tokens still throw,
since the client must target a project explicitly.
The in-context editor bootstraps off GET /v2/api-keys/current-permissions, which handled only PAK and PAT and rejected OAuth tokens with INVALID_AUTHENTICATION_METHOD, breaking OAuth-based in-context editing. It now treats an OAuth token like a PAT: an explicit projectId is required, and getCurrentPermittedScopes applies the token's scope/project narrowing.
The consent screen now offers only the declared project plus "all projects" (instead of the user's whole project list), and warns when the requested project isn't accessible on this server rather than silently issuing an unscoped token that later fails in-context with project_not_selected. consent-info now returns requestedProjectId so the SPA can tell "no hint" apart from "hinted but inaccessible". Docs cover local in-context testing.
Harden the OAuth access-token path so a scoped-down token can never exceed its consented scope∩project ceiling: - Reject tokens issued before the user's tokensValidNotBefore on both the access path (OAuth2AccessTokenResolver) and the refresh grant (TolgeeOAuth2TokenCustomizer), via a single UserAccountDto.isTokenInvalidated predicate now shared with JwtService. The refresh gate fails closed on a missing user. - Deny the own-author / own-resource user-authority shortcut to OAuth tokens at every widening site: batch-job list (BatchJobService), batch-job view/cancel (BatchJobManagementController), comment update/delete (TranslationCommentController), suggestion delete (SuggestionController), and the task-assignment paths (SecurityService). An OAuth token stays bound by its scope, like a PAK. - Require super-authentication to revoke a connected app. Adds regression tests locking each guard (negative + positive controls) across OAuth2AccessTokenAuthTest, OAuth2AuthorizationCodeFlowTest, SecurityServiceOAuthNarrowingTest, and a new SuggestionControllerOAuthNarrowingTest.
- Return the requested project id from the consent-info endpoint and drop the dead accessible-projects list; the consent screen derives its options (All projects + the declared project) from it via a pure deriveConsentProjects helper, unit-tested in consentProjectOptions.test.ts. - Reword the inaccessible-project warning so it no longer steers the user toward an all-projects grant. - Restore the raw-scope fallback on the connected-app scope chip so an unmapped scope renders its scope string instead of an empty chip.
- Collapse the changeset-wide narration back to the project comment style: drop per-property/per-constant KDoc that restated name+type and the multi-line class/function KDoc carrying design rationale that belongs in the PR. - Extract OAuth2TokenCredentials.singleProjectId() and use it from RequestContextService and McpRequestContext instead of duplicating projectIds?.singleOrNull(). No behavior change.
Clarify that @tolgee/web ships two builds — the main ESM testapps/react imports locally (so most in-context tweaks just work on rebuild) and the separately lazy-loaded editor UMD fetched from the CDN as the published release. OAuth is the first change to that UMD's own request path (DevBackend's Bearer branch), so the popup runs published code that lacks it until the loader URL is overridden.
…verywhere Bring OAuth tokens to parity with the other token families, which all stop working on the next request after a credential or authorization change: - Password change / forced sign-out: reject an access token issued before tokensValidNotBefore, and on the refresh grant revoke the grant in a new transaction so the rejection survives the rolled-back refresh. The cutoff is truncated to whole seconds, so a token minted in the same second is not wrongly rejected (fixes every token family via the shared isTokenInvalidated predicate). - Disconnect / logout-everywhere: stamp the authorization id into the access token (tg.aid) and verify on every request that the authorization still exists, via a cache keyed by that id and evicted on revoke — so revocation takes effect on the next request and propagates across replicas. A token with no tg.aid, or whose authorization row is gone, fails closed. Disconnecting an app and invalidating all tokens now delete the user's OAuth grants and evict the cache. The raw JDBC over oauth2_authorization is consolidated into a dedicated OAuth2AuthorizationJdbcRepository; the query and liveness services orchestrate cache eviction and transaction boundaries on top of it.
…copes Group the requested scopes by resource, move the project selector above the permissions section, and add a Modify view: required scopes are shown checked and locked, optional ones can be deselected. Clients declare their required scopes through a client setting; the browser extension requires keys.view and translations.view, everything else it requests stays optional.
When session-bootstrap fails because the stored webapp JWT is invalid or expired (e.g. after a password change invalidates it), drop the token so the private route redirects to login and returns to the authorization flow, instead of dead-ending the browser authorization window.
/v2/api-keys/current-permissions has no project path variable, so ProjectAuthorizationInterceptor never narrows an OAuth token on it. Only the `scopes` field was emptied for a project outside the token's set, while the project name, the user's role and permitted-language ids were still returned — disclosing projects the token was never consented to. Reject the request when the token does not cover the requested project.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements round 1 of OAuth 2.1 authorization-server support for Tolgee (issue #3826): Tolgee becomes an OAuth 2.1 authorization server so non-webapp clients (starting with the browser extension) can authorize with exactly a community-level, consented, project-scoped credential instead of a long-lived PAK/PAT.
What's included (backend + consent UI)
NONE) clients, self-contained RS256 access tokens, JWKS, rotating refresh tokens (incl. a custom generator + client-auth so public clients can refresh, which stock SAS withholds).FileStorage(stablekidacross restarts/replicas), first-boot generation serialized withLockingProvider.scope, a project-set claimtg.prj, andaud(single REST resource this round).OAuth2AccessTokenResolverdispatches Bearer tokens by theaudclaim and validates RS256 against the JWKS;SecurityServiceintersects the token's scope ∩ project-set ∩ the user's live permissions at every project checkpoint (including the task-assignee fallbacks andcheckAnyProjectPermission)./oauth2/consent+/oauth2/bootstrap; asession-bootstrapendpoint turns the webapp JWT into an HTTP session (session-fixation-safe). The consent HTTP session is backed by Spring Session JDBC so the flow works across replicas without ingress affinity./v2/user/connected-apps); nightly cleanup of expired and abandoned pre-consent authorizations; RFC 9728 protected-resource metadata.Out of scope (deferred, per the pitch's No-Goes)
chrome-plugin (Part B) and
@tolgee/web(Part C) client work, and MCP client / CLI / Figma / DCR / org-level scopes / per-resource (RFC 8707) audiences / automated key rotation.Notes
docs/oauth/README.md(vite proxy, mkcert HTTPS).Draft: opened for end-to-end verification of the browser-extension flow against a preview deployment.