Skip to content

Commit 10f7747

Browse files
committed
docs: local chrome-extension testing guide; correct public-client refresh 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.
1 parent d9c9dde commit 10f7747

1 file changed

Lines changed: 116 additions & 14 deletions

File tree

docs/oauth/README.md

Lines changed: 116 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@ above is a plain browser navigation, which **can't** send that JWT. So when the
5858
short-lived server session (a cookie), then continues to `/oauth2/authorize`. After that the standard
5959
flow 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

167174
These 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

Comments
 (0)