Init auth-backend-module-openshift-provider
Signed-off-by: Yannik Daellenbach <git@daellenbach.org>
This commit is contained in:
@@ -37,6 +37,7 @@
|
||||
"@backstage/plugin-auth-backend": "workspace:^",
|
||||
"@backstage/plugin-auth-backend-module-github-provider": "workspace:^",
|
||||
"@backstage/plugin-auth-backend-module-guest-provider": "workspace:^",
|
||||
"@backstage/plugin-auth-backend-module-openshift-provider": "workspace:^",
|
||||
"@backstage/plugin-auth-node": "workspace:^",
|
||||
"@backstage/plugin-catalog-backend": "workspace:^",
|
||||
"@backstage/plugin-catalog-backend-module-backstage-openapi": "workspace:^",
|
||||
|
||||
@@ -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,338 @@
|
||||
/*
|
||||
* 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'],
|
||||
});
|
||||
},
|
||||
),
|
||||
http.delete(
|
||||
'https://api.openshift.test/apis/oauth.openshift.io/v1/oauthaccesstokens/:id',
|
||||
({ params }) => {
|
||||
const { id } = params;
|
||||
|
||||
if (typeof id !== 'string') {
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
|
||||
if (!id.startsWith('sha256~')) {
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
|
||||
return new Response(null, { status: 200 });
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#refresh', () => {
|
||||
it('gets new refresh token (access token)', async () => {
|
||||
const refreshResponse = await openshiftAuthenticator.refresh(
|
||||
{
|
||||
scope: 'user:full',
|
||||
refreshToken: 'access-token',
|
||||
req: {} as express.Request,
|
||||
},
|
||||
implementation,
|
||||
);
|
||||
|
||||
expect(refreshResponse.session.refreshToken).toBe('access-token');
|
||||
});
|
||||
|
||||
it('should throw error when invalid access token was provided', async () => {
|
||||
mswServer.use(
|
||||
http.get(
|
||||
'https://api.openshift.test/apis/user.openshift.io/v1/users/~',
|
||||
async () => {
|
||||
return HttpResponse.json(
|
||||
{
|
||||
kind: 'Status',
|
||||
apiVersion: 'v1',
|
||||
metadata: {},
|
||||
status: 'Failure',
|
||||
message: 'Unauthorized',
|
||||
reason: 'Unauthorized',
|
||||
code: 401,
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
openshiftAuthenticator.refresh(
|
||||
{
|
||||
scope: 'user:full',
|
||||
refreshToken: 'invalid-access-token',
|
||||
req: {} as express.Request,
|
||||
},
|
||||
implementation,
|
||||
),
|
||||
).rejects.toThrow('HTTP error! Status: 401');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#logout', () => {
|
||||
it('should delete valid access token', async () => {
|
||||
await expect(
|
||||
openshiftAuthenticator.logout?.(
|
||||
{
|
||||
refreshToken: 'access-token',
|
||||
req: {} as express.Request,
|
||||
},
|
||||
implementation,
|
||||
),
|
||||
).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when refresh token is not set', async () => {
|
||||
await expect(
|
||||
openshiftAuthenticator.logout?.(
|
||||
{
|
||||
req: {} as express.Request,
|
||||
},
|
||||
implementation,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should throw when access cannot be deleted', async () => {
|
||||
mswServer.use(
|
||||
http.delete(
|
||||
'https://api.openshift.test/apis/oauth.openshift.io/v1/oauthaccesstokens/:id',
|
||||
() => {
|
||||
return new Response(null, { status: 401 });
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
openshiftAuthenticator.logout?.(
|
||||
{
|
||||
refreshToken: 'access-token',
|
||||
req: {} as express.Request,
|
||||
},
|
||||
implementation,
|
||||
),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -4120,6 +4120,27 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@backstage/plugin-auth-backend-module-openshift-provider@workspace:^, @backstage/plugin-auth-backend-module-openshift-provider@workspace:plugins/auth-backend-module-openshift-provider":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@backstage/plugin-auth-backend-module-openshift-provider@workspace:plugins/auth-backend-module-openshift-provider"
|
||||
dependencies:
|
||||
"@backstage/backend-defaults": "workspace:^"
|
||||
"@backstage/backend-plugin-api": "workspace:^"
|
||||
"@backstage/backend-test-utils": "workspace:^"
|
||||
"@backstage/catalog-model": "workspace:^"
|
||||
"@backstage/cli": "workspace:^"
|
||||
"@backstage/config": "workspace:^"
|
||||
"@backstage/plugin-auth-backend": "workspace:^"
|
||||
"@backstage/plugin-auth-node": "workspace:^"
|
||||
"@backstage/types": "workspace:^"
|
||||
express: "npm:^4.18.2"
|
||||
msw: "npm:^2.7.3"
|
||||
passport-oauth2: "npm:^1.8.0"
|
||||
supertest: "npm:^7.1.0"
|
||||
zod: "npm:^3.24.2"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@backstage/plugin-auth-backend-module-pinniped-provider@workspace:plugins/auth-backend-module-pinniped-provider":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@backstage/plugin-auth-backend-module-pinniped-provider@workspace:plugins/auth-backend-module-pinniped-provider"
|
||||
@@ -11081,9 +11102,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@mswjs/interceptors@npm:^0.39.1":
|
||||
version: 0.39.2
|
||||
resolution: "@mswjs/interceptors@npm:0.39.2"
|
||||
"@mswjs/interceptors@npm:^0.37.0":
|
||||
version: 0.37.1
|
||||
resolution: "@mswjs/interceptors@npm:0.37.1"
|
||||
dependencies:
|
||||
"@open-draft/deferred-promise": "npm:^2.2.0"
|
||||
"@open-draft/logger": "npm:^0.3.0"
|
||||
@@ -11091,7 +11112,7 @@ __metadata:
|
||||
is-node-process: "npm:^1.2.0"
|
||||
outvariant: "npm:^1.4.3"
|
||||
strict-event-emitter: "npm:^0.5.1"
|
||||
checksum: 10/faaa95d636363a197f125c32066457fa74d5063d8ccae4c9c0e0510179060d92b1faf8640df45a0623e0bf42a30d610c83364a58e0eb0ca412c87b2e835936c1
|
||||
checksum: 10/332d8aa50beb4834ccbda6a800ca00b1204adc0eba23e1c1f7bb9f4e564a92707e563f7a2424d4a8607404ec91424e5d8c34a87c250b191ca7b24dff12eba2c5
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -26393,10 +26414,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"component-emitter@npm:^1.3.1":
|
||||
version: 1.3.1
|
||||
resolution: "component-emitter@npm:1.3.1"
|
||||
checksum: 10/94550aa462c7bd5a61c1bc480e28554aa306066930152d1b1844a0dd3845d4e5db7e261ddec62ae184913b3e59b55a2ad84093b9d3596a8f17c341514d6c483d
|
||||
"component-emitter@npm:^1.3.0":
|
||||
version: 1.3.0
|
||||
resolution: "component-emitter@npm:1.3.0"
|
||||
checksum: 10/dfc1ec2e7aa2486346c068f8d764e3eefe2e1ca0b24f57506cd93b2ae3d67829a7ebd7cc16e2bf51368fac2f45f78fcff231718e40b1975647e4a86be65e1d05
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -27620,15 +27641,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"debug@npm:4, debug@npm:^4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.6, debug@npm:^4.3.7, debug@npm:^4.4.0":
|
||||
version: 4.4.1
|
||||
resolution: "debug@npm:4.4.1"
|
||||
"debug@npm:4, debug@npm:^4, debug@npm:^4.0.0, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:^4.3.6, debug@npm:^4.4.0":
|
||||
version: 4.4.0
|
||||
resolution: "debug@npm:4.4.0"
|
||||
dependencies:
|
||||
ms: "npm:^2.1.3"
|
||||
peerDependenciesMeta:
|
||||
supports-color:
|
||||
optional: true
|
||||
checksum: 10/8e2709b2144f03c7950f8804d01ccb3786373df01e406a0f66928e47001cf2d336cbed9ee137261d4f90d68d8679468c755e3548ed83ddacdc82b194d2468afe
|
||||
checksum: 10/1847944c2e3c2c732514b93d11886575625686056cd765336212dc15de2d2b29612b6cd80e1afba767bb8e1803b778caf9973e98169ef1a24a7a7009e1820367
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -30049,6 +30070,7 @@ __metadata:
|
||||
"@backstage/plugin-auth-backend": "workspace:^"
|
||||
"@backstage/plugin-auth-backend-module-github-provider": "workspace:^"
|
||||
"@backstage/plugin-auth-backend-module-guest-provider": "workspace:^"
|
||||
"@backstage/plugin-auth-backend-module-openshift-provider": "workspace:^"
|
||||
"@backstage/plugin-auth-node": "workspace:^"
|
||||
"@backstage/plugin-catalog-backend": "workspace:^"
|
||||
"@backstage/plugin-catalog-backend-module-backstage-openapi": "workspace:^"
|
||||
@@ -31103,7 +31125,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"formidable@npm:^3.5.4":
|
||||
"formidable@npm:^3.5.1":
|
||||
version: 3.5.4
|
||||
resolution: "formidable@npm:3.5.4"
|
||||
dependencies:
|
||||
@@ -38633,15 +38655,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"msw@npm:^2.0.0, msw@npm:^2.0.8":
|
||||
version: 2.10.4
|
||||
resolution: "msw@npm:2.10.4"
|
||||
"msw@npm:^2.0.0, msw@npm:^2.0.8, msw@npm:^2.7.3":
|
||||
version: 2.7.3
|
||||
resolution: "msw@npm:2.7.3"
|
||||
dependencies:
|
||||
"@bundled-es-modules/cookie": "npm:^2.0.1"
|
||||
"@bundled-es-modules/statuses": "npm:^1.0.1"
|
||||
"@bundled-es-modules/tough-cookie": "npm:^0.1.6"
|
||||
"@inquirer/confirm": "npm:^5.0.0"
|
||||
"@mswjs/interceptors": "npm:^0.39.1"
|
||||
"@mswjs/interceptors": "npm:^0.37.0"
|
||||
"@open-draft/deferred-promise": "npm:^2.2.0"
|
||||
"@open-draft/until": "npm:^2.1.0"
|
||||
"@types/cookie": "npm:^0.6.0"
|
||||
@@ -38662,7 +38684,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
msw: cli/index.js
|
||||
checksum: 10/e2f25dda1aba66c7444c29c41d3157cb15c0332055ab7ebfb74ef4b506e7b90098cf37c577768edb5b2b2dbf0d6ed6a7a3ca8ee6da3d72df5a25823d82f33316
|
||||
checksum: 10/f193329a68fc22e477a6f8504aa44a92bd12847f2eeac1dfbd8ec1cc43ff293112ec067de1c7fe312ba02beecb313fb00aeeebf5817432b57af2d796b2dff2fa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -40612,7 +40634,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"passport-oauth2@npm:1.8.0, passport-oauth2@npm:1.x.x, passport-oauth2@npm:^1.1.2, passport-oauth2@npm:^1.4.0, passport-oauth2@npm:^1.6.0, passport-oauth2@npm:^1.6.1, passport-oauth2@npm:^1.7.0":
|
||||
"passport-oauth2@npm:1.8.0, passport-oauth2@npm:1.x.x, passport-oauth2@npm:^1.1.2, passport-oauth2@npm:^1.4.0, passport-oauth2@npm:^1.6.0, passport-oauth2@npm:^1.6.1, passport-oauth2@npm:^1.7.0, passport-oauth2@npm:^1.8.0":
|
||||
version: 1.8.0
|
||||
resolution: "passport-oauth2@npm:1.8.0"
|
||||
dependencies:
|
||||
@@ -42268,7 +42290,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"qs@npm:^6.10.1, qs@npm:^6.10.3, qs@npm:^6.11.2, qs@npm:^6.12.2, qs@npm:^6.12.3, qs@npm:^6.14.0, qs@npm:^6.7.0, qs@npm:^6.9.4":
|
||||
"qs@npm:^6.10.1, qs@npm:^6.10.3, qs@npm:^6.11.0, qs@npm:^6.11.2, qs@npm:^6.12.2, qs@npm:^6.12.3, qs@npm:^6.14.0, qs@npm:^6.7.0, qs@npm:^6.9.4":
|
||||
version: 6.14.0
|
||||
resolution: "qs@npm:6.14.0"
|
||||
dependencies:
|
||||
@@ -46507,30 +46529,30 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"superagent@npm:^10.2.3":
|
||||
version: 10.2.3
|
||||
resolution: "superagent@npm:10.2.3"
|
||||
"superagent@npm:^9.0.1":
|
||||
version: 9.0.2
|
||||
resolution: "superagent@npm:9.0.2"
|
||||
dependencies:
|
||||
component-emitter: "npm:^1.3.1"
|
||||
component-emitter: "npm:^1.3.0"
|
||||
cookiejar: "npm:^2.1.4"
|
||||
debug: "npm:^4.3.7"
|
||||
debug: "npm:^4.3.4"
|
||||
fast-safe-stringify: "npm:^2.1.1"
|
||||
form-data: "npm:^4.0.4"
|
||||
formidable: "npm:^3.5.4"
|
||||
form-data: "npm:^4.0.0"
|
||||
formidable: "npm:^3.5.1"
|
||||
methods: "npm:^1.1.2"
|
||||
mime: "npm:2.6.0"
|
||||
qs: "npm:^6.11.2"
|
||||
checksum: 10/377bf938e68927dd772169c5285be27872bf6e84fac01c52bcd9396bc5b348c9ded8f8be54649510ec09a67bc5096055847b37cb01b3bca0eb06ff1856170e35
|
||||
qs: "npm:^6.11.0"
|
||||
checksum: 10/d3c0c9051ceec84d5b431eaa410ad81bcd53255cea57af1fc66d683a24c34f3ba4761b411072a9bf489a70e3d5b586a78a0e6f2eac6a561067e7d196ddab0907
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"supertest@npm:^7.0.0":
|
||||
version: 7.1.4
|
||||
resolution: "supertest@npm:7.1.4"
|
||||
"supertest@npm:^7.0.0, supertest@npm:^7.1.0":
|
||||
version: 7.1.0
|
||||
resolution: "supertest@npm:7.1.0"
|
||||
dependencies:
|
||||
methods: "npm:^1.1.2"
|
||||
superagent: "npm:^10.2.3"
|
||||
checksum: 10/ecb5d41f2b62b257dbdcabac245c32b8e8fb264fe2636dd85c2c883569d23dc14adc0a471abb84187cbdb49bc36ad870ad355b4a0b85973f510fd57fc229e6cc
|
||||
superagent: "npm:^9.0.1"
|
||||
checksum: 10/20069f739a44821dfa4f7f397b9086ef31a358366331138f97945eedb2e231796e7c55b032125d3bd12f9839f089fbb809893dbc0f98edc57e12333b9f42b726
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -50029,10 +50051,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"zod@npm:^3.22.4, zod@npm:^3.23.8":
|
||||
version: 3.25.76
|
||||
resolution: "zod@npm:3.25.76"
|
||||
checksum: 10/f0c963ec40cd96858451d1690404d603d36507c1fc9682f2dae59ab38b578687d542708a7fdbf645f77926f78c9ed558f57c3d3aa226c285f798df0c4da16995
|
||||
"zod@npm:^3.22.4, zod@npm:^3.23.8, zod@npm:^3.24.2":
|
||||
version: 3.25.67
|
||||
resolution: "zod@npm:3.25.67"
|
||||
checksum: 10/0e35432dcca7f053e63f5dd491a87c78abe0d981817547252c3b6d05f0f58788695d1a69724759c6501dff3fd62929be24c9f314a3625179bee889150f7a61fa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user