Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions ui/apps/pmm/src/sep/SepAuthGate.messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const Messages = {
loading: 'Authenticating with Smart Expert Platform…',
signedOutTitle: 'Not signed in',
signedOut:
'Smart Expert Platform could not verify your PMM session. Sign in to PMM again, then retry.',
errorTitle: 'Could not reach Smart Expert Platform',
error:
'Authenticating with Smart Expert Platform failed. This is usually temporary.',
retry: 'Try again',
};
101 changes: 101 additions & 0 deletions ui/apps/pmm/src/sep/SepAuthGate.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { ApiError, postSessionExchange, setTokenMinter } from '@sep/api';
import { SepAuthGate } from './SepAuthGate';
import { initSepAuth } from './bootstrap';
import { resetSepAuthStore } from './sepTokenStore';

vi.mock('@sep/api', async (importOriginal) => ({
...(await importOriginal<typeof import('@sep/api')>()),
postSessionExchange: vi.fn(),
}));

const exchange = vi.mocked(postSessionExchange);

const unauthorized = () =>
new ApiError({ kind: 'http', status: 401, message: 'no session' });

const renderGate = () =>
render(
<SepAuthGate>
<div>plugin content</div>
</SepAuthGate>
);

beforeEach(() => {
exchange.mockReset();
resetSepAuthStore();
initSepAuth();
});

afterEach(() => {
resetSepAuthStore();
setTokenMinter(null);
});

describe('SepAuthGate', () => {
it('withholds children until the exchange resolves', async () => {
let resolveExchange: (value: {
access_token: string;
expires_in: number;
}) => void = () => {};
exchange.mockReturnValue(
new Promise((resolve) => {
resolveExchange = resolve;
})
);

renderGate();

expect(screen.queryByText('plugin content')).not.toBeInTheDocument();
expect(screen.getByRole('progressbar')).toBeInTheDocument();

resolveExchange({ access_token: 'bearer-1', expires_in: 300 });

expect(await screen.findByText('plugin content')).toBeInTheDocument();
});

it('renders children once a bearer is held', async () => {
exchange.mockResolvedValue({ access_token: 'bearer-1', expires_in: 300 });

renderGate();

expect(await screen.findByText('plugin content')).toBeInTheDocument();
expect(exchange).toHaveBeenCalledOnce();
});

it('reports a rejected session instead of looping on the exchange', async () => {
exchange.mockRejectedValue(unauthorized());

renderGate();

expect(await screen.findByTestId('sep-auth-error')).toHaveTextContent(
'Not signed in'
);
expect(screen.queryByText('plugin content')).not.toBeInTheDocument();
// Waiting past any plausible retry delay: the failure must stay put.
await waitFor(() => expect(exchange).toHaveBeenCalledOnce());
expect(exchange).toHaveBeenCalledOnce();
});

it('exchanges again when the user retries', async () => {
exchange.mockRejectedValue(unauthorized());
renderGate();
await screen.findByTestId('sep-auth-error');

exchange.mockResolvedValue({ access_token: 'bearer-1', expires_in: 300 });
fireEvent.click(screen.getByRole('button', { name: 'Try again' }));

expect(await screen.findByText('plugin content')).toBeInTheDocument();
expect(exchange).toHaveBeenCalledTimes(2);
});

it('distinguishes a transient failure from a rejected session', async () => {
exchange.mockRejectedValue(new Error('network down'));

renderGate();

expect(await screen.findByTestId('sep-auth-error')).toHaveTextContent(
'Could not reach Smart Expert Platform'
);
});
});
82 changes: 82 additions & 0 deletions ui/apps/pmm/src/sep/SepAuthGate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { FC, PropsWithChildren, useEffect, useSyncExternalStore } from 'react';
import {
Alert,
AlertTitle,
Box,
Button,
CircularProgress,
} from '@mui/material';
import { Messages } from './SepAuthGate.messages';
import {
ensureSepToken,
getSepAuthStatus,
retrySepAuth,
subscribeSepAuth,
} from './sepTokenStore';

