PMM-15293 Mint the SEP bearer from the PMM session - #5739
Draft
nachodd wants to merge 5 commits into
Draft
Conversation
`refreshAccessToken()` hardcoded `POST /oauth/refresh` as the only way to obtain a token. An embedded host that owns the session — PMM — has no refresh cookie, so every recovery attempt would 401 there. `setTokenMinter()` replaces just that call; the default is unchanged, so the standalone SPA behaves exactly as before. Everything downstream is minter-agnostic already: the single-flight coalescer, the axios 401 retry, and the `setOnRefreshed` notification. Two supporting changes: The 401 retry now skips `/oauth/session*` as well as `/oauth/refresh`. Minting is single-flighted, so routing a mint's own 401 back through the retry interceptor would hand it the very promise it is running inside — an await on itself that never settles. The unauthorized handler still fires for those endpoints: a rejected exchange means "not signed in" and the auth layer needs to hear it. The openapi-fetch transport gained the 401 retry the axios one already had; it previously only reported unauthorized, so typed hooks could not recover at all. `fetch` consumes a Request's body, so the middleware stashes a clone taken before dispatch and replays that. The replay goes through raw `fetch` so it cannot re-enter the middleware and loop. Signed-off-by: Ignacio Durand <nachodurand@gmail.com>
The embedded SEP UI authenticated as SEP's internal service principal: the token provider returned null and the proxy injected PMM_DEV_SEP_INTERNAL_TOKEN server-side. That principal hardcodes `is_admin = False`, so every admin-gated SEP surface answered 403. It now authenticates as the actual PMM user. `sepTokenStore` exchanges the ambient `pmm_session` cookie for a short-lived SEP bearer via `POST /api/oauth/session/exchange` (SEP-1692) and holds it in memory only — no localStorage, no sessionStorage, no query cache. It renews 30s ahead of the 5-minute expiry, and the transports' 401 retry covers the case where a throttled background tab misses that window. Concurrency is delegated to `refreshAccessToken()`, so a burst of parallel SEP requests triggers one exchange. A 401 from the exchange itself is sticky: minting is refused until the user retries, so a rejected session cannot drive an exchange loop. `SepAuthGate` triggers the first exchange when a SEP route mounts rather than at app startup — the UI has no PMM_ENABLE_SEP flag, so an eager exchange would hit SEP on every page load for every PMM user. It also closes a race the provider cannot: `setTokenProvider` is synchronous, so a plugin's first queries would otherwise fire before the exchange resolved. The dev proxy no longer injects the internal token on `/api/oauth/*`. Overwriting Authorization there would authenticate the exchange as the service principal and mask whether the cookie path works at all. Retiring the injection entirely is a follow-up. Signed-off-by: Ignacio Durand <nachodurand@gmail.com>
nachodd
requested review from
Copilot,
fabio-silva and
mattiasimonato
and removed request for
a team
August 6, 2026 12:12
nachodd
marked this pull request as draft
August 6, 2026 12:19
Contributor
There was a problem hiding this comment.
Pull request overview
This PR updates the embedded SEP integration in PMM UI to authenticate SEP requests as the active PMM user by exchanging the PMM session cookie for a short-lived SEP bearer token, replacing the interim dev-proxy server-side token injection approach. It does this by introducing a pluggable “token minter” seam in @sep/api, adding 401 replay recovery to the typed openapi-fetch client, and wiring PMM’s SEP routes through an in-memory token store + auth gate.
Changes:
- Add a pluggable token-minter API (
setTokenMinter) and broaden “mint endpoint” detection to avoid retry self-deadlocks. - Add one-shot 401 recovery + request replay (including body replay) for the typed
openapi-fetchclient. - Implement PMM’s in-memory SEP bearer store + route gate, and adjust the dev proxy to avoid overriding OAuth/session exchange requests.
Reviewed changes
Copilot reviewed 14 out of 14 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| ui/packages/sep/api/tests/typed-client.test.ts | Adds typed-client 401 recovery/replay tests (including request body replay and mint-endpoint guard). |
| ui/packages/sep/api/tests/client.test.ts | Adds axios client tests for the token-minter seam and self-await regression guard. |
| ui/packages/sep/api/src/typed-client.ts | Implements typed-client 401 recovery via token mint + raw fetch replay using a pre-dispatch Request clone. |
| ui/packages/sep/api/src/index.ts | Exports setTokenMinter and MintedToken from the package surface. |
| ui/packages/sep/api/src/client.ts | Introduces MintedToken, setTokenMinter, broadens mint-endpoint detection, and routes refresh through the minter. |
| ui/packages/sep/api/README.md | Documents the token-minter seam and PMM embedded-session wiring. |
| ui/apps/pmm/vite.config.ts | Prevents dev-proxy internal-token injection from overriding /api/oauth/* routes (needed for session exchange). |
| ui/apps/pmm/src/sep/sepTokenStore.ts | New in-memory SEP bearer store with renewal scheduling and sticky signed-out behavior on rejected session. |
| ui/apps/pmm/src/sep/sepTokenStore.test.ts | Unit tests for token acquisition, renewal, storage guarantees, concurrency coalescing, and sticky signed-out handling. |
| ui/apps/pmm/src/sep/SepPage.tsx | Wraps SEP routes in SepAuthGate so the initial exchange happens on SEP route mount. |
| ui/apps/pmm/src/sep/SepAuthGate.tsx | New gate that blocks SEP content until the bearer is minted; provides retry UI for failures. |
| ui/apps/pmm/src/sep/SepAuthGate.test.tsx | Tests gating behavior (withhold children, sticky signed-out, retry, transient error messaging). |
| ui/apps/pmm/src/sep/SepAuthGate.messages.ts | New copy for the SEP auth gate’s loading/error/signed-out states. |
| ui/apps/pmm/src/sep/bootstrap.ts | Wires @sep/api to the PMM token store (provider + minter + refreshed + unauthorized callbacks). |
`onRequest` cloned every outbound Request so a 401 could be replayed, including the minting and login endpoints that `onResponse` explicitly excludes from the retry. Cloning buffers the body, and those clones were never going to be used. Both call sites now share one `isReplayEligible` predicate, so the clone and the retry cannot drift apart. Raised by Copilot on #5739. Signed-off-by: Ignacio Durand <nachodurand@gmail.com>
Reworks how the store reports failure, against the updated ACs. Two
rules now shape it, and they pull in opposite directions.
Fail closed. Every exchange failure drops the bearer, so no request can
proceed on a stale, expired, or unverified credential, and there is no
cached value to fall back on. A session SEP has rejected stays sticky:
minting is refused outright until the user retries, so a rejection can
never drive an exchange loop.
Never destroy user work. The failure now lands at one of two altitudes.
Before a bearer has ever been held the page does not exist yet, so a
bootstrap failure takes the page over — there is nothing to preserve.
Once mounted the page stays mounted and the failure becomes an inline
notice beside it. Previously a background renewal being rejected moved
the phase to `signedOut`, which unmounted the plugin and threw away
whatever was half-typed into it.
The two are reconciled by keeping the bearer and the reporting separate:
`failClosed` always drops the credential, then chooses between a phase
change and a notice based on whether the page is up.
A renewal that fails for a reason that may not repeat is now retried
quietly with backoff — 2s, 4s, 8s, 16s — and only surfaces if all four
attempts fail. A 401 skips the backoff: the session is genuinely gone
and retrying would only repeat the rejection, so the user is told at
once, non-destructively, that submissions from this page will fail.
`getSepAuthStatus()` is replaced by `getSepAuthState()`, returning a
cached `{ phase, notice }` snapshot so `useSyncExternalStore` does not
re-render subscribers on a no-op. The old `error` phase is renamed
`unreachable`, matching the notice of the same name.
Signed-off-by: Ignacio Durand <nachodurand@gmail.com>
nachodd
added a commit
to percona/SEP
that referenced
this pull request
Aug 6, 2026
Upstreams the change PMM needed for the embedded UI (percona/pmm#5739), so the next sync of this tree into PMM does not clobber it. `refreshAccessToken()` hardcoded `POST /oauth/refresh` as the only way to obtain a token. That is right for this SPA, but PMM embeds SEP with no refresh cookie at all — it trades its own session cookie for a bearer via `POST /oauth/session/exchange` (SEP-1692) — so there the default 401s on every recovery attempt, and the whole retry machinery is dead weight. `setTokenMinter()` replaces just that call and defaults to the existing one, so nothing changes here. Everything downstream was already minter-agnostic: the single-flight coalescer, the axios 401 retry, and the `setOnRefreshed` notification all work the same whichever endpoint produced the token. `isTokenMintRequest` composes the existing `isRefreshRequest` and `isSessionRequest` guards, which the axios retry condition already listed separately, and is exported so the typed client shares one definition. Keeping them together matters: minting is single-flighted, so letting a mint's own 401 into the retry path would hand the interceptor the very promise it is running inside. The `openapi-fetch` transport gained the 401 retry the axios one already had. It previously only reported unauthorized, so every typed hook — `useCurrentUser` and all the generated-path ones — surfaced an expired token as a failure instead of recovering from it. This is a fix for both deployments, not just the embedded one. `fetch` consumes a Request's body, so the middleware stashes a clone taken before dispatch and replays that; only replay-eligible requests are cloned, and the replay goes through raw `fetch` so it cannot re-enter the middleware and loop.
8 tasks
yyyyyyyan
pushed a commit
to percona/SEP
that referenced
this pull request
Aug 7, 2026
) The CodeRabbit review of the SEP-UI-into-PMM migration PR ([percona/pmm#5653](percona/pmm#5653), PMM-15216) raised these findings against the ported copy of `@sep/api` / `@sep/framework`. Every one of them is present in the original, so they are fixed here too — otherwise the next sync in either direction re-introduces them. PMM-only wiring (route constants, the route-level admin gate, the dev-proxy env variables) is out of scope. ## Token-minter seam (upstreamed, not a review fix) The last commit is a different kind of change from the rest of this PR, so it is called out separately: it upstreams a capability PMM needed ([percona/pmm#5739](percona/pmm#5739), PMM-15293) rather than fixing a review finding. It lands here for the same reason as everything else — otherwise the next sync clobbers it. `refreshAccessToken()` hardcoded `POST /oauth/refresh` as the only way to obtain a token. That is correct for this SPA. It is not workable for PMM, which embeds SEP with no refresh cookie at all: it trades its own session cookie for a bearer through `POST /oauth/session/exchange` (SEP-1692), so the default 401s on every recovery attempt and the entire retry path is dead weight there. - **`setTokenMinter()`** replaces just that one call, defaulting to the existing behaviour — **nothing changes for a standalone SEP deployment**. Everything downstream was already minter-agnostic: the single-flight coalescer, the axios 401 retry, and the `setOnRefreshed` notification do not care which endpoint produced the token. - **`isTokenMintRequest`** composes the existing `isRefreshRequest` and `isSessionRequest` guards — which the axios retry condition already listed separately — and is exported so the typed client shares one definition rather than growing a second copy. Keeping them together is load-bearing: minting is single-flighted, so letting a mint's own 401 into the retry path hands the interceptor the very promise it is running inside, an await on itself that never settles. There is a regression test that times out rather than fails if that guard is lost. - **The `openapi-fetch` transport gained the 401 retry the axios one already had.** This one is a fix for *both* deployments: it previously only reported unauthorized, so every typed hook — `useCurrentUser` and all the generated-path ones — surfaced an expired token as a failure instead of recovering. `fetch` consumes a Request's body, so the middleware stashes a clone taken before dispatch and replays that; only replay-eligible requests are cloned, and the replay goes through raw `fetch` so it cannot re-enter the middleware and loop. `@sep/api` grows 10 tests for this: minting through a registered minter, coalescing a burst of 401s into one exchange, the self-await regression guard, a null-resolving minter, restoring the default, and on the typed side mint-and-replay, replaying a request body, replaying at most once, one mint across concurrent 401s, and no recovery attempt on a minting endpoint's own 401. **Correctness** - **`refreshAccessToken`** called the injected `_onRefreshed` handler inside the async executor, so a synchronous throw from it rejected the shared `refreshInFlight` promise — every awaiting caller saw a failed refresh, and a force-logout, for a cookie rotation that had already succeeded on the backend. The function's own comment says this must not happen. - **`SchemaFormRenderer`** returned from `handleFormSubmit` on a section violation, which react-hook-form reads as a *successful* submit (`isSubmitSuccessful = true`). `useUnsavedChangesGuard` is `isDirty && !isSubmitSuccessful` and only re-arms when `submitError` is truthy — never on this path — so the guard stayed disarmed: no `beforeunload` prompt, no `UnsavedChangesBlocker`, and the user could navigate away from a dirty form and lose it. The gate now runs in the submit event handler, ahead of `handleSubmit`. - **`normalizeChoiceDefaults`** read and wrote flat keys, but `flattenSectionFields` also returns `one_of` branch fields, whose names are dotted paths stored nested. A case-mismatched nested choice value was never canonicalised and rendered as an empty selection — the exact failure that function exists to prevent. - **`AppListPage` / `AppDetailPage`** dereferenced the optional `list_view` (`schema.list_view!.columns`), reachable through an unresolved entity route. The list page now renders `Not found`; the Overview tab falls back to an empty column set and still lists the task's own fields. - **`HostSelector`** looked up `errors[name]`, which never resolves for a dotted branch-field name, so an affected field showed no validation error. - **`extractId`** used `Number`, which turns a whitespace-only string into `0` and accepts `'1.5'` / `'0x10'`. Each result reads as a resolvable inventory id downstream: `useResolvedServiceField` enables a lookup for service `0`, and `SchemaSelector` fires `useSchemas({ serviceId: 0 })` for a service that cannot exist. - **`validationMapper`** submitted `parseInt('2.5', 10)` as `2` and `parseFloat('3.14invalid')` as `3.14`. Numeric fields now validate with `Number.isFinite` (plus `Number.isInteger` for `integer`) and coerce with `Number`. - **`useTaskLogs`** guarded on `!step`, dropping a log line with `step: ''`. `useExecutionEvents` treats `''` as the stepless bucket and the viewer labels it "General", so the two streams disagreed and stepless output was silently lost. **Stability** - **`useExecutionEvents`** returned `undefined` from `onerror` for every non-sentinel error, so `fetchEventSource` retried forever while `sseError` stayed unset and `sseLoading` stayed true — a persistently failing endpoint (500, DNS failure) left the panel spinning and reconnecting indefinitely. Consecutive failures are now counted, reset on a successful open, and the loop stops with the error surfaced. - **`StandaloneHostSelector`** disabled its Autocomplete when the hosts query failed, but `onOpen` holds the only `refetch()` trigger and a disabled Autocomplete never opens — one failure wedged the control until the page remounted. - **A scheduled-task enable/disable toggle** sent `kwargs: '{}'` in a full PUT, wiping the arguments of any task created with non-default kwargs. `kwargs` is preserved when the response carries it; `'{}'` stays the fallback until `PeriodicTaskResponse` declares the field. **Contract and consistency** - **`useCascadingField`** cleared with `undefined` while `buildFormDefaults` seeds these selector types to `''`, and counted `''` as ready — a downstream selector fetched options for an empty parent, and a bound MUI input flipped from controlled to uncontrolled. - **`SchemaDrivenApp`**'s `renderEditForm` invocation omitted `capabilities`, `submitError` and `fieldErrors`, so a consumer supplying the slot could render neither the 422 banner nor the inline field errors. `AppTaskEditPage` already passes all three to the same slot type. - **`SchemaListView`** pinned `bgcolor: 'common.white'`, which renders a white table in dark mode; it now reads the mode's own opaque surface. - **`FileField`**'s file-picker `IconButton` had no accessible name. - **`useResolvedServiceField`** discarded `useServices`' error, so callers could not tell a failed lookup from an id that matched no service. - **`packages/framework/test/setup.ts`** was an unreferenced sibling of `tests/setup.ts` registering the jest-dom matchers but no `afterEach(cleanup)`. Deleted, so a future `setupFiles` edit cannot silently drop DOM isolation for the package. **Deliberately not ported**, matching the decisions recorded on the PMM PR: production SEP routing / token exchange, flattening dotted app argument names (they denote nested backend models, which `coerceFormValues` already produces), converting `ScriptPreviewField` to TanStack Query, committing untrimmed free-solo input, relocating the `api` / `atw` test suites, `related_apps` alongside `entities`, and the hard-coded `Roboto Mono` stacks. `formatCellValue`'s `undefined` guard is already here — in a better form than the port has, which should go back to PMM.
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.
Ticket number: PMM-15293
Feature build: SUBMODULES-0
What
Authenticate the embedded SEP UI as the actual PMM user by exchanging the PMM session for a short-lived SEP bearer, replacing the interim server-side token injection.
Why
ui/apps/pmm/src/sep/bootstrap.tsregisteredsetTokenProvider(() => null)and the dev proxy injectedPMM_DEV_SEP_INTERNAL_TOKENserver-side. That authenticates as SEP's internal service principal, which hardcodesis_admin = False, so every admin-gated SEP surface returns 403. Fine for verifying the UI migration; not shippable.SEP's side shipped in SEP-1692:
POST /api/oauth/session/exchangeis same-origin through PMM's proxy, so the browser attachespmm_sessionautomatically. SEP validates it against Grafana, maps the org role, and returnsaccess_token+expires_in. No cookie is set and no refresh token is issued.Acceptance criteria
sepTokenStore.ts, single-flight in@sep/apisessionRejectedfailClosed()— every failure path drops the bearer firstphase: 'signedOut' | 'unreachable'ready; page never unmountsnotice; transient retried with backoffHow
@sep/api— token-minter seam (commits 1, 3)refreshAccessToken()hardcodedPOST /oauth/refreshas the only way to obtain a token. PMM's embedding has no refresh cookie, so every recovery attempt would 401 there.setTokenMinter()replaces just that call. The default is unchanged, so the standalone SPA behaves exactly as before. Everything downstream was already minter-agnostic: the single-flight coalescer, the axios 401 retry, thesetOnRefreshednotification.Two supporting changes:
/oauth/session*is excluded from the 401 retry branch. Minting is single-flighted, so routing a mint's own 401 back through the retry interceptor hands it the very promise it is running inside — an await on itself that never settles. The unauthorized handler still fires for those endpoints: a rejected exchange means "not signed in" and the auth layer needs to hear it. There is a regression test that would time out if this guard were removed.useCurrentUserand every generated-path hook) could not recover at all.fetchconsumes a Request's body, so the middleware stashes a clone taken before dispatch and replays that; the replay goes through rawfetchso it cannot re-enter the middleware and loop. Only replay-eligible requests are cloned (thanks @copilot).PMM — session exchange (commit 2)
sepTokenStore.tsholds the bearer in memory only — nolocalStorage, nosessionStorage, no query cache. Renews 30s ahead of the 5-minute expiry; the transports' 401 retry is the backstop for a throttled background tab that misses the window. Concurrency is delegated torefreshAccessToken(), so a burst of parallel SEP requests triggers one exchange.SepAuthGatetriggers the first exchange when a SEP route mounts rather than at app startup. The UI has noPMM_ENABLE_SEPflag, so an eager exchange would hit SEP on every page load for every PMM user. It also closes a race the provider cannot:setTokenProvideris synchronous, so a plugin's first queries would otherwise fire before the exchange resolved. It sits insideSepPage's existing admin check, so the exchange only runs for a user allowed on the page./api/oauth/*. OverwritingAuthorizationthere would authenticate the exchange as the service principal and mask whether the cookie path works at all.Fail closed, without discarding user work (commit 4)
These two rules pull in opposite directions, and reconciling them is most of this commit.
Fail closed. Every failure path runs through
failClosed(), which drops the bearer before doing anything else.getSepToken()independently returns null past the expiry, so nothing proceeds on a stale, expired, or unverified credential, and there is no cached value to fall back on. A rejected session sets a stickysessionRejectedthat refuses minting outright — the loop is cut before a request is made, not after it fails.Never destroy user work. The failure lands at one of two altitudes, chosen by whether a bearer has ever been held:
phase: 'signedOut'— takes over the pagenotice: 'signedOut'— inline, page untouchedphase: 'unreachable'— takes over the pagenotice: 'unreachable'At load there is no work in progress, so a full-page state is right. Once mounted, it is not: the previous revision moved the phase to
signedOuton a rejected renewal, which unmounted the plugin and threw away whatever was half-typed into it. The page now stays exactly as it is and the failure appears beside it, telling the user submissions will fail and offering a retry.Transient renewal failures back off at 2s, 4s, 8s, 16s and only surface if all four fail. A 401 skips the backoff entirely — the session is genuinely gone, so retrying would just repeat the rejection.
getSepAuthStatus()becamegetSepAuthState(), returning a cached{ phase, notice }snapshot souseSyncExternalStoredoes not re-render subscribers on a no-op.Testing
pnpm check-types,pnpm lint(0 errors),pnpm format:check,pnpm test— 1170 tests pass. 36 are new:packages/sep/api/tests/client.test.ts— minting through the registered minter, coalescing a burst of 401s into one exchange, the self-await regression guard, a minter resolving null, restoring the default.packages/sep/api/tests/typed-client.test.ts— mint-and-replay, replaying a request body (the clone is the crux), replaying at most once, one mint across concurrent 401s, no recovery attempt on a mint endpoint's own 401.apps/pmm/src/sep/sepTokenStore.test.ts— mocks onlypostSessionExchange, so the real@sep/apisingle-flight and unauthorized wiring are exercised. Covers acquisition, the synchronous provider, coalescing, no web-storage writes, snapshot stability, and one group per AC: failing closed (expiry, both renewal failure kinds, sticky refusal), bootstrap failure, and renewal on a mounted page (quiet backoff, surfacing only on persistence, terminal-401 immediacy, never leavingready).apps/pmm/src/sep/SepAuthGate.test.tsx— bootstrap paths, plus the non-destructive ones: a rejected session mid-session keeps the page mounted and preserves typed-in form state, and a successful retry clears the notice with the form still intact.Not verified end to end
Blocked by PMM-15280 ([BE] Provision a Grafana service account for SEP when
PMM_ENABLE_SEP=1). SEP validates the session using its own Grafana service-account token, which the embedded profile deliberately ships without. Until that credential is provisioned the exchange returns 401 for everything, so a live run today exercises thesignedOutpath and nothing else. The happy path exists only in tests.Known gap, by scope
The dev proxy still injects
PMM_DEV_SEP_INTERNAL_TOKENon non-OAuth paths. In dev, that means a request sent while the store holds no bearer arrives at SEP authenticated as the service principal instead of failing — a fallback to an injected credential, which is exactly what "fail closed" rules out. It does not apply in production, where no injection exists, and retiring the injection is the agreed follow-up rather than part of this PR. Worth knowing when reading dev-environment behaviour.Out of scope / follow-ups
SEP_INTERNAL_TOKENinjection from the proxy configuration entirely (closes the gap above).setTokenMinterseam back topercona/SEPso the next frontend sync does not clobber it.Related work
Depends on PMM-15216 Migrate the SEP UI into PMM #5653 (PMM-15216, [FE] Frontend SEP to PMM Integration — UI), which is this PR's base.
Which in turn depends on PMM-15288 Migrate UI toolchain to pnpm + oxlint/oxfmt #5728 (PMM-15288, UI toolchain migration to pnpm + oxlint/oxfmt).
Blocked for verification by PMM-15280.
SEP side: SEP-1692 (merged 2026-08-03).
API Docs updated