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
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/app-defaults': minor
---
Add and configure the OpenShift authentication provider to the default APIs.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-plugin-api': minor
---
Make `openshiftAuthApiRef` available in `@backstage/core-plugin-api`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app': minor
---
Add implementation of OpenShift authentication provider.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-user-settings': patch
---
Add the OpenShift authenticator provider to the default `user-settings` providers page.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend-module-openshift-provider': minor
---
Add new `auth-backend-module-openshift-provider`. This authentication provider enables Backstage to sign in with OpenShift.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': minor
---
Make `openshiftApiRef` available to the new frontend system.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-app-api': minor
---
Add `OpenShiftAuth` helper to create default OAuth flow for OpenShift.
+1
View File
@@ -34,6 +34,7 @@ Backstage comes with many common authentication providers in the core library:
- [Okta](okta/provider.md)
- [OAuth 2 Custom Proxy](oauth2-proxy/provider.md)
- [OneLogin](onelogin/provider.md)
- [OpenShift](openshift/provider.md)
- [VMware Cloud](vmware-cloud/provider.md)
These built-in providers handle the authentication flow for a particular service, including required scopes, callbacks, etc. These providers are each added to a
+130
View File
@@ -0,0 +1,130 @@
---
id: provider
title: OpenShift Authentication Provider
sidebar_label: OpenShift
description: Adding OpenShift OAuth as an authentication provider in Backstage
---
The Backstage `core-plugin-api` package comes with a OpenShift authentication
provider that can authenticate users using OpenShift OAuth.
## Use Case
This setup enables the [Kubernetes plugin](../../features/kubernetes/index.md) to access OpenShift clusters using the user's permissions,
leveraging OAuth 2.0 _On-Behalf-Of_ flow via the [Kubernetes Client Side Provider](../../features/kubernetes/authentication.md).
To make this work, the corresponding `User` entities must exist in the Backstage catalog,
and their names must match the OpenShift users.
Although the OpenShift authentication provider does not support OIDC natively,
you can still configure it for use with the Kubernetes integration by treating it as an OIDC provider
in the `KubernetesAuthProviders` configuration.
```ts title="packages/app/src/apis.ts"
import {
KubernetesAuthProviders,
kubernetesAuthProvidersApiRef,
} from '@backstage/plugin-kubernetes';
import {
googleAuthApiRef,
microsoftAuthApiRef,
openshiftAuthApiRef,
} from '@backstage/core-plugin-api';
export const apis: AnyApiFactory[] = [
// ...
createApiFactory({
api: kubernetesAuthProvidersApiRef,
deps: {
microsoftAuthApi: microsoftAuthApiRef,
googleAuthApi: googleAuthApiRef,
openshiftAuthApi: openshiftAuthApiRef,
},
factory({ microsoftAuthApi, googleAuthApi, openshiftAuthApi }) {
return new KubernetesAuthProviders({
microsoftAuthApi,
googleAuthApi,
oidcProviders: {
openshift: {
async getIdToken(_) {
return await openshiftAuthApi.getAccessToken('user:full');
},
},
},
});
},
}),
//...
];
```
:::note Note
The OpenShift auth API does **not** implement the `OpenIdConnectApi` interface. In other words, it does **not** return an ID token.
Instead, it returns an **access token**, which is used by the Kubernetes integration in place of an ID token.
This is the only functional difference from the standard OIDC-based authentication flow.
:::
## Create an OAuth client in OpenShift
Make sure that an OAuth client exists in the OpenShift cluster.
To configure the OpenShift integration, create an [`OAuthClient`](https://docs.redhat.com/en/documentation/openshift_container_platform/latest/html/authentication_and_authorization/configuring-oauth-clients).
The redirect URI must be in the following format: `https://<fqdn>/api/auth/openshift/handler/frame`.
## Configuration
The provider configuration can then be added to your `app-config.yaml` under the
root `auth` configuration:
```yaml
auth:
environment: development
providers:
openshift:
development:
clientId: ${AUTH_OPENSHIFT_CLIENT_ID}
clientSecret: ${AUTH_OPENSHIFT_CLIENT_SECRET}
authorizationUrl: ${AUTH_OPENSHIFT_AUTHORIZATION_URL}
tokenUrl: ${AUTH_OPENSHIFT_TOKEN_URL}
openshiftApiServerUrl: ${OPENSHIFT_API_SERVER_URL}
## uncomment to set lifespan of user session
# sessionDuration: { hours: 24 } # supports `ms` library format (e.g. '24h', '2 days'), ISO duration, "human duration" as used in code
# sessionDuration: 1d
signIn:
resolvers:
- resolver: displayNameMatchingUserEntityName
```
The OpenShift provider is a structure with these configuration keys:
- `clientId`: The client ID of your OpenShift OAuth client, e.g., `my-backstage`
- `clientSecret`: The client secret tied to the OpenShift OAuth client.
- `authorizationUrl`: The OpenShift OAuth client auth endpoint, format: `https://<oauth-client-route>/oauth/authorize`.
- `tokenUrl`: The OpenShift OAuth client token endpoint, format: `https://<oauth-client-route>/oauth/token`.
- `openshiftApiServerUrl`: The OpenShift API server endpoint, format: `https://<openshift-api>`.
- `sessionDuration`: (optional): Lifespan of the user session.
- `signIn`: The configuration for the sign-in process, including the **resolvers**
that should be used to match the user from the auth provider with the user
entity in the Backstage catalog (typically a single resolver is sufficient).
The provider needs to use the scope **user:full**.
## Backend Installation
To add the provider to the backend we will first need to install the package by running this command:
```bash title="from your Backstage root directory"
yarn --cwd packages/backend add @backstage/plugin-auth-backend-module-openshift-provider
```
Then we will need to add this line:
```ts title="in packages/backend/src/index.ts"
backend.add(import('@backstage/plugin-auth-backend'));
/* highlight-add-start */
backend.add(import('@backstage/plugin-auth-backend-module-openshift-provider'));
/* highlight-add-end */
```
@@ -35,6 +35,7 @@ import {
FetchMiddlewares,
VMwareCloudAuth,
FrontendHostDiscovery,
OpenShiftAuth,
} from '@backstage/core-app-api';
import {
@@ -58,6 +59,7 @@ import {
bitbucketServerAuthApiRef,
atlassianAuthApiRef,
vmwareCloudAuthApiRef,
openshiftAuthApiRef,
} from '@backstage/core-plugin-api';
import {
permissionApiRef,
@@ -275,6 +277,22 @@ export const apis = [
});
},
}),
createApiFactory({
api: openshiftAuthApiRef,
deps: {
discoveryApi: discoveryApiRef,
oauthRequestApi: oauthRequestApiRef,
configApi: configApiRef,
},
factory: ({ discoveryApi, oauthRequestApi, configApi }) => {
return OpenShiftAuth.create({
configApi,
discoveryApi,
oauthRequestApi,
environment: configApi.getOptionalString('auth.environment'),
});
},
}),
createApiFactory({
api: permissionApiRef,
deps: {
+7
View File
@@ -23,6 +23,7 @@ import {
oneloginAuthApiRef,
bitbucketAuthApiRef,
bitbucketServerAuthApiRef,
openshiftAuthApiRef,
} from '@backstage/core-plugin-api';
export const providers = [
@@ -74,4 +75,10 @@ export const providers = [
message: 'Sign In using Bitbucket Server',
apiRef: bitbucketServerAuthApiRef,
},
{
id: 'openshift-auth-provider',
title: 'OpenShift',
message: 'Sign In using OpenShift',
apiRef: openshiftAuthApiRef,
},
];
+1
View File
@@ -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:^",
+1
View File
@@ -33,6 +33,7 @@ const searchLoader = createBackendFeatureLoader({
backend.add(import('@backstage/plugin-auth-backend'));
backend.add(import('./authModuleGithubProvider'));
backend.add(import('@backstage/plugin-auth-backend-module-guest-provider'));
backend.add(import('@backstage/plugin-auth-backend-module-openshift-provider'));
backend.add(import('@backstage/plugin-app-backend'));
backend.add(import('@backstage/plugin-catalog-backend-module-unprocessed'));
backend.add(
+7
View File
@@ -52,6 +52,7 @@ import { Observable } from '@backstage/types';
import { oktaAuthApiRef } from '@backstage/core-plugin-api';
import { oneloginAuthApiRef } from '@backstage/core-plugin-api';
import { OpenIdConnectApi } from '@backstage/core-plugin-api';
import { openshiftAuthApiRef } from '@backstage/core-plugin-api';
import { PendingOAuthRequest } from '@backstage/core-plugin-api';
import { ProfileInfo } from '@backstage/core-plugin-api';
import { ProfileInfoApi } from '@backstage/core-plugin-api';
@@ -651,6 +652,12 @@ export type OpenLoginPopupOptions = {
height?: number;
};
// @public
export class OpenShiftAuth {
// (undocumented)
static create(options: OAuthApiCreateOptions): typeof openshiftAuthApiRef.T;
}
// @public
export type PopupOptions = {
size?:
@@ -26,4 +26,5 @@ export * from './bitbucket';
export * from './bitbucketServer';
export * from './atlassian';
export * from './vmwareCloud';
export * from './openshift';
export type { OAuthApiCreateOptions, AuthApiCreateOptions } from './types';
@@ -0,0 +1,52 @@
/*
* 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 { openshiftAuthApiRef } from '@backstage/core-plugin-api';
import { OAuth2 } from '../oauth2';
import { OAuthApiCreateOptions } from '../types';
const DEFAULT_PROVIDER = {
id: 'openshift',
title: 'OpenShift',
icon: () => null,
};
/**
* Implements the OAuth flow to OpenShift
*
* @public
*/
export default class OpenShiftAuth {
static create(options: OAuthApiCreateOptions): typeof openshiftAuthApiRef.T {
const {
configApi,
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = ['user:info'],
} = options;
return OAuth2.create({
configApi,
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes,
});
}
}
@@ -0,0 +1,17 @@
/*
* 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 as OpenShiftAuth } from './OpenShiftAuth';
+5
View File
@@ -604,6 +604,11 @@ export type OpenIdConnectApi = {
getIdToken(options?: AuthRequestOptions): Promise<string>;
};
// @public
export const openshiftAuthApiRef: ApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
>;
// @public @deprecated
export type OptionalParams<
Params extends {
@@ -474,3 +474,20 @@ export const vmwareCloudAuthApiRef: ApiRef<
> = createApiRef({
id: 'core.auth.vmware-cloud',
});
/**
* Provides authentication towards OpenShift APIs and identities.
*
* @public
* @remarks
*
* See {@link https://docs.redhat.com/en/documentation/openshift_container_platform/latest/html/authentication_and_authorization/configuring-oauth-clients}
* on how to configure the OAuth clients and
* {@link https://docs.redhat.com/en/documentation/openshift_container_platform/latest/html-single/authentication_and_authorization/index#tokens-scoping-about_configuring-internal-oauth}
* for available scopes.
*/
export const openshiftAuthApiRef: ApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi
> = createApiRef({
id: 'core.auth.openshift',
});
@@ -372,6 +372,7 @@ describe('createApp', () => {
<api:app/bitbucket-server-auth out=[core.api.factory] />
<api:app/atlassian-auth out=[core.api.factory] />
<api:app/vmware-cloud-auth out=[core.api.factory] />
<api:app/openshift-auth out=[core.api.factory] />
<api:app/permission out=[core.api.factory] />
<api:app/scm-auth out=[core.api.factory] />
<api:app/scm-integrations out=[core.api.factory] />
@@ -69,6 +69,7 @@ import { OAuthScope } from '@backstage/core-plugin-api';
import { oktaAuthApiRef } from '@backstage/core-plugin-api';
import { oneloginAuthApiRef } from '@backstage/core-plugin-api';
import { OpenIdConnectApi } from '@backstage/core-plugin-api';
import { openshiftAuthApiRef } from '@backstage/core-plugin-api';
import { PendingOAuthRequest } from '@backstage/core-plugin-api';
import { ProfileInfo } from '@backstage/core-plugin-api';
import { ProfileInfoApi } from '@backstage/core-plugin-api';
@@ -1518,6 +1519,8 @@ export { oneloginAuthApiRef };
export { OpenIdConnectApi };
export { openshiftAuthApiRef };
// @public
export interface OverridableFrontendPlugin<
TRoutes extends {
@@ -37,4 +37,5 @@ export {
microsoftAuthApiRef,
oneloginAuthApiRef,
vmwareCloudAuthApiRef,
openshiftAuthApiRef,
} from '@backstage/core-plugin-api';
+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}
/>
)}
</>
);
};
+60 -38
View File
@@ -4122,6 +4122,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"
@@ -11122,9 +11143,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"
@@ -11132,7 +11153,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
@@ -26441,10 +26462,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
@@ -27668,15 +27689,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
@@ -30098,6 +30119,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:^"
@@ -31155,7 +31177,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:
@@ -38701,15 +38723,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"
@@ -38730,7 +38752,7 @@ __metadata:
optional: true
bin:
msw: cli/index.js
checksum: 10/e2f25dda1aba66c7444c29c41d3157cb15c0332055ab7ebfb74ef4b506e7b90098cf37c577768edb5b2b2dbf0d6ed6a7a3ca8ee6da3d72df5a25823d82f33316
checksum: 10/f193329a68fc22e477a6f8504aa44a92bd12847f2eeac1dfbd8ec1cc43ff293112ec067de1c7fe312ba02beecb313fb00aeeebf5817432b57af2d796b2dff2fa
languageName: node
linkType: hard
@@ -40680,7 +40702,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:
@@ -42336,7 +42358,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:
@@ -46586,30 +46608,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
@@ -50109,10 +50131,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