/**
* Holds a SEP route until a SEP bearer has been minted from the PMM session.
*
* Gating here rather than exchanging at app startup keeps SEP out of the boot
* path for the PMM users who never open a SEP page — the UI has no
* `PMM_ENABLE_SEP` flag to check, so an eager exchange would hit SEP on every
* page load for everybody.
*
* It also removes a race the token provider cannot: `setTokenProvider` is
* synchronous, so a plugin's first queries would otherwise fire before the
* exchange resolves and 401 on arrival. Children do not render until a bearer
* is in hand.
*/
export const SepAuthGate: FC<PropsWithChildren> = ({ children }) => {
const status = useSyncExternalStore(subscribeSepAuth, getSepAuthStatus);

useEffect(() => {
// No-ops when a bearer is already held or the session was rejected; a
// previous transient failure is retried on the next visit to a SEP route.
void ensureSepToken();
}, []);

if (status === 'ready') {
return <>{children}</>;
}

if (status === 'signedOut' || status === 'error') {
const signedOut = status === 'signedOut';
return (
<Alert
severity={signedOut ? 'warning' : 'error'}
data-testid="sep-auth-error"
action={
<Button
color="inherit"
size="small"
onClick={() => {
void retrySepAuth();
}}
>
{Messages.retry}
</Button>
}
>
<AlertTitle>
{signedOut ? Messages.signedOutTitle : Messages.errorTitle}
</AlertTitle>
{signedOut ? Messages.signedOut : Messages.error}
</Alert>
);
}

return (
<Box
sx={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 4,
}}
>
<CircularProgress aria-label={Messages.loading} />
</Box>
);
};
8 changes: 7 additions & 1 deletion ui/apps/pmm/src/sep/SepPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Stack from '@mui/material/Stack';
import { Page } from 'components/page';
import { useUser } from 'contexts/user';
import { OrgRole } from 'types/user.types';
import { SepAuthGate } from './SepAuthGate';

/**
* Shared container for SEP apps mounted as native PMM routes.
Expand All @@ -19,6 +20,9 @@ import { OrgRole } from 'types/user.types';
* `isPMMAdmin` is `isGrafanaAdmin || orgRole === Admin`, and `roles` (org-role
* only) cannot express the Grafana-admin half on its own, so it gates the
* remaining case and Page renders its standard unauthorized card.
*
* `SepAuthGate` sits inside that check, so the SEP session exchange only runs
* for a user who is allowed on the page in the first place.
*/
export const SepPage: FC<PropsWithChildren> = ({ children }) => {
const { user } = useUser();
Expand All @@ -29,7 +33,9 @@ export const SepPage: FC<PropsWithChildren> = ({ children }) => {
roles={user?.isPMMAdmin ? undefined : [OrgRole.Admin]}
>
<Stack gap={3} sx={{ flex: 1 }}>
<div>{children}</div>
<SepAuthGate>
<div>{children}</div>
</SepAuthGate>
</Stack>
</Page>
);
Expand Down
41 changes: 28 additions & 13 deletions ui/apps/pmm/src/sep/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,35 @@
import { setTokenProvider, setOnUnauthorized } from '@sep/api';
import {
setOnRefreshed,
setOnUnauthorized,
setTokenMinter,
setTokenProvider,
} from '@sep/api';
import {
getSepToken,
markSepSignedOut,
mintSepToken,
recordSepToken,
} from './sepTokenStore';

/**
* Interim SEP auth wiring (migration Option D).
* SEP auth wiring for the embedded UI.
*
* SEP's axios client delegates the bearer token via `setTokenProvider`. During the
* migration the PMM dev proxy injects `PMM_DEV_SEP_INTERNAL_TOKEN` server-side,
* so the browser sends no token — the provider returns `null`. `setOnUnauthorized` is a
* no-op because there is no SEP login flow to redirect to (PMM owns the session).
* PMM owns the session, so SEP is authenticated as the actual PMM user by
* exchanging the `pmm_session` cookie for a short-lived SEP bearer
* (`POST /api/oauth/session/exchange`, SEP-1692) rather than by logging in.
* This replaces the interim wiring in which the dev proxy injected
* `PMM_DEV_SEP_INTERNAL_TOKEN` server-side: that authenticated as SEP's
* internal service principal, which hardcodes `is_admin = False`, so every
* admin-gated SEP surface answered 403.
*
* This is replaced by the token-exchange provider (Option B), which calls
* `postSessionExchange()` (`POST /api/oauth/session/exchange`, SEP-1692) to trade
* PMM's session cookie for a short-lived SEP bearer, at which point `isAdmin` also
* comes from the token's role claim rather than the internal token's service
* principal, which hardcodes `is_admin = False`.
* Registration is side-effect free — no network call happens here. The first
* exchange is triggered by `SepAuthGate` when a SEP route mounts, so PMM users
* who never open one never talk to SEP. State and lifetime live in
* `./sepTokenStore`.
*/
export const initSepAuth = () => {
setTokenProvider(() => null);
setOnUnauthorized(() => {});
setTokenProvider(getSepToken);
setTokenMinter(mintSepToken);
setOnRefreshed(recordSepToken);
setOnUnauthorized(markSepSignedOut);
};
Loading
Loading