diff --git a/.changeset/warm-pumas-beam.md b/.changeset/warm-pumas-beam.md new file mode 100644 index 0000000000..83ac3f2c3c --- /dev/null +++ b/.changeset/warm-pumas-beam.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-auth-backend': patch +'@backstage/plugin-auth': patch +--- + +Improved the OAuth consent dialog for MCP authorization by showing more client details, including the client metadata host for CIMD clients, the metadata URL, callback URL, and requested scopes. diff --git a/plugins/auth-backend/src/service/OidcRouter.test.ts b/plugins/auth-backend/src/service/OidcRouter.test.ts index 215477656f..6b6ba0b8ff 100644 --- a/plugins/auth-backend/src/service/OidcRouter.test.ts +++ b/plugins/auth-backend/src/service/OidcRouter.test.ts @@ -476,6 +476,7 @@ describe('OidcRouter', () => { expect(response.body).toEqual({ id: authSession.id, + clientId: client.clientId, clientName: 'Test Client', scope: 'openid', redirectUri: 'https://example.com/callback', diff --git a/plugins/auth-backend/src/service/OidcRouter.ts b/plugins/auth-backend/src/service/OidcRouter.ts index e7ed82b6bd..5753874b0e 100644 --- a/plugins/auth-backend/src/service/OidcRouter.ts +++ b/plugins/auth-backend/src/service/OidcRouter.ts @@ -331,6 +331,7 @@ export class OidcRouter { return res.json({ id: session.id, + clientId: session.clientId, clientName: session.clientName, scope: session.scope, redirectUri: session.redirectUri, diff --git a/plugins/auth/src/components/ConsentPage/ConsentPage.module.css b/plugins/auth/src/components/ConsentPage/ConsentPage.module.css index 049c512c54..99ebb53d0a 100644 --- a/plugins/auth/src/components/ConsentPage/ConsentPage.module.css +++ b/plugins/auth/src/components/ConsentPage/ConsentPage.module.css @@ -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 { diff --git a/plugins/auth/src/components/ConsentPage/ConsentPage.test.tsx b/plugins/auth/src/components/ConsentPage/ConsentPage.test.tsx new file mode 100644 index 0000000000..22710aafdb --- /dev/null +++ b/plugins/auth/src/components/ConsentPage/ConsentPage.test.tsx @@ -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['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( + 'Backstage Example App', + }, + ], + ]} + > + + } /> + + , + { + 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(); + }); +}); diff --git a/plugins/auth/src/components/ConsentPage/ConsentPage.tsx b/plugins/auth/src/components/ConsentPage/ConsentPage.tsx index 59e7b512a5..d24dc4e3b0 100644 --- a/plugins/auth/src/components/ConsentPage/ConsentPage.tsx +++ b/plugins/auth/src/components/ConsentPage/ConsentPage.tsx @@ -46,6 +46,14 @@ const ConsentPageLayout = ({ children }: { children: React.ReactNode }) => ( ); +function getUrlHostname(value: string): string | undefined { + try { + return new URL(value).host; + } 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 ( @@ -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. -
- {session.redirectUri} -
+
+ {metadataHost && ( + <> +
+
Client metadata host
+
{metadataHost}
+
+
+
Client metadata URL
+
+ {session.clientId} +
+
+ + )} +
+
Callback URL
+
+ {session.redirectUri} +
+
+ {session.scope && ( +
+
Requested scopes
+
+ {session.scope} +
+
+ )} +
} /> - Make sure you trust this application and recognize the callback - URL above. Only authorize applications you trust. + {trustMessage} diff --git a/plugins/auth/src/components/ConsentPage/useConsentSession.ts b/plugins/auth/src/components/ConsentPage/useConsentSession.ts index d416c66a0b..6d20fda510 100644 --- a/plugins/auth/src/components/ConsentPage/useConsentSession.ts +++ b/plugins/auth/src/components/ConsentPage/useConsentSession.ts @@ -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 =