Merge pull request #29470 from ioboi/auth-backend-module-openshift-provider

Init auth-backend-module-openshift-provider [ContribFest]
This commit is contained in:
Patrik Oldsberg
2025-09-09 20:45:37 +02:00
committed by GitHub
38 changed files with 1120 additions and 38 deletions
+15
View File
@@ -539,6 +539,21 @@ const appPlugin: OverridableFrontendPlugin<
params: ApiFactory<TApi, TImpl, TDeps>,
) => ExtensionBlueprintParams<AnyApiFactory>;
}>;
'api:app/openshift-auth': ExtensionDefinition<{
kind: 'api';
name: 'openshift-auth';
config: {};
configInput: {};
output: ExtensionDataRef<AnyApiFactory, 'core.api.factory', {}>;
inputs: {};
params: <
TApi,
TImpl extends TApi,
TDeps extends { [name in string]: unknown },
>(
params: ApiFactory<TApi, TImpl, TDeps>,
) => ExtensionBlueprintParams<AnyApiFactory>;
}>;
'api:app/permission': ExtensionDefinition<{
kind: 'api';
name: 'permission';
+22
View File
@@ -35,6 +35,7 @@ import {
createFetchApi,
FetchMiddlewares,
VMwareCloudAuth,
OpenShiftAuth,
} from '../../../packages/core-app-api/src/apis/implementations';
import {
@@ -56,6 +57,7 @@ import {
bitbucketServerAuthApiRef,
atlassianAuthApiRef,
vmwareCloudAuthApiRef,
openshiftAuthApiRef,
} from '@backstage/core-plugin-api';
import { ApiBlueprint, dialogApiRef } from '@backstage/frontend-plugin-api';
import {
@@ -353,6 +355,26 @@ export const apis = [
},
}),
}),
ApiBlueprint.make({
name: 'openshift-auth',
params: defineParams =>
defineParams({
api: openshiftAuthApiRef,
deps: {
discoveryApi: discoveryApiRef,
oauthRequestApi: oauthRequestApiRef,
configApi: configApiRef,
},
factory: ({ discoveryApi, oauthRequestApi, configApi }) => {
return OpenShiftAuth.create({
configApi,
discoveryApi,
oauthRequestApi,
environment: configApi.getOptionalString('auth.environment'),
});
},
}),
}),
ApiBlueprint.make({
name: 'permission',
params: defineParams =>
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
@@ -0,0 +1,5 @@
# @backstage/plugin-auth-backend-module-openshift-provider
The openshift-provider backend module for the auth plugin.
_This plugin was created through the Backstage CLI_
@@ -0,0 +1,10 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: backstage-plugin-auth-backend-module-openshift-provider
title: '@backstage/plugin-auth-backend-module-openshift-provider'
description: The OpenShift backend module for the auth plugin.
spec:
lifecycle: experimental
type: backstage-backend-plugin-module
owner: auth-maintainers
@@ -0,0 +1,44 @@
/*
* 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 { HumanDuration } from '@backstage/types';
export interface Config {
auth?: {
providers?: {
/** @visibility frontend */
openshift?: {
[authEnv: string]: {
clientId: string;
/**
* @visibility secret
*/
clientSecret: string;
authorizationUrl: string;
tokenUrl: string;
callbackUrl?: string;
openshiftApiServerUrl: string;
signIn?: {
resolvers: Array<{
resolver: 'displayNameMatchingUserEntityName';
dangerouslyAllowSignInWithoutUserInCatalog?: boolean;
}>;
};
sessionDuration?: HumanDuration | string;
};
};
};
};
}
@@ -0,0 +1,26 @@
/*
* 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 { createBackend } from '@backstage/backend-defaults';
import authPlugin from '@backstage/plugin-auth-backend';
import authModuleOpenShiftProvider from '../src';
const backend = createBackend();
backend.add(authPlugin);
backend.add(authModuleOpenShiftProvider);
backend.start();
@@ -0,0 +1,55 @@
{
"name": "@backstage/plugin-auth-backend-module-openshift-provider",
"version": "0.0.0",
"description": "The OpenShift backend module for the auth plugin.",
"backstage": {
"role": "backend-plugin-module",
"pluginId": "auth",
"pluginPackage": "@backstage/plugin-auth-backend"
},
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"repository": {
"type": "git",
"url": "https://github.com/backstage/backstage",
"directory": "plugins/auth-backend-module-openshift-provider"
},
"license": "Apache-2.0",
"main": "src/index.ts",
"types": "src/index.ts",
"files": [
"dist",
"config.d.ts"
],
"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/backend-plugin-api": "workspace:^",
"@backstage/catalog-model": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@backstage/types": "workspace:^",
"passport-oauth2": "^1.8.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@backstage/backend-defaults": "workspace:^",
"@backstage/backend-test-utils": "workspace:^",
"@backstage/cli": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/plugin-auth-backend": "workspace:^",
"express": "^4.18.2",
"msw": "^2.7.3",
"supertest": "^7.1.0"
},
"configSchema": "config.d.ts"
}
@@ -0,0 +1,28 @@
## API Report File for "@backstage/plugin-auth-backend-module-openshift-provider"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { BackendFeature } from '@backstage/backend-plugin-api';
import { OAuthAuthenticator } from '@backstage/plugin-auth-node';
import { PassportOAuthAuthenticatorHelper } from '@backstage/plugin-auth-node';
import { PassportProfile } from '@backstage/plugin-auth-node';
// @public (undocumented)
const authModuleOpenshiftProvider: BackendFeature;
export default authModuleOpenshiftProvider;
// @public (undocumented)
export const openshiftAuthenticator: OAuthAuthenticator<
OpenShiftAuthenticatorContext,
PassportProfile
>;
// @public (undocumented)
export interface OpenShiftAuthenticatorContext {
// (undocumented)
helper: PassportOAuthAuthenticatorHelper;
// (undocumented)
openshiftApiServerUrl: string;
}
```
@@ -0,0 +1,226 @@
/*
* 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 { setupServer } from 'msw/node';
import {
decodeOAuthState,
encodeOAuthState,
} from '@backstage/plugin-auth-node';
import { registerMswTestHooks } from '@backstage/backend-test-utils';
import { http, HttpResponse } from 'msw';
import { openshiftAuthenticator } from './authenticator';
import { ConfigReader } from '@backstage/config';
import {
OAuthState,
OAuthAuthenticatorStartInput,
OAuthAuthenticatorAuthenticateInput,
} from '@backstage/plugin-auth-node';
import express from 'express';
describe('openshiftAuthenticator', () => {
let implementation: any;
let oauthState: OAuthState;
const mswServer = setupServer();
registerMswTestHooks(mswServer);
beforeEach(() => {
mswServer.use(
http.post('https://openshift.test/oauth/token', () => {
return HttpResponse.json({
access_token: 'accessToken',
scope: 'user:full',
expires_in: 60 * 60 * 24,
});
}),
http.get(
'https://api.openshift.test/apis/user.openshift.io/v1/users/~',
async () => {
return HttpResponse.json({
kind: 'User',
apiVersion: 'user.openshift.io/v1',
metadata: {
name: 'alice',
uid: 'ca993628-8817-4a3b-9811-be4a34c60bf4',
resourceVersion: '1',
creationTimestamp: '2022-01-11T13:10:45Z',
managedFields: [],
},
fullName: 'Alice Adams',
identities: ['SSO:id'],
groups: ['system:authenticated', 'system:authenticated:oauth'],
});
},
),
);
implementation = openshiftAuthenticator.initialize({
callbackUrl: 'https://backstage.test/callback',
config: new ConfigReader({
clientId: 'clientId',
clientSecret: 'clientSecret',
authorizationUrl: 'https://openshift.test/oauth/authorize',
tokenUrl: 'https://openshift.test/oauth/token',
openshiftApiServerUrl: 'https://api.openshift.test',
}),
});
oauthState = {
nonce: 'nonce',
env: 'env',
};
});
afterEach(() => {
jest.clearAllMocks();
});
describe('#start', () => {
let fakeSession: Record<string, any>;
let startRequest: OAuthAuthenticatorStartInput;
beforeEach(() => {
fakeSession = {};
startRequest = {
state: encodeOAuthState(oauthState),
req: {
method: 'GET',
url: 'test',
session: fakeSession,
},
} as unknown as OAuthAuthenticatorStartInput;
});
it('initiates authorization code grant', async () => {
const startResponse = await openshiftAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('response_type')).toBe('code');
});
it('passes client ID from config', async () => {
const startResponse = await openshiftAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('client_id')).toBe('clientId');
});
it('passes callback URL from config', async () => {
const startResponse = await openshiftAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('redirect_uri')).toBe(
'https://backstage.test/callback',
);
});
it('encodes OAuth state in query param', async () => {
const startResponse = await openshiftAuthenticator.start(
startRequest,
implementation,
);
const { searchParams } = new URL(startResponse.url);
const stateParam = searchParams.get('state');
const decodedState = decodeOAuthState(stateParam!);
expect(decodedState).toMatchObject(oauthState);
});
});
describe('#authenticate', () => {
let handlerRequest: OAuthAuthenticatorAuthenticateInput;
beforeEach(() => {
handlerRequest = {
req: {
method: 'GET',
query: {
code: 'authorization_code',
state: encodeOAuthState(oauthState),
},
session: {
'oauth2:openshift': {
state: encodeOAuthState(oauthState),
},
},
} as unknown as express.Request,
};
});
it('exchanges authorization code for access token', async () => {
const authenticatorResult = await openshiftAuthenticator.authenticate(
handlerRequest,
implementation,
);
const accessToken = authenticatorResult.session.accessToken;
expect(accessToken).toEqual('accessToken');
});
it('returns granted scope', async () => {
const authenticatorResult = await openshiftAuthenticator.authenticate(
handlerRequest,
implementation,
);
const responseScope = authenticatorResult.session.scope;
expect(responseScope).toEqual('user:full');
});
it('returns a default session.tokentype field', async () => {
const authenticatorResult = await openshiftAuthenticator.authenticate(
handlerRequest,
implementation,
);
const tokenType = authenticatorResult.session.tokenType;
expect(tokenType).toEqual('bearer');
});
it('returns displayName', async () => {
const authenticatorResult = await openshiftAuthenticator.authenticate(
handlerRequest,
implementation,
);
expect(authenticatorResult).toMatchObject({
fullProfile: {
displayName: 'alice',
},
});
});
it('should store access token as refresh token', async () => {
const authenticatorResult = await openshiftAuthenticator.authenticate(
handlerRequest,
implementation,
);
expect(authenticatorResult.session.refreshToken).toBe(
authenticatorResult.session.accessToken,
);
});
});
});
@@ -0,0 +1,184 @@
/*
* 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 {
createOAuthAuthenticator,
PassportOAuthAuthenticatorHelper,
PassportOAuthDoneCallback,
PassportProfile,
} from '@backstage/plugin-auth-node';
import { createHash } from 'node:crypto';
import OAuth2Strategy from 'passport-oauth2';
import { z } from 'zod';
/** @public */
export interface OpenShiftAuthenticatorContext {
openshiftApiServerUrl: string;
helper: PassportOAuthAuthenticatorHelper;
}
/** @private
* Schema for user.openshift.io/v1,
* see https://docs.redhat.com/en/documentation/openshift_container_platform/latest/html/user_and_group_apis/user-user-openshift-io-v1#user-user-openshift-io-v1
*/
const OpenShiftUser = z.object({
metadata: z.object({
name: z.string(),
}),
});
/** @public */
export const openshiftAuthenticator = createOAuthAuthenticator<
OpenShiftAuthenticatorContext,
PassportProfile
>({
defaultProfileTransform:
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
scopes: {
required: ['user:full'],
},
initialize({ callbackUrl, config }) {
const clientId = config.getString('clientId');
const clientSecret = config.getString('clientSecret');
const authorizationUrl = config.getString('authorizationUrl');
const tokenUrl = config.getString('tokenUrl');
const openshiftApiServerUrl = config.getString('openshiftApiServerUrl');
// userUrl: `${openshiftApiServerUrl}/apis/user.openshift.io/v1/users/~`,
const strategy = new OAuth2Strategy(
{
clientID: clientId,
clientSecret: clientSecret,
callbackURL: callbackUrl,
authorizationURL: authorizationUrl,
tokenURL: tokenUrl,
passReqToCallback: false,
},
(
accessToken: any,
refreshToken: string,
params: any,
fullProfile: PassportProfile,
done: PassportOAuthDoneCallback,
) => {
done(undefined, { fullProfile, params, accessToken }, { refreshToken });
},
);
strategy.userProfile = function userProfile(
accessToken: string,
done: (err?: unknown, profile?: any) => void,
): void {
this._oauth2.useAuthorizationHeaderforGET(true);
this._oauth2.get(
`${openshiftApiServerUrl}/apis/user.openshift.io/v1/users/~`,
accessToken,
(error, data, _) => {
if (error !== null && error.statusCode !== 200) {
done(new Error(`HTTP error! Status: ${error.statusCode}`));
return;
}
if (!data) {
done(new Error('No data provided!'));
return;
}
if (typeof data !== 'string') {
done(new Error('Data of type Buffer is not supported!'));
return;
}
const user = OpenShiftUser.parse(JSON.parse(data));
done(null, { displayName: user.metadata.name });
},
);
};
return {
openshiftApiServerUrl,
helper: PassportOAuthAuthenticatorHelper.from(strategy),
};
},
async start(input, { helper }) {
return helper.start(input, {
accessType: 'offline',
prompt: 'consent',
});
},
async authenticate(input, { helper }) {
// Same workaround as the GitHub provider; see https://github.com/backstage/backstage/issues/25383
const { fullProfile, session } = await helper.authenticate(input);
session.refreshToken = session.accessToken;
session.refreshTokenExpiresInSeconds = session.expiresInSeconds;
return { fullProfile, session };
},
async refresh(input, { helper }) {
// Because the session is refreshed on login, this override is crucial,
// see https://github.com/backstage/backstage/issues/25383
const accessToken = input.refreshToken;
const fullProfile = await helper.fetchProfile(accessToken).catch(error => {
if (error.oauthError?.statusCode === 401) {
throw new Error('Invalid access token');
}
throw error;
});
return {
fullProfile,
session: {
accessToken,
tokenType: 'bearer',
scope: input.scope,
refreshToken: input.refreshToken,
},
};
},
async logout(input, { openshiftApiServerUrl, helper }) {
// Due to the implementation of createOAuthRouteHandlers, only the refresh token is set.
// In this provider, the refresh token actually IS the access token.
const accessToken = input.refreshToken;
if (!accessToken) {
throw new Error('access token/refresh token needs to be set for logout');
}
// Check if access token is still valid.
try {
await helper.fetchProfile(accessToken);
} catch {
// Invalid token, no need to delete OAuthAccessToken.
return;
}
// Calculate token name, see:
// https://docs.redhat.com/en/documentation/openshift_container_platform/latest/html/oauth_apis/oauthaccesstoken-oauth-openshift-io-v1#apis-oauth-openshift-io-v1-oauthaccesstokens
const tokenName = createHash('sha256')
.update(accessToken.slice('sha256~'.length))
.digest()
.toString('base64url');
const response = await fetch(
`${openshiftApiServerUrl}/apis/oauth.openshift.io/v1/oauthaccesstokens/sha256~${tokenName}`,
{ method: 'DELETE', headers: { Authorization: `Bearer ${accessToken}` } },
);
if (response.status === 401) {
throw new Error('unauthorized');
}
},
});
@@ -0,0 +1,25 @@
/*
* 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.
*/
/**
* The openshift-provider backend module for the auth plugin.
*
* @packageDocumentation
*/
export {
openshiftAuthenticator,
type OpenShiftAuthenticatorContext,
} from './authenticator';
export { authModuleOpenshiftProvider as default } from './module';
@@ -0,0 +1,45 @@
/*
* 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 { createBackendModule } from '@backstage/backend-plugin-api';
import {
authProvidersExtensionPoint,
createOAuthProviderFactory,
} from '@backstage/plugin-auth-node';
import { openshiftAuthenticator } from './authenticator';
import { openshiftSignInResolvers } from './resolvers';
/** @public */
export const authModuleOpenshiftProvider = createBackendModule({
pluginId: 'auth',
moduleId: 'openshift-provider',
register(reg) {
reg.registerInit({
deps: { providers: authProvidersExtensionPoint },
async init({ providers }) {
providers.registerProvider({
providerId: 'openshift',
factory: createOAuthProviderFactory({
authenticator: openshiftAuthenticator,
signInResolverFactories: {
...openshiftSignInResolvers,
},
}),
});
},
});
},
});
@@ -0,0 +1,68 @@
/*
* 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 {
createSignInResolverFactory,
OAuthAuthenticatorResult,
PassportProfile,
SignInInfo,
} from '@backstage/plugin-auth-node';
import {
DEFAULT_NAMESPACE,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { z } from 'zod';
export namespace openshiftSignInResolvers {
export const displayNameMatchingUserEntityName = createSignInResolverFactory({
optionsSchema: z
.object({
dangerouslyAllowSignInWithoutUserInCatalog: z.boolean().optional(),
})
.optional(),
create(options = {}) {
return async (
info: SignInInfo<OAuthAuthenticatorResult<PassportProfile>>,
ctx,
) => {
const { displayName } = info.profile;
if (!displayName) {
throw new Error(
`OpenShift user profile does not contain a displayName`,
);
}
const userRef = stringifyEntityRef({
kind: 'User',
name: displayName,
namespace: DEFAULT_NAMESPACE,
});
return await ctx.signInWithCatalogUser(
{ entityRef: userRef },
{
dangerousEntityRefFallback:
options?.dangerouslyAllowSignInWithoutUserInCatalog
? { entityRef: { name: displayName } }
: undefined,
},
);
};
},
});
}
@@ -26,6 +26,7 @@ import {
bitbucketServerAuthApiRef,
atlassianAuthApiRef,
oneloginAuthApiRef,
openshiftAuthApiRef,
} from '@backstage/core-plugin-api';
import { userSettingsTranslationRef } from '../../translation';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
@@ -128,6 +129,14 @@ export const DefaultProviderSettings = (props: {
icon={Star}
/>
)}
{configuredProviders.includes('openshift') && (
<ProviderSettingsItem
title="OpenShift"
description="Provides authentication towards OpenShift APIs and identities"
apiRef={openshiftAuthApiRef}
icon={Star}
/>
)}
</>
);
};