feat(auth): TURTLE-395: show MCP client metadata
Signed-off-by: djamaile <rdjamaile@gail.com>
This commit is contained in:
@@ -19,14 +19,35 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.callbackUrl {
|
||||
font-family: var(--bui-font-monospace);
|
||||
.clientDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--bui-space-2);
|
||||
margin: var(--bui-space-3) 0 0;
|
||||
}
|
||||
|
||||
.clientDetail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--bui-space-1);
|
||||
}
|
||||
|
||||
.clientDetail dt {
|
||||
font-size: var(--bui-font-size-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.clientDetail dd {
|
||||
margin: 0;
|
||||
background: var(--bui-bg-neutral-2);
|
||||
padding: var(--bui-space-2);
|
||||
border-radius: var(--bui-radius-2);
|
||||
word-break: break-all;
|
||||
font-size: var(--bui-font-size-3);
|
||||
margin-top: var(--bui-space-2);
|
||||
}
|
||||
|
||||
.monospaceValue {
|
||||
font-family: var(--bui-font-monospace);
|
||||
}
|
||||
|
||||
.completedIcon {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { configApiRef } from '@backstage/frontend-plugin-api';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { ConsentPage } from './ConsentPage';
|
||||
import { useConsentSession } from './useConsentSession';
|
||||
|
||||
jest.mock('./useConsentSession');
|
||||
|
||||
const mockUseConsentSession = useConsentSession as jest.MockedFunction<
|
||||
typeof useConsentSession
|
||||
>;
|
||||
|
||||
function mockLoadedSession(
|
||||
session: Partial<
|
||||
Extract<
|
||||
ReturnType<typeof useConsentSession>['state'],
|
||||
{ status: 'loaded' }
|
||||
>['session']
|
||||
>,
|
||||
) {
|
||||
mockUseConsentSession.mockReturnValue({
|
||||
state: {
|
||||
status: 'loaded',
|
||||
session: {
|
||||
id: 'session-id',
|
||||
clientId: 'client-id',
|
||||
redirectUri: 'http://127.0.0.1:8055/callback',
|
||||
...session,
|
||||
},
|
||||
},
|
||||
handleAction: async () => {},
|
||||
});
|
||||
}
|
||||
|
||||
async function renderConsentPage() {
|
||||
await renderInTestApp(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[
|
||||
configApiRef,
|
||||
{
|
||||
getOptionalString: () => 'Backstage Example App',
|
||||
},
|
||||
],
|
||||
]}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/authorize/:sessionId" element={<ConsentPage />} />
|
||||
</Routes>
|
||||
</TestApiProvider>,
|
||||
{
|
||||
routeEntries: ['/authorize/session-id'],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe('ConsentPage', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows CIMD client metadata details', async () => {
|
||||
mockLoadedSession({
|
||||
clientId: 'https://claude.ai/.well-known/oauth-client/cli.json',
|
||||
clientName: 'Claude Code',
|
||||
redirectUri: 'http://127.0.0.1:54136/callback',
|
||||
scope: 'openid offline_access',
|
||||
});
|
||||
|
||||
await renderConsentPage();
|
||||
|
||||
expect(await screen.findByText('Claude Code')).toBeInTheDocument();
|
||||
expect(screen.getByText('Client metadata host')).toBeInTheDocument();
|
||||
expect(screen.getByText('claude.ai')).toBeInTheDocument();
|
||||
expect(screen.getByText('Client metadata URL')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('https://claude.ai/.well-known/oauth-client/cli.json'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Callback URL')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('http://127.0.0.1:54136/callback'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Requested scopes')).toBeInTheDocument();
|
||||
expect(screen.getByText('openid offline_access')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show metadata host details for non-URL client IDs', async () => {
|
||||
mockLoadedSession({
|
||||
clientId: 'dcr-client-id',
|
||||
clientName: 'Registered Client',
|
||||
redirectUri: 'cursor://callback',
|
||||
});
|
||||
|
||||
await renderConsentPage();
|
||||
|
||||
expect(await screen.findByText('Registered Client')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Client metadata host')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Client metadata URL')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Callback URL')).toBeInTheDocument();
|
||||
expect(screen.getByText('cursor://callback')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Make sure you trust this application and recognize the callback URL above. Only authorize applications you trust.',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,14 @@ const ConsentPageLayout = ({ children }: { children: React.ReactNode }) => (
|
||||
</FullPage>
|
||||
);
|
||||
|
||||
function getUrlHostname(value: string): string | undefined {
|
||||
try {
|
||||
return new URL(value).hostname;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const ConsentPage = () => {
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
const { state, handleAction } = useConsentSession({ sessionId });
|
||||
@@ -131,6 +139,10 @@ export const ConsentPage = () => {
|
||||
const session = state.session;
|
||||
const isSubmitting = state.status === 'submitting';
|
||||
const appName = session.clientName ?? session.clientId;
|
||||
const metadataHost = getUrlHostname(session.clientId);
|
||||
const trustMessage = metadataHost
|
||||
? 'Make sure you trust this application and recognize the client metadata host and callback URL above. Only authorize applications you trust.'
|
||||
: 'Make sure you trust this application and recognize the callback URL above. Only authorize applications you trust.';
|
||||
|
||||
return (
|
||||
<ConsentPageLayout>
|
||||
@@ -160,16 +172,42 @@ export const ConsentPage = () => {
|
||||
By authorizing this application, you are granting it access to
|
||||
your {appTitle} account. The application will receive an
|
||||
access token that allows it to act on your behalf.
|
||||
<div className={styles.callbackUrl}>
|
||||
{session.redirectUri}
|
||||
</div>
|
||||
<dl className={styles.clientDetails}>
|
||||
{metadataHost && (
|
||||
<>
|
||||
<div className={styles.clientDetail}>
|
||||
<dt>Client metadata host</dt>
|
||||
<dd>{metadataHost}</dd>
|
||||
</div>
|
||||
<div className={styles.clientDetail}>
|
||||
<dt>Client metadata URL</dt>
|
||||
<dd className={styles.monospaceValue}>
|
||||
{session.clientId}
|
||||
</dd>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className={styles.clientDetail}>
|
||||
<dt>Callback URL</dt>
|
||||
<dd className={styles.monospaceValue}>
|
||||
{session.redirectUri}
|
||||
</dd>
|
||||
</div>
|
||||
{session.scope && (
|
||||
<div className={styles.clientDetail}>
|
||||
<dt>Requested scopes</dt>
|
||||
<dd className={styles.monospaceValue}>
|
||||
{session.scope}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Text variant="body-small" color="secondary">
|
||||
Make sure you trust this application and recognize the callback
|
||||
URL above. Only authorize applications you trust.
|
||||
{trustMessage}
|
||||
</Text>
|
||||
</Flex>
|
||||
</CardBody>
|
||||
|
||||
@@ -30,13 +30,7 @@ interface Session {
|
||||
clientName?: string;
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
scopes?: string[];
|
||||
responseType?: string;
|
||||
state?: string;
|
||||
nonce?: string;
|
||||
codeChallenge?: string;
|
||||
codeChallengeMethod?: string;
|
||||
expiresAt?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
type ConsentState =
|
||||
|
||||
Reference in New Issue
Block a user