@@ -58,6 +58,13 @@ above is a plain browser navigation, which **can't** send that JWT. So when the
5858short-lived server session (a cookie), then continues to ` /oauth2/authorize ` . After that the standard
5959flow above runs.
6060
61+ This bootstrap session is the * only* stateful part of the otherwise-stateless app. To keep it working on
62+ multi-replica deployments (Tolgee Cloud, self-hosted HA) without requiring load-balancer session affinity,
63+ the HTTP session is backed by ** Spring Session JDBC** over the existing Postgres (`spring.session.store-type:
64+ jdbc` ; schema in ` db/changelog/spring-session` ). The bootstrapped ` SecurityContext` is therefore visible on
65+ any replica, so the bootstrap → authorize → consent requests can land on different replicas. No shared Redis
66+ and no ingress stickiness are needed.
67+
6168## 3. Reference — the jargon
6269
6370### PKCE ("pixy", Proof Key for Code Exchange)
@@ -166,17 +173,112 @@ hands back a freshly-minted `client_id`.
166173
167174These are known gaps, deferred to the client rounds that first exercise them:
168175
169- - ** Refresh is stock rotate-on-use.** ` reuseRefreshTokens(false) ` gives Spring's plain rotation with
170- no grace window and no reuse-detection family-revocation. A client that refreshes proactively can
171- hit ` invalid_grant ` on a near-simultaneous second refresh. When the first refreshing client lands
172- (browser extension / CLI), replace ` OAuth2RefreshTokenAuthenticationProvider ` with one that accepts
173- a just-superseded token within a short grace window and revokes the authorization family on replay
174- of an already-rotated token. (SAS also does not issue refresh tokens to public/` NONE ` -auth clients
175- by default, so round-1 clients receive only short-lived access tokens.)
176- - ** Nightly cleanup does not prune abandoned pre-consent rows.** SAS persists an ` oauth2_authorization `
177- row when consent is * required* , before any code/token is issued — its expiry columns are all NULL,
178- so the COALESCE-based ` deleteExpiredBefore ` never removes it. No round-1 client is consent-required
179- by default (the CLI client skips consent; the browser-extension client is only seeded when redirect
180- URIs are configured; CIMD is off), so no such rows are created today. When a consent-required client
181- is enabled, add a ` created_at ` column (DB default) to the SAS schema and extend the cleanup to also
182- delete all-NULL-expiry rows older than a short grace window.
176+ - ** Refresh is stock rotate-on-use.** Public clients * do* get rotating refresh tokens — SAS withholds
177+ them by default (both on the code grant and by refusing to authenticate a public client on the
178+ refresh grant), so we add ` PublicClientRefreshTokenGenerator ` plus ` PublicClientRefreshAuthentication `
179+ (a converter + provider that authenticate a bare ` client_id ` , gated strictly to
180+ ` grant_type=refresh_token ` ). But ` reuseRefreshTokens(false) ` is plain rotation with no grace window
181+ and no reuse-detection family-revocation, so a client that refreshes proactively can hit
182+ ` invalid_grant ` on a near-simultaneous second refresh. Follow-up: replace
183+ ` OAuth2RefreshTokenAuthenticationProvider ` with one that accepts a just-superseded token within a
184+ short grace window and revokes the whole authorization family on replay of an already-rotated token.
185+ - ** Disconnect kills the refresh token immediately, but access tokens live out their TTL.** Access
186+ tokens are self-contained RS256 JWTs verified against the JWKS, so ` DELETE /v2/user/connected-apps/{id} `
187+ (which deletes the authorization + consent rows) stops all * future* tokens and kills the refresh token
188+ at once, but an already-issued access token keeps working until it expires — up to
189+ ` tolgee.oauth2.access-token-validity-minutes ` (default 30). This is standard stateless-JWT behaviour;
190+ keep the access-token TTL short. The pitch's optional Redis revocation denylist (reject a token by
191+ ` jti ` until its TTL passes) is the follow-up for immediate revocation and lands with the MCP round.
192+ - ** Signing key rotation is a follow-up.** ` OAuth2KeyConfig ` persists a single active RSA key via
193+ ` FileStorage ` (shared across replicas) and coordinates first-boot generation with ` LockingProvider ` so a
194+ fresh multi-replica deployment converges on one ` kid ` . There is no overlap-rotation mechanism yet — a
195+ two-key JWKS would need verify-only key selection (SAS's ` JwtGenerator ` sets no ` kid ` , so two RS256
196+ signing candidates make ` NimbusJwtEncoder ` ambiguous). A rotation-with-overlap story (refreshable
197+ ` JWKSource ` + verify-only previous key) is the follow-up. There is no compliance requirement for periodic
198+ rotation, and the stable key has no user-facing impact (tokens keep validating; nobody is logged out).
199+
200+ ## Testing the browser extension locally (development)
201+
202+ The browser OAuth flow assumes the Tolgee instance serves its SPA ** and** its API/authorization-server
203+ on ** one origin** (relative redirect, SPA-served ` /oauth2/consent ` + ` /oauth2/bootstrap ` , and a
204+ session-bootstrap cookie that must belong to the origin ` /oauth2/authorize ` runs on). Production is
205+ single-origin (the backend serves the built frontend), so nothing below is needed there — this is only
206+ to reproduce the flow against a local dev checkout, where the webapp (vite, ` :3995 ` ) and backend
207+ (` :8995 ` ) are split.
208+
209+ ### 1. Single-origin dev server (vite proxy)
210+
211+ ` webapp/vite.config.ts ` proxies the backend-owned paths (` /v2 ` , ` /api ` , ` /oauth2/authorize ` ,
212+ ` /oauth2/token ` , ` /oauth2/jwks ` , ` /.well-known ` ) to the backend, leaving ` /oauth2/consent ` and
213+ ` /oauth2/bootstrap ` as SPA routes:
214+
215+ ``` ts
216+ // webapp/vite.config.ts — inside defineConfig(...).server
217+ proxy : Object .fromEntries (
218+ [' /v2' , ' /api' , ' /oauth2/authorize' , ' /oauth2/token' , ' /oauth2/jwks' , ' /.well-known' ].map ((path ) => [
219+ path ,
220+ {
221+ target: process .env .VITE_DEV_PROXY_TARGET || ' http://localhost:8080' ,
222+ changeOrigin: false ,
223+ },
224+ ])
225+ ),
226+ ```
227+
228+ Point the app at the same origin and set the proxy target in ` webapp/.env.development.local ` :
229+
230+ ``` bash
231+ VITE_APP_API_URL= # empty → app calls the API on its own origin (:3995)
232+ VITE_DEV_PROXY_TARGET=http://localhost:8995 # where the backend actually runs
233+ ```
234+
235+ Both are needed together: with ` VITE_APP_API_URL ` non-empty the app bypasses the proxy and the
236+ session-bootstrap cookie lands on the wrong origin. Restart vite after changing env (build-time vars).
237+
238+ ### 2. Trusted HTTPS (required by ` launchWebAuthFlow ` )
239+
240+ ` chrome.identity.launchWebAuthFlow ` will not intercept the final ` https://<id>.chromiumapp.org/ `
241+ redirect when the flow runs over plain ` http:// ` — it navigates to the (DNS-less) redirect host and
242+ fails with * "Authorization page could not be loaded."* A ** trusted** cert is required (self-signed is
243+ rejected too). Use [ ` mkcert ` ] ( https://github.com/FiloSottile/mkcert ) :
244+
245+ ``` bash
246+ brew install mkcert && mkcert -install # installs a locally-trusted CA
247+ cd webapp && mkcert localhost # → localhost.pem + localhost-key.pem
248+ ```
249+
250+ then enable HTTPS in ` webapp/vite.config.ts ` under ` server ` :
251+
252+ ``` ts
253+ https : { cert : ' localhost.pem' , key : ' localhost-key.pem' },
254+ ```
255+
256+ Now the extension's API url is ` https://localhost:3995 ` and the whole flow is HTTPS end to end.
257+
258+ ### 3. Register the extension's redirect URI on the local backend
259+
260+ Load the unpacked extension (` chrome://extensions ` → Developer mode → Load unpacked → ` dist-chrome `
261+ after ` npm run build ` in the chrome-plugin repo). In its ** service worker** console run
262+ ` chrome.identity.getRedirectURL() ` and add that exact value (trailing slash included) to the local
263+ backend config, then restart the backend so ` PreRegisteredClients ` seeds the client:
264+
265+ ``` yaml
266+ tolgee :
267+ oauth2 :
268+ browser-extension-redirect-uris :
269+ - https://<your-unpacked-extension-id>.chromiumapp.org/
270+ ` ` `
271+
272+ An unpacked extension keeps its id as long as ` dist-chrome` isn't moved. (Production/testing/preview
273+ already register the *published* extension's redirect in the deployment repo, so this step is
274+ dev-only.)
275+
276+ # ## 4. Connect
277+
278+ Log into the webapp at `https://localhost:3995` (so the webapp JWT is in `localStorage` — the bootstrap
279+ step reads it), open the extension popup, set **API url** to `https://localhost:3995`, and click
280+ **Connect with Tolgee** → bootstrap → consent → Allow → "Connected". The access token is injected into
281+ the page as `__tolgee_authToken`; the refresh token stays in the service worker.
282+
283+ To re-show the consent screen after a first approval (Spring remembers consent per client+user), revoke
284+ the grant : ` DELETE /v2/user/connected-apps/tolgee-browser-extension` with your JWT.
0 commit comments