Merge pull request #31092 from backstage/blam/auth-frontend

Added `auth` frontend plugin
This commit is contained in:
Ben Lambert
2025-09-09 16:54:27 +02:00
committed by GitHub
22 changed files with 785 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-auth-backend': patch
'@backstage/plugin-auth-react': patch
'@backstage/plugin-auth-node': patch
---
Updating plugin metadata
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth': minor
---
Initial publish of the `auth` frontend package
+1
View File
@@ -49,6 +49,7 @@
"@backstage/plugin-api-docs": "workspace:^",
"@backstage/plugin-app": "workspace:^",
"@backstage/plugin-app-visualizer": "workspace:^",
"@backstage/plugin-auth": "workspace:^",
"@backstage/plugin-auth-react": "workspace:^",
"@backstage/plugin-catalog": "workspace:^",
"@backstage/plugin-catalog-common": "workspace:^",
+1
View File
@@ -6,6 +6,7 @@
"role": "backend-plugin",
"pluginId": "auth",
"pluginPackages": [
"@backstage/plugin-auth",
"@backstage/plugin-auth-backend",
"@backstage/plugin-auth-node",
"@backstage/plugin-auth-react"
+1
View File
@@ -5,6 +5,7 @@
"role": "node-library",
"pluginId": "auth",
"pluginPackages": [
"@backstage/plugin-auth",
"@backstage/plugin-auth-backend",
"@backstage/plugin-auth-node",
"@backstage/plugin-auth-react"
+1
View File
@@ -6,6 +6,7 @@
"role": "web-library",
"pluginId": "auth",
"pluginPackages": [
"@backstage/plugin-auth",
"@backstage/plugin-auth-backend",
"@backstage/plugin-auth-node",
"@backstage/plugin-auth-react"
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+16
View File
@@ -0,0 +1,16 @@
# @backstage/plugin-auth
A Backstage frontend plugin that provides user interface components for authentication flows, specifically for OpenID Connect (OIDC) consent management.
## Installation
This plugin is designed to work with the `@backstage/plugin-auth-backend` package that provides OIDC provider functionality.
```bash
# From your Backstage app directory
yarn --cwd packages/app add @backstage/plugin-auth
```
## Usage
The plugin provides the route `/oauth2/authorize/:sessionId` for approving of oauth2 sessions for clients. You should see an approval flow for any sessions created through the `auth-backend`.
+9
View File
@@ -0,0 +1,9 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-plugin-auth
title: '@backstage/plugin-auth'
spec:
lifecycle: experimental
type: backstage-frontend-plugin
owner: auth-maintainers
+28
View File
@@ -0,0 +1,28 @@
/*
* Copyright 2025 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 { createApp } from '@backstage/frontend-defaults';
import { createRoot } from 'react-dom/client';
import plugin from '../src';
const app = createApp({
features: [plugin],
});
const container = document.getElementById('root');
const root = createRoot(container!);
root.render(app.createRoot());
+84
View File
@@ -0,0 +1,84 @@
{
"name": "@backstage/plugin-auth",
"version": "0.0.0",
"backstage": {
"role": "frontend-plugin",
"pluginId": "auth",
"pluginPackages": [
"@backstage/plugin-auth",
"@backstage/plugin-auth-backend",
"@backstage/plugin-auth-node",
"@backstage/plugin-auth-react"
]
},
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/auth"
},
"license": "Apache-2.0",
"sideEffects": false,
"exports": {
".": "./src/index.ts",
"./package.json": "./package.json"
},
"main": "src/index.ts",
"types": "src/index.ts",
"typesVersions": {
"*": {
"package.json": [
"package.json"
]
}
},
"files": [
"dist"
],
"scripts": {
"build": "backstage-cli package build",
"clean": "backstage-cli package clean",
"lint": "backstage-cli package lint",
"prepack": "backstage-cli package prepack",
"postpack": "backstage-cli package postpack",
"start": "backstage-cli package start",
"test": "backstage-cli package test"
},
"dependencies": {
"@backstage/core-components": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/theme": "workspace:^",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.61",
"react-use": "^17.2.4"
},
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/dev-utils": "workspace:^",
"@backstage/frontend-defaults": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.0.0",
"@types/react": "^18.0.0",
"msw": "^1.0.0",
"react": "^18.0.2",
"react-dom": "^18.0.2",
"react-router-dom": "^6.3.0"
},
"peerDependencies": {
"@types/react": "^17.0.0 || ^18.0.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0",
"react-router-dom": "^6.3.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
}
+52
View File
@@ -0,0 +1,52 @@
## API Report File for "@backstage/plugin-auth"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { ExtensionDataRef } from '@backstage/frontend-plugin-api';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { JSX as JSX_2 } from 'react';
import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api';
import { RouteRef } from '@backstage/frontend-plugin-api';
// @public (undocumented)
const _default: OverridableFrontendPlugin<
{
root: RouteRef<undefined>;
},
{},
{
'page:auth': ExtensionDefinition<{
kind: 'page';
name: undefined;
config: {
path: string | undefined;
};
configInput: {
path?: string | undefined;
};
output:
| ExtensionDataRef<string, 'core.routing.path', {}>
| ExtensionDataRef<JSX_2.Element, 'core.reactElement', {}>
| ExtensionDataRef<
RouteRef<AnyRouteRefParams>,
'core.routing.ref',
{
optional: true;
}
>;
inputs: {};
params: {
defaultPath?: [Error: `Use the 'path' param instead`];
path: string;
loader: () => Promise<JSX.Element>;
routeRef?: RouteRef;
};
}>;
}
>;
export default _default;
// (No @packageDocumentation comment for this package)
```
@@ -0,0 +1,246 @@
/*
* Copyright 2025 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 { useParams } from 'react-router-dom';
import {
Box,
Button,
Card,
CardContent,
CardActions,
Typography,
makeStyles,
Divider,
} from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import CheckCircleIcon from '@material-ui/icons/CheckCircle';
import CancelIcon from '@material-ui/icons/Cancel';
import AppsIcon from '@material-ui/icons/Apps';
import WarningIcon from '@material-ui/icons/Warning';
import {
Header,
Page,
Content,
Progress,
EmptyState,
ResponseErrorPanel,
} from '@backstage/core-components';
import { useConsentSession } from './useConsentSession';
const useStyles = makeStyles(theme => ({
authCard: {
maxWidth: 600,
margin: '0 auto',
marginTop: theme.spacing(4),
},
appHeader: {
display: 'flex',
alignItems: 'center',
marginBottom: theme.spacing(2),
},
appIcon: {
marginRight: theme.spacing(2),
fontSize: 40,
},
appName: {
fontSize: '1.5rem',
fontWeight: 'bold',
},
securityWarning: {
margin: theme.spacing(2, 0),
},
buttonContainer: {
display: 'flex',
justifyContent: 'space-between',
gap: theme.spacing(2),
padding: theme.spacing(2),
},
callbackUrl: {
fontFamily: 'monospace',
backgroundColor: theme.palette.background.default,
padding: theme.spacing(1),
borderRadius: theme.shape.borderRadius,
wordBreak: 'break-all',
fontSize: '0.875rem',
},
scopeList: {
backgroundColor: theme.palette.background.default,
borderRadius: theme.shape.borderRadius,
padding: theme.spacing(1),
},
}));
const ConsentPageLayout = ({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) => (
<Page themeId="tool">
<Header title={title} />
<Content>{children}</Content>
</Page>
);
export const ConsentPage = () => {
const classes = useStyles();
const { sessionId } = useParams<{ sessionId: string }>();
const { state, handleAction } = useConsentSession({ sessionId });
if (!sessionId) {
return (
<ConsentPageLayout title="Authorization Error">
<EmptyState
missing="data"
title="Invalid Request"
description="The consent request ID is missing or invalid."
/>
</ConsentPageLayout>
);
}
if (state.status === 'loading') {
return (
<ConsentPageLayout title="Authorization Request">
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight={300}
>
<Progress />
</Box>
</ConsentPageLayout>
);
}
if (state.status === 'error') {
return (
<ConsentPageLayout title="Authorization Error">
<ResponseErrorPanel error={new Error(state.error)} />
</ConsentPageLayout>
);
}
if (state.status === 'completed') {
return (
<ConsentPageLayout title="Authorization Complete">
<Card className={classes.authCard}>
<CardContent>
<Box textAlign="center">
{state.action === 'approve' ? (
<CheckCircleIcon
style={{ fontSize: 64, color: 'green', marginBottom: 16 }}
/>
) : (
<CancelIcon
style={{ fontSize: 64, color: 'red', marginBottom: 16 }}
/>
)}
<Typography variant="h5" gutterBottom>
{state.action === 'approve'
? 'Authorization Approved'
: 'Authorization Denied'}
</Typography>
<Typography variant="body1" color="textSecondary" gutterBottom>
{state.action === 'approve'
? 'You have successfully authorized the application to access your Backstage account.'
: 'You have denied the application access to your Backstage account.'}
</Typography>
<Typography variant="body2" color="textSecondary">
Redirecting to the application...
</Typography>
</Box>
</CardContent>
</Card>
</ConsentPageLayout>
);
}
const session = state.session;
const isSubmitting = state.status === 'submitting';
const appName = session.clientName ?? session.clientId;
return (
<ConsentPageLayout title="Authorization Request">
<Card className={classes.authCard}>
<CardContent>
<Box className={classes.appHeader}>
<AppsIcon className={classes.appIcon} />
<Box>
<Typography className={classes.appName}>{appName}</Typography>
<Typography variant="body2" color="textSecondary">
wants to access your Backstage account
</Typography>
</Box>
</Box>
<Divider />
<Alert
severity="warning"
icon={<WarningIcon />}
className={classes.securityWarning}
>
<Typography variant="body2">
<strong>Security Notice:</strong> By authorizing this application,
you are granting it access to your Backstage account. The
application will receive an access token that allows it to act on
your behalf.
</Typography>
<Box mt={1}>
<Typography variant="body2">
<strong>Callback URL:</strong>
</Typography>
<Box className={classes.callbackUrl}>{session.redirectUri}</Box>
</Box>
</Alert>
<Box mt={2}>
<Typography variant="body2" color="textSecondary">
Make sure you trust this application and recognize the callback
URL above. Only authorize applications you trust.
</Typography>
</Box>
</CardContent>
<CardActions className={classes.buttonContainer}>
<Button
variant="outlined"
color="secondary"
size="large"
disabled={isSubmitting}
onClick={() => handleAction('reject')}
startIcon={<CancelIcon />}
>
Cancel
</Button>
<Button
variant="contained"
color="primary"
size="large"
disabled={isSubmitting}
onClick={() => handleAction('approve')}
startIcon={<CheckCircleIcon />}
>
{isSubmitting ? 'Authorizing...' : 'Authorize'}
</Button>
</CardActions>
</Card>
</ConsentPageLayout>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2025 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.
*/
export { ConsentPage } from './ConsentPage';
@@ -0,0 +1,144 @@
/*
* Copyright 2025 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 {
useApi,
alertApiRef,
fetchApiRef,
discoveryApiRef,
} from '@backstage/frontend-plugin-api';
import { useCallback } from 'react';
import useAsync from 'react-use/esm/useAsync';
import useAsyncFn from 'react-use/esm/useAsyncFn';
import { isError } from '@backstage/errors';
interface Session {
id: string;
clientName?: string;
clientId: string;
redirectUri: string;
scopes?: string[];
responseType?: string;
state?: string;
nonce?: string;
codeChallenge?: string;
codeChallengeMethod?: string;
expiresAt?: string;
}
type ConsentState =
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'loaded'; session: Session }
| { status: 'submitting'; session: Session; action: 'approve' | 'reject' }
| { status: 'completed'; action: 'approve' | 'reject' };
export const useConsentSession = (opts: { sessionId?: string }) => {
const alertApi = useApi(alertApiRef);
const fetchApi = useApi(fetchApiRef);
const discoveryApi = useApi(discoveryApiRef);
const { sessionId } = opts;
const sessionState = useAsync(async () => {
if (!sessionId) {
throw new Error('Session ID is missing');
}
const baseUrl = await discoveryApi.getBaseUrl('auth');
const response = await fetchApi.fetch(
`${baseUrl}/v1/sessions/${sessionId}`,
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return (await response.json()) as Session;
}, [sessionId]);
const [actionState, handleActionInternal] = useAsyncFn(
async (action: 'approve' | 'reject', session: Session) => {
const baseUrl = await discoveryApi.getBaseUrl('auth');
const response = await fetchApi.fetch(
`${baseUrl}/v1/sessions/${session.id}/${action}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
},
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (result.redirectUrl) {
window.location.href = result.redirectUrl;
}
return { action, redirectUrl: result.redirectUrl };
},
[discoveryApi, fetchApi],
);
const getConsentState = (): ConsentState => {
if (actionState.value) {
return { status: 'completed', action: actionState.value.action };
}
if (actionState.loading && sessionState.value) {
return {
status: 'submitting',
session: sessionState.value,
action: 'approve', // This will be set properly when called
};
}
if (sessionState.error) {
return {
status: 'error',
error: isError(sessionState.error)
? sessionState.error.message
: 'Failed to load consent request',
};
}
if (sessionState.value) {
return { status: 'loaded', session: sessionState.value };
}
return { status: 'loading' };
};
const state = getConsentState();
return {
state,
handleAction: useCallback(
async (action: 'approve' | 'reject') => {
if (state.status !== 'loaded') return;
try {
await handleActionInternal(action, state.session);
} catch (err) {
alertApi.post({
message: isError(err) ? err.message : `Failed to ${action} consent`,
severity: 'error',
});
}
},
[state, handleActionInternal, alertApi],
),
};
};
+29
View File
@@ -0,0 +1,29 @@
/*
* Copyright 2025 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 { Routes, Route } from 'react-router-dom';
import { ConsentPage } from './ConsentPage';
/**
* Router component for the auth plugin
* @public
*/
export const Router = () => {
return (
<Routes>
<Route path="/authorize/:sessionId" element={<ConsentPage />} />
</Routes>
);
};
+16
View File
@@ -0,0 +1,16 @@
/*
* Copyright 2025 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.
*/
export { default } from './plugin';
+22
View File
@@ -0,0 +1,22 @@
/*
* Copyright 2025 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 { default as authPlugin } from './plugin';
describe('auth', () => {
it('should export plugin', () => {
expect(authPlugin).toBeDefined();
});
});
+36
View File
@@ -0,0 +1,36 @@
/*
* Copyright 2025 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 {
createFrontendPlugin,
PageBlueprint,
} from '@backstage/frontend-plugin-api';
import { rootRouteRef } from './routes';
export const AuthPage = PageBlueprint.make({
params: {
path: '/oauth2',
routeRef: rootRouteRef,
loader: () => import('./components/Router').then(m => <m.Router />),
},
});
export default createFrontendPlugin({
pluginId: 'auth',
extensions: [AuthPage],
routes: {
root: rootRouteRef,
},
});
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2025 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 { createRouteRef } from '@backstage/frontend-plugin-api';
export const rootRouteRef = createRouteRef();
+16
View File
@@ -0,0 +1,16 @@
/*
* Copyright 2025 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 '@testing-library/jest-dom';
+36
View File
@@ -4266,6 +4266,41 @@ __metadata:
languageName: unknown
linkType: soft
"@backstage/plugin-auth@workspace:^, @backstage/plugin-auth@workspace:plugins/auth":
version: 0.0.0-use.local
resolution: "@backstage/plugin-auth@workspace:plugins/auth"
dependencies:
"@backstage/cli": "workspace:^"
"@backstage/core-components": "workspace:^"
"@backstage/dev-utils": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/frontend-defaults": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@material-ui/core": "npm:^4.12.2"
"@material-ui/icons": "npm:^4.9.1"
"@material-ui/lab": "npm:4.0.0-alpha.61"
"@testing-library/jest-dom": "npm:^6.0.0"
"@testing-library/react": "npm:^16.0.0"
"@testing-library/user-event": "npm:^14.0.0"
"@types/react": "npm:^18.0.0"
msw: "npm:^1.0.0"
react: "npm:^18.0.2"
react-dom: "npm:^18.0.2"
react-router-dom: "npm:^6.3.0"
react-use: "npm:^17.2.4"
peerDependencies:
"@types/react": ^17.0.0 || ^18.0.0
react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0
react-router-dom: ^6.3.0
peerDependenciesMeta:
"@types/react":
optional: true
languageName: unknown
linkType: soft
"@backstage/plugin-bitbucket-cloud-common@workspace:^, @backstage/plugin-bitbucket-cloud-common@workspace:plugins/bitbucket-cloud-common":
version: 0.0.0-use.local
resolution: "@backstage/plugin-bitbucket-cloud-common@workspace:plugins/bitbucket-cloud-common"
@@ -29928,6 +29963,7 @@ __metadata:
"@backstage/plugin-api-docs": "workspace:^"
"@backstage/plugin-app": "workspace:^"
"@backstage/plugin-app-visualizer": "workspace:^"
"@backstage/plugin-auth": "workspace:^"
"@backstage/plugin-auth-react": "workspace:^"
"@backstage/plugin-catalog": "workspace:^"
"@backstage/plugin-catalog-common": "workspace:^"