From 5abc2fd4d648316e5ed063222599da4a86975627 Mon Sep 17 00:00:00 2001 From: Sabrina Lo Date: Wed, 30 Aug 2023 15:14:48 -0700 Subject: [PATCH 01/59] feat(catalog-backend-module-aws): add transformer to create the (Resource) entity for a cluster Signed-off-by: Sabrina Lo --- .changeset/twenty-masks-exist.md | 5 ++ .../catalog-backend-module-aws/api-report.md | 26 ++++++-- .../src/constants.ts | 28 +++++++++ .../catalog-backend-module-aws/src/index.ts | 1 + .../src/lib/defaultTransformers.ts | 60 ++++++++++++++++++ .../src/lib/index.ts | 16 +++++ .../src/processors/AwsEKSClusterProcessor.ts | 62 +++++++------------ .../src/processors/index.ts | 1 + .../src/processors/types.ts | 27 ++++++++ 9 files changed, 183 insertions(+), 43 deletions(-) create mode 100644 .changeset/twenty-masks-exist.md create mode 100644 plugins/catalog-backend-module-aws/src/constants.ts create mode 100644 plugins/catalog-backend-module-aws/src/lib/defaultTransformers.ts create mode 100644 plugins/catalog-backend-module-aws/src/lib/index.ts create mode 100644 plugins/catalog-backend-module-aws/src/processors/types.ts diff --git a/.changeset/twenty-masks-exist.md b/.changeset/twenty-masks-exist.md new file mode 100644 index 0000000000..c864f705d7 --- /dev/null +++ b/.changeset/twenty-masks-exist.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-aws': minor +--- + +AwsEksClusterProcessor supports Entity callback function diff --git a/plugins/catalog-backend-module-aws/api-report.md b/plugins/catalog-backend-module-aws/api-report.md index 83b072061b..5fa59d417f 100644 --- a/plugins/catalog-backend-module-aws/api-report.md +++ b/plugins/catalog-backend-module-aws/api-report.md @@ -8,7 +8,9 @@ import { AwsCredentialsManager } from '@backstage/integration-aws-node'; import { CatalogProcessor } from '@backstage/plugin-catalog-node'; import { CatalogProcessorEmit } from '@backstage/plugin-catalog-node'; import { CatalogProcessorParser } from '@backstage/plugin-catalog-node'; +import type { Cluster } from '@aws-sdk/client-eks'; import { Config } from '@backstage/config'; +import type { Entity } from '@backstage/catalog-model'; import { EntityProvider } from '@backstage/plugin-catalog-node'; import { EntityProviderConnection } from '@backstage/plugin-catalog-node'; import { LocationSpec } from '@backstage/plugin-catalog-common'; @@ -17,6 +19,12 @@ import { PluginTaskScheduler } from '@backstage/backend-tasks'; import { TaskRunner } from '@backstage/backend-tasks'; import { UrlReader } from '@backstage/backend-common'; +// @public +export const ANNOTATION_AWS_ACCOUNT_ID: string; + +// @public +export const ANNOTATION_AWS_ARN: string; + // @public export type AWSCredentialFactory = ( awsAccountId: string, @@ -24,17 +32,21 @@ export type AWSCredentialFactory = ( // @public export class AwsEKSClusterProcessor implements CatalogProcessor { - constructor(options: { + constructor(options?: { credentialsFactory?: AWSCredentialFactory; credentialsManager?: AwsCredentialsManager; + clusterEntityTransformer?: EKSClusterEntityTransformer; }); // (undocumented) - static fromConfig(configRoot: Config): AwsEKSClusterProcessor; + static fromConfig( + configRoot: Config, + options?: { + clusterEntityTransformer?: EKSClusterEntityTransformer; + }, + ): AwsEKSClusterProcessor; // (undocumented) getProcessorName(): string; // (undocumented) - normalizeName(name: string): string; - // (undocumented) readLocation( location: LocationSpec, _optional: boolean, @@ -93,4 +105,10 @@ export class AwsS3EntityProvider implements EntityProvider { // (undocumented) refresh(logger: Logger): Promise; } + +// @public +export type EKSClusterEntityTransformer = ( + cluster: Cluster, + accountId: string, +) => Promise; ``` diff --git a/plugins/catalog-backend-module-aws/src/constants.ts b/plugins/catalog-backend-module-aws/src/constants.ts new file mode 100644 index 0000000000..7756031ed7 --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/constants.ts @@ -0,0 +1,28 @@ +/* + * Copyright 2023 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. + */ + +/** + * Annotation for specifying AWS account id + * + * @public + */ +export const ANNOTATION_AWS_ACCOUNT_ID: string = 'amazonaws.com/account-id'; +/** + * Annotation for specifying AWS arn + * + * @public + */ +export const ANNOTATION_AWS_ARN: string = 'amazonaws.com/arn'; diff --git a/plugins/catalog-backend-module-aws/src/index.ts b/plugins/catalog-backend-module-aws/src/index.ts index 212308edaa..72ddc6386a 100644 --- a/plugins/catalog-backend-module-aws/src/index.ts +++ b/plugins/catalog-backend-module-aws/src/index.ts @@ -23,3 +23,4 @@ export * from './processors'; export * from './providers'; export * from './types'; +export * from './constants'; diff --git a/plugins/catalog-backend-module-aws/src/lib/defaultTransformers.ts b/plugins/catalog-backend-module-aws/src/lib/defaultTransformers.ts new file mode 100644 index 0000000000..9d1fef8ca8 --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/lib/defaultTransformers.ts @@ -0,0 +1,60 @@ +/* + * Copyright 2023 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 type { EKSClusterEntityTransformer } from '../processors/types'; +import { type Cluster } from '@aws-sdk/client-eks'; +import { + ANNOTATION_KUBERNETES_API_SERVER, + ANNOTATION_KUBERNETES_API_SERVER_CA, + ANNOTATION_KUBERNETES_AUTH_PROVIDER, +} from '@backstage/plugin-kubernetes-common'; +import { ANNOTATION_AWS_ACCOUNT_ID, ANNOTATION_AWS_ARN } from '../constants'; + +/** + * Default transformer for EKS Cluster to Resource Entity + * @public + */ +export const defaultEKSClusterTransformer: EKSClusterEntityTransformer = async ( + cluster: Cluster, + accountId: string, +) => { + const { arn, endpoint, certificateAuthority, name } = cluster; + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Resource', + metadata: { + annotations: { + [ANNOTATION_AWS_ACCOUNT_ID]: accountId, + [ANNOTATION_AWS_ARN]: arn || '', + [ANNOTATION_KUBERNETES_API_SERVER]: endpoint || '', + [ANNOTATION_KUBERNETES_API_SERVER_CA]: certificateAuthority?.data || '', + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'aws', + }, + name: normalizeName(name as string), + namespace: 'default', + }, + spec: { + type: 'kubernetes-cluster', + owner: 'unknown', + }, + }; +}; + +function normalizeName(name: string): string { + return name + .trim() + .toLocaleLowerCase('en-US') + .replace(/[^a-zA-Z0-9\-]/g, '-'); +} diff --git a/plugins/catalog-backend-module-aws/src/lib/index.ts b/plugins/catalog-backend-module-aws/src/lib/index.ts new file mode 100644 index 0000000000..1dc70c98f3 --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/lib/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 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 * from './defaultTransformers'; diff --git a/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts b/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts index 4a19b9eb7f..c5d48a7690 100644 --- a/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts +++ b/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts @@ -19,11 +19,6 @@ import { CatalogProcessorEmit, } from '@backstage/plugin-catalog-node'; import { LocationSpec } from '@backstage/plugin-catalog-common'; -import { - ANNOTATION_KUBERNETES_API_SERVER, - ANNOTATION_KUBERNETES_API_SERVER_CA, - ANNOTATION_KUBERNETES_AUTH_PROVIDER, -} from '@backstage/plugin-kubernetes-common'; import { EKS } from '@aws-sdk/client-eks'; import { AWSCredentialFactory } from '../types'; import { AwsCredentialIdentity, Provider } from '@aws-sdk/types'; @@ -33,8 +28,8 @@ import { } from '@backstage/integration-aws-node'; import { Config } from '@backstage/config'; -const ACCOUNTID_ANNOTATION: string = 'amazonaws.com/account-id'; -const ARN_ANNOTATION: string = 'amazonaws.com/arn'; +import type { EKSClusterEntityTransformer } from './types'; +import { defaultEKSClusterTransformer } from '../lib'; /** * A processor for automatic discovery of resources from EKS clusters. Handles the @@ -46,34 +41,39 @@ const ARN_ANNOTATION: string = 'amazonaws.com/arn'; export class AwsEKSClusterProcessor implements CatalogProcessor { private credentialsFactory?: AWSCredentialFactory; private credentialsManager?: AwsCredentialsManager; + private readonly clusterEntityTransformer: EKSClusterEntityTransformer; - static fromConfig(configRoot: Config): AwsEKSClusterProcessor { + static fromConfig( + configRoot: Config, + options?: { + clusterEntityTransformer?: EKSClusterEntityTransformer; + }, + ): AwsEKSClusterProcessor { const awsCredentaislManager = DefaultAwsCredentialsManager.fromConfig(configRoot); return new AwsEKSClusterProcessor({ credentialsManager: awsCredentaislManager, + ...options, }); } - constructor(options: { + constructor(options?: { credentialsFactory?: AWSCredentialFactory; credentialsManager?: AwsCredentialsManager; + clusterEntityTransformer?: EKSClusterEntityTransformer; }) { - this.credentialsFactory = options.credentialsFactory; - this.credentialsManager = options.credentialsManager; + this.credentialsFactory = options?.credentialsFactory; + this.credentialsManager = options?.credentialsManager; + + // If the callback function is not passed in, then default to the one upstream is using + this.clusterEntityTransformer = + options?.clusterEntityTransformer || defaultEKSClusterTransformer; } getProcessorName(): string { return 'aws-eks'; } - normalizeName(name: string): string { - return name - .trim() - .toLocaleLowerCase('en-US') - .replace(/[^a-zA-Z0-9\-]/g, '-'); - } - async readLocation( location: LocationSpec, _optional: boolean, @@ -119,27 +119,11 @@ export class AwsEKSClusterProcessor implements CatalogProcessor { .map(async describedClusterPromise => { const describedCluster = await describedClusterPromise; if (describedCluster.cluster) { - const entity = { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Resource', - metadata: { - annotations: { - [ACCOUNTID_ANNOTATION]: accountId, - [ARN_ANNOTATION]: describedCluster.cluster.arn || '', - [ANNOTATION_KUBERNETES_API_SERVER]: - describedCluster.cluster.endpoint || '', - [ANNOTATION_KUBERNETES_API_SERVER_CA]: - describedCluster.cluster.certificateAuthority?.data || '', - [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'aws', - }, - name: this.normalizeName(describedCluster.cluster.name as string), - namespace: 'default', - }, - spec: { - type: 'kubernetes-cluster', - owner: 'unknown', - }, - }; + const entity = await this.clusterEntityTransformer( + describedCluster.cluster, + accountId, + ); + emit({ type: 'entity', entity, diff --git a/plugins/catalog-backend-module-aws/src/processors/index.ts b/plugins/catalog-backend-module-aws/src/processors/index.ts index 63b8a47139..e5bf93ceaf 100644 --- a/plugins/catalog-backend-module-aws/src/processors/index.ts +++ b/plugins/catalog-backend-module-aws/src/processors/index.ts @@ -17,3 +17,4 @@ export { AwsEKSClusterProcessor } from './AwsEKSClusterProcessor'; export { AwsOrganizationCloudAccountProcessor } from './AwsOrganizationCloudAccountProcessor'; export { AwsS3DiscoveryProcessor } from './AwsS3DiscoveryProcessor'; +export * from './types'; diff --git a/plugins/catalog-backend-module-aws/src/processors/types.ts b/plugins/catalog-backend-module-aws/src/processors/types.ts new file mode 100644 index 0000000000..f8c3b05efe --- /dev/null +++ b/plugins/catalog-backend-module-aws/src/processors/types.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2023 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 type { Cluster } from '@aws-sdk/client-eks'; +import type { Entity } from '@backstage/catalog-model'; + +/** + * Options for the eks cluster entity callback function + * + * @public + */ +export type EKSClusterEntityTransformer = ( + cluster: Cluster, + accountId: string, +) => Promise; From 3d1b747edbf20a6ffd2ae80165804aa023862aeb Mon Sep 17 00:00:00 2001 From: Sabrina Lo Date: Wed, 30 Aug 2023 15:18:22 -0700 Subject: [PATCH 02/59] fix(catalog-backend-module-aws): pass in region when initialize EKS cluster Signed-off-by: Sabrina Lo --- .changeset/five-mangos-joke.md | 5 +++++ .../src/processors/AwsEKSClusterProcessor.ts | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/five-mangos-joke.md diff --git a/.changeset/five-mangos-joke.md b/.changeset/five-mangos-joke.md new file mode 100644 index 0000000000..e8a285f279 --- /dev/null +++ b/.changeset/five-mangos-joke.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-backend-module-aws': patch +--- + +AwsEksClusterProcessor pass in region when initialize EKS cluster diff --git a/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts b/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts index c5d48a7690..b7d5f620d0 100644 --- a/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts +++ b/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts @@ -108,6 +108,7 @@ export class AwsEKSClusterProcessor implements CatalogProcessor { const eksClient = new EKS({ credentials, credentialDefaultProvider: providerFunction, + region, }); const clusters = await eksClient.listClusters({}); if (clusters.clusters === undefined) { From 831c6c711f5949b86a27d9f169cd945fdbd4de10 Mon Sep 17 00:00:00 2001 From: Abhay-soni-developer Date: Mon, 11 Sep 2023 19:54:31 +0530 Subject: [PATCH 03/59] jenkins JobRunTable added Signed-off-by: Abhay-soni-developer --- .../app/src/components/catalog/EntityPage.tsx | 4 + .../jenkins-backend/src/service/jenkinsApi.ts | 21 ++ plugins/jenkins-backend/src/service/router.ts | 26 +++ plugins/jenkins/src/api/JenkinsApi.ts | 52 +++++ .../components/JobRunsTable/JobRunsTable.tsx | 186 ++++++++++++++++++ .../src/components/JobRunsTable/index.ts | 16 ++ plugins/jenkins/src/components/useJobRuns.ts | 81 ++++++++ plugins/jenkins/src/index.ts | 1 + plugins/jenkins/src/plugin.ts | 10 + 9 files changed, 397 insertions(+) create mode 100644 plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx create mode 100644 plugins/jenkins/src/components/JobRunsTable/index.ts create mode 100644 plugins/jenkins/src/components/useJobRuns.ts diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 25630253d4..6036f89310 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -100,6 +100,7 @@ import { EntityJenkinsContent, EntityLatestJenkinsRunCard, isJenkinsAvailable, + EntityJobRunsTable, } from '@backstage/plugin-jenkins'; import { EntityKafkaContent } from '@backstage/plugin-kafka'; import { EntityKubernetesContent } from '@backstage/plugin-kubernetes'; @@ -245,6 +246,9 @@ export const cicdContent = ( +
+
+
diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.ts b/plugins/jenkins-backend/src/service/jenkinsApi.ts index 85271462eb..4072eb6ca2 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.ts @@ -64,6 +64,16 @@ export class JenkinsApiImpl { ${JenkinsApiImpl.jobTreeSpec} ]{0,50}`; + private static readonly jobBuildsTreeSpec = ` + name, + description, + url, + fullName, + displayName, + fullDisplayName, + inQueue, + builds[*]`; + constructor(private readonly permissionApi?: PermissionEvaluator) {} /** @@ -329,4 +339,15 @@ export class JenkinsApiImpl { const jobs = jobFullName.split('/'); return `${jenkinsInfo.baseUrl}/job/${jobs.join('/job/')}/${buildId}`; } + + async getJobBuilds(jenkinsInfo: JenkinsInfo) { + const client = await JenkinsApiImpl.getClient(jenkinsInfo); + + const jobBuilds = await client.job.get({ + name: jenkinsInfo.jobFullName, + tree: JenkinsApiImpl.jobBuildsTreeSpec.replace(/\s/g, ''), + }); + + return jobBuilds; + } } diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index eea067fab6..88777ff098 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -151,6 +151,32 @@ export async function createRouter( }, ); + router.get( + '/v1/entity/:namespace/:kind/:name/job/:jobFullName', + async (request, response) => { + const token = getBearerTokenFromAuthorizationHeader( + request.header('authorization'), + ); + const { namespace, kind, name, jobFullName } = request.params; + + const jenkinsInfo = await jenkinsInfoProvider.getInstance({ + entityRef: { + kind, + namespace, + name, + }, + jobFullName, + backstageToken: token, + }); + + const build = await jenkinsApi.getJobBuilds(jenkinsInfo); + + response.json({ + build: build, + }); + }, + ); + router.post( '/v1/entity/:namespace/:kind/:name/job/:jobFullName/:buildNumber', async (request, response) => { diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts index 04bd313227..4f083fdb9d 100644 --- a/plugins/jenkins/src/api/JenkinsApi.ts +++ b/plugins/jenkins/src/api/JenkinsApi.ts @@ -56,6 +56,30 @@ export interface Build { }; status: string; // == building ? 'running' : result, } +export interface JobBuild { + timestamp: number; + building: boolean; + duration: number; + result?: string; + fullDisplayName: string; + displayName: string; + url: string; + number: number; + inProgress: boolean; + queueId: number; + id: number; +} + +export interface Job { + name: string; + displayName: string; + description: string; + fullDisplayName: string; + inQueue: boolean; + fullName: string; + url: string; + builds: JobBuild[]; +} export interface Project { // standard Jenkins @@ -67,6 +91,10 @@ export interface Project { // added by us status: string; // == inQueue ? 'queued' : lastBuild.building ? 'running' : lastBuild.result, onRestartClick: () => Promise; // TODO rename to handle.* ? also, should this be on lastBuild? + getJobBuilds(options: { + entity: CompoundEntityRef; + jobFullName: string; + }): Promise; } export interface JenkinsApi { @@ -212,4 +240,28 @@ export class JenkinsClient implements JenkinsApi { const { token } = await this.identityApi.getCredentials(); return token; } + + async getJobBuilds(options: { + entity: CompoundEntityRef; + jobFullName: string; + }): Promise { + const { entity, jobFullName } = options; + const url = `${await this.discoveryApi.getBaseUrl( + 'jenkins', + )}/v1/entity/${encodeURIComponent(entity.namespace)}/${encodeURIComponent( + entity.kind, + )}/${encodeURIComponent(entity.name)}/job/${encodeURIComponent( + jobFullName, + )}`; + + const idToken = await this.getToken(); + const response = await fetch(url, { + method: 'GET', + headers: { + ...(idToken && { Authorization: `Bearer ${idToken}` }), + }, + }); + + return (await response.json()).build; + } } diff --git a/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx b/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx new file mode 100644 index 0000000000..3bf36c2823 --- /dev/null +++ b/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx @@ -0,0 +1,186 @@ +/* + * Copyright 2020 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 { Link, Table, TableColumn } from '@backstage/core-components'; +import { Box, IconButton, Tooltip, Typography } from '@material-ui/core'; +import { default as React } from 'react'; +import JenkinsLogo from './../../assets/JenkinsLogo.svg'; +import { useJobRuns } from './../useJobRuns'; +import { Job, JobBuild } from './../../api/JenkinsApi'; +import { JenkinsRunStatus } from './../BuildsPage/lib/Status'; +import VisibilityIcon from '@material-ui/icons/Visibility'; + +const generatedColumns: TableColumn[] = [ + { + title: 'Number', + field: 'number', + render: (row: Partial) => { + return ( + + + {row.number} + + + ); + }, + }, + { + title: 'Timestamp', + field: 'timestamp', + render: (row: Partial) => { + return ( + + + {row?.timestamp ? new Date(row?.timestamp).toLocaleString() : ' '} + + + ); + }, + }, + { + title: 'Result', + field: 'result', + render: (row: Partial) => { + return ( + + {row.inProgress ? ( + In Progress + ) : ( + + )} + + ); + }, + }, + { + title: 'Duration', + field: 'duration', + render: (row: Partial) => { + return ( + + + {row?.duration + ? (row.duration / 1000).toFixed(1).toString().concat(' s') + : ''} + + + ); + }, + }, + + { + title: 'Actions', + render: (row: Partial) => { + const ActionWrapper = () => { + return ( +
+ {row?.url && ( + + + + + + )} +
+ ); + }; + return ; + }, + width: '10%', + }, +]; + +type Props = { + loading: boolean; + jobRuns?: Job; + page: number; + onChangePage: (page: number) => void; + total: number; + pageSize: number; + onChangePageSize: (pageSize: number) => void; +}; + +export const JobRunsTableView = ({ + loading, + pageSize, + page, + jobRuns, + onChangePage, + onChangePageSize, + total, +}: Props) => { + const builds = jobRuns?.builds.slice( + page * pageSize, + page * pageSize + pageSize, + ); + let sumOfAllSuccessfullJobDuration = 0; + + const successfullJobCount = + builds?.reduce((count, build) => { + if (!build.inProgress && build.result === 'SUCCESS') { + sumOfAllSuccessfullJobDuration += build.duration; + return count + 1; + } + return count; + }, 0) || 0; + + let avgTime; + + if (successfullJobCount > 0) { + avgTime = (sumOfAllSuccessfullJobDuration / successfullJobCount / 1000) + .toFixed(1) + .toString(); + } + + return ( + + + Jenkins logo + + Job Runs + + + + Average Build Time For Last {successfullJobCount} Successfull jobs + : {avgTime || 0} + + + + } + columns={generatedColumns} + /> + ); +}; + +export const JobRunsTable = () => { + const [tableProps, { setPage, setPageSize }] = useJobRuns(); + + return ( + + ); +}; diff --git a/plugins/jenkins/src/components/JobRunsTable/index.ts b/plugins/jenkins/src/components/JobRunsTable/index.ts new file mode 100644 index 0000000000..b74beeb315 --- /dev/null +++ b/plugins/jenkins/src/components/JobRunsTable/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 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 { JobRunsTable } from './JobRunsTable'; diff --git a/plugins/jenkins/src/components/useJobRuns.ts b/plugins/jenkins/src/components/useJobRuns.ts new file mode 100644 index 0000000000..0724fd7495 --- /dev/null +++ b/plugins/jenkins/src/components/useJobRuns.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2020 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 { useState } from 'react'; +import useAsyncRetry from 'react-use/lib/useAsyncRetry'; +import { jenkinsApiRef } from '../api'; +import { errorApiRef, useApi } from '@backstage/core-plugin-api'; +import { useEntity } from '@backstage/plugin-catalog-react'; +import { getCompoundEntityRef } from '@backstage/catalog-model'; +import { JENKINS_ANNOTATION, LEGACY_JENKINS_ANNOTATION } from '../constants'; + +export enum ErrorType { + CONNECTION_ERROR, + NOT_FOUND, +} + +export function useJobRuns() { + const { entity } = useEntity(); + const api = useApi(jenkinsApiRef); + const errorApi = useApi(errorApiRef); + + const [total, setTotal] = useState(0); + const [page, setPage] = useState(0); + const [pageSize, setPageSize] = useState(5); + + const [error, setError] = useState<{ + message: string; + errorType: ErrorType; + }>(); + + const jobFullName = + entity.metadata.annotations?.[JENKINS_ANNOTATION] || + entity.metadata.annotations?.[LEGACY_JENKINS_ANNOTATION] || + ''; + + const { loading, value: jobRuns } = useAsyncRetry(async () => { + try { + const jobBuilds = await api.getJobBuilds({ + entity: getCompoundEntityRef(entity), + jobFullName, + }); + + setTotal(jobBuilds.builds.length); + + return jobBuilds; + } catch (e) { + const errorType = e.notFound + ? ErrorType.NOT_FOUND + : ErrorType.CONNECTION_ERROR; + setError({ message: e.message, errorType }); + throw e; + } + }, [api, errorApi, entity]); + + return [ + { + page, + pageSize, + loading, + jobRuns, + total, + error, + }, + { + setPage, + setPageSize, + }, + ] as const; +} diff --git a/plugins/jenkins/src/index.ts b/plugins/jenkins/src/index.ts index 7562026bdc..7063485511 100644 --- a/plugins/jenkins/src/index.ts +++ b/plugins/jenkins/src/index.ts @@ -23,6 +23,7 @@ export { jenkinsPlugin, jenkinsPlugin as plugin, + EntityJobRunsTable, EntityJenkinsContent, EntityLatestJenkinsRunCard, } from './plugin'; diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index 7dbb6bfde1..6e0bd77f91 100644 --- a/plugins/jenkins/src/plugin.ts +++ b/plugins/jenkins/src/plugin.ts @@ -72,3 +72,13 @@ export const EntityLatestJenkinsRunCard = jenkinsPlugin.provide( }, }), ); + +/** @public */ +export const EntityJobRunsTable = jenkinsPlugin.provide( + createComponentExtension({ + name: 'EntityLatestJenkinsRunCard', + component: { + lazy: () => import('./components/JobRunsTable').then(m => m.JobRunsTable), + }, + }), +); From 574f3c5edeb597da354f74b78a256351031914b9 Mon Sep 17 00:00:00 2001 From: Abhay-soni-developer Date: Mon, 11 Sep 2023 20:19:50 +0530 Subject: [PATCH 04/59] jenkins api interface extended Signed-off-by: Abhay-soni-developer --- plugins/jenkins/src/api/JenkinsApi.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts index 4f083fdb9d..dec7890aac 100644 --- a/plugins/jenkins/src/api/JenkinsApi.ts +++ b/plugins/jenkins/src/api/JenkinsApi.ts @@ -126,6 +126,11 @@ export interface JenkinsApi { buildNumber: string; }): Promise; + getJobBuilds(options: { + entity: CompoundEntityRef; + jobFullName: string; + }): Promise; + retry(options: { entity: CompoundEntityRef; jobFullName: string; From 411896faf98fd54d2403deae3b6ef2ef6a13a39b Mon Sep 17 00:00:00 2001 From: Abhay-soni-developer Date: Mon, 11 Sep 2023 20:34:48 +0530 Subject: [PATCH 05/59] changeset added Signed-off-by: Abhay-soni-developer --- .changeset/perfect-cobras-bake.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/perfect-cobras-bake.md diff --git a/.changeset/perfect-cobras-bake.md b/.changeset/perfect-cobras-bake.md new file mode 100644 index 0000000000..e0a4cff04d --- /dev/null +++ b/.changeset/perfect-cobras-bake.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-jenkins-backend': minor +'@backstage/plugin-jenkins': minor +'example-app': minor +--- + +Added JobRunTable in EntityPage +Added JobRunTable component in Jenkins frontend plugin. +Added new Route and extended Api to get buildJobs. From 26761ebec49d4f868dcf506c37ac086b8d8c3b81 Mon Sep 17 00:00:00 2001 From: Abhay-soni-developer Date: Tue, 12 Sep 2023 12:45:54 +0530 Subject: [PATCH 06/59] typo fixed Signed-off-by: Abhay-soni-developer --- .../jenkins/src/components/JobRunsTable/JobRunsTable.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx b/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx index 3bf36c2823..43b866523b 100644 --- a/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx +++ b/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx @@ -127,7 +127,7 @@ export const JobRunsTableView = ({ ); let sumOfAllSuccessfullJobDuration = 0; - const successfullJobCount = + const successfulJobCount = builds?.reduce((count, build) => { if (!build.inProgress && build.result === 'SUCCESS') { sumOfAllSuccessfullJobDuration += build.duration; @@ -138,8 +138,8 @@ export const JobRunsTableView = ({ let avgTime; - if (successfullJobCount > 0) { - avgTime = (sumOfAllSuccessfullJobDuration / successfullJobCount / 1000) + if (successfulJobCount > 0) { + avgTime = (sumOfAllSuccessfullJobDuration / successfulJobCount / 1000) .toFixed(1) .toString(); } @@ -162,7 +162,7 @@ export const JobRunsTableView = ({ - Average Build Time For Last {successfullJobCount} Successfull jobs + Average Build Time For Last {successfulJobCount} Successfull jobs : {avgTime || 0} From 5d6d2531001f93faa392bd6a18a03809a88c6950 Mon Sep 17 00:00:00 2001 From: Abhay-soni-developer Date: Tue, 12 Sep 2023 20:22:03 +0530 Subject: [PATCH 07/59] type fixed Signed-off-by: Abhay-soni-developer --- .../src/components/JobRunsTable/JobRunsTable.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx b/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx index 43b866523b..352417c29d 100644 --- a/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx +++ b/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx @@ -125,12 +125,12 @@ export const JobRunsTableView = ({ page * pageSize, page * pageSize + pageSize, ); - let sumOfAllSuccessfullJobDuration = 0; + let sumOfAllSuccessfulJobDuration = 0; const successfulJobCount = builds?.reduce((count, build) => { if (!build.inProgress && build.result === 'SUCCESS') { - sumOfAllSuccessfullJobDuration += build.duration; + sumOfAllSuccessfulJobDuration += build.duration; return count + 1; } return count; @@ -139,7 +139,7 @@ export const JobRunsTableView = ({ let avgTime; if (successfulJobCount > 0) { - avgTime = (sumOfAllSuccessfullJobDuration / successfulJobCount / 1000) + avgTime = (sumOfAllSuccessfulJobDuration / successfulJobCount / 1000) .toFixed(1) .toString(); } @@ -162,8 +162,8 @@ export const JobRunsTableView = ({ - Average Build Time For Last {successfulJobCount} Successfull jobs - : {avgTime || 0} + Average Build Time For Last {successfulJobCount} Successful jobs :{' '} + {avgTime || 0} From 9745ccf7d30bb8154ee30e9a170a6cb735b0c8a4 Mon Sep 17 00:00:00 2001 From: Sabrina Lo Date: Tue, 12 Sep 2023 15:31:01 -0700 Subject: [PATCH 08/59] chore: address PR comments about Updating new EKS name to eks and make the AwsEKSClusterProcessor constructor parameter nonoptional Signed-off-by: Sabrina Lo --- .../catalog-backend-module-aws/api-report.md | 8 ++-- .../src/lib/defaultTransformers.ts | 47 +++++++++---------- .../src/processors/AwsEKSClusterProcessor.ts | 18 +++---- .../src/processors/types.ts | 4 +- 4 files changed, 38 insertions(+), 39 deletions(-) diff --git a/plugins/catalog-backend-module-aws/api-report.md b/plugins/catalog-backend-module-aws/api-report.md index 5fa59d417f..a7a3f6816a 100644 --- a/plugins/catalog-backend-module-aws/api-report.md +++ b/plugins/catalog-backend-module-aws/api-report.md @@ -32,16 +32,16 @@ export type AWSCredentialFactory = ( // @public export class AwsEKSClusterProcessor implements CatalogProcessor { - constructor(options?: { + constructor(options: { credentialsFactory?: AWSCredentialFactory; credentialsManager?: AwsCredentialsManager; - clusterEntityTransformer?: EKSClusterEntityTransformer; + clusterEntityTransformer?: EksClusterEntityTransformer; }); // (undocumented) static fromConfig( configRoot: Config, options?: { - clusterEntityTransformer?: EKSClusterEntityTransformer; + clusterEntityTransformer?: EksClusterEntityTransformer; }, ): AwsEKSClusterProcessor; // (undocumented) @@ -107,7 +107,7 @@ export class AwsS3EntityProvider implements EntityProvider { } // @public -export type EKSClusterEntityTransformer = ( +export type EksClusterEntityTransformer = ( cluster: Cluster, accountId: string, ) => Promise; diff --git a/plugins/catalog-backend-module-aws/src/lib/defaultTransformers.ts b/plugins/catalog-backend-module-aws/src/lib/defaultTransformers.ts index 9d1fef8ca8..9de79e9811 100644 --- a/plugins/catalog-backend-module-aws/src/lib/defaultTransformers.ts +++ b/plugins/catalog-backend-module-aws/src/lib/defaultTransformers.ts @@ -13,44 +13,43 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import type { EKSClusterEntityTransformer } from '../processors/types'; import { type Cluster } from '@aws-sdk/client-eks'; import { ANNOTATION_KUBERNETES_API_SERVER, ANNOTATION_KUBERNETES_API_SERVER_CA, ANNOTATION_KUBERNETES_AUTH_PROVIDER, } from '@backstage/plugin-kubernetes-common'; +import type { EksClusterEntityTransformer } from '../processors/types'; import { ANNOTATION_AWS_ACCOUNT_ID, ANNOTATION_AWS_ARN } from '../constants'; /** * Default transformer for EKS Cluster to Resource Entity * @public */ -export const defaultEKSClusterTransformer: EKSClusterEntityTransformer = async ( - cluster: Cluster, - accountId: string, -) => { - const { arn, endpoint, certificateAuthority, name } = cluster; - return { - apiVersion: 'backstage.io/v1alpha1', - kind: 'Resource', - metadata: { - annotations: { - [ANNOTATION_AWS_ACCOUNT_ID]: accountId, - [ANNOTATION_AWS_ARN]: arn || '', - [ANNOTATION_KUBERNETES_API_SERVER]: endpoint || '', - [ANNOTATION_KUBERNETES_API_SERVER_CA]: certificateAuthority?.data || '', - [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'aws', +export const defaultEksClusterEntityTransformer: EksClusterEntityTransformer = + async (cluster: Cluster, accountId: string) => { + const { arn, endpoint, certificateAuthority, name } = cluster; + return { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Resource', + metadata: { + annotations: { + [ANNOTATION_AWS_ACCOUNT_ID]: accountId, + [ANNOTATION_AWS_ARN]: arn || '', + [ANNOTATION_KUBERNETES_API_SERVER]: endpoint || '', + [ANNOTATION_KUBERNETES_API_SERVER_CA]: + certificateAuthority?.data || '', + [ANNOTATION_KUBERNETES_AUTH_PROVIDER]: 'aws', + }, + name: normalizeName(name as string), + namespace: 'default', }, - name: normalizeName(name as string), - namespace: 'default', - }, - spec: { - type: 'kubernetes-cluster', - owner: 'unknown', - }, + spec: { + type: 'kubernetes-cluster', + owner: 'unknown', + }, + }; }; -}; function normalizeName(name: string): string { return name diff --git a/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts b/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts index b7d5f620d0..a491dbab7d 100644 --- a/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts +++ b/plugins/catalog-backend-module-aws/src/processors/AwsEKSClusterProcessor.ts @@ -28,8 +28,8 @@ import { } from '@backstage/integration-aws-node'; import { Config } from '@backstage/config'; -import type { EKSClusterEntityTransformer } from './types'; -import { defaultEKSClusterTransformer } from '../lib'; +import type { EksClusterEntityTransformer } from './types'; +import { defaultEksClusterEntityTransformer } from '../lib'; /** * A processor for automatic discovery of resources from EKS clusters. Handles the @@ -41,12 +41,12 @@ import { defaultEKSClusterTransformer } from '../lib'; export class AwsEKSClusterProcessor implements CatalogProcessor { private credentialsFactory?: AWSCredentialFactory; private credentialsManager?: AwsCredentialsManager; - private readonly clusterEntityTransformer: EKSClusterEntityTransformer; + private readonly clusterEntityTransformer: EksClusterEntityTransformer; static fromConfig( configRoot: Config, options?: { - clusterEntityTransformer?: EKSClusterEntityTransformer; + clusterEntityTransformer?: EksClusterEntityTransformer; }, ): AwsEKSClusterProcessor { const awsCredentaislManager = @@ -57,17 +57,17 @@ export class AwsEKSClusterProcessor implements CatalogProcessor { }); } - constructor(options?: { + constructor(options: { credentialsFactory?: AWSCredentialFactory; credentialsManager?: AwsCredentialsManager; - clusterEntityTransformer?: EKSClusterEntityTransformer; + clusterEntityTransformer?: EksClusterEntityTransformer; }) { - this.credentialsFactory = options?.credentialsFactory; - this.credentialsManager = options?.credentialsManager; + this.credentialsFactory = options.credentialsFactory; + this.credentialsManager = options.credentialsManager; // If the callback function is not passed in, then default to the one upstream is using this.clusterEntityTransformer = - options?.clusterEntityTransformer || defaultEKSClusterTransformer; + options.clusterEntityTransformer || defaultEksClusterEntityTransformer; } getProcessorName(): string { diff --git a/plugins/catalog-backend-module-aws/src/processors/types.ts b/plugins/catalog-backend-module-aws/src/processors/types.ts index f8c3b05efe..22e9e4982d 100644 --- a/plugins/catalog-backend-module-aws/src/processors/types.ts +++ b/plugins/catalog-backend-module-aws/src/processors/types.ts @@ -17,11 +17,11 @@ import type { Cluster } from '@aws-sdk/client-eks'; import type { Entity } from '@backstage/catalog-model'; /** - * Options for the eks cluster entity callback function + * Options for the EKS cluster entity callback function * * @public */ -export type EKSClusterEntityTransformer = ( +export type EksClusterEntityTransformer = ( cluster: Cluster, accountId: string, ) => Promise; From 4c948955dd8fe2b35496157e2f94d8c54f5993f6 Mon Sep 17 00:00:00 2001 From: Abhay-soni-developer Date: Thu, 14 Sep 2023 20:17:51 +0530 Subject: [PATCH 09/59] readme modified Signed-off-by: Abhay-soni-developer --- plugins/jenkins/README.md | 12 +++++++++++- plugins/jenkins/src/assets/jobrun-table.png | Bin 0 -> 184331 bytes 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 plugins/jenkins/src/assets/jobrun-table.png diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index 2a0ec451c8..b3e328da65 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -5,6 +5,7 @@ Website: [https://jenkins.io/](https://jenkins.io/) Last master build Folder results Build details +Job builds records ## Setup @@ -27,6 +28,7 @@ import { EntityJenkinsContent, EntityLatestJenkinsRunCard, isJenkinsAvailable, + EntityJobRunsTable, } from '@backstage/plugin-jenkins'; // You can add the tab to any number of pages, the service page is shown as an @@ -52,6 +54,7 @@ const serviceEntityPage = ( + {/* ... */} @@ -74,7 +77,9 @@ metadata: name: 'your-component' description: 'a description' annotations: - jenkins.io/github-folder: 'folder-name/project-name' + jenkins.io/github-folder: 'folder-name/project-name' # deprecated + jenkins.io/job-full-name: 'folder-name/project-name' # use this instead + spec: type: service lifecycle: experimental @@ -97,3 +102,8 @@ spec: - Only works with organization folder projects backed by GitHub - No pagination support currently, limited to 50 projects - don't run this on a Jenkins instance with lots of builds + +## EntityJobRunsTable + +- View all builds of a particular job +- shows average build for successful builds diff --git a/plugins/jenkins/src/assets/jobrun-table.png b/plugins/jenkins/src/assets/jobrun-table.png new file mode 100644 index 0000000000000000000000000000000000000000..9fff841c3bdbab53c8f43bd218c2e900290ac14b GIT binary patch literal 184331 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%0A%mWuuy-c1JJmNbyFA%6U+#Eg{`ECBTABRUFNhc6 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%0DL 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�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 literal 0 HcmV?d00001 From e0546aae058f47c6fcbb96edf0ab00410cb2d6c7 Mon Sep 17 00:00:00 2001 From: Abhay-soni-developer Date: Fri, 15 Sep 2023 11:59:45 +0530 Subject: [PATCH 10/59] fixed changeset Signed-off-by: Abhay-soni-developer --- .changeset/perfect-cobras-bake.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/.changeset/perfect-cobras-bake.md b/.changeset/perfect-cobras-bake.md index e0a4cff04d..c6b8f99d18 100644 --- a/.changeset/perfect-cobras-bake.md +++ b/.changeset/perfect-cobras-bake.md @@ -1,9 +1,7 @@ --- '@backstage/plugin-jenkins-backend': minor '@backstage/plugin-jenkins': minor -'example-app': minor --- Added JobRunTable in EntityPage -Added JobRunTable component in Jenkins frontend plugin. Added new Route and extended Api to get buildJobs. From 58f0cb6812547aa833e90264eaf1736d069a7562 Mon Sep 17 00:00:00 2001 From: Abhay-soni-developer Date: Tue, 19 Sep 2023 13:19:05 +0530 Subject: [PATCH 11/59] api docs Signed-off-by: Abhay-soni-developer --- plugins/jenkins/api-report.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/plugins/jenkins/api-report.md b/plugins/jenkins/api-report.md index 0782f0abd2..34d5ac731e 100644 --- a/plugins/jenkins/api-report.md +++ b/plugins/jenkins/api-report.md @@ -19,6 +19,9 @@ import { RouteRef } from '@backstage/core-plugin-api'; // @public (undocumented) export const EntityJenkinsContent: () => JSX_2.Element; +// @public (undocumented) +export const EntityJobRunsTable: () => JSX_2.Element; + // @public (undocumented) export const EntityLatestJenkinsRunCard: (props: { branch: string; @@ -45,6 +48,13 @@ export interface JenkinsApi { jobFullName: string; buildNumber: string; }): Promise; + // Warning: (ae-forgotten-export) The symbol "Job" needs to be exported by the entry point index.d.ts + // + // (undocumented) + getJobBuilds(options: { + entity: CompoundEntityRef; + jobFullName: string; + }): Promise; // Warning: (ae-forgotten-export) The symbol "Project" needs to be exported by the entry point index.d.ts getProjects(options: { entity: CompoundEntityRef; @@ -80,6 +90,11 @@ export class JenkinsClient implements JenkinsApi { buildNumber: string; }): Promise; // (undocumented) + getJobBuilds(options: { + entity: CompoundEntityRef; + jobFullName: string; + }): Promise; + // (undocumented) getProjects(options: { entity: CompoundEntityRef; filter: { From 6ef6b1e7e43bf56117768c6b91ea8f80322c55fb Mon Sep 17 00:00:00 2001 From: Abhay-soni-developer Date: Wed, 20 Sep 2023 13:21:49 +0530 Subject: [PATCH 12/59] removed unneeded member from interface Signed-off-by: Abhay-soni-developer --- plugins/jenkins/src/api/JenkinsApi.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/plugins/jenkins/src/api/JenkinsApi.ts b/plugins/jenkins/src/api/JenkinsApi.ts index dec7890aac..3d4017dec5 100644 --- a/plugins/jenkins/src/api/JenkinsApi.ts +++ b/plugins/jenkins/src/api/JenkinsApi.ts @@ -91,10 +91,6 @@ export interface Project { // added by us status: string; // == inQueue ? 'queued' : lastBuild.building ? 'running' : lastBuild.result, onRestartClick: () => Promise; // TODO rename to handle.* ? also, should this be on lastBuild? - getJobBuilds(options: { - entity: CompoundEntityRef; - jobFullName: string; - }): Promise; } export interface JenkinsApi { From 863cc1c99aebef03ccba43d9cad77507c194a466 Mon Sep 17 00:00:00 2001 From: Abhay-soni-developer Date: Wed, 20 Sep 2023 14:44:07 +0530 Subject: [PATCH 13/59] added icon in EntityJenkinsContent which when clicked will take you to JobRunsTable, with this approach we can support multiple jobs in this plugin in future. Signed-off-by: Abhay-soni-developer --- .../app/src/components/catalog/EntityPage.tsx | 4 ---- .../BuildsPage/lib/CITable/CITable.tsx | 17 +++++++++++++++-- .../components/JobRunsTable/JobRunsTable.tsx | 5 ++++- plugins/jenkins/src/components/Router.tsx | 4 +++- plugins/jenkins/src/components/useJobRuns.ts | 8 +------- plugins/jenkins/src/index.ts | 1 - plugins/jenkins/src/plugin.ts | 17 +++++++---------- 7 files changed, 30 insertions(+), 26 deletions(-) diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 6036f89310..25630253d4 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -100,7 +100,6 @@ import { EntityJenkinsContent, EntityLatestJenkinsRunCard, isJenkinsAvailable, - EntityJobRunsTable, } from '@backstage/plugin-jenkins'; import { EntityKafkaContent } from '@backstage/plugin-kafka'; import { EntityKubernetesContent } from '@backstage/plugin-kubernetes'; @@ -246,9 +245,6 @@ export const cicdContent = ( -
-
-
diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx index f82ce2e7ec..7c160ebcc7 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -19,10 +19,11 @@ import { useEntityPermission } from '@backstage/plugin-catalog-react/alpha'; import { Box, IconButton, Tooltip, Typography } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; import VisibilityIcon from '@material-ui/icons/Visibility'; +import HistoryIcon from '@material-ui/icons/History'; import { default as React, useState } from 'react'; import { Project } from '../../../../api/JenkinsApi'; import JenkinsLogo from '../../../../assets/JenkinsLogo.svg'; -import { buildRouteRef } from '../../../../plugin'; +import { buildRouteRef, jobRunsRouteRef } from '../../../../plugin'; import { useBuilds } from '../../../useBuilds'; import { JenkinsRunStatus } from '../Status'; import { jenkinsExecutePermission } from '@backstage/plugin-jenkins-common'; @@ -182,6 +183,7 @@ const generatedColumns: TableColumn[] = [ ); const alertApi = useApi(alertApiRef); + const jobRunsLink = useRouteRef(jobRunsRouteRef); const onRebuild = async () => { if (row.onRestartClick) { @@ -205,7 +207,7 @@ const generatedColumns: TableColumn[] = [ }; return ( -
+
{row.lastBuild?.url && ( @@ -221,6 +223,17 @@ const generatedColumns: TableColumn[] = [ )} + + + + + + +
); }; diff --git a/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx b/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx index 352417c29d..6861a5854d 100644 --- a/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx +++ b/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx @@ -21,6 +21,8 @@ import { useJobRuns } from './../useJobRuns'; import { Job, JobBuild } from './../../api/JenkinsApi'; import { JenkinsRunStatus } from './../BuildsPage/lib/Status'; import VisibilityIcon from '@material-ui/icons/Visibility'; +import { jobRunsRouteRef } from '../../plugin'; +import { useRouteRefParams } from '@backstage/core-plugin-api'; const generatedColumns: TableColumn[] = [ { @@ -174,7 +176,8 @@ export const JobRunsTableView = ({ }; export const JobRunsTable = () => { - const [tableProps, { setPage, setPageSize }] = useJobRuns(); + const { jobFullName } = useRouteRefParams(jobRunsRouteRef); + const [tableProps, { setPage, setPageSize }] = useJobRuns(jobFullName); return ( @@ -39,6 +40,7 @@ export const Router = () => { return ( } /> + } /> } /> ); diff --git a/plugins/jenkins/src/components/useJobRuns.ts b/plugins/jenkins/src/components/useJobRuns.ts index 0724fd7495..183524c8a8 100644 --- a/plugins/jenkins/src/components/useJobRuns.ts +++ b/plugins/jenkins/src/components/useJobRuns.ts @@ -19,14 +19,13 @@ import { jenkinsApiRef } from '../api'; import { errorApiRef, useApi } from '@backstage/core-plugin-api'; import { useEntity } from '@backstage/plugin-catalog-react'; import { getCompoundEntityRef } from '@backstage/catalog-model'; -import { JENKINS_ANNOTATION, LEGACY_JENKINS_ANNOTATION } from '../constants'; export enum ErrorType { CONNECTION_ERROR, NOT_FOUND, } -export function useJobRuns() { +export function useJobRuns(jobFullName: string) { const { entity } = useEntity(); const api = useApi(jenkinsApiRef); const errorApi = useApi(errorApiRef); @@ -40,11 +39,6 @@ export function useJobRuns() { errorType: ErrorType; }>(); - const jobFullName = - entity.metadata.annotations?.[JENKINS_ANNOTATION] || - entity.metadata.annotations?.[LEGACY_JENKINS_ANNOTATION] || - ''; - const { loading, value: jobRuns } = useAsyncRetry(async () => { try { const jobBuilds = await api.getJobBuilds({ diff --git a/plugins/jenkins/src/index.ts b/plugins/jenkins/src/index.ts index 7063485511..7562026bdc 100644 --- a/plugins/jenkins/src/index.ts +++ b/plugins/jenkins/src/index.ts @@ -23,7 +23,6 @@ export { jenkinsPlugin, jenkinsPlugin as plugin, - EntityJobRunsTable, EntityJenkinsContent, EntityLatestJenkinsRunCard, } from './plugin'; diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index 6e0bd77f91..046d3f7eb6 100644 --- a/plugins/jenkins/src/plugin.ts +++ b/plugins/jenkins/src/plugin.ts @@ -38,6 +38,13 @@ export const buildRouteRef = createSubRouteRef({ parent: rootRouteRef, }); +/** @public */ +export const jobRunsRouteRef = createSubRouteRef({ + id: 'jenkins/job/runs', + path: '/builds/:jobFullName/runs', + parent: rootRouteRef, +}); + /** @public */ export const jenkinsPlugin = createPlugin({ id: 'jenkins', @@ -72,13 +79,3 @@ export const EntityLatestJenkinsRunCard = jenkinsPlugin.provide( }, }), ); - -/** @public */ -export const EntityJobRunsTable = jenkinsPlugin.provide( - createComponentExtension({ - name: 'EntityLatestJenkinsRunCard', - component: { - lazy: () => import('./components/JobRunsTable').then(m => m.JobRunsTable), - }, - }), -); From 693ef6e5f0196bbfc0a98ac05a16555bc2e345a0 Mon Sep 17 00:00:00 2001 From: Abhay-soni-developer Date: Wed, 20 Sep 2023 14:55:58 +0530 Subject: [PATCH 14/59] readme and changeset modified according to the new changes Signed-off-by: Abhay-soni-developer --- .changeset/perfect-cobras-bake.md | 4 +++- plugins/jenkins/README.md | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/perfect-cobras-bake.md b/.changeset/perfect-cobras-bake.md index c6b8f99d18..bf66c12e83 100644 --- a/.changeset/perfect-cobras-bake.md +++ b/.changeset/perfect-cobras-bake.md @@ -3,5 +3,7 @@ '@backstage/plugin-jenkins': minor --- -Added JobRunTable in EntityPage +Added JobRunTable Component. Added new Route and extended Api to get buildJobs. +Actions column has a new icon button, clicking on which takes us to page where we +can see all the job runs. diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index b3e328da65..73602e3464 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -28,7 +28,6 @@ import { EntityJenkinsContent, EntityLatestJenkinsRunCard, isJenkinsAvailable, - EntityJobRunsTable, } from '@backstage/plugin-jenkins'; // You can add the tab to any number of pages, the service page is shown as an @@ -54,7 +53,6 @@ const serviceEntityPage = ( - {/* ... */} @@ -106,4 +104,4 @@ spec: ## EntityJobRunsTable - View all builds of a particular job -- shows average build for successful builds +- shows average build time for successful builds From 2ce1dceac940d34eeb31048aa5d0e1f708700232 Mon Sep 17 00:00:00 2001 From: Abhay-soni-developer Date: Wed, 20 Sep 2023 15:14:05 +0530 Subject: [PATCH 15/59] api-report modified Signed-off-by: Abhay-soni-developer --- plugins/jenkins/api-report.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/plugins/jenkins/api-report.md b/plugins/jenkins/api-report.md index 34d5ac731e..0ef3ec6fcb 100644 --- a/plugins/jenkins/api-report.md +++ b/plugins/jenkins/api-report.md @@ -19,9 +19,6 @@ import { RouteRef } from '@backstage/core-plugin-api'; // @public (undocumented) export const EntityJenkinsContent: () => JSX_2.Element; -// @public (undocumented) -export const EntityJobRunsTable: () => JSX_2.Element; - // @public (undocumented) export const EntityLatestJenkinsRunCard: (props: { branch: string; From 12c9fd6b7650c000498d82ed04c040b817e0390c Mon Sep 17 00:00:00 2001 From: Sabrina Lo Date: Tue, 3 Oct 2023 09:59:15 -0700 Subject: [PATCH 16/59] chore: having only 1 changeset Signed-off-by: Sabrina Lo --- .changeset/five-mangos-joke.md | 5 ----- .changeset/twenty-masks-exist.md | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 .changeset/five-mangos-joke.md diff --git a/.changeset/five-mangos-joke.md b/.changeset/five-mangos-joke.md deleted file mode 100644 index e8a285f279..0000000000 --- a/.changeset/five-mangos-joke.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog-backend-module-aws': patch ---- - -AwsEksClusterProcessor pass in region when initialize EKS cluster diff --git a/.changeset/twenty-masks-exist.md b/.changeset/twenty-masks-exist.md index c864f705d7..7e78cf0dec 100644 --- a/.changeset/twenty-masks-exist.md +++ b/.changeset/twenty-masks-exist.md @@ -2,4 +2,4 @@ '@backstage/plugin-catalog-backend-module-aws': minor --- -AwsEksClusterProcessor supports Entity callback function +AwsEksClusterProcessor supports Entity callback function and passes in region when initialize EKS cluster From 8e4af04300a82ee36b68507d6a183c3287c6392b Mon Sep 17 00:00:00 2001 From: Abhay-soni-developer Date: Thu, 5 Oct 2023 18:17:28 +0530 Subject: [PATCH 17/59] changes according to feedback Signed-off-by: Abhay-soni-developer --- .../BuildsPage/lib/CITable/CITable.tsx | 16 ++++++++++++++++ .../src/components/JobRunsTable/JobRunsTable.tsx | 2 +- plugins/jenkins/src/components/useJobRuns.ts | 9 +++++---- plugins/jenkins/src/index.ts | 1 + plugins/jenkins/src/plugin.ts | 10 ++++++++++ 5 files changed, 33 insertions(+), 5 deletions(-) diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx index 7c160ebcc7..2dab02156f 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/CITable.tsx @@ -148,6 +148,22 @@ const generatedColumns: TableColumn[] = [ ); }, }, + { + title: 'Last Run Duration', + align: 'center', + render: (row: Partial) => ( + <> + + {row?.lastBuild?.duration + ? (row?.lastBuild?.duration / 1000) + .toFixed(1) + .toString() + .concat(' s') + : ''}{' '} + + + ), + }, { title: 'Tests', sorting: false, diff --git a/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx b/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx index 6861a5854d..73f2e33fd5 100644 --- a/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx +++ b/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx @@ -160,7 +160,7 @@ export const JobRunsTableView = ({ Jenkins logo - Job Runs + {`${jobRuns?.displayName} Runs`} diff --git a/plugins/jenkins/src/components/useJobRuns.ts b/plugins/jenkins/src/components/useJobRuns.ts index 183524c8a8..5b4739ae67 100644 --- a/plugins/jenkins/src/components/useJobRuns.ts +++ b/plugins/jenkins/src/components/useJobRuns.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import useAsyncRetry from 'react-use/lib/useAsyncRetry'; import { jenkinsApiRef } from '../api'; import { errorApiRef, useApi } from '@backstage/core-plugin-api'; @@ -45,9 +45,6 @@ export function useJobRuns(jobFullName: string) { entity: getCompoundEntityRef(entity), jobFullName, }); - - setTotal(jobBuilds.builds.length); - return jobBuilds; } catch (e) { const errorType = e.notFound @@ -58,6 +55,10 @@ export function useJobRuns(jobFullName: string) { } }, [api, errorApi, entity]); + useEffect(() => { + if (jobRuns) setTotal(jobRuns.builds.length); + }, [jobRuns]); + return [ { page, diff --git a/plugins/jenkins/src/index.ts b/plugins/jenkins/src/index.ts index 7562026bdc..9d092014a0 100644 --- a/plugins/jenkins/src/index.ts +++ b/plugins/jenkins/src/index.ts @@ -25,6 +25,7 @@ export { jenkinsPlugin as plugin, EntityJenkinsContent, EntityLatestJenkinsRunCard, + EntityJobRunsTable, } from './plugin'; export { LatestRunCard } from './components/Cards'; export { diff --git a/plugins/jenkins/src/plugin.ts b/plugins/jenkins/src/plugin.ts index 046d3f7eb6..1b666da9a9 100644 --- a/plugins/jenkins/src/plugin.ts +++ b/plugins/jenkins/src/plugin.ts @@ -79,3 +79,13 @@ export const EntityLatestJenkinsRunCard = jenkinsPlugin.provide( }, }), ); + +/** @public */ +export const EntityJobRunsTable = jenkinsPlugin.provide( + createComponentExtension({ + name: 'EntityJobRunsTable', + component: { + lazy: () => import('./components/JobRunsTable').then(m => m.JobRunsTable), + }, + }), +); From 0ed546c2015a390b5db6736c4e54fc4afc6f4f00 Mon Sep 17 00:00:00 2001 From: Abhay-soni-developer Date: Thu, 5 Oct 2023 18:35:04 +0530 Subject: [PATCH 18/59] api-report modified Signed-off-by: Abhay-soni-developer --- plugins/jenkins/api-report.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/jenkins/api-report.md b/plugins/jenkins/api-report.md index 0ef3ec6fcb..34d5ac731e 100644 --- a/plugins/jenkins/api-report.md +++ b/plugins/jenkins/api-report.md @@ -19,6 +19,9 @@ import { RouteRef } from '@backstage/core-plugin-api'; // @public (undocumented) export const EntityJenkinsContent: () => JSX_2.Element; +// @public (undocumented) +export const EntityJobRunsTable: () => JSX_2.Element; + // @public (undocumented) export const EntityLatestJenkinsRunCard: (props: { branch: string; From 126990789f972b7d62f81036383514342059ae37 Mon Sep 17 00:00:00 2001 From: Abhay-soni-developer Date: Wed, 11 Oct 2023 13:44:43 +0530 Subject: [PATCH 19/59] modifications Signed-off-by: Abhay-soni-developer --- .../BuildsPage/lib/CITable/columns.tsx | 36 +++++++++++++++++-- .../BuildsPage/lib/CITable/presets.ts | 1 + 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/columns.tsx b/plugins/jenkins/src/components/BuildsPage/lib/CITable/columns.tsx index 733a60c1b2..f18d3e5b21 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/columns.tsx +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/columns.tsx @@ -19,9 +19,10 @@ import { useEntityPermission } from '@backstage/plugin-catalog-react/alpha'; import { Box, IconButton, Tooltip, Typography } from '@material-ui/core'; import RetryIcon from '@material-ui/icons/Replay'; import VisibilityIcon from '@material-ui/icons/Visibility'; +import HistoryIcon from '@material-ui/icons/History'; import { default as React, useState } from 'react'; import { Project } from '../../../../api/JenkinsApi'; -import { buildRouteRef } from '../../../../plugin'; +import { buildRouteRef, jobRunsRouteRef } from '../../../../plugin'; import { JenkinsRunStatus } from '../Status'; import { jenkinsExecutePermission } from '@backstage/plugin-jenkins-common'; @@ -186,6 +187,25 @@ export const columnFactories = Object.freeze({ }; }, + createLastRunDuration(): TableColumn { + return { + title: 'Last Run Duration', + align: 'left', + render: (row: Partial) => ( + <> + + {row?.lastBuild?.duration + ? (row?.lastBuild?.duration / 1000) + .toFixed(1) + .toString() + .concat(' s') + : ''}{' '} + + + ), + }; + }, + createActionsColumn(): TableColumn { return { title: 'Actions', @@ -198,6 +218,7 @@ export const columnFactories = Object.freeze({ ); const alertApi = useApi(alertApiRef); + const jobRunsLink = useRouteRef(jobRunsRouteRef); const onRebuild = async () => { if (row.onRestartClick) { @@ -221,7 +242,7 @@ export const columnFactories = Object.freeze({ }; return ( -
+
{row.lastBuild?.url && ( @@ -240,6 +261,17 @@ export const columnFactories = Object.freeze({ )} + + + + + + +
); }; diff --git a/plugins/jenkins/src/components/BuildsPage/lib/CITable/presets.ts b/plugins/jenkins/src/components/BuildsPage/lib/CITable/presets.ts index 7c0dda9970..828e035c8e 100644 --- a/plugins/jenkins/src/components/BuildsPage/lib/CITable/presets.ts +++ b/plugins/jenkins/src/components/BuildsPage/lib/CITable/presets.ts @@ -24,5 +24,6 @@ export const defaultCITableColumns: TableColumn[] = [ columnFactories.createBuildColumn(), columnFactories.createTestColumn(), columnFactories.createStatusColumn(), + columnFactories.createLastRunDuration(), columnFactories.createActionsColumn(), ]; From ad7dbe087c4a38bae7ed204694b3c5d1b7b0de63 Mon Sep 17 00:00:00 2001 From: Abhay-soni-developer Date: Wed, 11 Oct 2023 14:32:22 +0530 Subject: [PATCH 20/59] readme fixed Signed-off-by: Abhay-soni-developer --- plugins/jenkins/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/jenkins/README.md b/plugins/jenkins/README.md index d61c057237..b62eaab514 100644 --- a/plugins/jenkins/README.md +++ b/plugins/jenkins/README.md @@ -6,8 +6,8 @@ Website: [https://jenkins.io/](https://jenkins.io/) Folder results Build details Job builds records - Modify Table Columns + ## Setup 1. If you have a standalone app (you didn't clone this repo), then do @@ -106,6 +106,7 @@ spec: - View all builds of a particular job - shows average build time for successful builds + ## Modify Columns of EntityJenkinsContent - now you can pass down column props to show the columns/metadata as per your use case. From 0985bb63e77e4cf145d96d1367868dacba9a7252 Mon Sep 17 00:00:00 2001 From: Abhay-soni-developer Date: Wed, 11 Oct 2023 15:03:37 +0530 Subject: [PATCH 21/59] changes according to feedback Signed-off-by: Abhay-soni-developer --- .../jenkins/src/components/JobRunsTable/JobRunsTable.tsx | 8 +++----- plugins/jenkins/src/components/useJobRuns.ts | 6 ------ 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx b/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx index 73f2e33fd5..78b66b98eb 100644 --- a/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx +++ b/plugins/jenkins/src/components/JobRunsTable/JobRunsTable.tsx @@ -90,9 +90,9 @@ const generatedColumns: TableColumn[] = [
{row?.url && ( - + - + )}
@@ -109,7 +109,6 @@ type Props = { jobRuns?: Job; page: number; onChangePage: (page: number) => void; - total: number; pageSize: number; onChangePageSize: (pageSize: number) => void; }; @@ -121,7 +120,6 @@ export const JobRunsTableView = ({ jobRuns, onChangePage, onChangePageSize, - total, }: Props) => { const builds = jobRuns?.builds.slice( page * pageSize, @@ -150,7 +148,7 @@ export const JobRunsTableView = ({
{ - if (jobRuns) setTotal(jobRuns.builds.length); - }, [jobRuns]); - return [ { page, pageSize, loading, jobRuns, - total, error, }, { From cb25195c89fe80f40898bf47a5f9106437a5c4e1 Mon Sep 17 00:00:00 2001 From: Abhay-soni-developer Date: Wed, 11 Oct 2023 15:19:53 +0530 Subject: [PATCH 22/59] yarn tsc fix Signed-off-by: Abhay-soni-developer --- plugins/jenkins/src/components/useJobRuns.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/jenkins/src/components/useJobRuns.ts b/plugins/jenkins/src/components/useJobRuns.ts index 620bd92b84..d80e09b18b 100644 --- a/plugins/jenkins/src/components/useJobRuns.ts +++ b/plugins/jenkins/src/components/useJobRuns.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import useAsyncRetry from 'react-use/lib/useAsyncRetry'; import { jenkinsApiRef } from '../api'; import { errorApiRef, useApi } from '@backstage/core-plugin-api'; From b9ec93430e8d6959ce6153bd26c70cf3263ec6f7 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Wed, 11 Oct 2023 12:31:15 +0200 Subject: [PATCH 23/59] Replace mock-fs with createMockDirectory in the cli packages. Signed-off-by: Johan Persson --- .changeset/serious-ravens-serve.md | 5 + packages/cli/package.json | 2 - .../create-plugin/createPlugin.test.ts | 12 +- .../cli/src/commands/versions/bump.test.ts | 449 +++++++++++------- packages/cli/src/lib/builder/plugins.test.ts | 62 ++- .../lib/new/factories/backendModule.test.ts | 42 +- .../lib/new/factories/backendPlugin.test.ts | 42 +- .../lib/new/factories/common/tasks.test.ts | 32 +- .../src/lib/new/factories/common/testUtils.ts | 8 + .../lib/new/factories/frontendPlugin.test.ts | 81 ++-- .../new/factories/nodeLibraryPackage.test.ts | 53 +-- .../lib/new/factories/pluginCommon.test.ts | 34 +- .../src/lib/new/factories/pluginNode.test.ts | 34 +- .../src/lib/new/factories/pluginWeb.test.ts | 34 +- .../new/factories/scaffolderModule.test.ts | 36 +- .../new/factories/webLibraryPackage.test.ts | 53 +-- packages/cli/src/lib/role.test.ts | 18 +- packages/cli/src/lib/tasks.test.ts | 17 +- packages/cli/src/lib/version.test.ts | 11 +- .../cli/src/lib/versioning/Lockfile.test.ts | 67 ++- .../cli/src/lib/versioning/packages.test.ts | 46 +- .../src/actions/example/example.test.ts | 2 +- yarn.lock | 2 - 23 files changed, 622 insertions(+), 520 deletions(-) create mode 100644 .changeset/serious-ravens-serve.md diff --git a/.changeset/serious-ravens-serve.md b/.changeset/serious-ravens-serve.md new file mode 100644 index 0000000000..66cbd795c0 --- /dev/null +++ b/.changeset/serious-ravens-serve.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli': patch +--- + +The scaffolder-module template now recommends usage of `createMockDirectory` instead of `mock-fs`. diff --git a/packages/cli/package.json b/packages/cli/package.json index 36aee311f7..315ca12352 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -158,7 +158,6 @@ "@types/http-proxy": "^1.17.4", "@types/inquirer": "^8.1.3", "@types/minimatch": "^5.0.0", - "@types/mock-fs": "^4.13.0", "@types/node": "^18.17.8", "@types/npm-packlist": "^3.0.0", "@types/recursive-readdir": "^2.2.0", @@ -169,7 +168,6 @@ "@types/terser-webpack-plugin": "^5.0.4", "@types/yarnpkg__lockfile": "^1.1.4", "del": "^7.0.0", - "mock-fs": "^5.2.0", "msw": "^1.0.0", "nodemon": "^3.0.1", "ts-node": "^10.0.0", diff --git a/packages/cli/src/commands/create-plugin/createPlugin.test.ts b/packages/cli/src/commands/create-plugin/createPlugin.test.ts index aa59f94fb4..84af6ebf69 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.test.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.test.ts @@ -16,23 +16,21 @@ import fs from 'fs-extra'; import path from 'path'; -import mockFs from 'mock-fs'; import { movePlugin } from './createPlugin'; +import { createMockDirectory } from '@backstage/backend-test-utils'; const id = 'testPluginMock'; describe('createPlugin', () => { - afterEach(() => { - mockFs.restore(); - }); + const mockDir = createMockDirectory(); describe('movePlugin', () => { it('should move the temporary plugin directory to its final place', async () => { - mockFs({ + mockDir.setContent({ [id]: {}, }); - const tempDir = id; - const pluginDir = path.join('test-temp', 'plugins', id); + const tempDir = mockDir.resolve(id); + const pluginDir = mockDir.resolve('test-temp/plugins', id); await movePlugin(tempDir, pluginDir, id); await expect(fs.pathExists(pluginDir)).resolves.toBe(true); diff --git a/packages/cli/src/commands/versions/bump.test.ts b/packages/cli/src/commands/versions/bump.test.ts index 7af6c6160b..d00b4ce8da 100644 --- a/packages/cli/src/commands/versions/bump.test.ts +++ b/packages/cli/src/commands/versions/bump.test.ts @@ -15,10 +15,7 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; import { Command } from 'commander'; -import { resolve as resolvePath } from 'path'; -import { paths } from '../../lib/paths'; import * as runObj from '../../lib/run'; import bump, { bumpBackstageJsonVersion, createVersionFinder } from './bump'; import { @@ -30,6 +27,10 @@ import { setupServer } from 'msw/node'; import { rest } from 'msw'; import { NotFoundError } from '@backstage/errors'; import { Lockfile } from '../../lib/versioning/Lockfile'; +import { + MockDirectory, + createMockDirectory, +} from '@backstage/backend-test-utils'; // Avoid mutating the global http(s) agent used in other tests jest.mock('global-agent/bootstrap', () => {}); @@ -56,6 +57,19 @@ jest.mock('ora', () => ({ }, })); +let mockDir: MockDirectory; + +jest.mock('../../lib/paths', () => ({ + paths: { + resolveTargetRoot(filename: string) { + return mockDir.resolve(filename); + }, + get targetDir() { + return mockDir.path; + }, + }, +})); + jest.mock('../../lib/run', () => { return { run: jest.fn(), @@ -117,7 +131,17 @@ const lockfileMockResult = `${HEADER} version "1.0.0" `; +// Avoid flakes by comparing sorted log lines. File system access is async, which leads to the log line order being indeterministic +const expectLogsToMatch = ( + recievedLogs: String[], + expected: String[], +): void => { + expect(recievedLogs.filter(Boolean).sort()).toEqual(expected.sort()); +}; + describe('bump', () => { + mockDir = createMockDirectory(); + beforeEach(() => { mockFetchPackageInfo.mockImplementation(async name => ({ name: name, @@ -128,7 +152,6 @@ describe('bump', () => { }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); @@ -136,31 +159,34 @@ describe('bump', () => { setupRequestMockHandlers(worker); it('should bump backstage dependencies', async () => { - mockFs({ - '/yarn.lock': lockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -177,7 +203,7 @@ describe('bump', () => { const { log: logs } = await withLogCollector(['log'], async () => { await bump({ pattern: null, release: 'main' } as unknown as Command); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage/theme', @@ -208,17 +234,24 @@ describe('bump', () => { expect.any(Object), ); - const lockfileContents = await fs.readFile('/yarn.lock', 'utf8'); + const lockfileContents = await fs.readFile( + mockDir.resolve('yarn.lock'), + 'utf8', + ); expect(lockfileContents).toBe(lockfileMockResult); - const packageA = await fs.readJson('/packages/a/package.json'); + const packageA = await fs.readJson( + mockDir.resolve('packages/a/package.json'), + ); expect(packageA).toEqual({ name: 'a', dependencies: { '@backstage/core': '^1.0.6', }, }); - const packageB = await fs.readJson('/packages/b/package.json'); + const packageB = await fs.readJson( + mockDir.resolve('packages/b/package.json'), + ); expect(packageB).toEqual({ name: 'b', dependencies: { @@ -229,31 +262,34 @@ describe('bump', () => { }); it('should bump backstage dependencies but not install them', async () => { - mockFs({ - '/yarn.lock': lockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -274,7 +310,7 @@ describe('bump', () => { skipInstall: true, } as unknown as Command); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage/theme', @@ -304,17 +340,24 @@ describe('bump', () => { expect.any(Object), ); - const lockfileContents = await fs.readFile('/yarn.lock', 'utf8'); + const lockfileContents = await fs.readFile( + mockDir.resolve('yarn.lock'), + 'utf8', + ); expect(lockfileContents).toBe(lockfileMockResult); - const packageA = await fs.readJson('/packages/a/package.json'); + const packageA = await fs.readJson( + mockDir.resolve('packages/a/package.json'), + ); expect(packageA).toEqual({ name: 'a', dependencies: { '@backstage/core': '^1.0.6', }, }); - const packageB = await fs.readJson('/packages/b/package.json'); + const packageB = await fs.readJson( + mockDir.resolve('packages/b/package.json'), + ); expect(packageB).toEqual({ name: 'b', dependencies: { @@ -325,31 +368,34 @@ describe('bump', () => { }); it('should prefer dependency versions from release manifest', async () => { - mockFs({ - '/yarn.lock': lockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -376,7 +422,7 @@ describe('bump', () => { const { log: logs } = await withLogCollector(['log'], async () => { await bump({ pattern: null, release: 'main' } as unknown as Command); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage/theme', @@ -408,17 +454,24 @@ describe('bump', () => { expect.any(Object), ); - const lockfileContents = await fs.readFile('/yarn.lock', 'utf8'); + const lockfileContents = await fs.readFile( + mockDir.resolve('yarn.lock'), + 'utf8', + ); expect(lockfileContents).toBe(lockfileMockResult); - const packageA = await fs.readJson('/packages/a/package.json'); + const packageA = await fs.readJson( + mockDir.resolve('packages/a/package.json'), + ); expect(packageA).toEqual({ name: 'a', dependencies: { '@backstage/core': '^1.0.6', }, }); - const packageB = await fs.readJson('/packages/b/package.json'); + const packageB = await fs.readJson( + mockDir.resolve('packages/b/package.json'), + ); expect(packageB).toEqual({ name: 'b', dependencies: { @@ -429,30 +482,33 @@ describe('bump', () => { }); it('should only bump packages in the manifest when a specific release is specified', async () => { - mockFs({ - '/yarn.lock': lockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( @@ -472,14 +528,18 @@ describe('bump', () => { expect(runObj.run).toHaveBeenCalledTimes(0); - const packageA = await fs.readJson('/packages/a/package.json'); + const packageA = await fs.readJson( + mockDir.resolve('packages/a/package.json'), + ); expect(packageA).toEqual({ name: 'a', dependencies: { '@backstage/core': '^1.0.5', }, }); - const packageB = await fs.readJson('/packages/b/package.json'); + const packageB = await fs.readJson( + mockDir.resolve('packages/b/package.json'), + ); expect(packageB).toEqual({ name: 'b', dependencies: { @@ -489,32 +549,36 @@ describe('bump', () => { }); }); + // eslint-disable-next-line jest/expect-expect it('should prefer versions from the highest manifest version when main is not specified', async () => { - mockFs({ - '/yarn.lock': lockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -561,7 +625,7 @@ describe('bump', () => { const { log: logs } = await withLogCollector(['log'], async () => { await bump({ pattern: null, release: 'next' } as unknown as Command); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage/theme', @@ -609,35 +673,38 @@ describe('bump', () => { "@backstage/theme@^1.0.0": version "1.0.0" `; - mockFs({ - '/yarn.lock': customLockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': customLockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', - '@backstage-extra/custom': '^1.0.1', - '@backstage-extra/custom-two': '^1.0.0', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + '@backstage-extra/custom': '^1.0.1', + '@backstage-extra/custom-two': '^1.0.0', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', - '@backstage-extra/custom': '^1.1.0', - '@backstage-extra/custom-two': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + '@backstage-extra/custom': '^1.1.0', + '@backstage-extra/custom-two': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -657,7 +724,7 @@ describe('bump', () => { release: 'main', } as any); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using custom pattern glob @{backstage,backstage-extra}/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage-extra/custom', @@ -696,10 +763,15 @@ describe('bump', () => { expect.any(Object), ); - const lockfileContents = await fs.readFile('/yarn.lock', 'utf8'); + const lockfileContents = await fs.readFile( + mockDir.resolve('yarn.lock'), + 'utf8', + ); expect(lockfileContents).toEqual(customLockfileMockResult); - const packageA = await fs.readJson('/packages/a/package.json'); + const packageA = await fs.readJson( + mockDir.resolve('packages/a/package.json'), + ); expect(packageA).toEqual({ name: 'a', dependencies: { @@ -708,7 +780,9 @@ describe('bump', () => { '@backstage/core': '^1.0.6', }, }); - const packageB = await fs.readJson('/packages/b/package.json'); + const packageB = await fs.readJson( + mockDir.resolve('packages/b/package.json'), + ); expect(packageB).toEqual({ name: 'b', dependencies: { @@ -721,31 +795,34 @@ describe('bump', () => { }); it('should ignore not found packages', async () => { - mockFs({ - '/yarn.lock': lockfileMockResult, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMockResult, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^2.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^2.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); mockFetchPackageInfo.mockRejectedValue(new NotFoundError('Nope')); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( @@ -763,7 +840,7 @@ describe('bump', () => { const { log: logs } = await withLogCollector(['log'], async () => { await bump({ pattern: null, release: 'main' } as unknown as Command); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage/theme', @@ -778,17 +855,24 @@ describe('bump', () => { expect(runObj.run).toHaveBeenCalledTimes(0); - const lockfileContents = await fs.readFile('/yarn.lock', 'utf8'); + const lockfileContents = await fs.readFile( + mockDir.resolve('yarn.lock'), + 'utf8', + ); expect(lockfileContents).toBe(lockfileMockResult); - const packageA = await fs.readJson('/packages/a/package.json'); + const packageA = await fs.readJson( + mockDir.resolve('packages/a/package.json'), + ); expect(packageA).toEqual({ name: 'a', dependencies: { '@backstage/core': '^1.0.5', // not bumped }, }); - const packageB = await fs.readJson('/packages/b/package.json'); + const packageB = await fs.readJson( + mockDir.resolve('packages/b/package.json'), + ); expect(packageB).toEqual({ name: 'b', dependencies: { @@ -798,6 +882,7 @@ describe('bump', () => { }); }); + // eslint-disable-next-line jest/expect-expect it('should log duplicates', async () => { jest.spyOn(Lockfile.prototype, 'analyze').mockReturnValue({ invalidRanges: [], @@ -826,31 +911,34 @@ describe('bump', () => { }, ], }); - mockFs({ - '/yarn.lock': lockfileMock, - '/package.json': JSON.stringify({ + mockDir.setContent({ + 'yarn.lock': lockfileMock, + 'package.json': JSON.stringify({ workspaces: { packages: ['packages/*'], }, }), - '/packages/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '^1.0.5', + packages: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '^1.0.5', + }, + }), }, - }), - '/packages/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '^1.0.3', - '@backstage/theme': '^1.0.0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '^1.0.3', + '@backstage/theme': '^1.0.0', + }, + }), }, - }), + }, }); - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); jest.spyOn(runObj, 'run').mockResolvedValue(undefined); worker.use( rest.get( @@ -867,7 +955,7 @@ describe('bump', () => { const { log: logs } = await withLogCollector(['log'], async () => { await bump({ pattern: null, release: 'main' } as unknown as Command); }); - expect(logs.filter(Boolean)).toEqual([ + expectLogsToMatch(logs, [ 'Using default pattern glob @backstage/*', 'Checking for updates of @backstage/core', 'Checking for updates of @backstage/theme', @@ -891,24 +979,23 @@ describe('bump', () => { }); describe('bumpBackstageJsonVersion', () => { + mockDir = createMockDirectory(); + afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should bump version in backstage.json', async () => { - mockFs({ - '/backstage.json': JSON.stringify({ version: '0.0.1' }), + mockDir.setContent({ + 'backstage.json': JSON.stringify({ version: '0.0.1' }), }); - paths.targetDir = '/'; - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); const { log } = await withLogCollector(async () => { await bumpBackstageJsonVersion('1.4.1'); }); - expect(await fs.readJson('/backstage.json')).toEqual({ version: '1.4.1' }); + expect(await fs.readJson(mockDir.resolve('backstage.json'))).toEqual({ + version: '1.4.1', + }); expect(log).toEqual([ 'Upgraded from release 0.0.1 to 1.4.1, please review these template changes:', undefined, @@ -918,17 +1005,15 @@ describe('bumpBackstageJsonVersion', () => { }); it("should create backstage.json if doesn't exist", async () => { - mockFs({}); - paths.targetDir = '/'; + mockDir.clear(); // empty temp test folder const latest = '1.4.1'; - jest - .spyOn(paths, 'resolveTargetRoot') - .mockImplementation((...path) => resolvePath('/', ...path)); const { log } = await withLogCollector(async () => { await bumpBackstageJsonVersion(latest); }); - expect(await fs.readJson('/backstage.json')).toEqual({ version: latest }); + expect(await fs.readJson(mockDir.resolve('backstage.json'))).toEqual({ + version: latest, + }); expect(log).toEqual([ 'Your project is now at version 1.4.1, which has been written to backstage.json', ]); diff --git a/packages/cli/src/lib/builder/plugins.test.ts b/packages/cli/src/lib/builder/plugins.test.ts index dcbdc55cc0..f6984dd67e 100644 --- a/packages/cli/src/lib/builder/plugins.test.ts +++ b/packages/cli/src/lib/builder/plugins.test.ts @@ -15,7 +15,6 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; import { NormalizedOutputOptions, OutputAsset, @@ -24,6 +23,7 @@ import { } from 'rollup'; import { forwardFileImports } from './plugins'; +import { createMockDirectory } from '@backstage/backend-test-utils'; const context = { meta: { @@ -91,16 +91,18 @@ describe('forwardFileImports', () => { ); }); - describe('with mock fs', () => { - beforeEach(() => { - mockFs({ - '/dev/src/my-module.ts': '', - '/dev/src/dir/my-image.png': 'my-image', - }); - }); + describe('with createMockDirectory', () => { + const mockDir = createMockDirectory(); - afterEach(() => { - mockFs.restore(); + beforeEach(() => { + mockDir.setContent({ + dev: { + src: { + 'my-module.ts': '', + dir: { 'my-image.png': 'my-image' }, + }, + }, + }); }); it('should extract files', async () => { @@ -111,23 +113,33 @@ describe('forwardFileImports', () => { throw new Error('options.external is not a function'); } - expect(options.external('./my-module', '/dev/src/index.ts', false)).toBe( - false, - ); expect( - options.external('./my-image.png', '/dev/src/dir/index.ts', false), + options.external( + './my-module', + mockDir.resolve('dev/src/index.ts'), + false, + ), + ).toBe(false); + expect( + options.external( + './my-image.png', + mockDir.resolve('dev', 'src', 'dir', 'index.ts'), + false, + ), ).toBe(true); - const outPath = '/dev/dist/dir/my-image.png'; + const outPath = mockDir.resolve('dev', 'dist', 'dir', 'my-image.png'); await expect(fs.pathExists(outPath)).resolves.toBe(false); await plugin.generateBundle?.call( context, - { dir: '/dev/dist' } as NormalizedOutputOptions, + { + dir: mockDir.resolve('dev/dist'), + } as NormalizedOutputOptions, { ['index.js']: { type: 'chunk', - facadeModuleId: '/dev/src/index.ts', + facadeModuleId: mockDir.resolve('dev/src/index.ts'), } as OutputChunk, }, false, // isWrite = false -> no write @@ -136,7 +148,9 @@ describe('forwardFileImports', () => { await plugin.generateBundle?.call( context, - { dir: '/dev/dist' } as NormalizedOutputOptions, + { + dir: mockDir.resolve('dev/dist'), + } as NormalizedOutputOptions, { // output assets should not cause a write ['index.js']: { type: 'asset' } as OutputAsset, @@ -150,11 +164,13 @@ describe('forwardFileImports', () => { // output chunk + isWrite -> generate files await plugin.generateBundle?.call( context, - { dir: '/dev/dist' } as NormalizedOutputOptions, + { + dir: mockDir.resolve('dev/dist'), + } as NormalizedOutputOptions, { ['index.js']: { type: 'chunk', - facadeModuleId: '/dev/src/index.ts', + facadeModuleId: mockDir.resolve('dev/src/index.ts'), } as OutputChunk, }, true, @@ -164,11 +180,13 @@ describe('forwardFileImports', () => { // should not break when triggering another write await plugin.generateBundle?.call( context, - { file: '/dev/dist/my-output.js' } as NormalizedOutputOptions, + { + file: mockDir.resolve('dev/dist/my-output.js'), + } as NormalizedOutputOptions, { ['index.js']: { type: 'chunk', - facadeModuleId: '/dev/src/index.ts', + facadeModuleId: mockDir.resolve('dev/src/index.ts'), } as OutputChunk, }, true, diff --git a/packages/cli/src/lib/new/factories/backendModule.test.ts b/packages/cli/src/lib/new/factories/backendModule.test.ts index c633b1c1b2..78ef5f96bb 100644 --- a/packages/cli/src/lib/new/factories/backendModule.test.ts +++ b/packages/cli/src/lib/new/factories/backendModule.test.ts @@ -15,39 +15,38 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { backendModule } from './backendModule'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('backendModule factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a backend plugin', async () => { - mockFs({ - '/root': { - packages: { - backend: { - 'package.json': JSON.stringify({}), - }, + mockDir.setContent({ + packages: { + backend: { + 'package.json': JSON.stringify({}), }, - plugins: mockFs.directory(), }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + plugins: {}, }); const options = await FactoryRegistry.populateOptions(backendModule, { @@ -73,8 +72,7 @@ describe('backendModule factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating backend module backstage-plugin-test-backend-module-tester-two', 'Checking Prerequisites:', `availability plugins${sep}test-backend-module-tester-two`, @@ -91,14 +89,14 @@ describe('backendModule factory', () => { ]); await expect( - fs.readJson('/root/packages/backend/package.json'), + fs.readJson(mockDir.resolve('packages/backend/package.json')), ).resolves.toEqual({ dependencies: { 'backstage-plugin-test-backend-module-tester-two': '^1.0.0', }, }); const moduleFile = await fs.readFile( - '/root/plugins/test-backend-module-tester-two/src/module.ts', + mockDir.resolve('plugins/test-backend-module-tester-two/src/module.ts'), 'utf-8', ); @@ -110,11 +108,11 @@ describe('backendModule factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test-backend-module-tester-two'), + cwd: mockDir.resolve('plugins/test-backend-module-tester-two'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test-backend-module-tester-two'), + cwd: mockDir.resolve('plugins/test-backend-module-tester-two'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/backendPlugin.test.ts b/packages/cli/src/lib/new/factories/backendPlugin.test.ts index f326de88b8..5c1d496e49 100644 --- a/packages/cli/src/lib/new/factories/backendPlugin.test.ts +++ b/packages/cli/src/lib/new/factories/backendPlugin.test.ts @@ -15,39 +15,38 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { backendPlugin } from './backendPlugin'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('backendPlugin factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a backend plugin', async () => { - mockFs({ - '/root': { - packages: { - backend: { - 'package.json': JSON.stringify({}), - }, + mockDir.setContent({ + packages: { + backend: { + 'package.json': JSON.stringify({}), }, - plugins: mockFs.directory(), }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + plugins: {}, }); const options = await FactoryRegistry.populateOptions(backendPlugin, { @@ -72,8 +71,7 @@ describe('backendPlugin factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating backend plugin backstage-plugin-test-backend', 'Checking Prerequisites:', `availability plugins${sep}test-backend`, @@ -94,14 +92,14 @@ describe('backendPlugin factory', () => { ]); await expect( - fs.readJson('/root/packages/backend/package.json'), + fs.readJson(mockDir.resolve('packages/backend/package.json')), ).resolves.toEqual({ dependencies: { 'backstage-plugin-test-backend': '^1.0.0', }, }); const standaloneServerFile = await fs.readFile( - '/root/plugins/test-backend/src/service/standaloneServer.ts', + mockDir.resolve('plugins/test-backend/src/service/standaloneServer.ts'), 'utf-8', ); @@ -112,11 +110,11 @@ describe('backendPlugin factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test-backend'), + cwd: mockDir.resolve('plugins/test-backend'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test-backend'), + cwd: mockDir.resolve('plugins/test-backend'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/common/tasks.test.ts b/packages/cli/src/lib/new/factories/common/tasks.test.ts index 49381676e7..60783344fc 100644 --- a/packages/cli/src/lib/new/factories/common/tasks.test.ts +++ b/packages/cli/src/lib/new/factories/common/tasks.test.ts @@ -15,32 +15,37 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; import { sep } from 'path'; -import { createMockOutputStream, mockPaths } from './testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './testUtils'; import { CreateContext } from '../../types'; import { executePluginPackageTemplate } from './tasks'; +import { createMockDirectory } from '@backstage/backend-test-utils'; + +const mockDir = createMockDirectory(); mockPaths({ - ownDir: '/own', - targetRoot: '/root', + ownDir: mockDir.resolve('own'), + targetRoot: mockDir.resolve('root'), }); describe('executePluginPackageTemplate', () => { afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should execute template', async () => { - mockFs({ - '/root': { + mockDir.setContent({ + root: { 'yarn.lock': ` some-package@^1.1.0: version "1.5.0" `, }, - '/own': { + own: { templates: { 'test-template': { 'package.json.hbs': ` @@ -78,7 +83,7 @@ some-package@^1.1.0: } as CreateContext, { templateName: 'test-template', - targetDir: '/target', + targetDir: mockDir.resolve('target'), values: { id: 'testing', makePrivate: true, @@ -87,7 +92,7 @@ some-package@^1.1.0: ); expect(modified).toBe(true); - expect(output).toEqual([ + expectLogsToMatch(output, [ 'Checking Prerequisites:', `availability ..${sep}target`, 'creating temp dir', @@ -98,7 +103,8 @@ some-package@^1.1.0: 'Installing:', `moving ..${sep}target`, ]); - await expect(fs.readFile('/target/package.json', 'utf8')).resolves.toBe(`{ + await expect(fs.readFile(mockDir.resolve('target/package.json'), 'utf8')) + .resolves.toBe(`{ "name": "my-testing-plugin", "private": true, "description": "testing", @@ -109,10 +115,10 @@ some-package@^1.1.0: } `); await expect( - fs.readFile('/target/subdir/templated.txt', 'utf8'), + fs.readFile(mockDir.resolve('target/subdir/templated.txt'), 'utf8'), ).resolves.toBe('Hello testing!'); await expect( - fs.readFile('/target/subdir/not-templated.txt', 'utf8'), + fs.readFile(mockDir.resolve('target/subdir/not-templated.txt'), 'utf8'), ).resolves.toBe('Hello {{id}}!'); }); }); diff --git a/packages/cli/src/lib/new/factories/common/testUtils.ts b/packages/cli/src/lib/new/factories/common/testUtils.ts index 01081a7486..1ade13a996 100644 --- a/packages/cli/src/lib/new/factories/common/testUtils.ts +++ b/packages/cli/src/lib/new/factories/common/testUtils.ts @@ -73,3 +73,11 @@ export function createMockOutputStream() { } as unknown as WriteStream & { fd: any }, ] as const; } + +// Avoid flakes by comparing sorted log lines. File system access is async, which leads to the log line order being indeterministic +export function expectLogsToMatch( + recievedLogs: String[], + expected: String[], +): void { + expect(recievedLogs.filter(Boolean).sort()).toEqual(expected.sort()); +} diff --git a/packages/cli/src/lib/new/factories/frontendPlugin.test.ts b/packages/cli/src/lib/new/factories/frontendPlugin.test.ts index 8390eb192e..94d4a7eb4c 100644 --- a/packages/cli/src/lib/new/factories/frontendPlugin.test.ts +++ b/packages/cli/src/lib/new/factories/frontendPlugin.test.ts @@ -15,13 +15,16 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { frontendPlugin } from './frontendPlugin'; +import { createMockDirectory } from '@backstage/backend-test-utils'; const appTsxContent = ` import { createApp } from '@backstage/app-defaults'; @@ -34,33 +37,29 @@ const router = ( `; describe('frontendPlugin factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a frontend plugin', async () => { - mockFs({ - '/root': { - packages: { - app: { - 'package.json': JSON.stringify({}), - src: { - 'App.tsx': appTsxContent, - }, + mockDir.setContent({ + packages: { + app: { + 'package.json': JSON.stringify({}), + src: { + 'App.tsx': appTsxContent, }, }, - plugins: mockFs.directory(), }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + plugins: {}, }); const options = await FactoryRegistry.populateOptions(frontendPlugin, { @@ -85,8 +84,7 @@ describe('frontendPlugin factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating frontend plugin backstage-plugin-test', 'Checking Prerequisites:', `availability plugins${sep}test`, @@ -114,15 +112,16 @@ describe('frontendPlugin factory', () => { ]); await expect( - fs.readJson('/root/packages/app/package.json'), + fs.readJson(mockDir.resolve('packages/app/package.json')), ).resolves.toEqual({ dependencies: { 'backstage-plugin-test': '^1.0.0', }, }); - await expect(fs.readFile('/root/packages/app/src/App.tsx', 'utf8')).resolves - .toBe(` + await expect( + fs.readFile(mockDir.resolve('packages/app/src/App.tsx'), 'utf8'), + ).resolves.toBe(` import { createApp } from '@backstage/app-defaults'; import { TestPage } from 'backstage-plugin-test'; @@ -136,32 +135,27 @@ const router = ( expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test'), + cwd: mockDir.resolve('plugins/test'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test'), + cwd: mockDir.resolve('plugins/test'), optional: true, }); }); it('should create a frontend plugin with more options and codeowners', async () => { - mockFs({ - '/root': { - CODEOWNERS: '', - packages: { - app: { - 'package.json': JSON.stringify({}), - src: { - 'App.tsx': appTsxContent, - }, + mockDir.setContent({ + CODEOWNERS: '', + packages: { + app: { + 'package.json': JSON.stringify({}), + src: { + 'App.tsx': appTsxContent, }, }, - plugins: mockFs.directory(), }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + plugins: {}, }); const options = await FactoryRegistry.populateOptions(frontendPlugin, { @@ -183,15 +177,16 @@ const router = ( }); await expect( - fs.readJson('/root/packages/app/package.json'), + fs.readJson(mockDir.resolve('packages/app/package.json')), ).resolves.toEqual({ dependencies: { '@internal/plugin-test': '^1.0.0', }, }); - await expect(fs.readFile('/root/packages/app/src/App.tsx', 'utf8')).resolves - .toBe(` + await expect( + fs.readFile(mockDir.resolve('packages/app/src/App.tsx'), 'utf8'), + ).resolves.toBe(` import { createApp } from '@backstage/app-defaults'; import { TestPage } from '@internal/plugin-test'; @@ -205,11 +200,11 @@ const router = ( expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test'), + cwd: mockDir.resolve('plugins/test'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test'), + cwd: mockDir.resolve('plugins/test'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/nodeLibraryPackage.test.ts b/packages/cli/src/lib/new/factories/nodeLibraryPackage.test.ts index d0b08f4f06..e3673d21ba 100644 --- a/packages/cli/src/lib/new/factories/nodeLibraryPackage.test.ts +++ b/packages/cli/src/lib/new/factories/nodeLibraryPackage.test.ts @@ -15,36 +15,35 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { resolve as resolvePath, join as joinPath } from 'path'; -import { paths } from '../../paths'; +import { join as joinPath } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { nodeLibraryPackage } from './nodeLibraryPackage'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('nodeLibraryPackage factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a node library package', async () => { const expectedNodeLibraryPackageName = 'test'; - mockFs({ - '/root': { - packages: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + packages: {}, }); const options = await FactoryRegistry.populateOptions(nodeLibraryPackage, { @@ -69,8 +68,7 @@ describe('nodeLibraryPackage factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ `Creating node-library package ${expectedNodeLibraryPackageName}`, 'Checking Prerequisites:', `availability ${joinPath('packages', expectedNodeLibraryPackageName)}`, @@ -87,7 +85,11 @@ describe('nodeLibraryPackage factory', () => { await expect( fs.readJson( - `/root/packages/${expectedNodeLibraryPackageName}/package.json`, + mockDir.resolve( + 'packages', + expectedNodeLibraryPackageName, + 'package.json', + ), ), ).resolves.toEqual( expect.objectContaining({ @@ -99,11 +101,11 @@ describe('nodeLibraryPackage factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath(`/root/packages/${expectedNodeLibraryPackageName}`), + cwd: mockDir.resolve('packages', expectedNodeLibraryPackageName), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath(`/root/packages/${expectedNodeLibraryPackageName}`), + cwd: mockDir.resolve('packages', expectedNodeLibraryPackageName), optional: true, }); }); @@ -111,14 +113,9 @@ describe('nodeLibraryPackage factory', () => { it('should create a node library plugin with options and codeowners', async () => { const expectedNodeLibraryPackageName = 'test'; - mockFs({ - '/root': { - CODEOWNERS: '', - packages: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + CODEOWNERS: '', + packages: {}, }); const options = await FactoryRegistry.populateOptions(nodeLibraryPackage, { @@ -141,11 +138,11 @@ describe('nodeLibraryPackage factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath(`/root/${expectedNodeLibraryPackageName}`), + cwd: mockDir.resolve(expectedNodeLibraryPackageName), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath(`/root/${expectedNodeLibraryPackageName}`), + cwd: mockDir.resolve(expectedNodeLibraryPackageName), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/pluginCommon.test.ts b/packages/cli/src/lib/new/factories/pluginCommon.test.ts index 25b6b51905..d85e490510 100644 --- a/packages/cli/src/lib/new/factories/pluginCommon.test.ts +++ b/packages/cli/src/lib/new/factories/pluginCommon.test.ts @@ -15,34 +15,33 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { pluginCommon } from './pluginCommon'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('pluginCommon factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a common plugin package', async () => { - mockFs({ - '/root': { - plugins: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + plugins: {}, }); const options = await FactoryRegistry.populateOptions(pluginCommon, { @@ -67,8 +66,7 @@ describe('pluginCommon factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating backend plugin backstage-plugin-test-common', 'Checking Prerequisites:', `availability plugins${sep}test-common`, @@ -84,7 +82,7 @@ describe('pluginCommon factory', () => { ]); await expect( - fs.readJson('/root/plugins/test-common/package.json'), + fs.readJson(mockDir.resolve('plugins/test-common/package.json')), ).resolves.toEqual( expect.objectContaining({ name: 'backstage-plugin-test-common', @@ -96,11 +94,11 @@ describe('pluginCommon factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test-common'), + cwd: mockDir.resolve('plugins/test-common'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test-common'), + cwd: mockDir.resolve('plugins/test-common'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/pluginNode.test.ts b/packages/cli/src/lib/new/factories/pluginNode.test.ts index 50971ff191..d8d9ea33ab 100644 --- a/packages/cli/src/lib/new/factories/pluginNode.test.ts +++ b/packages/cli/src/lib/new/factories/pluginNode.test.ts @@ -15,34 +15,33 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { pluginNode } from './pluginNode'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('pluginNode factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a node plugin package', async () => { - mockFs({ - '/root': { - plugins: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + plugins: {}, }); const options = await FactoryRegistry.populateOptions(pluginNode, { @@ -67,8 +66,7 @@ describe('pluginNode factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating Node.js plugin library backstage-plugin-test-node', 'Checking Prerequisites:', `availability plugins${sep}test-node`, @@ -84,7 +82,7 @@ describe('pluginNode factory', () => { ]); await expect( - fs.readJson('/root/plugins/test-node/package.json'), + fs.readJson(mockDir.resolve('plugins/test-node/package.json')), ).resolves.toEqual( expect.objectContaining({ name: 'backstage-plugin-test-node', @@ -96,11 +94,11 @@ describe('pluginNode factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test-node'), + cwd: mockDir.resolve('plugins/test-node'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test-node'), + cwd: mockDir.resolve('plugins/test-node'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/pluginWeb.test.ts b/packages/cli/src/lib/new/factories/pluginWeb.test.ts index bcd62f3bcd..ff4211d8fa 100644 --- a/packages/cli/src/lib/new/factories/pluginWeb.test.ts +++ b/packages/cli/src/lib/new/factories/pluginWeb.test.ts @@ -15,34 +15,33 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { pluginWeb } from './pluginWeb'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('pluginWeb factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a react plugin package', async () => { - mockFs({ - '/root': { - plugins: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + plugins: {}, }); const options = await FactoryRegistry.populateOptions(pluginWeb, { @@ -67,8 +66,7 @@ describe('pluginWeb factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating web plugin library backstage-plugin-test-react', 'Checking Prerequisites:', `availability plugins${sep}test-react`, @@ -91,7 +89,7 @@ describe('pluginWeb factory', () => { ]); await expect( - fs.readJson('/root/plugins/test-react/package.json'), + fs.readJson(mockDir.resolve('plugins/test-react/package.json')), ).resolves.toEqual( expect.objectContaining({ name: 'backstage-plugin-test-react', @@ -103,11 +101,11 @@ describe('pluginWeb factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/test-react'), + cwd: mockDir.resolve('plugins/test-react'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/test-react'), + cwd: mockDir.resolve('plugins/test-react'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/scaffolderModule.test.ts b/packages/cli/src/lib/new/factories/scaffolderModule.test.ts index 38d1207a3f..b43946228b 100644 --- a/packages/cli/src/lib/new/factories/scaffolderModule.test.ts +++ b/packages/cli/src/lib/new/factories/scaffolderModule.test.ts @@ -15,34 +15,33 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { sep, resolve as resolvePath } from 'path'; -import { paths } from '../../paths'; +import { sep } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { scaffolderModule } from './scaffolderModule'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('scaffolderModule factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a scaffolder backend module package', async () => { - mockFs({ - '/root': { - plugins: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + plugins: {}, }); const options = await FactoryRegistry.populateOptions(scaffolderModule, { @@ -67,8 +66,7 @@ describe('scaffolderModule factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ 'Creating module backstage-plugin-scaffolder-backend-module-test', 'Checking Prerequisites:', `availability plugins${sep}scaffolder-backend-module-test`, @@ -87,7 +85,9 @@ describe('scaffolderModule factory', () => { ]); await expect( - fs.readJson('/root/plugins/scaffolder-backend-module-test/package.json'), + fs.readJson( + mockDir.resolve('plugins/scaffolder-backend-module-test/package.json'), + ), ).resolves.toEqual( expect.objectContaining({ name: 'backstage-plugin-scaffolder-backend-module-test', @@ -99,11 +99,11 @@ describe('scaffolderModule factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath('/root/plugins/scaffolder-backend-module-test'), + cwd: mockDir.resolve('plugins/scaffolder-backend-module-test'), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath('/root/plugins/scaffolder-backend-module-test'), + cwd: mockDir.resolve('plugins/scaffolder-backend-module-test'), optional: true, }); }); diff --git a/packages/cli/src/lib/new/factories/webLibraryPackage.test.ts b/packages/cli/src/lib/new/factories/webLibraryPackage.test.ts index 50fef44a67..b0c8461189 100644 --- a/packages/cli/src/lib/new/factories/webLibraryPackage.test.ts +++ b/packages/cli/src/lib/new/factories/webLibraryPackage.test.ts @@ -15,36 +15,35 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { resolve as resolvePath, join as joinPath } from 'path'; -import { paths } from '../../paths'; +import { join as joinPath } from 'path'; import { Task } from '../../tasks'; import { FactoryRegistry } from '../FactoryRegistry'; -import { createMockOutputStream, mockPaths } from './common/testUtils'; +import { + createMockOutputStream, + expectLogsToMatch, + mockPaths, +} from './common/testUtils'; import { webLibraryPackage } from './webLibraryPackage'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('webLibraryPackage factory', () => { + const mockDir = createMockDirectory(); + beforeEach(() => { mockPaths({ - targetRoot: '/root', + targetRoot: mockDir.path, }); }); afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should create a web library package', async () => { const expectedwebLibraryPackageName = 'test'; - mockFs({ - '/root': { - packages: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + packages: {}, }); const options = await FactoryRegistry.populateOptions(webLibraryPackage, { @@ -69,8 +68,7 @@ describe('webLibraryPackage factory', () => { expect(modified).toBe(true); - expect(output).toEqual([ - '', + expectLogsToMatch(output, [ `Creating web-library package ${expectedwebLibraryPackageName}`, 'Checking Prerequisites:', `availability ${joinPath('packages', expectedwebLibraryPackageName)}`, @@ -87,7 +85,11 @@ describe('webLibraryPackage factory', () => { await expect( fs.readJson( - `/root/packages/${expectedwebLibraryPackageName}/package.json`, + mockDir.resolve( + 'packages', + expectedwebLibraryPackageName, + 'package.json', + ), ), ).resolves.toEqual( expect.objectContaining({ @@ -99,11 +101,11 @@ describe('webLibraryPackage factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath(`/root/packages/${expectedwebLibraryPackageName}`), + cwd: mockDir.resolve('packages', expectedwebLibraryPackageName), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath(`/root/packages/${expectedwebLibraryPackageName}`), + cwd: mockDir.resolve('packages', expectedwebLibraryPackageName), optional: true, }); }); @@ -111,14 +113,9 @@ describe('webLibraryPackage factory', () => { it('should create a web library plugin with options and codeowners', async () => { const expectedwebLibraryPackageName = 'test'; - mockFs({ - '/root': { - CODEOWNERS: '', - packages: mockFs.directory(), - }, - [paths.resolveOwn('templates')]: mockFs.load( - paths.resolveOwn('templates'), - ), + mockDir.setContent({ + CODEOWNERS: '', + packages: {}, }); const options = await FactoryRegistry.populateOptions(webLibraryPackage, { @@ -141,11 +138,11 @@ describe('webLibraryPackage factory', () => { expect(Task.forCommand).toHaveBeenCalledTimes(2); expect(Task.forCommand).toHaveBeenCalledWith('yarn install', { - cwd: resolvePath(`/root/${expectedwebLibraryPackageName}`), + cwd: mockDir.resolve(expectedwebLibraryPackageName), optional: true, }); expect(Task.forCommand).toHaveBeenCalledWith('yarn lint --fix', { - cwd: resolvePath(`/root/${expectedwebLibraryPackageName}`), + cwd: mockDir.resolve(expectedwebLibraryPackageName), optional: true, }); }); diff --git a/packages/cli/src/lib/role.test.ts b/packages/cli/src/lib/role.test.ts index adca2d70b8..8163512b76 100644 --- a/packages/cli/src/lib/role.test.ts +++ b/packages/cli/src/lib/role.test.ts @@ -14,10 +14,20 @@ * limitations under the License. */ -import mockFs from 'mock-fs'; +import { createMockDirectory } from '@backstage/backend-test-utils'; import { Command } from 'commander'; import { findRoleFromCommand } from './role'; +const mockDir = createMockDirectory(); + +jest.mock('./paths', () => ({ + paths: { + resolveTarget(filename: string) { + return mockDir.resolve(filename); + }, + }, +})); + describe('findRoleFromCommand', () => { function mkCommand(args: string) { const parsed = new Command() @@ -27,7 +37,7 @@ describe('findRoleFromCommand', () => { } beforeEach(() => { - mockFs({ + mockDir.setContent({ 'package.json': JSON.stringify({ name: 'test', backstage: { @@ -37,10 +47,6 @@ describe('findRoleFromCommand', () => { }); }); - afterEach(() => { - mockFs.restore(); - }); - it('provides role info by role', async () => { await expect(findRoleFromCommand(mkCommand(''))).resolves.toEqual( 'web-library', diff --git a/packages/cli/src/lib/tasks.test.ts b/packages/cli/src/lib/tasks.test.ts index fb1f38536f..1119adb7f7 100644 --- a/packages/cli/src/lib/tasks.test.ts +++ b/packages/cli/src/lib/tasks.test.ts @@ -15,14 +15,11 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { resolve as resolvePath } from 'path'; import { templatingTask } from './tasks'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('templatingTask', () => { - afterEach(() => { - mockFs.restore(); - }); + const mockDir = createMockDirectory(); it('should template a directory with mix of regular files and templates', async () => { // Testing template directory @@ -36,7 +33,7 @@ describe('templatingTask', () => { const testVersionFileContent = "version: {{pluginVersion}} {{versionQuery 'mock-pkg'}}"; - mockFs({ + mockDir.setContent({ [tmplDir]: { sub: { 'version.txt.hbs': testVersionFileContent, @@ -47,8 +44,8 @@ describe('templatingTask', () => { }); await templatingTask( - tmplDir, - destDir, + mockDir.resolve(tmplDir), + mockDir.resolve(destDir), { pluginVersion: '0.0.0', }, @@ -57,10 +54,10 @@ describe('templatingTask', () => { ); await expect( - fs.readFile(resolvePath(destDir, 'test.txt'), 'utf8'), + fs.readFile(mockDir.resolve(destDir, 'test.txt'), 'utf8'), ).resolves.toBe(testFileContent); await expect( - fs.readFile(resolvePath(destDir, 'sub/version.txt'), 'utf8'), + fs.readFile(mockDir.resolve(destDir, 'sub/version.txt'), 'utf8'), ).resolves.toBe('version: 0.0.0 ^0.1.2'); }); }); diff --git a/packages/cli/src/lib/version.test.ts b/packages/cli/src/lib/version.test.ts index 604129679b..6b1aa391d5 100644 --- a/packages/cli/src/lib/version.test.ts +++ b/packages/cli/src/lib/version.test.ts @@ -14,15 +14,13 @@ * limitations under the License. */ -import mockFs from 'mock-fs'; import { packageVersions, createPackageVersionProvider } from './version'; import { Lockfile } from './versioning'; import corePluginApiPkg from '@backstage/core-plugin-api/package.json'; +import { createMockDirectory } from '@backstage/backend-test-utils'; describe('createPackageVersionProvider', () => { - afterEach(() => { - mockFs.restore(); - }); + const mockDir = createMockDirectory(); const HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. # yarn lockfile v1 @@ -30,7 +28,7 @@ describe('createPackageVersionProvider', () => { `; it('should provide package versions', async () => { - mockFs({ + mockDir.setContent({ 'yarn.lock': `${HEADER} "a@^0.1.0": version "0.1.5" @@ -55,7 +53,8 @@ describe('createPackageVersionProvider', () => { `, }); - const lockfile = await Lockfile.load('yarn.lock'); + const lockfilePath = mockDir.resolve('yarn.lock'); + const lockfile = await Lockfile.load(lockfilePath); const provider = createPackageVersionProvider(lockfile); expect(provider('a', '0.1.5')).toBe('^0.1.0'); diff --git a/packages/cli/src/lib/versioning/Lockfile.test.ts b/packages/cli/src/lib/versioning/Lockfile.test.ts index 909f8be9aa..a4267acdb7 100644 --- a/packages/cli/src/lib/versioning/Lockfile.test.ts +++ b/packages/cli/src/lib/versioning/Lockfile.test.ts @@ -15,9 +15,9 @@ */ import fs from 'fs-extra'; -import mockFs from 'mock-fs'; import { BackstagePackage } from '@backstage/cli-node'; import { Lockfile } from './Lockfile'; +import { createMockDirectory } from '@backstage/backend-test-utils'; const LEGACY_HEADER = `# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. # yarn lockfile v1 @@ -76,16 +76,14 @@ const mockBDedup = `${LEGACY_HEADER} `; describe('Lockfile', () => { - afterEach(() => { - mockFs.restore(); - }); + const mockDir = createMockDirectory(); it('should load and serialize mockA', async () => { - mockFs({ - '/yarn.lock': mockA, + mockDir.setContent({ + 'yarn.lock': mockA, }); - const lockfile = await Lockfile.load('/yarn.lock'); + const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock')); expect(lockfile.get('a')).toEqual([ { range: '^1', version: '1.0.1', dataKey: 'a@^1' }, ]); @@ -97,11 +95,12 @@ describe('Lockfile', () => { }); it('should deduplicate and save mockA', async () => { - mockFs({ - '/yarn.lock': mockA, + mockDir.setContent({ + 'yarn.lock': mockA, }); - const lockfile = await Lockfile.load('/yarn.lock'); + const lockfilePath = mockDir.resolve('yarn.lock'); + const lockfile = await Lockfile.load(lockfilePath); const result = lockfile.analyze({ localPackages: new Map() }); expect(result).toEqual({ invalidRanges: [], @@ -120,17 +119,17 @@ describe('Lockfile', () => { lockfile.replaceVersions(result.newVersions); expect(lockfile.toString()).toBe(mockADedup); - await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(mockA); - await expect(lockfile.save('/yarn.lock')).resolves.toBeUndefined(); - await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(mockADedup); + await expect(fs.readFile(lockfilePath, 'utf8')).resolves.toBe(mockA); + await expect(lockfile.save(lockfilePath)).resolves.toBeUndefined(); + await expect(fs.readFile(lockfilePath, 'utf8')).resolves.toBe(mockADedup); }); it('should deduplicate mockB', async () => { - mockFs({ - '/yarn.lock': mockB, + mockDir.setContent({ + 'yarn.lock': mockB, }); - const lockfile = await Lockfile.load('/yarn.lock'); + const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock')); const result = lockfile.analyze({ localPackages: new Map() }); expect(result).toEqual({ invalidRanges: [], @@ -226,16 +225,14 @@ b@^2: `; describe('New Lockfile', () => { - afterEach(() => { - mockFs.restore(); - }); + const mockDir = createMockDirectory(); it('should load and serialize mockANew', async () => { - mockFs({ - '/yarn.lock': mockANew, + mockDir.setContent({ + 'yarn.lock': mockANew, }); - const lockfile = await Lockfile.load('/yarn.lock'); + const lockfile = await Lockfile.load(mockDir.resolve('yarn.lock')); expect(lockfile.get('a')).toEqual([ { range: '^1', version: '1.0.1', dataKey: 'a@^1' }, ]); @@ -248,11 +245,12 @@ describe('New Lockfile', () => { }); it('should deduplicate and save mockANew', async () => { - mockFs({ - '/yarn.lock': mockANew, + mockDir.setContent({ + 'yarn.lock': mockANew, }); - const lockfile = await Lockfile.load('/yarn.lock'); + const lockfilePath = mockDir.resolve('yarn.lock'); + const lockfile = await Lockfile.load(lockfilePath); const result = lockfile.analyze({ localPackages: new Map() }); expect(result).toEqual({ invalidRanges: [], @@ -271,19 +269,20 @@ describe('New Lockfile', () => { lockfile.replaceVersions(result.newVersions); expect(lockfile.toString()).toBe(mockANewDedup); - await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe(mockANew); - await expect(lockfile.save('/yarn.lock')).resolves.toBeUndefined(); - await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe( + await expect(fs.readFile(lockfilePath, 'utf8')).resolves.toBe(mockANew); + await expect(lockfile.save(lockfilePath)).resolves.toBeUndefined(); + await expect(fs.readFile(lockfilePath, 'utf8')).resolves.toBe( mockANewDedup, ); }); it('should deduplicate and save mockANewLocal', async () => { - mockFs({ - '/yarn.lock': mockANewLocal, + mockDir.setContent({ + 'yarn.lock': mockANewLocal, }); - const lockfile = await Lockfile.load('/yarn.lock'); + const lockfilePath = mockDir.resolve('yarn.lock'); + const lockfile = await Lockfile.load(lockfilePath); const result = lockfile.analyze({ localPackages: new Map([ [ @@ -311,11 +310,11 @@ describe('New Lockfile', () => { lockfile.replaceVersions(result.newVersions); expect(lockfile.toString()).toBe(mockANewLocalDedup); - await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe( + await expect(fs.readFile(lockfilePath, 'utf8')).resolves.toBe( mockANewLocal, ); - await expect(lockfile.save('/yarn.lock')).resolves.toBeUndefined(); - await expect(fs.readFile('/yarn.lock', 'utf8')).resolves.toBe( + await expect(lockfile.save(lockfilePath)).resolves.toBeUndefined(); + await expect(fs.readFile(lockfilePath, 'utf8')).resolves.toBe( mockANewLocalDedup, ); }); diff --git a/packages/cli/src/lib/versioning/packages.test.ts b/packages/cli/src/lib/versioning/packages.test.ts index 5a255532f9..23ff927c48 100644 --- a/packages/cli/src/lib/versioning/packages.test.ts +++ b/packages/cli/src/lib/versioning/packages.test.ts @@ -14,12 +14,11 @@ * limitations under the License. */ -import mockFs from 'mock-fs'; -import path from 'path'; import * as runObj from '../run'; import * as yarn from '../yarn'; import { fetchPackageInfo, mapDependencies } from './packages'; import { NotFoundError } from '../errors'; +import { createMockDirectory } from '@backstage/backend-test-utils'; jest.mock('../run', () => { return { @@ -96,34 +95,41 @@ describe('fetchPackageInfo', () => { }); describe('mapDependencies', () => { + const mockDir = createMockDirectory(); + afterEach(() => { - mockFs.restore(); jest.resetAllMocks(); }); it('should read dependencies', async () => { - mockFs({ - '/root/package.json': JSON.stringify({ + mockDir.setContent({ + 'package.json': JSON.stringify({ workspaces: { packages: ['pkgs/*'], }, }), - '/root/pkgs/a/package.json': JSON.stringify({ - name: 'a', - dependencies: { - '@backstage/core': '1 || 2', + pkgs: { + a: { + 'package.json': JSON.stringify({ + name: 'a', + dependencies: { + '@backstage/core': '1 || 2', + }, + }), }, - }), - '/root/pkgs/b/package.json': JSON.stringify({ - name: 'b', - dependencies: { - '@backstage/core': '3', - '@backstage/cli': '^0', + b: { + 'package.json': JSON.stringify({ + name: 'b', + dependencies: { + '@backstage/core': '3', + '@backstage/cli': '^0', + }, + }), }, - }), + }, }); - const dependencyMap = await mapDependencies('/root', '@backstage/*'); + const dependencyMap = await mapDependencies(mockDir.path, '@backstage/*'); expect(Array.from(dependencyMap)).toEqual([ [ '@backstage/core', @@ -131,12 +137,12 @@ describe('mapDependencies', () => { { name: 'a', range: '1 || 2', - location: path.resolve('/root/pkgs/a'), + location: mockDir.resolve('pkgs/a'), }, { name: 'b', range: '3', - location: path.resolve('/root/pkgs/b'), + location: mockDir.resolve('pkgs/b'), }, ], ], @@ -146,7 +152,7 @@ describe('mapDependencies', () => { { name: 'b', range: '^0', - location: path.resolve('/root/pkgs/b'), + location: mockDir.resolve('pkgs/b'), }, ], ], diff --git a/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts b/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts index 3f91a8f49d..242f93c2f3 100644 --- a/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts +++ b/packages/cli/templates/scaffolder-module/src/actions/example/example.test.ts @@ -22,7 +22,7 @@ describe('acme:example', () => { logStream: new PassThrough(), output: jest.fn(), createTemporaryDirectory() { - // Usage of mock-fs is recommended for testing of filesystem operations + // Usage of createMockDirectory is recommended for testing of filesystem operations throw new Error('Not implemented'); }, }); diff --git a/yarn.lock b/yarn.lock index 369b6c0356..4e26293aa8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3784,7 +3784,6 @@ __metadata: "@types/inquirer": ^8.1.3 "@types/jest": ^29.0.0 "@types/minimatch": ^5.0.0 - "@types/mock-fs": ^4.13.0 "@types/node": ^18.17.8 "@types/npm-packlist": ^3.0.0 "@types/recursive-readdir": ^2.2.0 @@ -3839,7 +3838,6 @@ __metadata: lodash: ^4.17.21 mini-css-extract-plugin: ^2.4.2 minimatch: ^5.1.1 - mock-fs: ^5.2.0 msw: ^1.0.0 node-fetch: ^2.6.7 node-libs-browser: ^2.2.1 From b514c862f59512a6786c030a641906f8ded16ff2 Mon Sep 17 00:00:00 2001 From: Abhay-soni-developer Date: Thu, 12 Oct 2023 11:42:59 +0530 Subject: [PATCH 24/59] fixing to support pipeline, multibranch-pipeline and job in JobRunTable Signed-off-by: Abhay-soni-developer --- .../jenkins-backend/src/service/jenkinsApi.ts | 33 +++++++++++++++---- plugins/jenkins-backend/src/service/router.ts | 2 +- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/plugins/jenkins-backend/src/service/jenkinsApi.ts b/plugins/jenkins-backend/src/service/jenkinsApi.ts index 4072eb6ca2..2cf432343f 100644 --- a/plugins/jenkins-backend/src/service/jenkinsApi.ts +++ b/plugins/jenkins-backend/src/service/jenkinsApi.ts @@ -54,6 +54,7 @@ export class JenkinsApiImpl { private static readonly jobTreeSpec = `actions[*], ${JenkinsApiImpl.lastBuildTreeSpec} jobs{0,1}, + url, name, fullName, displayName, @@ -340,13 +341,33 @@ export class JenkinsApiImpl { return `${jenkinsInfo.baseUrl}/job/${jobs.join('/job/')}/${buildId}`; } - async getJobBuilds(jenkinsInfo: JenkinsInfo) { - const client = await JenkinsApiImpl.getClient(jenkinsInfo); + async getJobBuilds(jenkinsInfo: JenkinsInfo, jobFullName: string) { + let jobName = jobFullName; - const jobBuilds = await client.job.get({ - name: jenkinsInfo.jobFullName, - tree: JenkinsApiImpl.jobBuildsTreeSpec.replace(/\s/g, ''), - }); + if (jobFullName.includes('/')) { + const arr = jobFullName.split('/'); + const multibranchJobName = arr.shift(); + jobName = [ + multibranchJobName, + 'job', + encodeURIComponent(arr.join('/')), + ].join('/'); + } + + const response = await fetch( + `${ + jenkinsInfo.baseUrl + }/job/${jobName}/api/json?tree=${JenkinsApiImpl.jobBuildsTreeSpec.replace( + /\s/g, + '', + )}`, + { + method: 'get', + headers: jenkinsInfo.headers as HeaderInit, + }, + ); + + const jobBuilds = await response.json(); return jobBuilds; } diff --git a/plugins/jenkins-backend/src/service/router.ts b/plugins/jenkins-backend/src/service/router.ts index 88777ff098..06a04d8266 100644 --- a/plugins/jenkins-backend/src/service/router.ts +++ b/plugins/jenkins-backend/src/service/router.ts @@ -169,7 +169,7 @@ export async function createRouter( backstageToken: token, }); - const build = await jenkinsApi.getJobBuilds(jenkinsInfo); + const build = await jenkinsApi.getJobBuilds(jenkinsInfo, jobFullName); response.json({ build: build, From d4cdf46e49f5be2e01949c33939f80d14fed8be7 Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Sun, 26 Mar 2023 21:57:09 -0400 Subject: [PATCH 25/59] extract pinniped auth provider a modified copy of the oidc auth provider, with a slight change in config schema. A single high-level integration test checks this. Signed-off-by: Jamie Klassen --- plugins/auth-backend/api-report.md | 15 ++ .../src/providers/pinniped/index.test.ts | 149 ++++++++++++++++++ .../src/providers/pinniped/index.ts | 71 +++++++++ .../auth-backend/src/providers/providers.ts | 3 + 4 files changed, 238 insertions(+) create mode 100644 plugins/auth-backend/src/providers/pinniped/index.test.ts create mode 100644 plugins/auth-backend/src/providers/pinniped/index.ts diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 41536f2a3f..c320d16abc 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -619,6 +619,21 @@ export const providers: Readonly<{ ) => AuthProviderFactory_2; resolvers: never; }>; + pinniped: Readonly<{ + create: ( + options?: + | { + authHandler?: AuthHandler | undefined; + signIn?: + | { + resolver: SignInResolver; + } + | undefined; + } + | undefined, + ) => AuthProviderFactory; + resolvers: never; + }>; saml: Readonly<{ create: ( options?: diff --git a/plugins/auth-backend/src/providers/pinniped/index.test.ts b/plugins/auth-backend/src/providers/pinniped/index.test.ts new file mode 100644 index 0000000000..32749d3a1c --- /dev/null +++ b/plugins/auth-backend/src/providers/pinniped/index.test.ts @@ -0,0 +1,149 @@ +/* + * Copyright 2023 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 { pinniped } from '.'; +import { getVoidLogger } from '@backstage/backend-common'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { ConfigReader } from '@backstage/config'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; +import crypto from 'crypto'; +import express from 'express'; +import request from 'supertest'; +import cookieParser from 'cookie-parser'; +import passport from 'passport'; +import session from 'express-session'; + +describe('pinniped.create', () => { + const server = setupServer(); + setupRequestMockHandlers(server); + + describe('#start', () => { + const nonce = 'AAAAAAAAAAAAAAAAAAAAAA=='; // 16 bytes of zeros in base64 + const state = Buffer.from( + `nonce=${encodeURIComponent(nonce)}&env=development`, + ).toString('hex'); + + const randomBytes = jest.spyOn( + crypto, + 'randomBytes', + ) as unknown as jest.MockedFunction<(size: number) => Buffer>; + + afterEach(() => { + randomBytes.mockRestore(); + }); + + it('redirects to authorization endpoint returned from federationDomain config value', async () => { + randomBytes.mockReturnValue(Buffer.from(nonce, 'base64')); + server.use( + rest.all( + 'https://pinniped.test/.well-known/openid-configuration', + (_, res, ctx) => + res( + ctx.json({ + issuer: 'https://pinniped.test', + authorization_endpoint: + 'https://pinniped.test/oauth2/authorize', + token_endpoint: 'https://pinniped.test/oauth2/token', + jwks_uri: 'https://pinniped.test/jwks.json', + response_types_supported: ['code'], + response_modes_supported: ['query', 'form_post'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['ES256'], + token_endpoint_auth_methods_supported: ['client_secret_basic'], + scopes_supported: [ + 'openid', + 'offline_access', + 'pinniped:request-audience', + 'username', + 'groups', + ], + claims_supported: ['username', 'groups', 'additionalClaims'], + code_challenge_methods_supported: ['S256'], + 'discovery.supervisor.pinniped.dev/v1alpha1': { + pinniped_identity_providers_endpoint: + 'https://pinniped.test/v1alpha1/pinniped_identity_providers', + }, + }), + ), + ), + ); + + const provider = pinniped.create()({ + providerId: 'pinniped', + globalConfig: { + baseUrl: 'http://backstage.test/api/auth', + appUrl: 'http://backstage.test', + isOriginAllowed: _ => true, + }, + config: new ConfigReader({ + development: { + federationDomain: 'https://pinniped.test', + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + }), + logger: getVoidLogger(), + resolverContext: { + issueToken: async _ => ({ token: '' }), + findCatalogUser: async _ => ({ + entity: { + apiVersion: '', + kind: '', + metadata: { name: '' }, + }, + }), + signInWithCatalogUser: async _ => ({ token: '' }), + }, + }); + + const secret = 'secret'; + const app = express() + .use(cookieParser(secret)) + .use( + session({ + secret, + saveUninitialized: false, + resave: false, + cookie: { secure: false }, + }), + ) + .use(passport.initialize()) + .use(passport.session()) + .use('/api/auth/pinniped/start', provider.start.bind(provider)); + const responsePromise = request(app).get( + '/api/auth/pinniped/start?' + + 'env=development&scope=openid+pinniped:request-audience+username', + ); + const reqUrl = new URL(responsePromise.url); + reqUrl.search = ''; + server.use(rest.all(reqUrl.toString(), req => req.passthrough())); + + const response = await responsePromise; + expect((response as any).headers.location).toMatch( + 'https://pinniped.test/oauth2/authorize' + + '?client_id=clientId' + + `&scope=${encodeURIComponent( + 'openid pinniped:request-audience username', + )}` + + '&response_type=code' + + `&redirect_uri=${encodeURIComponent( + 'http://backstage.test/api/auth/pinniped/handler/frame', + )}` + + `&state=${state}`, + ); + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/pinniped/index.ts b/plugins/auth-backend/src/providers/pinniped/index.ts new file mode 100644 index 0000000000..2c2f6e278a --- /dev/null +++ b/plugins/auth-backend/src/providers/pinniped/index.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2023 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 { OidcAuthResult } from '../oidc'; +import { OidcAuthProvider } from '../oidc/provider'; +import { OAuthAdapter, OAuthEnvironmentHandler } from '../../lib/oauth'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; +import { AuthHandler, SignInResolver } from '../types'; + +/** + * Auth provider integration for Pinniped + * + * @public + */ +export const pinniped = createAuthProviderIntegration({ + create(options?: { + authHandler?: AuthHandler; + signIn?: { + resolver: SignInResolver; + }; + }) { + return ({ providerId, globalConfig, config, resolverContext }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); + const callbackUrl = + customCallbackUrl || + `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const metadataUrl = `${envConfig.getString( + 'federationDomain', + )}/.well-known/openid-configuration`; + const tokenSignedResponseAlg = 'ES256'; + const prompt = 'auto'; + const authHandler: AuthHandler = async ({ + userinfo, + }) => ({ + profile: {}, + }); + + const provider = new OidcAuthProvider({ + clientId, + clientSecret, + callbackUrl, + tokenSignedResponseAlg, + metadataUrl, + prompt, + signInResolver: options?.signIn?.resolver, + authHandler, + resolverContext, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + providerId, + callbackUrl, + }); + }); + }, +}); diff --git a/plugins/auth-backend/src/providers/providers.ts b/plugins/auth-backend/src/providers/providers.ts index 36a24f4f6c..cf5a6df68d 100644 --- a/plugins/auth-backend/src/providers/providers.ts +++ b/plugins/auth-backend/src/providers/providers.ts @@ -33,6 +33,7 @@ import { saml } from './saml'; import { AuthProviderFactory } from './types'; import { bitbucketServer } from './bitbucketServer'; import { easyAuth } from './azure-easyauth'; +import { pinniped } from './pinniped'; /** * All built-in auth provider integrations. @@ -56,6 +57,7 @@ export const providers = Object.freeze({ oidc, okta, onelogin, + pinniped, saml, easyAuth, }); @@ -83,4 +85,5 @@ export const defaultAuthProviderFactories: { bitbucket: bitbucket.create(), bitbucketServer: bitbucketServer.create(), atlassian: atlassian.create(), + pinniped: pinniped.create(), }; From 50223e77449b87de6e1ad8e16c89e1bdc8dd2732 Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Mon, 27 Mar 2023 05:56:48 -0400 Subject: [PATCH 26/59] WIP: conditionally skip user profile OIDC auth provider returns an empty user profile when the associated issuer has no userinfo_endpoint. In theory this would enable access-delegation-only use cases, but I haven't thought through all the consequences. Signed-off-by: Jamie Klassen --- .../src/providers/oidc/provider.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/plugins/auth-backend/src/providers/oidc/provider.ts b/plugins/auth-backend/src/providers/oidc/provider.ts index 7638027b01..96bcbf6e00 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.ts @@ -130,7 +130,9 @@ export class OidcAuthProvider implements OAuthHandlers { if (!tokenset.access_token) { throw new Error('Refresh failed'); } - const userinfo = await client.userinfo(tokenset.access_token); + const userinfo = client.issuer.userinfo_endpoint + ? await client.userinfo(tokenset.access_token) + : { sub: '' }; return { response: await this.handleResult({ tokenset, userinfo }), @@ -159,17 +161,23 @@ export class OidcAuthProvider implements OAuthHandlers { }, ( tokenset: TokenSet, - userinfo: UserinfoResponse, - done: PassportDoneCallback, + userinfo: + | UserinfoResponse + | PassportDoneCallback, + done?: PassportDoneCallback, ) => { - if (typeof done !== 'function') { - throw new Error( - 'OIDC IdP must provide a userinfo_endpoint in the metadata response', + if (typeof userinfo === 'function') { + userinfo( + undefined, + { tokenset, userinfo: { sub: '' } }, + { + refreshToken: tokenset.refresh_token, + }, ); } - done( + done!( undefined, - { tokenset, userinfo }, + { tokenset, userinfo: userinfo as UserinfoResponse }, { refreshToken: tokenset.refresh_token, }, From 7b4b8a00349692928e0682f8065023f7dddf4c41 Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Tue, 28 Mar 2023 10:41:10 -0400 Subject: [PATCH 27/59] wip: auth-backed performs rfc 8693 token exchange Just driving with the test at this point, hitting some trouble with express-session/cookies but I have an idea for an approach when I return to it: Hopefully it will be enough to enable all the right middlewares in our express app under test and then use `request.agent` from supertest as in https://github.com/ladjs/supertest/blob/25920e7a1d246b590123417bfce33221db88e947/README.md?plain=1#L244-L256 which can make an initial request to the `/start` endpoint and persist cookies to the next request (the interesting one under test) to `/handler/frame`. Signed-off-by: Jamie Klassen --- plugins/auth-backend/package.json | 6 + .../src/providers/pinniped/index.test.ts | 216 +++++++++++------- yarn.lock | 179 ++++++++++++++- 3 files changed, 317 insertions(+), 84 deletions(-) diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 355c8eb554..85cfc81d01 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -32,6 +32,7 @@ "clean": "backstage-cli package clean" }, "dependencies": { + "-": "^0.0.1", "@backstage/backend-common": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", "@backstage/catalog-client": "workspace:^", @@ -53,8 +54,12 @@ "@types/passport": "^1.0.3", "compression": "^1.7.4", "connect-session-knex": "^3.0.1", + "cookie": "^0.5.0", "cookie-parser": "^1.4.5", + "cookie-signature": "^1.2.1", "cors": "^2.8.5", + "d": "^1.0.1", + "e": "^0.2.32", "express": "^4.17.1", "express-promise-router": "^4.1.0", "express-session": "^1.17.1", @@ -81,6 +86,7 @@ "passport-onelogin-oauth": "^0.0.1", "passport-saml": "^3.1.2", "uuid": "^8.0.0", + "v": "^0.3.0", "winston": "^3.2.1", "yn": "^4.0.0" }, diff --git a/plugins/auth-backend/src/providers/pinniped/index.test.ts b/plugins/auth-backend/src/providers/pinniped/index.test.ts index 32749d3a1c..08d833fd34 100644 --- a/plugins/auth-backend/src/providers/pinniped/index.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/index.test.ts @@ -14,6 +14,7 @@ * limitations under the License. */ import { pinniped } from '.'; +import { AuthProviderRouteHandlers } from '../types'; import { getVoidLogger } from '@backstage/backend-common'; import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { ConfigReader } from '@backstage/config'; @@ -25,17 +26,101 @@ import request from 'supertest'; import cookieParser from 'cookie-parser'; import passport from 'passport'; import session from 'express-session'; +import signature from 'cookie-signature'; +import cookie from 'cookie'; describe('pinniped.create', () => { const server = setupServer(); setupRequestMockHandlers(server); + const nonce = 'AAAAAAAAAAAAAAAAAAAAAA=='; // 16 bytes of zeros in base64 + const state = Buffer.from( + `nonce=${encodeURIComponent(nonce)}&env=development`, + ).toString('hex'); + + let app: express.Express; + let provider: AuthProviderRouteHandlers; + + beforeEach(() => { + server.use( + rest.all( + 'https://pinniped.test/.well-known/openid-configuration', + (_, res, ctx) => + res( + ctx.json({ + issuer: 'https://pinniped.test', + authorization_endpoint: 'https://pinniped.test/oauth2/authorize', + token_endpoint: 'https://pinniped.test/oauth2/token', + jwks_uri: 'https://pinniped.test/jwks.json', + response_types_supported: ['code'], + response_modes_supported: ['query', 'form_post'], + subject_types_supported: ['public'], + id_token_signing_alg_values_supported: ['ES256'], + token_endpoint_auth_methods_supported: ['client_secret_basic'], + scopes_supported: [ + 'openid', + 'offline_access', + 'pinniped:request-audience', + 'username', + 'groups', + ], + claims_supported: ['username', 'groups', 'additionalClaims'], + code_challenge_methods_supported: ['S256'], + 'discovery.supervisor.pinniped.dev/v1alpha1': { + pinniped_identity_providers_endpoint: + 'https://pinniped.test/v1alpha1/pinniped_identity_providers', + }, + }), + ), + ), + ); + provider = pinniped.create()({ + providerId: 'pinniped', + globalConfig: { + baseUrl: 'http://backstage.test/api/auth', + appUrl: 'http://backstage.test', + isOriginAllowed: _ => true, + }, + config: new ConfigReader({ + development: { + federationDomain: 'https://pinniped.test', + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + }), + logger: getVoidLogger(), + resolverContext: { + issueToken: async _ => ({ token: '' }), + findCatalogUser: async _ => ({ + entity: { + apiVersion: '', + kind: '', + metadata: { name: '' }, + }, + }), + signInWithCatalogUser: async _ => ({ token: '' }), + }, + }); + const secret = 'secret'; + app = express() + .use(cookieParser(secret)) + .use( + session({ + secret, + saveUninitialized: false, + resave: false, + cookie: { secure: false }, + }), + ) + .use(passport.initialize()) + .use(passport.session()) + .use('/api/auth/pinniped/start', provider.start.bind(provider)) + .use( + '/api/auth/pinniped/handler/frame', + provider.frameHandler.bind(provider), + ); + }); describe('#start', () => { - const nonce = 'AAAAAAAAAAAAAAAAAAAAAA=='; // 16 bytes of zeros in base64 - const state = Buffer.from( - `nonce=${encodeURIComponent(nonce)}&env=development`, - ).toString('hex'); - const randomBytes = jest.spyOn( crypto, 'randomBytes', @@ -47,82 +132,7 @@ describe('pinniped.create', () => { it('redirects to authorization endpoint returned from federationDomain config value', async () => { randomBytes.mockReturnValue(Buffer.from(nonce, 'base64')); - server.use( - rest.all( - 'https://pinniped.test/.well-known/openid-configuration', - (_, res, ctx) => - res( - ctx.json({ - issuer: 'https://pinniped.test', - authorization_endpoint: - 'https://pinniped.test/oauth2/authorize', - token_endpoint: 'https://pinniped.test/oauth2/token', - jwks_uri: 'https://pinniped.test/jwks.json', - response_types_supported: ['code'], - response_modes_supported: ['query', 'form_post'], - subject_types_supported: ['public'], - id_token_signing_alg_values_supported: ['ES256'], - token_endpoint_auth_methods_supported: ['client_secret_basic'], - scopes_supported: [ - 'openid', - 'offline_access', - 'pinniped:request-audience', - 'username', - 'groups', - ], - claims_supported: ['username', 'groups', 'additionalClaims'], - code_challenge_methods_supported: ['S256'], - 'discovery.supervisor.pinniped.dev/v1alpha1': { - pinniped_identity_providers_endpoint: - 'https://pinniped.test/v1alpha1/pinniped_identity_providers', - }, - }), - ), - ), - ); - const provider = pinniped.create()({ - providerId: 'pinniped', - globalConfig: { - baseUrl: 'http://backstage.test/api/auth', - appUrl: 'http://backstage.test', - isOriginAllowed: _ => true, - }, - config: new ConfigReader({ - development: { - federationDomain: 'https://pinniped.test', - clientId: 'clientId', - clientSecret: 'clientSecret', - }, - }), - logger: getVoidLogger(), - resolverContext: { - issueToken: async _ => ({ token: '' }), - findCatalogUser: async _ => ({ - entity: { - apiVersion: '', - kind: '', - metadata: { name: '' }, - }, - }), - signInWithCatalogUser: async _ => ({ token: '' }), - }, - }); - - const secret = 'secret'; - const app = express() - .use(cookieParser(secret)) - .use( - session({ - secret, - saveUninitialized: false, - resave: false, - cookie: { secure: false }, - }), - ) - .use(passport.initialize()) - .use(passport.session()) - .use('/api/auth/pinniped/start', provider.start.bind(provider)); const responsePromise = request(app).get( '/api/auth/pinniped/start?' + 'env=development&scope=openid+pinniped:request-audience+username', @@ -146,4 +156,50 @@ describe('pinniped.create', () => { ); }); }); + describe('#frameHandler', () => { + it('performs an rfc 8693 token exchange after getting access token', async () => { + server.use( + rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => + res( + ctx.json( + new URLSearchParams(await req.text()).get('grant_type') === + 'urn:ietf:params:oauth:grant-type:token-exchange' + ? { access_token: 'accessToken' } + : { id_token: 'clusterToken' }, + ), + ), + ), + ); + + const responsePromise = request(app) + .get( + '/api/auth/pinniped/handler/frame?' + + 'code=pin_ac_xU69qZGejOCu8Loz5iOD6Bm25SgQewmT0VVE1hOAQzA.WzxrI9bCder5UJHtCOX_yEnsM2OVh8pVSFI7NPs5yUM&' + + 'scope=openid+pinniped%3Arequest-audience+username&' + + `state=${state}`, + ) + .set( + 'Cookie', + `pinniped-nonce=${nonce}; ` + + 'connect.sid=s:p3_hKHiFr_i58jyTPIZxtWN9pejiOujD.SN2irLt6oIL18v0GzGCPO1sibEmzybiVlT9ca3ZjT68', + ); + const reqUrl = new URL(responsePromise.url); + reqUrl.search = ''; + server.use(rest.all(reqUrl.toString(), req => req.passthrough())); + + expect((await responsePromise).text).toContain( + encodeURIComponent( + JSON.stringify({ + type: 'authorization_response', + response: { + providerInfo: { + idToken: 'clusterToken', + }, + profile: {}, + }, + }), + ), + ); + }); + }); }); diff --git a/yarn.lock b/yarn.lock index fd3b9da734..9f64dacf56 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,13 @@ __metadata: version: 6 cacheKey: 8 +"-@npm:^0.0.1": + version: 0.0.1 + resolution: "-@npm:0.0.1" + checksum: 33786d96a8c404f3ce4db242b50d9a8f6013b3a0673bba52186b92f016429135ec2d9b9f77abf85c2a2b757c85e9b33a1f44d5dbf9740fd6294de87198681fd6 + languageName: node + linkType: hard + "@aashutoshrathi/word-wrap@npm:^1.2.3": version: 1.2.6 resolution: "@aashutoshrathi/word-wrap@npm:1.2.6" @@ -4976,6 +4983,7 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend@workspace:plugins/auth-backend" dependencies: + "-": ^0.0.1 "@backstage/backend-common": "workspace:^" "@backstage/backend-defaults": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" @@ -5011,8 +5019,12 @@ __metadata: "@types/xml2js": ^0.4.7 compression: ^1.7.4 connect-session-knex: ^3.0.1 + cookie: ^0.5.0 cookie-parser: ^1.4.5 + cookie-signature: ^1.2.1 cors: ^2.8.5 + d: ^1.0.1 + e: ^0.2.32 express: ^4.17.1 express-promise-router: ^4.1.0 express-session: ^1.17.1 @@ -5041,6 +5053,7 @@ __metadata: passport-saml: ^3.1.2 supertest: ^6.1.3 uuid: ^8.0.0 + v: ^0.3.0 winston: ^3.2.1 yn: ^4.0.0 languageName: unknown @@ -20392,6 +20405,13 @@ __metadata: languageName: node linkType: hard +"async-limiter@npm:~1.0.0": + version: 1.0.1 + resolution: "async-limiter@npm:1.0.1" + checksum: 2b849695b465d93ad44c116220dee29a5aeb63adac16c1088983c339b0de57d76e82533e8e364a93a9f997f28bbfc6a92948cefc120652bd07f3b59f8d75cf2b + languageName: node + linkType: hard + "async-lock@npm:^1.1.0": version: 1.2.4 resolution: "async-lock@npm:1.2.4" @@ -22748,6 +22768,13 @@ __metadata: languageName: node linkType: hard +"cookie-signature@npm:^1.2.1": + version: 1.2.1 + resolution: "cookie-signature@npm:1.2.1" + checksum: bb464aacac390b5d7d8ead2d6fff7c1c3b7378c7d0250921f48923fe889688e081ab33950448929db5f24d4f9f1506589a7ee1c685de8f12a3fdb30c49667ec5 + languageName: node + linkType: hard + "cookie@npm:0.4.1": version: 0.4.1 resolution: "cookie@npm:0.4.1" @@ -22762,7 +22789,7 @@ __metadata: languageName: node linkType: hard -"cookie@npm:0.5.0, cookie@npm:~0.5.0": +"cookie@npm:0.5.0, cookie@npm:^0.5.0, cookie@npm:~0.5.0": version: 0.5.0 resolution: "cookie@npm:0.5.0" checksum: 1f4bd2ca5765f8c9689a7e8954183f5332139eb72b6ff783d8947032ec1fdf43109852c178e21a953a30c0dd42257828185be01b49d1eb1a67fd054ca588a180 @@ -23528,6 +23555,16 @@ __metadata: languageName: node linkType: hard +"d@npm:1, d@npm:^1.0.1": + version: 1.0.1 + resolution: "d@npm:1.0.1" + dependencies: + es5-ext: ^0.10.50 + type: ^1.0.1 + checksum: 49ca0639c7b822db670de93d4fbce44b4aa072cd848c76292c9978a8cd0fff1028763020ff4b0f147bd77bfe29b4c7f82e0f71ade76b2a06100543cdfd948d19 + languageName: node + linkType: hard + "dagre@npm:^0.8.5": version: 0.8.5 resolution: "dagre@npm:0.8.5" @@ -23613,6 +23650,16 @@ __metadata: languageName: node linkType: hard +"deasync@npm:^0.1.9": + version: 0.1.28 + resolution: "deasync@npm:0.1.28" + dependencies: + bindings: ^1.5.0 + node-addon-api: ^1.7.1 + checksum: e0c1ef427875c897e0d903a08410df1d0a3dfd0d2a0a1e43fb6c2824dfbc504b810bd08a0d30653117259316e1aa65409c96dbed40101c934f75bac7499e1265 + languageName: node + linkType: hard + "debounce@npm:^1.1.0, debounce@npm:^1.2.0": version: 1.2.1 resolution: "debounce@npm:1.2.1" @@ -23620,7 +23667,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:2.6.9, debug@npm:^2.6.0": +"debug@npm:2.6.9, debug@npm:^2.6.0, debug@npm:^2.6.1": version: 2.6.9 resolution: "debug@npm:2.6.9" dependencies: @@ -23641,7 +23688,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:^3.2.7": +"debug@npm:^3.1.0, debug@npm:^3.2.7": version: 3.2.7 resolution: "debug@npm:3.2.7" dependencies: @@ -24334,6 +24381,13 @@ __metadata: languageName: unknown linkType: soft +"e@npm:^0.2.32": + version: 0.2.32 + resolution: "e@npm:0.2.32" + checksum: 6fcebe65c37d44e69b03d1db3ea1a949855aaae227a3a7f80e807983d6f31f9dc4c9420c02ec6d37046ca0c5523fb836215c10e1cd9d8f438e6df701dc24de35 + languageName: node + linkType: hard + "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -24738,6 +24792,17 @@ __metadata: languageName: node linkType: hard +"es5-ext@npm:^0.10.35, es5-ext@npm:^0.10.50": + version: 0.10.62 + resolution: "es5-ext@npm:0.10.62" + dependencies: + es6-iterator: ^2.0.3 + es6-symbol: ^3.1.3 + next-tick: ^1.1.0 + checksum: 25f42f6068cfc6e393cf670bc5bba249132c5f5ec2dd0ed6e200e6274aca2fed8e9aec8a31c76031744c78ca283c57f0b41c7e737804c6328c7b8d3fbcba7983 + languageName: node + linkType: hard + "es6-error@npm:^4.1.1": version: 4.1.1 resolution: "es6-error@npm:4.1.1" @@ -24745,6 +24810,17 @@ __metadata: languageName: node linkType: hard +"es6-iterator@npm:^2.0.3": + version: 2.0.3 + resolution: "es6-iterator@npm:2.0.3" + dependencies: + d: 1 + es5-ext: ^0.10.35 + es6-symbol: ^3.1.1 + checksum: 6e48b1c2d962c21dee604b3d9f0bc3889f11ed5a8b33689155a2065d20e3107e2a69cc63a71bd125aeee3a589182f8bbcb5c8a05b6a8f38fa4205671b6d09697 + languageName: node + linkType: hard + "es6-object-assign@npm:^1.1.0": version: 1.1.0 resolution: "es6-object-assign@npm:1.1.0" @@ -24752,6 +24828,16 @@ __metadata: languageName: node linkType: hard +"es6-symbol@npm:^3.1.1, es6-symbol@npm:^3.1.3": + version: 3.1.3 + resolution: "es6-symbol@npm:3.1.3" + dependencies: + d: ^1.0.1 + ext: ^1.1.2 + checksum: cd49722c2a70f011eb02143ef1c8c70658d2660dead6641e160b94619f408b9cf66425515787ffe338affdf0285ad54f4eae30ea5bd510e33f8659ec53bcaa70 + languageName: node + linkType: hard + "esbuild-loader@npm:^2.18.0": version: 2.21.0 resolution: "esbuild-loader@npm:2.21.0" @@ -26073,6 +26159,15 @@ __metadata: languageName: node linkType: hard +"ext@npm:^1.1.2": + version: 1.7.0 + resolution: "ext@npm:1.7.0" + dependencies: + type: ^2.7.2 + checksum: ef481f9ef45434d8c867cfd09d0393b60945b7c8a1798bedc4514cb35aac342ccb8d8ecb66a513e6a2b4ec1e294a338e3124c49b29736f8e7c735721af352c31 + languageName: node + linkType: hard + "extend@npm:3.0.2, extend@npm:^3.0.0, extend@npm:^3.0.2, extend@npm:~3.0.2": version: 3.0.2 resolution: "extend@npm:3.0.2" @@ -33570,6 +33665,13 @@ __metadata: languageName: node linkType: hard +"next-tick@npm:^1.1.0": + version: 1.1.0 + resolution: "next-tick@npm:1.1.0" + checksum: 83b5cf36027a53ee6d8b7f9c0782f2ba87f4858d977342bfc3c20c21629290a2111f8374d13a81221179603ffc4364f38374b5655d17b6a8f8a8c77bdea4fe8b + languageName: node + linkType: hard + "nimma@npm:0.2.2": version: 0.2.2 resolution: "nimma@npm:0.2.2" @@ -33628,6 +33730,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^1.7.1": + version: 1.7.2 + resolution: "node-addon-api@npm:1.7.2" + dependencies: + node-gyp: latest + checksum: 938922b3d7cb34ee137c5ec39df6289a3965e8cab9061c6848863324c21a778a81ae3bc955554c56b6b86962f6ccab2043dd5fa3f33deab633636bd28039333f + languageName: node + linkType: hard + "node-addon-api@npm:^3.2.1": version: 3.2.1 resolution: "node-addon-api@npm:3.2.1" @@ -36634,7 +36745,7 @@ __metadata: languageName: node linkType: hard -"randombytes@npm:^2.0.0, randombytes@npm:^2.0.1, randombytes@npm:^2.0.5, randombytes@npm:^2.1.0": +"randombytes@npm:^2.0.0, randombytes@npm:^2.0.1, randombytes@npm:^2.0.3, randombytes@npm:^2.0.5, randombytes@npm:^2.1.0": version: 2.1.0 resolution: "randombytes@npm:2.1.0" dependencies: @@ -39183,6 +39294,20 @@ __metadata: languageName: node linkType: hard +"simple-websocket@npm:^5.0.0": + version: 5.1.1 + resolution: "simple-websocket@npm:5.1.1" + dependencies: + debug: ^3.1.0 + inherits: ^2.0.1 + randombytes: ^2.0.3 + readable-stream: ^2.0.5 + safe-buffer: ^5.0.1 + ws: ^3.3.1 + checksum: 846ba5a4e8419a4b3186e8618ed55969d498d494c8c78002a7f6adfef51edfb1f673380ad28cd92d59c8a70d2247395ad8ad1117c1597839500e6c5efd1c3f30 + languageName: node + linkType: hard + "sinon@npm:^14.0.2": version: 14.0.2 resolution: "sinon@npm:14.0.2" @@ -41294,6 +41419,20 @@ __metadata: languageName: node linkType: hard +"type@npm:^1.0.1": + version: 1.2.0 + resolution: "type@npm:1.2.0" + checksum: dae8c64f82c648b985caf321e9dd6e8b7f4f2e2d4f846fc6fd2c8e9dc7769382d8a52369ddbaccd59aeeceb0df7f52fb339c465be5f2e543e81e810e413451ee + languageName: node + linkType: hard + +"type@npm:^2.7.2": + version: 2.7.2 + resolution: "type@npm:2.7.2" + checksum: 0f42379a8adb67fe529add238a3e3d16699d95b42d01adfe7b9a7c5da297f5c1ba93de39265ba30ffeb37dfd0afb3fb66ae09f58d6515da442219c086219f6f4 + languageName: node + linkType: hard + "typed-array-buffer@npm:^1.0.0": version: 1.0.0 resolution: "typed-array-buffer@npm:1.0.0" @@ -41481,6 +41620,13 @@ __metadata: languageName: node linkType: hard +"ultron@npm:~1.1.0": + version: 1.1.1 + resolution: "ultron@npm:1.1.1" + checksum: aa7b5ebb1b6e33287b9d873c6756c4b7aa6d1b23d7162ff25b0c0ce5c3c7e26e2ab141a5dc6e96c10ac4d00a372e682ce298d784f06ffcd520936590b4bc0653 + languageName: node + linkType: hard + "unbox-primitive@npm:^1.0.2": version: 1.0.2 resolution: "unbox-primitive@npm:1.0.2" @@ -42050,6 +42196,20 @@ __metadata: languageName: node linkType: hard +"v@npm:^0.3.0": + version: 0.3.0 + resolution: "v@npm:0.3.0" + dependencies: + deasync: ^0.1.9 + debug: ^2.6.1 + simple-websocket: ^5.0.0 + dependenciesMeta: + deasync: + optional: true + checksum: 55a52287b7d417f348516d50e2103f3ef46db371e736da94d55ae2fdc61bdeb3f58eea689ffa69aebe2e93a1abd7a9424eabc552f6060884e434b872229b2045 + languageName: node + linkType: hard + "valid-url@npm:^1.0.9": version: 1.0.9 resolution: "valid-url@npm:1.0.9" @@ -42878,6 +43038,17 @@ __metadata: languageName: node linkType: hard +"ws@npm:^3.3.1": + version: 3.3.3 + resolution: "ws@npm:3.3.3" + dependencies: + async-limiter: ~1.0.0 + safe-buffer: ~5.1.0 + ultron: ~1.1.0 + checksum: 20b7bf34bb88715b9e2d435b76088d770e063641e7ee697b07543815fabdb752335261c507a973955e823229d0af8549f39cc669825e5c8404aa0422615c81d9 + languageName: node + linkType: hard + "ws@npm:^5.2.0 || ^6.0.0 || ^7.0.0, ws@npm:^7.4.6": version: 7.5.9 resolution: "ws@npm:7.5.9" From 07df9ce153e47ffa40e86a4e6209dcd88a3cfaf0 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Tue, 11 Jul 2023 12:27:48 -0400 Subject: [PATCH 28/59] implement consent redirect in new PinnipedAuthProvider Signed-off-by: Ruben Vallejo --- .../src/providers/pinniped/index.ts | 106 +++++----- .../src/providers/pinniped/provider.test.ts | 174 +++++++++++++++ .../src/providers/pinniped/provider.ts | 198 ++++++++++++++++++ 3 files changed, 430 insertions(+), 48 deletions(-) create mode 100644 plugins/auth-backend/src/providers/pinniped/provider.test.ts create mode 100644 plugins/auth-backend/src/providers/pinniped/provider.ts diff --git a/plugins/auth-backend/src/providers/pinniped/index.ts b/plugins/auth-backend/src/providers/pinniped/index.ts index 2c2f6e278a..943b0549e8 100644 --- a/plugins/auth-backend/src/providers/pinniped/index.ts +++ b/plugins/auth-backend/src/providers/pinniped/index.ts @@ -13,59 +13,69 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { OidcAuthResult } from '../oidc'; -import { OidcAuthProvider } from '../oidc/provider'; -import { OAuthAdapter, OAuthEnvironmentHandler } from '../../lib/oauth'; -import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; -import { AuthHandler, SignInResolver } from '../types'; +// import { OidcAuthResult } from '../oidc'; +// import { OidcAuthProvider } from '../oidc/provider'; +// import { OAuthAdapter, OAuthEnvironmentHandler } from '../../lib/oauth'; +// import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; +// import { AuthHandler, SignInResolver } from '../types'; +// import { PinnipedAuthProvider } from './provider'; /** * Auth provider integration for Pinniped * * @public */ -export const pinniped = createAuthProviderIntegration({ - create(options?: { - authHandler?: AuthHandler; - signIn?: { - resolver: SignInResolver; - }; - }) { - return ({ providerId, globalConfig, config, resolverContext }) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); - const callbackUrl = - customCallbackUrl || - `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const metadataUrl = `${envConfig.getString( - 'federationDomain', - )}/.well-known/openid-configuration`; - const tokenSignedResponseAlg = 'ES256'; - const prompt = 'auto'; - const authHandler: AuthHandler = async ({ - userinfo, - }) => ({ - profile: {}, - }); +// export const pinniped = createAuthProviderIntegration({ +// create(options?: { +// authHandler?: AuthHandler; +// signIn?: { +// resolver: SignInResolver; +// }; +// }) { +// return ({ providerId, globalConfig, config, resolverContext }) => +// OAuthEnvironmentHandler.mapConfig(config, envConfig => { +// const clientId = envConfig.getString('clientId'); +// const clientSecret = envConfig.getString('clientSecret'); +// const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); +// const callbackUrl = +// customCallbackUrl || +// `${globalConfig.baseUrl}/${providerId}/handler/frame`; +// const metadataUrl = `${envConfig.getString( +// 'federationDomain', +// )}/.well-known/openid-configuration`; +// const federationDomain = envConfig.getString('federationDomain'); +// const tokenSignedResponseAlg = 'ES256'; +// const prompt = 'auto'; +// const authHandler: AuthHandler = async ({ +// userinfo, +// }) => ({ +// profile: {}, +// }); - const provider = new OidcAuthProvider({ - clientId, - clientSecret, - callbackUrl, - tokenSignedResponseAlg, - metadataUrl, - prompt, - signInResolver: options?.signIn?.resolver, - authHandler, - resolverContext, - }); +// // const provider = new OidcAuthProvider({ +// // clientId, +// // clientSecret, +// // callbackUrl, +// // tokenSignedResponseAlg, +// // metadataUrl, +// // prompt, +// // signInResolver: options?.signIn?.resolver, +// // authHandler, +// // resolverContext, +// // }); - return OAuthAdapter.fromConfig(globalConfig, provider, { - providerId, - callbackUrl, - }); - }); - }, -}); +// const provider = new PinnipedAuthProvider({ +// federationDomain, +// clientId, +// clientSecret, +// }); + +// return OAuthAdapter.fromConfig(globalConfig, provider, { +// providerId, +// callbackUrl, +// }); +// }); +// }, +// }); + +export { pinniped } from './provider'; diff --git a/plugins/auth-backend/src/providers/pinniped/provider.test.ts b/plugins/auth-backend/src/providers/pinniped/provider.test.ts new file mode 100644 index 0000000000..5ce30df717 --- /dev/null +++ b/plugins/auth-backend/src/providers/pinniped/provider.test.ts @@ -0,0 +1,174 @@ +/* + * Copyright 2023 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 { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { OAuthStartRequest } from '../../lib/oauth'; +import { AuthResolverContext } from '../types'; +import { PinnipedAuthProvider, PinnipedOptions } from './provider'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; +import { ClientMetadata, IssuerMetadata } from 'openid-client'; + +describe('PinnipedAuthProvider', () => { + let startRequest: OAuthStartRequest; + let fakeSession: Record; + let provider: PinnipedAuthProvider; + + const worker = setupServer(); + setupRequestMockHandlers(worker); + + const issuerMetadata = { + issuer: 'https://pinniped.test', + authorization_endpoint: 'https://pinniped.test/oauth2/authorize', + token_endpoint: 'https://pinniped.test/oauth2/token', + revocation_endpoint: 'https://pinniped.test/oauth2/revoke_token', + userinfo_endpoint: 'https://pinniped.test/idp/userinfo.openid', + introspection_endpoint: 'https://pinniped.test/introspect.oauth2', + jwks_uri: 'https://pinniped.test/pf/JWKS', + scopes_supported: [ + 'openid', + 'offline_access', + 'pinniped:request-audience', + 'username', + 'groups', + ], + claims_supported: ['email', 'username', 'groups', 'additionalClaims'], + response_types_supported: ['code'], + id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + token_endpoint_auth_signing_alg_values_supported: [ + 'RS256', + 'RS512', + 'HS256', + ], + request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + }; + + const clientMetadata: PinnipedOptions = { + federationDomain: 'https://federationDomain.test', + clientId: 'clientId.test', + clientSecret: 'secret.test', + callbackUrl: 'https://federationDomain.test/callback', + resolverContext: {} as AuthResolverContext, + authHandler: async () => ({ + profile: {}, + }), + }; + + beforeEach(() => { + jest.clearAllMocks(); + fakeSession = {}; + startRequest = { + session: fakeSession, + method: 'GET', + url: 'test', + } as unknown as OAuthStartRequest; + + const handler = jest.fn((_req, res, ctx) => { + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(issuerMetadata), + ); + }); + + worker.use( + rest.all( + 'https://federationDomain.test/.well-known/openid-configuration', + handler, + ), + ); + + provider = new PinnipedAuthProvider(clientMetadata); + }); + + it('hits the metadata url', async () => { + const handler = jest.fn((_req, res, ctx) => { + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(issuerMetadata), + ); + }); + + worker.use( + rest.get( + 'https://federationDomain.test/.well-known/openid-configuration', + handler, + ), + ); + + provider = new PinnipedAuthProvider(clientMetadata); + + const { strategy } = (await (provider as any).implementation) as any as { + strategy: { + _client: ClientMetadata; + _issuer: IssuerMetadata; + }; + }; + + expect(handler).toHaveBeenCalledTimes(1); + expect(strategy._client.client_id).toBe(clientMetadata.clientId); + expect(strategy._issuer.token_endpoint).toBe(issuerMetadata.token_endpoint); + }); + + describe('/start', () => { + it('redirects to authorization endpoint returned from federationDomain config value', async () => { + const startResponse = await provider.start(startRequest); + const url = new URL(startResponse.url); + + expect(url.protocol).toBe('https:'); + expect(url.hostname).toBe('pinniped.test'); + expect(url.pathname).toBe('/oauth2/authorize'); + }); + + it('passes client ID from config', async () => { + const startResponse = await provider.start(startRequest); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('client_id')).toBe('clientId.test'); + }); + + it('passes callback URL', async () => { + const startResponse = await provider.start(startRequest); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('redirect_uri')).toBe( + 'https://federationDomain.test/callback', + ); + }); + + it('generates PKCE challenge', async () => { + const startResponse = await provider.start(startRequest); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('code_challenge_method')).toBe('S256'); + expect(searchParams.get('code_challenge')).not.toBeNull(); + }); + + it('stores PKCE verifier in session', async () => { + await provider.start(startRequest); + expect(fakeSession['oidc:pinniped.test'].code_verifier).toBeDefined(); + }); + + it('fails when request has no session', async () => { + return expect( + provider.start({ + method: 'GET', + url: 'test', + } as unknown as OAuthStartRequest), + ).rejects.toThrow('authentication requires session support'); + }); + }); +}); diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts new file mode 100644 index 0000000000..9ed184abc3 --- /dev/null +++ b/plugins/auth-backend/src/providers/pinniped/provider.ts @@ -0,0 +1,198 @@ +/* + * Copyright 2023 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 { + Client, + Issuer, + Strategy as OidcStrategy, + TokenSet, + UserinfoResponse, +} from 'openid-client'; +import { + OAuthHandlers, + OAuthProviderOptions, + OAuthResponse, + OAuthStartRequest, + encodeState, +} from '../../lib/oauth'; +import { + executeFrameHandlerStrategy, + executeRedirectStrategy, + PassportDoneCallback, +} from '../../lib/passport'; +import { AuthResolverContext, OAuthStartResponse } from '../types'; +import express from 'express'; +import { OidcAuthResult } from '../oidc'; +import { OAuthAdapter, OAuthEnvironmentHandler } from '../../lib/oauth'; +import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; +import { AuthHandler, SignInResolver } from '../types'; + +type OidcImpl = { + strategy: OidcStrategy; + client: Client; +}; + +type PrivateInfo = { + refreshToken?: string; +}; + +export type PinnipedOptions = OAuthProviderOptions & { + federationDomain: string; + clientId: string; + clientSecret: string; + callbackUrl: string; + scope?: string; + prompt?: string; + tokenSignedResponseAlg?: string; + signInResolver?: SignInResolver; + authHandler: AuthHandler; + resolverContext: AuthResolverContext; +}; + +export class PinnipedAuthProvider implements OAuthHandlers { + private readonly implementation: Promise; + private readonly federationDomain: string; + private readonly clientId: string; + private readonly clientSecret: string; + private readonly callbackUrl: string; + private readonly scope?: string; + private readonly prompt?: string; + private readonly signInResolver?: SignInResolver; + private readonly authHandler: AuthHandler; + private readonly resolverContext: AuthResolverContext; + + constructor(options: PinnipedOptions) { + this.implementation = this.setupStrategy(options); + this.federationDomain = options.federationDomain; + this.clientId = options.clientId; + this.clientSecret = options.clientSecret; + this.callbackUrl = options.callbackUrl; + this.scope = options.scope; + this.prompt = options.prompt; + this.signInResolver = options.signInResolver; + this.authHandler = options.authHandler; + this.resolverContext = options.resolverContext; + } + + async start(req: OAuthStartRequest): Promise { + const { strategy } = await this.implementation; + const options: Record = { + scope: req.scope || this.scope || 'openid profile email', + state: encodeState(req.state), + }; + return new Promise((resolve, reject) => { + strategy.redirect = (url: string, status?: number) => { + resolve({ url, status: status ?? undefined }); + }; + strategy.error = (error: Error) => { + reject(error); + }; + strategy.authenticate(req, { ...options }); + }); + } + + private async setupStrategy(options: PinnipedOptions): Promise { + const issuer = await Issuer.discover( + `${options.federationDomain}/.well-known/openid-configuration`, + ); + const client = new issuer.Client({ + access_type: 'offline', // this option must be passed to provider to receive a refresh token + client_id: options.clientId, + client_secret: options.clientSecret, + redirect_uris: [options.callbackUrl], + response_types: ['code'], + id_token_signed_response_alg: options.tokenSignedResponseAlg || 'RS256', + scope: options.scope || '', + }); + + const strategy = new OidcStrategy( + { + client, + passReqToCallback: false, + }, + ( + tokenset: TokenSet, + userinfo: + | UserinfoResponse + | PassportDoneCallback, + done?: PassportDoneCallback, + ) => { + if (typeof userinfo === 'function') { + userinfo( + undefined, + { tokenset, userinfo: { sub: '' } }, + { + refreshToken: tokenset.refresh_token, + }, + ); + } + done!( + undefined, + { tokenset, userinfo: userinfo as UserinfoResponse }, + { + refreshToken: tokenset.refresh_token, + }, + ); + }, + ); + return { strategy, client }; + } +} + +/** + * Auth provider integration for Pinniped auth + * + * @public + */ +export const pinniped = createAuthProviderIntegration({ + create(options?: { + authHandler?: AuthHandler; + signIn?: { + resolver: SignInResolver; + }; + }) { + return ({ providerId, globalConfig, config, resolverContext }) => + OAuthEnvironmentHandler.mapConfig(config, envConfig => { + const clientId = envConfig.getString('clientId'); + const clientSecret = envConfig.getString('clientSecret'); + const federationDomain = envConfig.getString('federationDomain'); + const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); + const callbackUrl = + customCallbackUrl || + `${globalConfig.baseUrl}/${providerId}/handler/frame`; + const tokenSignedResponseAlg = 'ES256'; + const prompt = 'auto'; + const authHandler: AuthHandler = async () => ({ + profile: {}, + }); + + const provider = new PinnipedAuthProvider({ + federationDomain, + clientId, + clientSecret, + callbackUrl, + tokenSignedResponseAlg, + prompt, + authHandler, + resolverContext, + }); + + return OAuthAdapter.fromConfig(globalConfig, provider, { + providerId, + callbackUrl, + }); + }); + }, +}); From 295dae8ab535966f7d269c19192c9a60218ae457 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Mon, 24 Jul 2023 11:52:18 -0400 Subject: [PATCH 29/59] passing pinniped authprovider #handler responds with Id token test Signed-off-by: Ruben Vallejo --- plugins/auth-backend/package.json | 3 +- .../src/providers/pinniped/provider.test.ts | 155 +++++++++++++++++- .../src/providers/pinniped/provider.ts | 103 +++++++++++- yarn.lock | 20 ++- 4 files changed, 269 insertions(+), 12 deletions(-) diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 85cfc81d01..0d9fc96ce8 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -66,12 +66,13 @@ "fs-extra": "10.1.0", "google-auth-library": "^8.0.0", "jose": "^4.6.0", - "jwt-decode": "^3.1.0", + "jwt-decode": "^3.1.2", "knex": "^2.0.0", "lodash": "^4.17.21", "luxon": "^3.0.0", "minimatch": "^5.0.0", "morgan": "^1.10.0", + "njwt": "^2.0.0", "node-cache": "^5.1.2", "node-fetch": "^2.6.7", "openid-client": "^5.2.1", diff --git a/plugins/auth-backend/src/providers/pinniped/provider.test.ts b/plugins/auth-backend/src/providers/pinniped/provider.test.ts index 5ce30df717..ef409e78c4 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.test.ts @@ -14,17 +14,20 @@ * limitations under the License. */ import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; -import { OAuthStartRequest } from '../../lib/oauth'; +import { OAuthStartRequest, OAuthState, encodeState } from '../../lib/oauth'; import { AuthResolverContext } from '../types'; import { PinnipedAuthProvider, PinnipedOptions } from './provider'; import { setupServer } from 'msw/node'; import { rest } from 'msw'; import { ClientMetadata, IssuerMetadata } from 'openid-client'; +import express from 'express'; +import nJwt from 'njwt'; +import { UnsecuredJWT } from 'jose'; describe('PinnipedAuthProvider', () => { + let provider: PinnipedAuthProvider; let startRequest: OAuthStartRequest; let fakeSession: Record; - let provider: PinnipedAuthProvider; const worker = setupServer(); setupRequestMockHandlers(worker); @@ -61,20 +64,62 @@ describe('PinnipedAuthProvider', () => { clientSecret: 'secret.test', callbackUrl: 'https://federationDomain.test/callback', resolverContext: {} as AuthResolverContext, + tokenSignedResponseAlg: 'none', authHandler: async () => ({ profile: {}, }), }; + // const idToken: string = nJwt + // .create( + // { + // iss: 'https://pinniped.test', + // sub: 'test', + // aud: clientMetadata.clientId, + // claims: { + // given_name: 'Givenname', + // family_name: 'Familyname', + // email: 'user@example.com', + // }, + // }, + // Buffer.from('signing key'), + // ) + // .compact(); + + const sub = 'test'; + const iss = 'https://pinniped.test'; + const iat = Date.now(); + const aud = clientMetadata.clientId; + const exp = Date.now() + 10000; + const idToken = new UnsecuredJWT({ iss, sub, aud, iat, exp }) + .setIssuer(iss) + .setAudience(aud) + .setSubject(sub) + .setIssuedAt(iat) + .setExpirationTime(exp) + .encode(); + beforeEach(() => { jest.clearAllMocks(); + worker.use( + rest.post('https://pinniped.test/oauth2/token', (req, res, ctx) => + res( + req.headers.get('Authorization') + ? ctx.json({ + access_token: 'accessToken', + refresh_token: 'refreshToken', + id_token: idToken, + }) + : ctx.status(401), + ), + ), + ); fakeSession = {}; startRequest = { session: fakeSession, method: 'GET', url: 'test', } as unknown as OAuthStartRequest; - const handler = jest.fn((_req, res, ctx) => { return res( ctx.status(200), @@ -82,14 +127,12 @@ describe('PinnipedAuthProvider', () => { ctx.json(issuerMetadata), ); }); - worker.use( rest.all( 'https://federationDomain.test/.well-known/openid-configuration', handler, ), ); - provider = new PinnipedAuthProvider(clientMetadata); }); @@ -123,7 +166,7 @@ describe('PinnipedAuthProvider', () => { expect(strategy._issuer.token_endpoint).toBe(issuerMetadata.token_endpoint); }); - describe('/start', () => { + describe('#start', () => { it('redirects to authorization endpoint returned from federationDomain config value', async () => { const startResponse = await provider.start(startRequest); const url = new URL(startResponse.url); @@ -170,5 +213,105 @@ describe('PinnipedAuthProvider', () => { } as unknown as OAuthStartRequest), ).rejects.toThrow('authentication requires session support'); }); + // false passing test: passes because we compare two falsy values undefined and undefined + // need to add the logic that makes this true + it.skip('adds session ID handle to state param', async () => { + const startResponse = await provider.start(startRequest); + // stateParam is empty string + const stateParam = new URL(startResponse.url).searchParams.get('state'); + const state = Object.fromEntries( + new URLSearchParams(Buffer.from(stateParam!, 'hex').toString('utf-8')), + ); + // handle is currently undefined + const { handle } = fakeSession['oidc:pinniped.test'].state; + console.log(`This is the param:`, stateParam); + // state.handle = undefined + expect(state.handle ?? '').toEqual(handle); + }); + }); + + describe('#handler', () => { + let handlerRequest: express.Request; + + beforeEach(() => { + provider = new PinnipedAuthProvider(clientMetadata); + + const testState = encodeState({ + nonce: 'nonce', + env: 'development', + origin: 'undefined', + }); + + handlerRequest = { + method: 'GET', + url: `https://test?code=authorization_code&state=${testState}`, + session: { + 'oidc:pinniped.test': { + state: testState, + }, + }, + } as unknown as express.Request; + + worker.use( + rest.post('https://pinniped.test/oauth2/token', (req, res, ctx) => + res( + req.headers.get('Authorization') + ? ctx.json({ + access_token: 'accessToken', + refresh_token: 'refreshToken', + id_token: idToken, + }) + : ctx.status(401), + ), + ), + rest.get( + 'https://pinniped.test/idp/userinfo.openid', + (_req, res, ctx) => + res( + ctx.json({ + iss: 'https://pinniped.test', + sub: 'test', + aud: clientMetadata.clientId, + claims: { + given_name: 'Givenname', + family_name: 'Familyname', + email: 'user@example.com', + }, + }), + ctx.status(200), + ), + ), + ); + }); + + it('responds with ID token', async () => { + const { response } = await provider.handler(handlerRequest); + expect(response.providerInfo.idToken).toBe(idToken); + }); + + it.only('decodes profile from ID token', async () => { + const { response } = await provider.handler(handlerRequest); + + expect(response.profile).toStrictEqual({ + displayName: 'Givenname Familyname', + email: 'user@example.com', + }); + }); + + it('fails when request has no state', async () => { + return expect( + provider.handler({ + method: 'GET', + url: `https://test?code=authorization_code}`, + session: { + ['oidc:pinniped.test']: { + state: { handle: 'sessionid', code_verifier: 'foo' }, + }, + }, + } as unknown as express.Request), + ).rejects.toThrow( + 'Authentication rejected, state missing from the response', + ); + }); }); }); diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts index 9ed184abc3..d74004cb10 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.ts @@ -23,13 +23,13 @@ import { import { OAuthHandlers, OAuthProviderOptions, + OAuthRefreshRequest, OAuthResponse, OAuthStartRequest, encodeState, } from '../../lib/oauth'; import { executeFrameHandlerStrategy, - executeRedirectStrategy, PassportDoneCallback, } from '../../lib/passport'; import { AuthResolverContext, OAuthStartResponse } from '../types'; @@ -38,6 +38,9 @@ import { OidcAuthResult } from '../oidc'; import { OAuthAdapter, OAuthEnvironmentHandler } from '../../lib/oauth'; import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; import { AuthHandler, SignInResolver } from '../types'; +import { BACKSTAGE_SESSION_EXPIRATION } from '../../lib/session'; +import { InternalOAuthError } from 'passport-oauth2'; +import jwtDecoder from 'jwt-decode'; type OidcImpl = { strategy: OidcStrategy; @@ -72,6 +75,7 @@ export class PinnipedAuthProvider implements OAuthHandlers { private readonly signInResolver?: SignInResolver; private readonly authHandler: AuthHandler; private readonly resolverContext: AuthResolverContext; + // private readonly state?; constructor(options: PinnipedOptions) { this.implementation = this.setupStrategy(options); @@ -92,6 +96,7 @@ export class PinnipedAuthProvider implements OAuthHandlers { scope: req.scope || this.scope || 'openid profile email', state: encodeState(req.state), }; + // this.state = options.state return new Promise((resolve, reject) => { strategy.redirect = (url: string, status?: number) => { resolve({ url, status: status ?? undefined }); @@ -103,6 +108,102 @@ export class PinnipedAuthProvider implements OAuthHandlers { }); } + async handler( + req: express.Request, + ): Promise<{ response: OAuthResponse; refreshToken?: string }> { + const { strategy } = await this.implementation; + + // we are passed a state inside of a session object + // const options: Record = { + // state: encodeState(req.state), + // }; + + console.log(req); + // return { + // response: { + // profile: {}, + // providerInfo: { accessToken: '', scope: '' }, + // }, + // }; + + // const stateParam = new URL(startResponse.url).searchParams.get('state'); + // const state = Object.fromEntries( + // new URLSearchParams(Buffer.from(stateParam!, 'hex').toString('utf-8')), + // ); + return new Promise((resolve, reject) => { + strategy.success = ( + user: { + tokenset: { + id_token: string; + }; + }, + info: { refreshToken: string }, + ) => { + // const identity: Record = jwtDecoder( + // user.tokenset.id_token, + // ); + // const identity2 = + // console.log(identity); + resolve({ + response: { + profile: {}, + providerInfo: { + idToken: user.tokenset.id_token, + accessToken: '', + scope: '', + }, + }, + refreshToken: info.refreshToken, + }); + }; + + strategy.fail = info => { + if (info.message) { + reject(new Error(`Authentication rejected, ${info.message ?? ''}`)); + } else { + console.log('what the heckhappened'); + } + }; + + strategy.error = (error: InternalOAuthError) => { + let message = `Authentication failed, ${error.message}`; + if (error.oauthError?.data) { + try { + const errorData = JSON.parse(error.oauthError.data); + + if (errorData.message) { + message += ` - ${errorData.message}`; + } + } catch (parseError) { + message += ` - ${error.oauthError}`; + } + } + reject(new Error(message)); + }; + + strategy.redirect = () => { + reject(new Error('Unexpected redirect')); + }; + + strategy.authenticate(req); + }); + } + // async refresh(req: OAuthRefreshRequest) { + // const { client } = await this.implementation; + // const tokenset = await client.refresh(req.refreshToken); + // if (!tokenset.access_token) { + // throw new Error('Refresh failed'); + // } + // const userinfo = client.issuer.userinfo_endpoint + // ? await client.userinfo(tokenset.access_token) + // : { sub: '' }; + + // return { + // response: await this.handleResult({ tokenset, userinfo }), + // refreshToken: tokenset.refresh_token, + // }; + // } + private async setupStrategy(options: PinnipedOptions): Promise { const issuer = await Issuer.discover( `${options.federationDomain}/.well-known/openid-configuration`, diff --git a/yarn.lock b/yarn.lock index 9f64dacf56..cdbbe10ced 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5031,13 +5031,14 @@ __metadata: fs-extra: 10.1.0 google-auth-library: ^8.0.0 jose: ^4.6.0 - jwt-decode: ^3.1.0 + jwt-decode: ^3.1.2 knex: ^2.0.0 lodash: ^4.17.21 luxon: ^3.0.0 minimatch: ^5.0.0 morgan: ^1.10.0 msw: ^1.0.0 + njwt: ^2.0.0 node-cache: ^5.1.2 node-fetch: ^2.6.7 openid-client: ^5.2.1 @@ -18284,7 +18285,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^15.6.1": +"@types/node@npm:^15.0.1, @types/node@npm:^15.6.1": version: 15.14.9 resolution: "@types/node@npm:15.14.9" checksum: 49f7f0522a3af4b8389aee660e88426490cd54b86356672a1fedb49919a8797c00d090ec2dcc4a5df34edc2099d57fc2203d796c4e7fbd382f2022ccd789eee7 @@ -24426,7 +24427,7 @@ __metadata: languageName: node linkType: hard -"ecdsa-sig-formatter@npm:1.0.11, ecdsa-sig-formatter@npm:^1.0.11": +"ecdsa-sig-formatter@npm:1.0.11, ecdsa-sig-formatter@npm:^1.0.11, ecdsa-sig-formatter@npm:^1.0.5": version: 1.0.11 resolution: "ecdsa-sig-formatter@npm:1.0.11" dependencies: @@ -31177,7 +31178,7 @@ __metadata: languageName: node linkType: hard -"jwt-decode@npm:*, jwt-decode@npm:^3.1.0": +"jwt-decode@npm:*, jwt-decode@npm:^3.1.0, jwt-decode@npm:^3.1.2": version: 3.1.2 resolution: "jwt-decode@npm:3.1.2" checksum: 20a4b072d44ce3479f42d0d2c8d3dabeb353081ba4982e40b83a779f2459a70be26441be6c160bfc8c3c6eadf9f6380a036fbb06ac5406b5674e35d8c4205eeb @@ -33704,6 +33705,17 @@ __metadata: languageName: node linkType: hard +"njwt@npm:^2.0.0": + version: 2.0.0 + resolution: "njwt@npm:2.0.0" + dependencies: + "@types/node": ^15.0.1 + ecdsa-sig-formatter: ^1.0.5 + uuid: ^8.3.2 + checksum: 3c6c33b2fd044bca7468171f5dca064f5a4f59ce0e63b567df62c1a8d720e3c3d65921d5e99ae72eb22fde3285ef42b6009b4c4469f06e3a0e66d88a6f393373 + languageName: node + linkType: hard + "no-case@npm:^3.0.4": version: 3.0.4 resolution: "no-case@npm:3.0.4" From 7c26171d2aa43cede7b0376118d59d3f4802cdc2 Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Tue, 8 Aug 2023 12:42:51 -0400 Subject: [PATCH 30/59] refactor: minimal passing implementation Removed all the code that wasn't impacting a failing test, and removed the "ID token" test -- we'll start with access tokens since they are more important for our token-exchange use case. Signed-off-by: Jamie Klassen Co-authored-by: Ruben Vallejo --- .../src/providers/pinniped/index.test.ts | 4 +- .../src/providers/pinniped/provider.test.ts | 70 +------- .../src/providers/pinniped/provider.ts | 164 ++---------------- 3 files changed, 15 insertions(+), 223 deletions(-) diff --git a/plugins/auth-backend/src/providers/pinniped/index.test.ts b/plugins/auth-backend/src/providers/pinniped/index.test.ts index 08d833fd34..5a0b259319 100644 --- a/plugins/auth-backend/src/providers/pinniped/index.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/index.test.ts @@ -26,8 +26,6 @@ import request from 'supertest'; import cookieParser from 'cookie-parser'; import passport from 'passport'; import session from 'express-session'; -import signature from 'cookie-signature'; -import cookie from 'cookie'; describe('pinniped.create', () => { const server = setupServer(); @@ -157,7 +155,7 @@ describe('pinniped.create', () => { }); }); describe('#frameHandler', () => { - it('performs an rfc 8693 token exchange after getting access token', async () => { + it.skip('performs an rfc 8693 token exchange after getting access token', async () => { server.use( rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => res( diff --git a/plugins/auth-backend/src/providers/pinniped/provider.test.ts b/plugins/auth-backend/src/providers/pinniped/provider.test.ts index ef409e78c4..4b44bcfb6c 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.test.ts @@ -14,14 +14,11 @@ * limitations under the License. */ import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; -import { OAuthStartRequest, OAuthState, encodeState } from '../../lib/oauth'; -import { AuthResolverContext } from '../types'; +import { OAuthStartRequest, encodeState } from '../../lib/oauth'; import { PinnipedAuthProvider, PinnipedOptions } from './provider'; import { setupServer } from 'msw/node'; import { rest } from 'msw'; -import { ClientMetadata, IssuerMetadata } from 'openid-client'; import express from 'express'; -import nJwt from 'njwt'; import { UnsecuredJWT } from 'jose'; describe('PinnipedAuthProvider', () => { @@ -63,29 +60,9 @@ describe('PinnipedAuthProvider', () => { clientId: 'clientId.test', clientSecret: 'secret.test', callbackUrl: 'https://federationDomain.test/callback', - resolverContext: {} as AuthResolverContext, tokenSignedResponseAlg: 'none', - authHandler: async () => ({ - profile: {}, - }), }; - // const idToken: string = nJwt - // .create( - // { - // iss: 'https://pinniped.test', - // sub: 'test', - // aud: clientMetadata.clientId, - // claims: { - // given_name: 'Givenname', - // family_name: 'Familyname', - // email: 'user@example.com', - // }, - // }, - // Buffer.from('signing key'), - // ) - // .compact(); - const sub = 'test'; const iss = 'https://pinniped.test'; const iat = Date.now(); @@ -136,36 +113,6 @@ describe('PinnipedAuthProvider', () => { provider = new PinnipedAuthProvider(clientMetadata); }); - it('hits the metadata url', async () => { - const handler = jest.fn((_req, res, ctx) => { - return res( - ctx.status(200), - ctx.set('Content-Type', 'application/json'), - ctx.json(issuerMetadata), - ); - }); - - worker.use( - rest.get( - 'https://federationDomain.test/.well-known/openid-configuration', - handler, - ), - ); - - provider = new PinnipedAuthProvider(clientMetadata); - - const { strategy } = (await (provider as any).implementation) as any as { - strategy: { - _client: ClientMetadata; - _issuer: IssuerMetadata; - }; - }; - - expect(handler).toHaveBeenCalledTimes(1); - expect(strategy._client.client_id).toBe(clientMetadata.clientId); - expect(strategy._issuer.token_endpoint).toBe(issuerMetadata.token_endpoint); - }); - describe('#start', () => { it('redirects to authorization endpoint returned from federationDomain config value', async () => { const startResponse = await provider.start(startRequest); @@ -213,6 +160,7 @@ describe('PinnipedAuthProvider', () => { } as unknown as OAuthStartRequest), ).rejects.toThrow('authentication requires session support'); }); + // false passing test: passes because we compare two falsy values undefined and undefined // need to add the logic that makes this true it.skip('adds session ID handle to state param', async () => { @@ -284,20 +232,6 @@ describe('PinnipedAuthProvider', () => { ); }); - it('responds with ID token', async () => { - const { response } = await provider.handler(handlerRequest); - expect(response.providerInfo.idToken).toBe(idToken); - }); - - it.only('decodes profile from ID token', async () => { - const { response } = await provider.handler(handlerRequest); - - expect(response.profile).toStrictEqual({ - displayName: 'Givenname Familyname', - email: 'user@example.com', - }); - }); - it('fails when request has no state', async () => { return expect( provider.handler({ diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts index d74004cb10..8f27072001 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.ts @@ -18,32 +18,23 @@ import { Issuer, Strategy as OidcStrategy, TokenSet, - UserinfoResponse, } from 'openid-client'; import { OAuthHandlers, OAuthProviderOptions, - OAuthRefreshRequest, OAuthResponse, OAuthStartRequest, encodeState, } from '../../lib/oauth'; -import { - executeFrameHandlerStrategy, - PassportDoneCallback, -} from '../../lib/passport'; -import { AuthResolverContext, OAuthStartResponse } from '../types'; +import { PassportDoneCallback } from '../../lib/passport'; +import { OAuthStartResponse } from '../types'; import express from 'express'; -import { OidcAuthResult } from '../oidc'; import { OAuthAdapter, OAuthEnvironmentHandler } from '../../lib/oauth'; import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; -import { AuthHandler, SignInResolver } from '../types'; -import { BACKSTAGE_SESSION_EXPIRATION } from '../../lib/session'; import { InternalOAuthError } from 'passport-oauth2'; -import jwtDecoder from 'jwt-decode'; type OidcImpl = { - strategy: OidcStrategy; + strategy: OidcStrategy; client: Client; }; @@ -57,49 +48,25 @@ export type PinnipedOptions = OAuthProviderOptions & { clientSecret: string; callbackUrl: string; scope?: string; - prompt?: string; tokenSignedResponseAlg?: string; - signInResolver?: SignInResolver; - authHandler: AuthHandler; - resolverContext: AuthResolverContext; }; export class PinnipedAuthProvider implements OAuthHandlers { private readonly implementation: Promise; - private readonly federationDomain: string; - private readonly clientId: string; - private readonly clientSecret: string; - private readonly callbackUrl: string; - private readonly scope?: string; - private readonly prompt?: string; - private readonly signInResolver?: SignInResolver; - private readonly authHandler: AuthHandler; - private readonly resolverContext: AuthResolverContext; - // private readonly state?; constructor(options: PinnipedOptions) { this.implementation = this.setupStrategy(options); - this.federationDomain = options.federationDomain; - this.clientId = options.clientId; - this.clientSecret = options.clientSecret; - this.callbackUrl = options.callbackUrl; - this.scope = options.scope; - this.prompt = options.prompt; - this.signInResolver = options.signInResolver; - this.authHandler = options.authHandler; - this.resolverContext = options.resolverContext; } async start(req: OAuthStartRequest): Promise { const { strategy } = await this.implementation; const options: Record = { - scope: req.scope || this.scope || 'openid profile email', + scope: req.scope || 'openid profile email', state: encodeState(req.state), }; - // this.state = options.state return new Promise((resolve, reject) => { - strategy.redirect = (url: string, status?: number) => { - resolve({ url, status: status ?? undefined }); + strategy.redirect = (url: string) => { + resolve({ url }); }; strategy.error = (error: Error) => { reject(error); @@ -112,97 +79,14 @@ export class PinnipedAuthProvider implements OAuthHandlers { req: express.Request, ): Promise<{ response: OAuthResponse; refreshToken?: string }> { const { strategy } = await this.implementation; - - // we are passed a state inside of a session object - // const options: Record = { - // state: encodeState(req.state), - // }; - - console.log(req); - // return { - // response: { - // profile: {}, - // providerInfo: { accessToken: '', scope: '' }, - // }, - // }; - - // const stateParam = new URL(startResponse.url).searchParams.get('state'); - // const state = Object.fromEntries( - // new URLSearchParams(Buffer.from(stateParam!, 'hex').toString('utf-8')), - // ); return new Promise((resolve, reject) => { - strategy.success = ( - user: { - tokenset: { - id_token: string; - }; - }, - info: { refreshToken: string }, - ) => { - // const identity: Record = jwtDecoder( - // user.tokenset.id_token, - // ); - // const identity2 = - // console.log(identity); - resolve({ - response: { - profile: {}, - providerInfo: { - idToken: user.tokenset.id_token, - accessToken: '', - scope: '', - }, - }, - refreshToken: info.refreshToken, - }); - }; - strategy.fail = info => { - if (info.message) { - reject(new Error(`Authentication rejected, ${info.message ?? ''}`)); - } else { - console.log('what the heckhappened'); - } - }; - - strategy.error = (error: InternalOAuthError) => { - let message = `Authentication failed, ${error.message}`; - if (error.oauthError?.data) { - try { - const errorData = JSON.parse(error.oauthError.data); - - if (errorData.message) { - message += ` - ${errorData.message}`; - } - } catch (parseError) { - message += ` - ${error.oauthError}`; - } - } - reject(new Error(message)); - }; - - strategy.redirect = () => { - reject(new Error('Unexpected redirect')); + reject(new Error(`Authentication rejected, ${info.message || ''}`)); }; strategy.authenticate(req); }); } - // async refresh(req: OAuthRefreshRequest) { - // const { client } = await this.implementation; - // const tokenset = await client.refresh(req.refreshToken); - // if (!tokenset.access_token) { - // throw new Error('Refresh failed'); - // } - // const userinfo = client.issuer.userinfo_endpoint - // ? await client.userinfo(tokenset.access_token) - // : { sub: '' }; - - // return { - // response: await this.handleResult({ tokenset, userinfo }), - // refreshToken: tokenset.refresh_token, - // }; - // } private async setupStrategy(options: PinnipedOptions): Promise { const issuer = await Issuer.discover( @@ -225,23 +109,11 @@ export class PinnipedAuthProvider implements OAuthHandlers { }, ( tokenset: TokenSet, - userinfo: - | UserinfoResponse - | PassportDoneCallback, - done?: PassportDoneCallback, + done: PassportDoneCallback<{ tokenset: TokenSet }, PrivateInfo>, ) => { - if (typeof userinfo === 'function') { - userinfo( - undefined, - { tokenset, userinfo: { sub: '' } }, - { - refreshToken: tokenset.refresh_token, - }, - ); - } - done!( + done( undefined, - { tokenset, userinfo: userinfo as UserinfoResponse }, + { tokenset }, { refreshToken: tokenset.refresh_token, }, @@ -258,13 +130,8 @@ export class PinnipedAuthProvider implements OAuthHandlers { * @public */ export const pinniped = createAuthProviderIntegration({ - create(options?: { - authHandler?: AuthHandler; - signIn?: { - resolver: SignInResolver; - }; - }) { - return ({ providerId, globalConfig, config, resolverContext }) => + create() { + return ({ providerId, globalConfig, config }) => OAuthEnvironmentHandler.mapConfig(config, envConfig => { const clientId = envConfig.getString('clientId'); const clientSecret = envConfig.getString('clientSecret'); @@ -274,10 +141,6 @@ export const pinniped = createAuthProviderIntegration({ customCallbackUrl || `${globalConfig.baseUrl}/${providerId}/handler/frame`; const tokenSignedResponseAlg = 'ES256'; - const prompt = 'auto'; - const authHandler: AuthHandler = async () => ({ - profile: {}, - }); const provider = new PinnipedAuthProvider({ federationDomain, @@ -285,9 +148,6 @@ export const pinniped = createAuthProviderIntegration({ clientSecret, callbackUrl, tokenSignedResponseAlg, - prompt, - authHandler, - resolverContext, }); return OAuthAdapter.fromConfig(globalConfig, provider, { From c1c062ad690fbcd548e2d0527c9e6ebdf2df5821 Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Tue, 8 Aug 2023 17:20:01 -0400 Subject: [PATCH 31/59] refine requirements for start method Now the unit tests for the start method should render the '#start' describe in index.test.ts redundant. Signed-off-by: Jamie Klassen Co-authored-by: Ruben Vallejo --- .../src/providers/pinniped/provider.test.ts | 46 +++++++++++++------ .../src/providers/pinniped/provider.ts | 13 ++---- 2 files changed, 34 insertions(+), 25 deletions(-) diff --git a/plugins/auth-backend/src/providers/pinniped/provider.test.ts b/plugins/auth-backend/src/providers/pinniped/provider.test.ts index 4b44bcfb6c..9c2d3d7d63 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.test.ts @@ -14,12 +14,13 @@ * limitations under the License. */ import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; -import { OAuthStartRequest, encodeState } from '../../lib/oauth'; +import { OAuthStartRequest, encodeState, readState } from '../../lib/oauth'; import { PinnipedAuthProvider, PinnipedOptions } from './provider'; import { setupServer } from 'msw/node'; import { rest } from 'msw'; import express from 'express'; import { UnsecuredJWT } from 'jose'; +import { OAuthState } from '../../lib/oauth'; describe('PinnipedAuthProvider', () => { let provider: PinnipedAuthProvider; @@ -75,6 +76,10 @@ describe('PinnipedAuthProvider', () => { .setIssuedAt(iat) .setExpirationTime(exp) .encode(); + const oauthState: OAuthState = { + nonce: 'nonce', + env: 'env', + }; beforeEach(() => { jest.clearAllMocks(); @@ -96,6 +101,7 @@ describe('PinnipedAuthProvider', () => { session: fakeSession, method: 'GET', url: 'test', + state: oauthState, } as unknown as OAuthStartRequest; const handler = jest.fn((_req, res, ctx) => { return res( @@ -114,7 +120,7 @@ describe('PinnipedAuthProvider', () => { }); describe('#start', () => { - it('redirects to authorization endpoint returned from federationDomain config value', async () => { + it('redirects to authorization endpoint returned from OIDC metadata endpoint', async () => { const startResponse = await provider.start(startRequest); const url = new URL(startResponse.url); @@ -123,6 +129,13 @@ describe('PinnipedAuthProvider', () => { expect(url.pathname).toBe('/oauth2/authorize'); }); + it('initiates an authorization code grant', async () => { + const startResponse = await provider.start(startRequest); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('response_type')).toBe('code'); + }); + it('passes client ID from config', async () => { const startResponse = await provider.start(startRequest); const { searchParams } = new URL(startResponse.url); @@ -152,6 +165,16 @@ describe('PinnipedAuthProvider', () => { expect(fakeSession['oidc:pinniped.test'].code_verifier).toBeDefined(); }); + it('requests sufficient scopes for token exchange', async () => { + const startResponse = await provider.start(startRequest); + const { searchParams } = new URL(startResponse.url); + const scopes = searchParams.get('scope')?.split(' ') ?? []; + + expect(scopes).toEqual( + expect.arrayContaining(['pinniped:request-audience', 'username']), + ); + }); + it('fails when request has no session', async () => { return expect( provider.start({ @@ -161,20 +184,13 @@ describe('PinnipedAuthProvider', () => { ).rejects.toThrow('authentication requires session support'); }); - // false passing test: passes because we compare two falsy values undefined and undefined - // need to add the logic that makes this true - it.skip('adds session ID handle to state param', async () => { + it('encodes OAuth state in query param', async () => { const startResponse = await provider.start(startRequest); - // stateParam is empty string - const stateParam = new URL(startResponse.url).searchParams.get('state'); - const state = Object.fromEntries( - new URLSearchParams(Buffer.from(stateParam!, 'hex').toString('utf-8')), - ); - // handle is currently undefined - const { handle } = fakeSession['oidc:pinniped.test'].state; - console.log(`This is the param:`, stateParam); - // state.handle = undefined - expect(state.handle ?? '').toEqual(handle); + const { searchParams } = new URL(startResponse.url); + const stateParam = searchParams.get('state'); + const decodedState = readState(stateParam!); + + expect(decodedState).toMatchObject(oauthState); }); }); diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts index 8f27072001..9eafb8bde0 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.ts @@ -31,7 +31,6 @@ import { OAuthStartResponse } from '../types'; import express from 'express'; import { OAuthAdapter, OAuthEnvironmentHandler } from '../../lib/oauth'; import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; -import { InternalOAuthError } from 'passport-oauth2'; type OidcImpl = { strategy: OidcStrategy; @@ -61,7 +60,7 @@ export class PinnipedAuthProvider implements OAuthHandlers { async start(req: OAuthStartRequest): Promise { const { strategy } = await this.implementation; const options: Record = { - scope: req.scope || 'openid profile email', + scope: req.scope || 'pinniped:request-audience username', state: encodeState(req.state), }; return new Promise((resolve, reject) => { @@ -79,7 +78,7 @@ export class PinnipedAuthProvider implements OAuthHandlers { req: express.Request, ): Promise<{ response: OAuthResponse; refreshToken?: string }> { const { strategy } = await this.implementation; - return new Promise((resolve, reject) => { + return new Promise((_, reject) => { strategy.fail = info => { reject(new Error(`Authentication rejected, ${info.message || ''}`)); }; @@ -111,13 +110,7 @@ export class PinnipedAuthProvider implements OAuthHandlers { tokenset: TokenSet, done: PassportDoneCallback<{ tokenset: TokenSet }, PrivateInfo>, ) => { - done( - undefined, - { tokenset }, - { - refreshToken: tokenset.refresh_token, - }, - ); + done(undefined, { tokenset }, {}); }, ); return { strategy, client }; From b6f103de192575ce33791979fee6df9c95e8a40f Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Wed, 9 Aug 2023 11:26:53 -0400 Subject: [PATCH 32/59] working integration test Some thoughts at this point: * it would be nice to gather all the fakePinnipedSupervisor setup together in the beforeEach rather than spreading it throughout the test body * we need a real JWK/JWKS endpoint for token signing Signed-off-by: Jamie Klassen Co-authored-by: Ruben Vallejo --- .../src/providers/pinniped/index.test.ts | 224 +++++++++++++----- .../src/providers/pinniped/provider.ts | 1 + 2 files changed, 167 insertions(+), 58 deletions(-) diff --git a/plugins/auth-backend/src/providers/pinniped/index.test.ts b/plugins/auth-backend/src/providers/pinniped/index.test.ts index 5a0b259319..4fd4428d30 100644 --- a/plugins/auth-backend/src/providers/pinniped/index.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/index.test.ts @@ -20,16 +20,21 @@ import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { ConfigReader } from '@backstage/config'; import { setupServer } from 'msw/node'; import { rest } from 'msw'; -import crypto from 'crypto'; +import { Server } from 'http'; +import { AddressInfo } from 'net'; import express from 'express'; import request from 'supertest'; import cookieParser from 'cookie-parser'; import passport from 'passport'; import session from 'express-session'; +import Router from 'express-promise-router'; +// import fetch from 'node-fetch'; +import { SignJWT, UnsecuredJWT, exportJWK, generateKeyPair, importJWK } from 'jose'; +import { v4 as uuid } from 'uuid'; describe('pinniped.create', () => { - const server = setupServer(); - setupRequestMockHandlers(server); + const fakePinnipedSupervisor = setupServer(); + setupRequestMockHandlers(fakePinnipedSupervisor); const nonce = 'AAAAAAAAAAAAAAAAAAAAAA=='; // 16 bytes of zeros in base64 const state = Buffer.from( `nonce=${encodeURIComponent(nonce)}&env=development`, @@ -37,9 +42,33 @@ describe('pinniped.create', () => { let app: express.Express; let provider: AuthProviderRouteHandlers; + let backstageServer: Server; + let appUrl: string; - beforeEach(() => { - server.use( + beforeEach(async () => { + const secret = 'secret'; + app = express() + .use(cookieParser(secret)) + .use( + session({ + secret, + saveUninitialized: false, + resave: false, + cookie: { secure: false }, + }), + ) + .use(passport.initialize()) + .use(passport.session()); + await new Promise(resolve => { + backstageServer = app.listen(0, '0.0.0.0', () => { + appUrl = `http://127.0.0.1:${ + (backstageServer.address() as AddressInfo).port + }`; + resolve(null); + }); + }); + fakePinnipedSupervisor.use( + rest.all(`${appUrl}/*`, req => req.passthrough()), rest.all( 'https://pinniped.test/.well-known/openid-configuration', (_, res, ctx) => @@ -52,8 +81,14 @@ describe('pinniped.create', () => { response_types_supported: ['code'], response_modes_supported: ['query', 'form_post'], subject_types_supported: ['public'], - id_token_signing_alg_values_supported: ['ES256'], token_endpoint_auth_methods_supported: ['client_secret_basic'], + id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256', 'ES256'], + token_endpoint_auth_signing_alg_values_supported: [ + 'RS256', + 'RS512', + 'HS256', + ], + request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], scopes_supported: [ 'openid', 'offline_access', @@ -71,11 +106,12 @@ describe('pinniped.create', () => { ), ), ); + provider = pinniped.create()({ providerId: 'pinniped', globalConfig: { - baseUrl: 'http://backstage.test/api/auth', - appUrl: 'http://backstage.test', + baseUrl: `${appUrl}/api/auth`, + appUrl, isOriginAllowed: _ => true, }, config: new ConfigReader({ @@ -98,65 +134,135 @@ describe('pinniped.create', () => { signInWithCatalogUser: async _ => ({ token: '' }), }, }); - const secret = 'secret'; - app = express() - .use(cookieParser(secret)) - .use( - session({ - secret, - saveUninitialized: false, - resave: false, - cookie: { secure: false }, - }), - ) - .use(passport.initialize()) - .use(passport.session()) + const router = Router(); + router .use('/api/auth/pinniped/start', provider.start.bind(provider)) .use( '/api/auth/pinniped/handler/frame', provider.frameHandler.bind(provider), ); + app.use(router); }); - describe('#start', () => { - const randomBytes = jest.spyOn( - crypto, - 'randomBytes', - ) as unknown as jest.MockedFunction<(size: number) => Buffer>; - - afterEach(() => { - randomBytes.mockRestore(); - }); - - it('redirects to authorization endpoint returned from federationDomain config value', async () => { - randomBytes.mockReturnValue(Buffer.from(nonce, 'base64')); - - const responsePromise = request(app).get( - '/api/auth/pinniped/start?' + - 'env=development&scope=openid+pinniped:request-audience+username', - ); - const reqUrl = new URL(responsePromise.url); - reqUrl.search = ''; - server.use(rest.all(reqUrl.toString(), req => req.passthrough())); - - const response = await responsePromise; - expect((response as any).headers.location).toMatch( - 'https://pinniped.test/oauth2/authorize' + - '?client_id=clientId' + - `&scope=${encodeURIComponent( - 'openid pinniped:request-audience username', - )}` + - '&response_type=code' + - `&redirect_uri=${encodeURIComponent( - 'http://backstage.test/api/auth/pinniped/handler/frame', - )}` + - `&state=${state}`, - ); - }); + afterEach(() => { + backstageServer.close(); }); + + it('/handler/frame exchanges authorization codes from /start for access tokens', async () => { + const agent = request.agent(''); + // make a /start request + const startResponse = await agent.get( + `${appUrl}/api/auth/pinniped/start?env=development`, + ); + // follow the redirect to pinniped authorization endpoint + fakePinnipedSupervisor.use( + rest.get( + 'https://pinniped.test/oauth2/authorize', + async (req, res, ctx) => { + const callbackUrl = new URL( + req.url.searchParams.get('redirect_uri')!, + ); + callbackUrl.searchParams.set('code', 'authorization_code'); + callbackUrl.searchParams.set( + 'state', + req.url.searchParams.get('state')!, + ); + return res( + ctx.status(302), + ctx.set('Location', callbackUrl.toString()), + ); + }, + ), + ); + const authorizationResponse = await agent.get( + startResponse.header.location, + ); + + // follow the redirect back to /handler/frame + const sub = 'test'; + const iss = 'https://pinniped.test'; + const iat = Date.now(); + const aud = 'clientId'; + const exp = Date.now() + 10000; + + + // const jwt = new UnsecuredJWT({ iss, sub, aud, iat, exp }) + // .setIssuer(iss) + // .setAudience(aud) + // .setSubject(sub) + // .setIssuedAt(iat) + // .setExpirationTime(exp) + // .encode(); + + //we must use a signed token for this endpoint to work since the authentication header of none is not accepted????but why does it not work with the none alg header type?? Tried using an unsigned token but alg header was not accepted. + + const key = await generateKeyPair('ES256'); + const publicKey = await exportJWK(key.publicKey); + const privateKey = await exportJWK(key.privateKey); + publicKey.kid = privateKey.kid = uuid(); + publicKey.alg = privateKey.alg = 'ES256'; + + + const jwt = await new SignJWT({ iss, sub, aud, iat, exp}) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .setIssuer(iss) + .setAudience(aud) + .setSubject(sub) + .setIssuedAt(iat) + .setExpirationTime(exp) + .sign(await importJWK(privateKey)); + + + + + fakePinnipedSupervisor.use( + rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => + res( + // TODO verify client ID + secret, etc -- real token endpoint + new URLSearchParams(await req.text()).get('code') === + 'authorization_code' + ? ctx.json({ access_token: 'accessToken', id_token: jwt }) + : ctx.status(401), + ), + ), + rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) => + res( + ctx.status(200), + ctx.json({ "keys": [{ + use: 'sig', + alg: 'ES256', + crv: 'P-256', + kid: '04f4bf90-3e70-4271-a17a-6ad2ff677b72', + kty: 'EC', + x: 'lt1BNQfB9Lu1TIWXxAyMDxd36arkK387lIU9Z6Z75pc', + y: 'nvNAmf9xAeBgVQcl5otaCJuTJV7Yea5n3B-4wZvuYCE', + }] + }) + )) + ); + + const handlerResponse = await agent.get( + authorizationResponse.header.location, + ); + + expect(handlerResponse.text).toContain( + encodeURIComponent( + JSON.stringify({ + type: 'authorization_response', + response: { + providerInfo: { + accessToken: 'accessToken', + }, + profile: {}, + }, + }), + ), + ); + }); + describe('#frameHandler', () => { it.skip('performs an rfc 8693 token exchange after getting access token', async () => { - server.use( + fakePinnipedSupervisor.use( rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => res( ctx.json( @@ -183,7 +289,9 @@ describe('pinniped.create', () => { ); const reqUrl = new URL(responsePromise.url); reqUrl.search = ''; - server.use(rest.all(reqUrl.toString(), req => req.passthrough())); + fakePinnipedSupervisor.use( + rest.all(reqUrl.toString(), req => req.passthrough()), + ); expect((await responsePromise).text).toContain( encodeURIComponent( diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts index 9eafb8bde0..6ef2c2f7d7 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.ts @@ -82,6 +82,7 @@ export class PinnipedAuthProvider implements OAuthHandlers { strategy.fail = info => { reject(new Error(`Authentication rejected, ${info.message || ''}`)); }; + strategy.error = reject; strategy.authenticate(req); }); From 7dc7a38f3f88ffb87195903768c3bbbe26c3ef68 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Tue, 15 Aug 2023 14:14:17 -0400 Subject: [PATCH 33/59] authorization code exchange for a valid access_token Signed-off-by: Ruben Vallejo --- .../src/providers/pinniped/index.test.ts | 32 ++++--------------- .../src/providers/pinniped/provider.test.ts | 9 +++++- .../src/providers/pinniped/provider.ts | 10 +++++- 3 files changed, 24 insertions(+), 27 deletions(-) diff --git a/plugins/auth-backend/src/providers/pinniped/index.test.ts b/plugins/auth-backend/src/providers/pinniped/index.test.ts index 4fd4428d30..1d3a8fa859 100644 --- a/plugins/auth-backend/src/providers/pinniped/index.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/index.test.ts @@ -29,7 +29,7 @@ import passport from 'passport'; import session from 'express-session'; import Router from 'express-promise-router'; // import fetch from 'node-fetch'; -import { SignJWT, UnsecuredJWT, exportJWK, generateKeyPair, importJWK } from 'jose'; +import { SignJWT, exportJWK, generateKeyPair, importJWK } from 'jose'; import { v4 as uuid } from 'uuid'; describe('pinniped.create', () => { @@ -78,7 +78,7 @@ describe('pinniped.create', () => { authorization_endpoint: 'https://pinniped.test/oauth2/authorize', token_endpoint: 'https://pinniped.test/oauth2/token', jwks_uri: 'https://pinniped.test/jwks.json', - response_types_supported: ['code'], + response_types_supported: ['code', 'access_token'], response_modes_supported: ['query', 'form_post'], subject_types_supported: ['public'], token_endpoint_auth_methods_supported: ['client_secret_basic'], @@ -96,7 +96,7 @@ describe('pinniped.create', () => { 'username', 'groups', ], - claims_supported: ['username', 'groups', 'additionalClaims'], + claims_supported: ['username', 'groups', 'additionalClaims', 'sub'], code_challenge_methods_supported: ['S256'], 'discovery.supervisor.pinniped.dev/v1alpha1': { pinniped_identity_providers_endpoint: @@ -185,17 +185,6 @@ describe('pinniped.create', () => { const aud = 'clientId'; const exp = Date.now() + 10000; - - // const jwt = new UnsecuredJWT({ iss, sub, aud, iat, exp }) - // .setIssuer(iss) - // .setAudience(aud) - // .setSubject(sub) - // .setIssuedAt(iat) - // .setExpirationTime(exp) - // .encode(); - - //we must use a signed token for this endpoint to work since the authentication header of none is not accepted????but why does it not work with the none alg header type?? Tried using an unsigned token but alg header was not accepted. - const key = await generateKeyPair('ES256'); const publicKey = await exportJWK(key.publicKey); const privateKey = await exportJWK(key.privateKey); @@ -228,15 +217,7 @@ describe('pinniped.create', () => { rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) => res( ctx.status(200), - ctx.json({ "keys": [{ - use: 'sig', - alg: 'ES256', - crv: 'P-256', - kid: '04f4bf90-3e70-4271-a17a-6ad2ff677b72', - kty: 'EC', - x: 'lt1BNQfB9Lu1TIWXxAyMDxd36arkK387lIU9Z6Z75pc', - y: 'nvNAmf9xAeBgVQcl5otaCJuTJV7Yea5n3B-4wZvuYCE', - }] + ctx.json({ "keys": [{...publicKey}] }) )) ); @@ -244,7 +225,7 @@ describe('pinniped.create', () => { const handlerResponse = await agent.get( authorizationResponse.header.location, ); - + //our assertion doesnt include a scope but the return type does...might need to change our assertion? expect(handlerResponse.text).toContain( encodeURIComponent( JSON.stringify({ @@ -252,6 +233,7 @@ describe('pinniped.create', () => { response: { providerInfo: { accessToken: 'accessToken', + scope: "none" }, profile: {}, }, @@ -261,7 +243,7 @@ describe('pinniped.create', () => { }); describe('#frameHandler', () => { - it.skip('performs an rfc 8693 token exchange after getting access token', async () => { + it('performs an rfc 8693 token exchange after getting access token', async () => { fakePinnipedSupervisor.use( rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => res( diff --git a/plugins/auth-backend/src/providers/pinniped/provider.test.ts b/plugins/auth-backend/src/providers/pinniped/provider.test.ts index 9c2d3d7d63..8aa977c37b 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.test.ts @@ -37,7 +37,7 @@ describe('PinnipedAuthProvider', () => { revocation_endpoint: 'https://pinniped.test/oauth2/revoke_token', userinfo_endpoint: 'https://pinniped.test/idp/userinfo.openid', introspection_endpoint: 'https://pinniped.test/introspect.oauth2', - jwks_uri: 'https://pinniped.test/pf/JWKS', + jwks_uri: 'https://pinniped.test/jwks.json', scopes_supported: [ 'openid', 'offline_access', @@ -263,5 +263,12 @@ describe('PinnipedAuthProvider', () => { 'Authentication rejected, state missing from the response', ); }); + + it.only('exchanges authorization code for a valid access_token', async() => { + const handlerResponse = await provider.handler(handlerRequest); + const accessToken = handlerResponse.response.providerInfo.accessToken + + expect(accessToken).toEqual('accessToken') + }) }); }); diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts index 6ef2c2f7d7..854d5e9272 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.ts @@ -78,7 +78,15 @@ export class PinnipedAuthProvider implements OAuthHandlers { req: express.Request, ): Promise<{ response: OAuthResponse; refreshToken?: string }> { const { strategy } = await this.implementation; - return new Promise((_, reject) => { + + //TODO: what do we do about a defined scope here? one is expected to be returned by this handler method and i currently have it hardcoded. does our accesstoken need to worry about scope at all? + return new Promise((resolve, reject) => { + strategy.success = user => { + resolve({ response: { + providerInfo: {accessToken: user.tokenset.access_token, scope: "none"}, + profile: {}, + }}) + } strategy.fail = info => { reject(new Error(`Authentication rejected, ${info.message || ''}`)); }; From ae059825cebd14f8e3a2879ae8dd2a061a0787ff Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Thu, 17 Aug 2023 17:09:31 -0400 Subject: [PATCH 34/59] Provider requests scopes Signed-off-by: Ruben Vallejo --- .../src/providers/pinniped/index.test.ts | 9 ++++-- .../src/providers/pinniped/provider.test.ts | 32 +++++++++++++++++-- .../src/providers/pinniped/provider.ts | 14 ++++++-- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/plugins/auth-backend/src/providers/pinniped/index.test.ts b/plugins/auth-backend/src/providers/pinniped/index.test.ts index 1d3a8fa859..12b34c5ee2 100644 --- a/plugins/auth-backend/src/providers/pinniped/index.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/index.test.ts @@ -151,6 +151,7 @@ describe('pinniped.create', () => { it('/handler/frame exchanges authorization codes from /start for access tokens', async () => { const agent = request.agent(''); // make a /start request + //add query parameter for the audience const startResponse = await agent.get( `${appUrl}/api/auth/pinniped/start?env=development`, ); @@ -167,6 +168,10 @@ describe('pinniped.create', () => { 'state', req.url.searchParams.get('state')!, ); + // callbackUrl.searchParams.set( + // 'scope', + // 'test-scope', + // ); return res( ctx.status(302), ctx.set('Location', callbackUrl.toString()), @@ -240,10 +245,10 @@ describe('pinniped.create', () => { }), ), ); - }); + }, 70000); describe('#frameHandler', () => { - it('performs an rfc 8693 token exchange after getting access token', async () => { + it.skip('performs an rfc 8693 token exchange after getting access token', async () => { fakePinnipedSupervisor.use( rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => res( diff --git a/plugins/auth-backend/src/providers/pinniped/provider.test.ts b/plugins/auth-backend/src/providers/pinniped/provider.test.ts index 8aa977c37b..46e5bfe630 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.test.ts @@ -21,6 +21,7 @@ import { rest } from 'msw'; import express from 'express'; import { UnsecuredJWT } from 'jose'; import { OAuthState } from '../../lib/oauth'; +import { EntityAzurePipelinesContent } from '@backstage/plugin-azure-devops'; describe('PinnipedAuthProvider', () => { let provider: PinnipedAuthProvider; @@ -136,6 +137,21 @@ describe('PinnipedAuthProvider', () => { expect(searchParams.get('response_type')).toBe('code'); }); + it('passes default audience as a scope parameter in the redirect url if not defined in the request', async () => { + const startResponse = await provider.start(startRequest) + const { searchParams } = new URL(startResponse.url) + + expect(searchParams.get('scope')).toBe('pinniped:request-audience username') + }) + + it('passes audience as a scope parameter in the redirect url when defined in the request', async () => { + startRequest.scope = 'pinniped:request-audience testusername' + const startResponse = await provider.start(startRequest) + const { searchParams } = new URL(startResponse.url) + + expect(searchParams.get('scope')).toBe('pinniped:request-audience testusername') + }) + it('passes client ID from config', async () => { const startResponse = await provider.start(startRequest); const { searchParams } = new URL(startResponse.url); @@ -208,7 +224,7 @@ describe('PinnipedAuthProvider', () => { handlerRequest = { method: 'GET', - url: `https://test?code=authorization_code&state=${testState}`, + url: `https://test?code=authorization_code&state=${testState}&scope=pinniped:request-audience username`, session: { 'oidc:pinniped.test': { state: testState, @@ -263,12 +279,22 @@ describe('PinnipedAuthProvider', () => { 'Authentication rejected, state missing from the response', ); }); - - it.only('exchanges authorization code for a valid access_token', async() => { + + it('exchanges authorization code for a valid access_token', async() => { const handlerResponse = await provider.handler(handlerRequest); const accessToken = handlerResponse.response.providerInfo.accessToken expect(accessToken).toEqual('accessToken') }) + + it('responds with the correct audience as scope', async() => { + const handlerResponse = await provider.handler(handlerRequest); + const audience = handlerResponse.response.providerInfo.scope + + expect(audience).toEqual('pinniped:request-audience username') + }) + + //if no valid key is in the jwks array or even an unsigned jwt + //have pinniped reject your clientid and secret possibly as a unit test }); }); diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts index 854d5e9272..3a347db681 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.ts @@ -79,23 +79,31 @@ export class PinnipedAuthProvider implements OAuthHandlers { ): Promise<{ response: OAuthResponse; refreshToken?: string }> { const { strategy } = await this.implementation; - //TODO: what do we do about a defined scope here? one is expected to be returned by this handler method and i currently have it hardcoded. does our accesstoken need to worry about scope at all? + //the query string inside the req should contain a code and a state, we can change the stub to reject any auth code, can this query string also include scope?? + + const { searchParams } = new URL(req.url, 'https://pinniped.com') + const audience = searchParams.get('scope') ?? "none" + return new Promise((resolve, reject) => { strategy.success = user => { resolve({ response: { - providerInfo: {accessToken: user.tokenset.access_token, scope: "none"}, + providerInfo: {accessToken: user.tokenset.access_token, scope: audience}, profile: {}, }}) } strategy.fail = info => { reject(new Error(`Authentication rejected, ${info.message || ''}`)); }; - strategy.error = reject; + + //TODO: unit test for provider to state the need for this error handler + // strategy.error = reject; strategy.authenticate(req); }); } + //will need a refresh method that covers our happy path + private async setupStrategy(options: PinnipedOptions): Promise { const issuer = await Issuer.discover( `${options.federationDomain}/.well-known/openid-configuration`, From 07dcd843791cf6dd8e8560488edf3c4ac678dcdb Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Mon, 21 Aug 2023 12:26:47 -0400 Subject: [PATCH 35/59] Add redirect and error methods to the handlers strategy along with unit tests Signed-off-by: Ruben Vallejo --- .../src/providers/pinniped/provider.test.ts | 15 ++++++ .../src/providers/pinniped/provider.ts | 47 ++++++++++++++----- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/plugins/auth-backend/src/providers/pinniped/provider.test.ts b/plugins/auth-backend/src/providers/pinniped/provider.test.ts index 46e5bfe630..5299d4e169 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.test.ts @@ -222,6 +222,7 @@ describe('PinnipedAuthProvider', () => { origin: 'undefined', }); + //we want to somehow pass an authentication header in this request for testing purposes handlerRequest = { method: 'GET', url: `https://test?code=authorization_code&state=${testState}&scope=pinniped:request-audience username`, @@ -294,6 +295,20 @@ describe('PinnipedAuthProvider', () => { expect(audience).toEqual('pinniped:request-audience username') }) + it('request errors out with missing authorization_code parameter in the request_url', async() => { + handlerRequest.url = "test" + return expect(provider.handler(handlerRequest)).rejects.toThrow('Unexpected redirect') + }) + + it('fails when request has no session', async () => { + return expect( + provider.handler({ + method: 'GET', + url: 'test', + } as unknown as OAuthStartRequest), + ).rejects.toThrow('authentication requires session support'); + }); + //if no valid key is in the jwks array or even an unsigned jwt //have pinniped reject your clientid and secret possibly as a unit test }); diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts index 3a347db681..5ee92ec6d0 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.ts @@ -59,8 +59,17 @@ export class PinnipedAuthProvider implements OAuthHandlers { async start(req: OAuthStartRequest): Promise { const { strategy } = await this.implementation; + + // we are practicing with this scope and it works openid pinniped:request-audience username + // whats the bare minimum needed for our request to fail? + + // http://127.0.0.1:7007/api/auth/pinniped/start?env=development&audience=host-cluster + // having scope seperated by + results in an error + + // `{"type":"authorization_response","error":{"name":"OPError","message":"invalid_scope (The requested scope is invalid, unknown, or malformed. The OAuth 2.0 Client is not allowed to request scope 'openid+pinniped:request-audience+username'.)"}}` + const options: Record = { - scope: req.scope || 'pinniped:request-audience username', + scope: req.scope || 'openid+pinniped:request-audience+username', state: encodeState(req.state), }; return new Promise((resolve, reject) => { @@ -79,30 +88,42 @@ export class PinnipedAuthProvider implements OAuthHandlers { ): Promise<{ response: OAuthResponse; refreshToken?: string }> { const { strategy } = await this.implementation; - //the query string inside the req should contain a code and a state, we can change the stub to reject any auth code, can this query string also include scope?? + // the query string inside the req should contain a code and a state, we can change the stub to reject any auth code, + // can this query string also include scope? It already does get a scope in its redirect url when start is called by default. + // but when the fake supervisor hits the handler a scope must be returned by the oauth2/authorize endpoint for it to be present in the req - const { searchParams } = new URL(req.url, 'https://pinniped.com') - const audience = searchParams.get('scope') ?? "none" + const { searchParams } = new URL(req.url, 'https://pinniped.com'); + const audience = searchParams.get('scope') ?? 'none'; return new Promise((resolve, reject) => { strategy.success = user => { - resolve({ response: { - providerInfo: {accessToken: user.tokenset.access_token, scope: audience}, - profile: {}, - }}) - } + resolve({ + response: { + providerInfo: { + accessToken: user.tokenset.access_token, + scope: audience, + }, + profile: {}, + }, + }); + }; strategy.fail = info => { reject(new Error(`Authentication rejected, ${info.message || ''}`)); }; - //TODO: unit test for provider to state the need for this error handler - // strategy.error = reject; + strategy.error = (error: Error) => { + reject(error); + }; + + strategy.redirect = () => { + reject(new Error('Unexpected redirect')); + }; strategy.authenticate(req); }); } - //will need a refresh method that covers our happy path + // will need a refresh method that covers our happy path private async setupStrategy(options: PinnipedOptions): Promise { const issuer = await Issuer.discover( @@ -114,7 +135,7 @@ export class PinnipedAuthProvider implements OAuthHandlers { client_secret: options.clientSecret, redirect_uris: [options.callbackUrl], response_types: ['code'], - id_token_signed_response_alg: options.tokenSignedResponseAlg || 'RS256', + id_token_signed_response_alg: options.tokenSignedResponseAlg || 'ES256', scope: options.scope || '', }); From 9fedd45be63e8183ce545f4976ab17f08c28ec91 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Tue, 22 Aug 2023 13:06:05 -0400 Subject: [PATCH 36/59] introduce audience parameter into start method and pass it into oauth state, fix integration tests Signed-off-by: Ruben Vallejo --- plugins/auth-backend/src/lib/oauth/types.ts | 17 +++++--- .../src/providers/pinniped/provider.test.ts | 43 +++++++++---------- .../src/providers/pinniped/provider.ts | 18 +++++--- 3 files changed, 43 insertions(+), 35 deletions(-) diff --git a/plugins/auth-backend/src/lib/oauth/types.ts b/plugins/auth-backend/src/lib/oauth/types.ts index b7205c9b85..49398ab279 100644 --- a/plugins/auth-backend/src/lib/oauth/types.ts +++ b/plugins/auth-backend/src/lib/oauth/types.ts @@ -92,11 +92,18 @@ export type OAuthProviderInfo = { scope: string; }; -/** - * @public - * @deprecated import from `@backstage/plugin-auth-node` instead - */ -export type OAuthState = _OAuthState; +/** @public */ +export type OAuthState = { + /* A type for the serialized value in the `state` parameter of the OAuth authorization flow + */ + nonce: string; + env: string; + origin?: string; + scope?: string; + redirectUrl?: string; + flow?: string; + audience? : string; +}; /** * @public diff --git a/plugins/auth-backend/src/providers/pinniped/provider.test.ts b/plugins/auth-backend/src/providers/pinniped/provider.test.ts index 5299d4e169..58bd9f2a1a 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.test.ts @@ -21,7 +21,6 @@ import { rest } from 'msw'; import express from 'express'; import { UnsecuredJWT } from 'jose'; import { OAuthState } from '../../lib/oauth'; -import { EntityAzurePipelinesContent } from '@backstage/plugin-azure-devops'; describe('PinnipedAuthProvider', () => { let provider: PinnipedAuthProvider; @@ -137,19 +136,15 @@ describe('PinnipedAuthProvider', () => { expect(searchParams.get('response_type')).toBe('code'); }); - it('passes default audience as a scope parameter in the redirect url if not defined in the request', async () => { + it('passes audience query parameter into OAuthState in the redirect url when defined in the request', async () => { + startRequest.query = { audience: 'test-cluster'} const startResponse = await provider.start(startRequest) const { searchParams } = new URL(startResponse.url) + const stateParam = searchParams.get('state'); + const decodedState = readState(stateParam!); - expect(searchParams.get('scope')).toBe('pinniped:request-audience username') - }) - - it('passes audience as a scope parameter in the redirect url when defined in the request', async () => { - startRequest.scope = 'pinniped:request-audience testusername' - const startResponse = await provider.start(startRequest) - const { searchParams } = new URL(startResponse.url) - - expect(searchParams.get('scope')).toBe('pinniped:request-audience testusername') + expect(decodedState).toMatchObject({nonce: 'nonce', + env: 'env', audience:'test-cluster' }) }) it('passes client ID from config', async () => { @@ -187,7 +182,7 @@ describe('PinnipedAuthProvider', () => { const scopes = searchParams.get('scope')?.split(' ') ?? []; expect(scopes).toEqual( - expect.arrayContaining(['pinniped:request-audience', 'username']), + expect.arrayContaining(['openid', 'pinniped:request-audience', 'username']), ); }); @@ -213,22 +208,23 @@ describe('PinnipedAuthProvider', () => { describe('#handler', () => { let handlerRequest: express.Request; + const testState = { + nonce: 'nonce', + env: 'development', + origin: 'undefined', + audience: 'test-cluster' + }; + beforeEach(() => { provider = new PinnipedAuthProvider(clientMetadata); - const testState = encodeState({ - nonce: 'nonce', - env: 'development', - origin: 'undefined', - }); - //we want to somehow pass an authentication header in this request for testing purposes handlerRequest = { method: 'GET', - url: `https://test?code=authorization_code&state=${testState}&scope=pinniped:request-audience username`, + url: `https://test?code=authorization_code&state=${encodeState(testState)}`, session: { 'oidc:pinniped.test': { - state: testState, + state: encodeState(testState), }, }, } as unknown as express.Request; @@ -288,7 +284,8 @@ describe('PinnipedAuthProvider', () => { expect(accessToken).toEqual('accessToken') }) - it('responds with the correct audience as scope', async() => { + //scope cannot be passed along in the redirect url and is different from audience + it.skip('responds with the correct audience as scope', async() => { const handlerResponse = await provider.handler(handlerRequest); const audience = handlerResponse.response.providerInfo.scope @@ -296,7 +293,7 @@ describe('PinnipedAuthProvider', () => { }) it('request errors out with missing authorization_code parameter in the request_url', async() => { - handlerRequest.url = "test" + handlerRequest.url = "https://test.com" return expect(provider.handler(handlerRequest)).rejects.toThrow('Unexpected redirect') }) @@ -304,7 +301,7 @@ describe('PinnipedAuthProvider', () => { return expect( provider.handler({ method: 'GET', - url: 'test', + url: 'https://test.com', } as unknown as OAuthStartRequest), ).rejects.toThrow('authentication requires session support'); }); diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts index 5ee92ec6d0..6d46a9f46b 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.ts @@ -25,6 +25,7 @@ import { OAuthResponse, OAuthStartRequest, encodeState, + readState, } from '../../lib/oauth'; import { PassportDoneCallback } from '../../lib/passport'; import { OAuthStartResponse } from '../types'; @@ -68,9 +69,12 @@ export class PinnipedAuthProvider implements OAuthHandlers { // `{"type":"authorization_response","error":{"name":"OPError","message":"invalid_scope (The requested scope is invalid, unknown, or malformed. The OAuth 2.0 Client is not allowed to request scope 'openid+pinniped:request-audience+username'.)"}}` + const stringifiedAudience = req.query?.audience as string + const state = {...req.state, audience: stringifiedAudience } + const options: Record = { - scope: req.scope || 'openid+pinniped:request-audience+username', - state: encodeState(req.state), + scope: req.scope || 'openid pinniped:request-audience username', + state: encodeState(state), }; return new Promise((resolve, reject) => { strategy.redirect = (url: string) => { @@ -89,11 +93,11 @@ export class PinnipedAuthProvider implements OAuthHandlers { const { strategy } = await this.implementation; // the query string inside the req should contain a code and a state, we can change the stub to reject any auth code, - // can this query string also include scope? It already does get a scope in its redirect url when start is called by default. - // but when the fake supervisor hits the handler a scope must be returned by the oauth2/authorize endpoint for it to be present in the req - const { searchParams } = new URL(req.url, 'https://pinniped.com'); - const audience = searchParams.get('scope') ?? 'none'; + //if we dont add a base url our integration fails with invalid_url error in integration test + const { searchParams } = new URL(req.url, 'https://pinniped.com') + const stateParam = searchParams.get('state') + const audience = stateParam ? readState(stateParam).audience : "none" return new Promise((resolve, reject) => { strategy.success = user => { @@ -101,7 +105,7 @@ export class PinnipedAuthProvider implements OAuthHandlers { response: { providerInfo: { accessToken: user.tokenset.access_token, - scope: audience, + scope: 'none', }, profile: {}, }, From 44483b9a0a551244ddb95d20482927832b914b6e Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Tue, 22 Aug 2023 16:50:10 -0400 Subject: [PATCH 37/59] refactor and add #refresh to pinnipedAuth provider Signed-off-by: Ruben Vallejo --- .../src/providers/pinniped/provider.test.ts | 204 +++++++++--------- .../src/providers/pinniped/provider.ts | 31 ++- 2 files changed, 129 insertions(+), 106 deletions(-) diff --git a/plugins/auth-backend/src/providers/pinniped/provider.test.ts b/plugins/auth-backend/src/providers/pinniped/provider.test.ts index 58bd9f2a1a..90fdf06239 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; -import { OAuthStartRequest, encodeState, readState } from '../../lib/oauth'; +import { OAuthRefreshRequest, OAuthStartRequest, encodeState, readState } from '../../lib/oauth'; import { PinnipedAuthProvider, PinnipedOptions } from './provider'; import { setupServer } from 'msw/node'; import { rest } from 'msw'; @@ -64,25 +64,31 @@ describe('PinnipedAuthProvider', () => { tokenSignedResponseAlg: 'none', }; - const sub = 'test'; - const iss = 'https://pinniped.test'; - const iat = Date.now(); - const aud = clientMetadata.clientId; - const exp = Date.now() + 10000; - const idToken = new UnsecuredJWT({ iss, sub, aud, iat, exp }) - .setIssuer(iss) - .setAudience(aud) - .setSubject(sub) - .setIssuedAt(iat) - .setExpirationTime(exp) + const testTokenMetadata = { + sub: 'test', + iss: 'https://pinniped.test', + iat: Date.now(), + aud: clientMetadata.clientId, + exp: Date.now() + 10000, + } + + const idToken = new UnsecuredJWT(testTokenMetadata) + .setIssuer(testTokenMetadata.iss) + .setAudience(testTokenMetadata.aud) + .setSubject(testTokenMetadata.sub) + .setIssuedAt(testTokenMetadata.iat) + .setExpirationTime(testTokenMetadata.exp) .encode(); + const oauthState: OAuthState = { nonce: 'nonce', env: 'env', + origin: 'undefined', }; beforeEach(() => { jest.clearAllMocks(); + worker.use( rest.post('https://pinniped.test/oauth2/token', (req, res, ctx) => res( @@ -95,7 +101,45 @@ describe('PinnipedAuthProvider', () => { : ctx.status(401), ), ), + rest.all( + 'https://federationDomain.test/.well-known/openid-configuration', (_req, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(issuerMetadata), + ) + + ), + rest.post('https://pinniped.test/oauth2/token', (req, res, ctx) => + res( + req.headers.get('Authorization') + ? ctx.json({ + access_token: 'accessToken', + refresh_token: 'refreshToken', + id_token: idToken, + }) + : ctx.status(401), + ), + ), + rest.get( + 'https://pinniped.test/idp/userinfo.openid', + (_req, res, ctx) => + res( + ctx.json({ + iss: 'https://pinniped.test', + sub: 'test', + aud: clientMetadata.clientId, + claims: { + given_name: 'Givenname', + family_name: 'Familyname', + email: 'user@example.com', + }, + }), + ctx.status(200), + ), + ), ); + fakeSession = {}; startRequest = { session: fakeSession, @@ -103,19 +147,7 @@ describe('PinnipedAuthProvider', () => { url: 'test', state: oauthState, } as unknown as OAuthStartRequest; - const handler = jest.fn((_req, res, ctx) => { - return res( - ctx.status(200), - ctx.set('Content-Type', 'application/json'), - ctx.json(issuerMetadata), - ); - }); - worker.use( - rest.all( - 'https://federationDomain.test/.well-known/openid-configuration', - handler, - ), - ); + provider = new PinnipedAuthProvider(clientMetadata); }); @@ -186,15 +218,6 @@ describe('PinnipedAuthProvider', () => { ); }); - it('fails when request has no session', async () => { - return expect( - provider.start({ - method: 'GET', - url: 'test', - } as unknown as OAuthStartRequest), - ).rejects.toThrow('authentication requires session support'); - }); - it('encodes OAuth state in query param', async () => { const startResponse = await provider.start(startRequest); const { searchParams } = new URL(startResponse.url); @@ -203,65 +226,46 @@ describe('PinnipedAuthProvider', () => { expect(decodedState).toMatchObject(oauthState); }); + + it('fails when request has no session', async () => { + return expect( + provider.start({ + method: 'GET', + url: 'test', + } as unknown as OAuthStartRequest), + ).rejects.toThrow('authentication requires session support'); + }); }); describe('#handler', () => { let handlerRequest: express.Request; - const testState = { - nonce: 'nonce', - env: 'development', - origin: 'undefined', - audience: 'test-cluster' - }; - beforeEach(() => { - provider = new PinnipedAuthProvider(clientMetadata); - //we want to somehow pass an authentication header in this request for testing purposes handlerRequest = { method: 'GET', - url: `https://test?code=authorization_code&state=${encodeState(testState)}`, + url: `https://test?code=authorization_code&state=${encodeState(oauthState)}`, session: { 'oidc:pinniped.test': { - state: encodeState(testState), + state: encodeState(oauthState), }, }, } as unknown as express.Request; - - worker.use( - rest.post('https://pinniped.test/oauth2/token', (req, res, ctx) => - res( - req.headers.get('Authorization') - ? ctx.json({ - access_token: 'accessToken', - refresh_token: 'refreshToken', - id_token: idToken, - }) - : ctx.status(401), - ), - ), - rest.get( - 'https://pinniped.test/idp/userinfo.openid', - (_req, res, ctx) => - res( - ctx.json({ - iss: 'https://pinniped.test', - sub: 'test', - aud: clientMetadata.clientId, - claims: { - given_name: 'Givenname', - family_name: 'Familyname', - email: 'user@example.com', - }, - }), - ctx.status(200), - ), - ), - ); }); + + it('exchanges authorization code for a valid access_token', async() => { + const handlerResponse = await provider.handler(handlerRequest); + const accessToken = handlerResponse.response.providerInfo.accessToken + + expect(accessToken).toEqual('accessToken') + }) - it('fails when request has no state', async () => { + it('request errors out with missing authorization_code parameter in the request_url', async() => { + handlerRequest.url = "https://test.com" + return expect(provider.handler(handlerRequest)).rejects.toThrow('Unexpected redirect') + }) + + it('fails when request has no state in req_url', async () => { return expect( provider.handler({ method: 'GET', @@ -276,26 +280,6 @@ describe('PinnipedAuthProvider', () => { 'Authentication rejected, state missing from the response', ); }); - - it('exchanges authorization code for a valid access_token', async() => { - const handlerResponse = await provider.handler(handlerRequest); - const accessToken = handlerResponse.response.providerInfo.accessToken - - expect(accessToken).toEqual('accessToken') - }) - - //scope cannot be passed along in the redirect url and is different from audience - it.skip('responds with the correct audience as scope', async() => { - const handlerResponse = await provider.handler(handlerRequest); - const audience = handlerResponse.response.providerInfo.scope - - expect(audience).toEqual('pinniped:request-audience username') - }) - - it('request errors out with missing authorization_code parameter in the request_url', async() => { - handlerRequest.url = "https://test.com" - return expect(provider.handler(handlerRequest)).rejects.toThrow('Unexpected redirect') - }) it('fails when request has no session', async () => { return expect( @@ -309,4 +293,30 @@ describe('PinnipedAuthProvider', () => { //if no valid key is in the jwks array or even an unsigned jwt //have pinniped reject your clientid and secret possibly as a unit test }); + + describe('#refresh', () => { + let refreshRequest: OAuthRefreshRequest; + + beforeEach(() => { + refreshRequest = { + refreshToken: 'otherRefreshToken' + } as unknown as OAuthRefreshRequest; + }); + + it('gets new refresh token', async() => { + const { refreshToken } = await provider.refresh(refreshRequest); + + expect(refreshToken).toBe('refreshToken') + }) + + it('gets an access_token', async() => { + const { response } = await provider.refresh(refreshRequest) + + expect(response.providerInfo.accessToken).toBe("accessToken") + }) + //find out when refresh requests are even made? + //so far looks like the response should be exactly like the one returned by the handler + //what are we exchanging exactly to get this refresh token?? + + }) }); diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts index 6d46a9f46b..ebc94da58f 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.ts @@ -22,6 +22,7 @@ import { import { OAuthHandlers, OAuthProviderOptions, + OAuthRefreshRequest, OAuthResponse, OAuthStartRequest, encodeState, @@ -61,14 +62,6 @@ export class PinnipedAuthProvider implements OAuthHandlers { async start(req: OAuthStartRequest): Promise { const { strategy } = await this.implementation; - // we are practicing with this scope and it works openid pinniped:request-audience username - // whats the bare minimum needed for our request to fail? - - // http://127.0.0.1:7007/api/auth/pinniped/start?env=development&audience=host-cluster - // having scope seperated by + results in an error - - // `{"type":"authorization_response","error":{"name":"OPError","message":"invalid_scope (The requested scope is invalid, unknown, or malformed. The OAuth 2.0 Client is not allowed to request scope 'openid+pinniped:request-audience+username'.)"}}` - const stringifiedAudience = req.query?.audience as string const state = {...req.state, audience: stringifiedAudience } @@ -127,7 +120,27 @@ export class PinnipedAuthProvider implements OAuthHandlers { }); } - // will need a refresh method that covers our happy path + async refresh(req: OAuthRefreshRequest): Promise<{ response: OAuthResponse; refreshToken?: string }> { + const { client } = await this.implementation; + const tokenset = await client.refresh(req.refreshToken); + + return new Promise((resolve, reject) => { + if(!tokenset.access_token){ + reject(new Error('Refresh Failed')) + } + + resolve({ + response: { + providerInfo: { + accessToken: tokenset.access_token!, + scope: 'none', + }, + profile: {}, + }, + refreshToken: tokenset.refresh_token, + }); + }) + } private async setupStrategy(options: PinnipedOptions): Promise { const issuer = await Issuer.discover( From 448ff7b10b38beef132428abeeb75c6ffe78f461 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Thu, 24 Aug 2023 12:44:42 -0400 Subject: [PATCH 38/59] add offline_access scope to default scope list in #start Signed-off-by: Ruben Vallejo --- .../src/providers/pinniped/provider.test.ts | 152 ++++++++++-------- .../src/providers/pinniped/provider.ts | 26 +-- 2 files changed, 100 insertions(+), 78 deletions(-) diff --git a/plugins/auth-backend/src/providers/pinniped/provider.test.ts b/plugins/auth-backend/src/providers/pinniped/provider.test.ts index 90fdf06239..d109ede98b 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.test.ts @@ -14,7 +14,12 @@ * limitations under the License. */ import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; -import { OAuthRefreshRequest, OAuthStartRequest, encodeState, readState } from '../../lib/oauth'; +import { + OAuthRefreshRequest, + OAuthStartRequest, + encodeState, + readState, +} from '../../lib/oauth'; import { PinnipedAuthProvider, PinnipedOptions } from './provider'; import { setupServer } from 'msw/node'; import { rest } from 'msw'; @@ -70,7 +75,7 @@ describe('PinnipedAuthProvider', () => { iat: Date.now(), aud: clientMetadata.clientId, exp: Date.now() + 10000, - } + }; const idToken = new UnsecuredJWT(testTokenMetadata) .setIssuer(testTokenMetadata.iss) @@ -102,41 +107,39 @@ describe('PinnipedAuthProvider', () => { ), ), rest.all( - 'https://federationDomain.test/.well-known/openid-configuration', (_req, res, ctx) => - res( + 'https://federationDomain.test/.well-known/openid-configuration', + (_req, res, ctx) => + res( ctx.status(200), ctx.set('Content-Type', 'application/json'), ctx.json(issuerMetadata), - ) - + ), ), rest.post('https://pinniped.test/oauth2/token', (req, res, ctx) => - res( - req.headers.get('Authorization') - ? ctx.json({ - access_token: 'accessToken', - refresh_token: 'refreshToken', - id_token: idToken, - }) - : ctx.status(401), - ), + res( + req.headers.get('Authorization') + ? ctx.json({ + access_token: 'accessToken', + refresh_token: 'refreshToken', + id_token: idToken, + }) + : ctx.status(401), + ), + ), + rest.get('https://pinniped.test/idp/userinfo.openid', (_req, res, ctx) => + res( + ctx.json({ + iss: 'https://pinniped.test', + sub: 'test', + aud: clientMetadata.clientId, + claims: { + given_name: 'Givenname', + family_name: 'Familyname', + email: 'user@example.com', + }, + }), + ctx.status(200), ), - rest.get( - 'https://pinniped.test/idp/userinfo.openid', - (_req, res, ctx) => - res( - ctx.json({ - iss: 'https://pinniped.test', - sub: 'test', - aud: clientMetadata.clientId, - claims: { - given_name: 'Givenname', - family_name: 'Familyname', - email: 'user@example.com', - }, - }), - ctx.status(200), - ), ), ); @@ -147,7 +150,7 @@ describe('PinnipedAuthProvider', () => { url: 'test', state: oauthState, } as unknown as OAuthStartRequest; - + provider = new PinnipedAuthProvider(clientMetadata); }); @@ -169,15 +172,18 @@ describe('PinnipedAuthProvider', () => { }); it('passes audience query parameter into OAuthState in the redirect url when defined in the request', async () => { - startRequest.query = { audience: 'test-cluster'} - const startResponse = await provider.start(startRequest) - const { searchParams } = new URL(startResponse.url) + startRequest.query = { audience: 'test-cluster' }; + const startResponse = await provider.start(startRequest); + const { searchParams } = new URL(startResponse.url); const stateParam = searchParams.get('state'); const decodedState = readState(stateParam!); - expect(decodedState).toMatchObject({nonce: 'nonce', - env: 'env', audience:'test-cluster' }) - }) + expect(decodedState).toMatchObject({ + nonce: 'nonce', + env: 'env', + audience: 'test-cluster', + }); + }); it('passes client ID from config', async () => { const startResponse = await provider.start(startRequest); @@ -214,7 +220,12 @@ describe('PinnipedAuthProvider', () => { const scopes = searchParams.get('scope')?.split(' ') ?? []; expect(scopes).toEqual( - expect.arrayContaining(['openid', 'pinniped:request-audience', 'username']), + expect.arrayContaining([ + 'openid', + 'pinniped:request-audience', + 'username', + 'offline_access', + ]), ); }); @@ -241,10 +252,12 @@ describe('PinnipedAuthProvider', () => { let handlerRequest: express.Request; beforeEach(() => { - //we want to somehow pass an authentication header in this request for testing purposes + // we want to somehow pass an authentication header in this request for testing purposes handlerRequest = { method: 'GET', - url: `https://test?code=authorization_code&state=${encodeState(oauthState)}`, + url: `https://test?code=authorization_code&state=${encodeState( + oauthState, + )}`, session: { 'oidc:pinniped.test': { state: encodeState(oauthState), @@ -252,18 +265,27 @@ describe('PinnipedAuthProvider', () => { }, } as unknown as express.Request; }); - - it('exchanges authorization code for a valid access_token', async() => { - const handlerResponse = await provider.handler(handlerRequest); - const accessToken = handlerResponse.response.providerInfo.accessToken - - expect(accessToken).toEqual('accessToken') - }) - it('request errors out with missing authorization_code parameter in the request_url', async() => { - handlerRequest.url = "https://test.com" - return expect(provider.handler(handlerRequest)).rejects.toThrow('Unexpected redirect') - }) + it('exchanges authorization code for a access_token', async () => { + const handlerResponse = await provider.handler(handlerRequest); + const accessToken = handlerResponse.response.providerInfo.accessToken; + + expect(accessToken).toEqual('accessToken'); + }); + + it('exchanges authorization code for a refresh_token', async () => { + const handlerResponse = await provider.handler(handlerRequest); + const refreshToken = handlerResponse.refreshToken; + + expect(refreshToken).toEqual('refreshToken'); + }); + + it('request errors out with missing authorization_code parameter in the request_url', async () => { + handlerRequest.url = 'https://test.com'; + return expect(provider.handler(handlerRequest)).rejects.toThrow( + 'Unexpected redirect', + ); + }); it('fails when request has no state in req_url', async () => { return expect( @@ -290,8 +312,8 @@ describe('PinnipedAuthProvider', () => { ).rejects.toThrow('authentication requires session support'); }); - //if no valid key is in the jwks array or even an unsigned jwt - //have pinniped reject your clientid and secret possibly as a unit test + // if no valid key is in the jwks array or even an unsigned jwt + // have pinniped reject your clientid and secret possibly as a unit test }); describe('#refresh', () => { @@ -299,24 +321,20 @@ describe('PinnipedAuthProvider', () => { beforeEach(() => { refreshRequest = { - refreshToken: 'otherRefreshToken' + refreshToken: 'otherRefreshToken', } as unknown as OAuthRefreshRequest; }); - it('gets new refresh token', async() => { + it('gets new refresh token', async () => { const { refreshToken } = await provider.refresh(refreshRequest); - - expect(refreshToken).toBe('refreshToken') - }) - it('gets an access_token', async() => { - const { response } = await provider.refresh(refreshRequest) + expect(refreshToken).toBe('refreshToken'); + }); - expect(response.providerInfo.accessToken).toBe("accessToken") - }) - //find out when refresh requests are even made? - //so far looks like the response should be exactly like the one returned by the handler - //what are we exchanging exactly to get this refresh token?? + it('gets an access_token', async () => { + const { response } = await provider.refresh(refreshRequest); - }) + expect(response.providerInfo.accessToken).toBe('accessToken'); + }); + }); }); diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts index ebc94da58f..c4ea1e40e3 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.ts @@ -62,11 +62,12 @@ export class PinnipedAuthProvider implements OAuthHandlers { async start(req: OAuthStartRequest): Promise { const { strategy } = await this.implementation; - const stringifiedAudience = req.query?.audience as string - const state = {...req.state, audience: stringifiedAudience } + const stringifiedAudience = req.query?.audience as string; + const state = { ...req.state, audience: stringifiedAudience }; const options: Record = { - scope: req.scope || 'openid pinniped:request-audience username', + scope: + req.scope || 'openid pinniped:request-audience username offline_access', state: encodeState(state), }; return new Promise((resolve, reject) => { @@ -87,10 +88,10 @@ export class PinnipedAuthProvider implements OAuthHandlers { // the query string inside the req should contain a code and a state, we can change the stub to reject any auth code, - //if we dont add a base url our integration fails with invalid_url error in integration test - const { searchParams } = new URL(req.url, 'https://pinniped.com') - const stateParam = searchParams.get('state') - const audience = stateParam ? readState(stateParam).audience : "none" + // if we dont add a base url our integration fails with invalid_url error in integration test + const { searchParams } = new URL(req.url, 'https://pinniped.com'); + const stateParam = searchParams.get('state'); + const audience = stateParam ? readState(stateParam).audience : 'none'; return new Promise((resolve, reject) => { strategy.success = user => { @@ -102,6 +103,7 @@ export class PinnipedAuthProvider implements OAuthHandlers { }, profile: {}, }, + refreshToken: user.tokenset.refresh_token, }); }; strategy.fail = info => { @@ -120,13 +122,15 @@ export class PinnipedAuthProvider implements OAuthHandlers { }); } - async refresh(req: OAuthRefreshRequest): Promise<{ response: OAuthResponse; refreshToken?: string }> { + async refresh( + req: OAuthRefreshRequest, + ): Promise<{ response: OAuthResponse; refreshToken?: string }> { const { client } = await this.implementation; const tokenset = await client.refresh(req.refreshToken); return new Promise((resolve, reject) => { - if(!tokenset.access_token){ - reject(new Error('Refresh Failed')) + if (!tokenset.access_token) { + reject(new Error('Refresh Failed')); } resolve({ @@ -139,7 +143,7 @@ export class PinnipedAuthProvider implements OAuthHandlers { }, refreshToken: tokenset.refresh_token, }); - }) + }); } private async setupStrategy(options: PinnipedOptions): Promise { From eb1dac4d84e42767c5a31c5e7776e6cb734c1c8d Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Thu, 24 Aug 2023 16:45:48 -0400 Subject: [PATCH 39/59] Change handler to return scope returned by the tokenset Signed-off-by: Ruben Vallejo --- .../src/providers/pinniped/index.test.ts | 61 +++++++++++-------- .../src/providers/pinniped/provider.test.ts | 19 +++--- .../src/providers/pinniped/provider.ts | 2 +- 3 files changed, 45 insertions(+), 37 deletions(-) diff --git a/plugins/auth-backend/src/providers/pinniped/index.test.ts b/plugins/auth-backend/src/providers/pinniped/index.test.ts index 12b34c5ee2..f9b5a01fc5 100644 --- a/plugins/auth-backend/src/providers/pinniped/index.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/index.test.ts @@ -82,13 +82,22 @@ describe('pinniped.create', () => { response_modes_supported: ['query', 'form_post'], subject_types_supported: ['public'], token_endpoint_auth_methods_supported: ['client_secret_basic'], - id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256', 'ES256'], + id_token_signing_alg_values_supported: [ + 'RS256', + 'RS512', + 'HS256', + 'ES256', + ], token_endpoint_auth_signing_alg_values_supported: [ 'RS256', 'RS512', 'HS256', ], - request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + request_object_signing_alg_values_supported: [ + 'RS256', + 'RS512', + 'HS256', + ], scopes_supported: [ 'openid', 'offline_access', @@ -96,7 +105,12 @@ describe('pinniped.create', () => { 'username', 'groups', ], - claims_supported: ['username', 'groups', 'additionalClaims', 'sub'], + claims_supported: [ + 'username', + 'groups', + 'additionalClaims', + 'sub', + ], code_challenge_methods_supported: ['S256'], 'discovery.supervisor.pinniped.dev/v1alpha1': { pinniped_identity_providers_endpoint: @@ -151,7 +165,7 @@ describe('pinniped.create', () => { it('/handler/frame exchanges authorization codes from /start for access tokens', async () => { const agent = request.agent(''); // make a /start request - //add query parameter for the audience + // add query parameter for the audience const startResponse = await agent.get( `${appUrl}/api/auth/pinniped/start?env=development`, ); @@ -196,18 +210,14 @@ describe('pinniped.create', () => { publicKey.kid = privateKey.kid = uuid(); publicKey.alg = privateKey.alg = 'ES256'; - - const jwt = await new SignJWT({ iss, sub, aud, iat, exp}) - .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) - .setIssuer(iss) - .setAudience(aud) - .setSubject(sub) - .setIssuedAt(iat) - .setExpirationTime(exp) - .sign(await importJWK(privateKey)); - - - + const jwt = await new SignJWT({ iss, sub, aud, iat, exp }) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .setIssuer(iss) + .setAudience(aud) + .setSubject(sub) + .setIssuedAt(iat) + .setExpirationTime(exp) + .sign(await importJWK(privateKey)); fakePinnipedSupervisor.use( rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => @@ -215,22 +225,23 @@ describe('pinniped.create', () => { // TODO verify client ID + secret, etc -- real token endpoint new URLSearchParams(await req.text()).get('code') === 'authorization_code' - ? ctx.json({ access_token: 'accessToken', id_token: jwt }) + ? ctx.json({ + access_token: 'accessToken', + id_token: jwt, + scope: 'testScope', + }) : ctx.status(401), ), ), - rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) => - res( - ctx.status(200), - ctx.json({ "keys": [{...publicKey}] - }) - )) + rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) => + res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), + ), ); const handlerResponse = await agent.get( authorizationResponse.header.location, ); - //our assertion doesnt include a scope but the return type does...might need to change our assertion? + // our assertion doesnt include a scope but the return type does...might need to change our assertion? expect(handlerResponse.text).toContain( encodeURIComponent( JSON.stringify({ @@ -238,7 +249,7 @@ describe('pinniped.create', () => { response: { providerInfo: { accessToken: 'accessToken', - scope: "none" + scope: 'testScope', }, profile: {}, }, diff --git a/plugins/auth-backend/src/providers/pinniped/provider.test.ts b/plugins/auth-backend/src/providers/pinniped/provider.test.ts index d109ede98b..a9e7d34824 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.test.ts @@ -95,17 +95,6 @@ describe('PinnipedAuthProvider', () => { jest.clearAllMocks(); worker.use( - rest.post('https://pinniped.test/oauth2/token', (req, res, ctx) => - res( - req.headers.get('Authorization') - ? ctx.json({ - access_token: 'accessToken', - refresh_token: 'refreshToken', - id_token: idToken, - }) - : ctx.status(401), - ), - ), rest.all( 'https://federationDomain.test/.well-known/openid-configuration', (_req, res, ctx) => @@ -122,6 +111,7 @@ describe('PinnipedAuthProvider', () => { access_token: 'accessToken', refresh_token: 'refreshToken', id_token: idToken, + scope: 'testScope', }) : ctx.status(401), ), @@ -280,6 +270,13 @@ describe('PinnipedAuthProvider', () => { expect(refreshToken).toEqual('refreshToken'); }); + it('exchanges authorization_code for a tokenset with a defined scope', async () => { + const handlerResponse = await provider.handler(handlerRequest); + const responseScope = handlerResponse.response.providerInfo.scope; + + expect(responseScope).toEqual('testScope'); + }); + it('request errors out with missing authorization_code parameter in the request_url', async () => { handlerRequest.url = 'https://test.com'; return expect(provider.handler(handlerRequest)).rejects.toThrow( diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts index c4ea1e40e3..de603b978b 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.ts @@ -99,7 +99,7 @@ export class PinnipedAuthProvider implements OAuthHandlers { response: { providerInfo: { accessToken: user.tokenset.access_token, - scope: 'none', + scope: user.tokenset.scope, }, profile: {}, }, From f5be2ec692920d82ecc04af24668a18c1c168fad Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Mon, 28 Aug 2023 18:30:09 -0400 Subject: [PATCH 40/59] Add rfc token exchange logic to #handler success Signed-off-by: Ruben Vallejo --- .../src/providers/pinniped/index.test.ts | 155 ++++++++++++++---- .../src/providers/pinniped/provider.test.ts | 51 +++++- .../src/providers/pinniped/provider.ts | 66 ++++++-- 3 files changed, 217 insertions(+), 55 deletions(-) diff --git a/plugins/auth-backend/src/providers/pinniped/index.test.ts b/plugins/auth-backend/src/providers/pinniped/index.test.ts index f9b5a01fc5..19ba3c1b10 100644 --- a/plugins/auth-backend/src/providers/pinniped/index.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/index.test.ts @@ -162,6 +162,9 @@ describe('pinniped.create', () => { backstageServer.close(); }); + // also include an audience parameter and only assert on the id_token + + // repurpose this test it('/handler/frame exchanges authorization codes from /start for access tokens', async () => { const agent = request.agent(''); // make a /start request @@ -182,10 +185,7 @@ describe('pinniped.create', () => { 'state', req.url.searchParams.get('state')!, ); - // callbackUrl.searchParams.set( - // 'scope', - // 'test-scope', - // ); + callbackUrl.searchParams.set('scope', 'test-scope'); return res( ctx.status(302), ctx.set('Location', callbackUrl.toString()), @@ -198,11 +198,13 @@ describe('pinniped.create', () => { ); // follow the redirect back to /handler/frame - const sub = 'test'; - const iss = 'https://pinniped.test'; - const iat = Date.now(); - const aud = 'clientId'; - const exp = Date.now() + 10000; + const testTokenMetadata = { + sub: 'test', + iss: 'https://pinniped.test', + iat: Date.now(), + aud: 'clientId', + exp: Date.now() + 10000, + }; const key = await generateKeyPair('ES256'); const publicKey = await exportJWK(key.publicKey); @@ -210,13 +212,13 @@ describe('pinniped.create', () => { publicKey.kid = privateKey.kid = uuid(); publicKey.alg = privateKey.alg = 'ES256'; - const jwt = await new SignJWT({ iss, sub, aud, iat, exp }) + const jwt = await new SignJWT(testTokenMetadata) .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) - .setIssuer(iss) - .setAudience(aud) - .setSubject(sub) - .setIssuedAt(iat) - .setExpirationTime(exp) + .setIssuer(testTokenMetadata.iss) + .setAudience(testTokenMetadata.aud) + .setSubject(testTokenMetadata.sub) + .setIssuedAt(testTokenMetadata.iat) + .setExpirationTime(testTokenMetadata.exp) .sign(await importJWK(privateKey)); fakePinnipedSupervisor.use( @@ -248,7 +250,7 @@ describe('pinniped.create', () => { type: 'authorization_response', response: { providerInfo: { - accessToken: 'accessToken', + idToken: 'accessToken', scope: 'testScope', }, profile: {}, @@ -260,44 +262,125 @@ describe('pinniped.create', () => { describe('#frameHandler', () => { it.skip('performs an rfc 8693 token exchange after getting access token', async () => { + const agent = request.agent(''); + + // make a start request with an audience query + const startResponse = await agent.get( + `${appUrl}/api/auth/pinniped/start?env=development&aud=testCluster`, + ); + + const testTokenMetadata = { + sub: 'test', + iss: 'https://pinniped.test', + iat: Date.now(), + aud: 'clientId', + exp: Date.now() + 10000, + }; + + const key = await generateKeyPair('ES256'); + const publicKey = await exportJWK(key.publicKey); + const privateKey = await exportJWK(key.privateKey); + publicKey.kid = privateKey.kid = uuid(); + publicKey.alg = privateKey.alg = 'ES256'; + + const jwt = await new SignJWT(testTokenMetadata) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .setIssuer(testTokenMetadata.iss) + .setAudience(testTokenMetadata.aud) + .setSubject(testTokenMetadata.sub) + .setIssuedAt(testTokenMetadata.iat) + .setExpirationTime(testTokenMetadata.exp) + .sign(await importJWK(privateKey)); + + // follow the redirect to pinniped authorization endpoint fakePinnipedSupervisor.use( + rest.get( + 'https://pinniped.test/oauth2/authorize', + async (req, res, ctx) => { + const callbackUrl = new URL( + req.url.searchParams.get('redirect_uri')!, + ); + callbackUrl.searchParams.set('code', 'authorization_code'); + callbackUrl.searchParams.set( + 'state', + req.url.searchParams.get('state')!, + ); + callbackUrl.searchParams.set('scope', 'test-scope'); + return res( + ctx.status(302), + ctx.set('Location', callbackUrl.toString()), + ); + }, + ), rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => res( ctx.json( new URLSearchParams(await req.text()).get('grant_type') === 'urn:ietf:params:oauth:grant-type:token-exchange' - ? { access_token: 'accessToken' } - : { id_token: 'clusterToken' }, + ? { access_token: 'accessToken', scope: 'test-scope' } + : { + accessToken: 'accessToken', + scope: 'test-scope', + idToken: jwt, + }, ), ), ), + rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) => + res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), + ), ); - const responsePromise = request(app) - .get( - '/api/auth/pinniped/handler/frame?' + - 'code=pin_ac_xU69qZGejOCu8Loz5iOD6Bm25SgQewmT0VVE1hOAQzA.WzxrI9bCder5UJHtCOX_yEnsM2OVh8pVSFI7NPs5yUM&' + - 'scope=openid+pinniped%3Arequest-audience+username&' + - `state=${state}`, - ) - .set( - 'Cookie', - `pinniped-nonce=${nonce}; ` + - 'connect.sid=s:p3_hKHiFr_i58jyTPIZxtWN9pejiOujD.SN2irLt6oIL18v0GzGCPO1sibEmzybiVlT9ca3ZjT68', - ); - const reqUrl = new URL(responsePromise.url); - reqUrl.search = ''; - fakePinnipedSupervisor.use( - rest.all(reqUrl.toString(), req => req.passthrough()), + // fakePinnipedSupervisor.use( + // rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => + // res( + // ctx.json( + // new URLSearchParams(await req.text()).get('grant_type') === + // 'urn:ietf:params:oauth:grant-type:token-exchange' + // ? { access_token: 'accessToken' } + // : { id_token: 'clusterToken' }, + // ), + // ), + // ), + // ); + + const authorizationResponse = await agent.get( + startResponse.header.location, ); - expect((await responsePromise).text).toContain( + const handlerResponse = await agent.get( + authorizationResponse.header.location, + ); + + // const responsePromise = request(app) + // .get( + // '/api/auth/pinniped/handler/frame?' + + // 'code=pin_ac_xU69qZGejOCu8Loz5iOD6Bm25SgQewmT0VVE1hOAQzA.WzxrI9bCder5UJHtCOX_yEnsM2OVh8pVSFI7NPs5yUM&' + + // 'scope=openid+pinniped%3Arequest-audience+username&' + + // `state=${state}`, + // ) + // .set( + // 'Cookie', + // `pinniped-nonce=${nonce}; ` + + // 'connect.sid=s:p3_hKHiFr_i58jyTPIZxtWN9pejiOujD.SN2irLt6oIL18v0GzGCPO1sibEmzybiVlT9ca3ZjT68', + // ); + + // const reqUrl = new URL(responsePromise.url); + // reqUrl.search = ''; + + // fakePinnipedSupervisor.use( + // rest.all(reqUrl.toString(), req => req.passthrough()), + // ); + + expect(handlerResponse.text).toContain( encodeURIComponent( JSON.stringify({ type: 'authorization_response', response: { providerInfo: { - idToken: 'clusterToken', + accessToken: 'accessToken', + scope: 'test-scope', + idToken: jwt, }, profile: {}, }, diff --git a/plugins/auth-backend/src/providers/pinniped/provider.test.ts b/plugins/auth-backend/src/providers/pinniped/provider.test.ts index a9e7d34824..9fbe0ff3e7 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.test.ts @@ -91,6 +91,8 @@ describe('PinnipedAuthProvider', () => { origin: 'undefined', }; + const clusterScopedIdToken = 'dummy-token'; + beforeEach(() => { jest.clearAllMocks(); @@ -104,18 +106,33 @@ describe('PinnipedAuthProvider', () => { ctx.json(issuerMetadata), ), ), - rest.post('https://pinniped.test/oauth2/token', (req, res, ctx) => - res( - req.headers.get('Authorization') + rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => { + const formBody = new URLSearchParams(await req.text()); + const isGrantTypeTokenExchange = + formBody.get('grant_type') === + 'urn:ietf:params:oauth:grant-type:token-exchange'; + const hasValidTokenExchangeParams = + formBody.get('subject_token') === 'accessToken' && + formBody.get('audience') === 'test_cluster' && + formBody.get('subject_token_type') === + 'urn:ietf:params:oauth:token-type:access_token' && + formBody.get('requested_token_type') === + 'urn:ietf:params:oauth:token-type:jwt'; + + return res( + req.headers.get('Authorization') && + (!isGrantTypeTokenExchange || hasValidTokenExchangeParams) ? ctx.json({ - access_token: 'accessToken', + access_token: isGrantTypeTokenExchange + ? clusterScopedIdToken + : 'accessToken', refresh_token: 'refreshToken', - id_token: idToken, + ...(!isGrantTypeTokenExchange && { id_token: idToken }), scope: 'testScope', }) : ctx.status(401), - ), - ), + ); + }), rest.get('https://pinniped.test/idp/userinfo.openid', (_req, res, ctx) => res( ctx.json({ @@ -277,6 +294,26 @@ describe('PinnipedAuthProvider', () => { expect(responseScope).toEqual('testScope'); }); + it('returns cluster-scoped ID token when audience is specified', async () => { + oauthState.audience = 'test_cluster'; + handlerRequest = { + method: 'GET', + url: `https://test?code=authorization_code&state=${encodeState( + oauthState, + )}`, + session: { + 'oidc:pinniped.test': { + state: encodeState(oauthState), + }, + }, + } as unknown as express.Request; + + const handlerResponse = await provider.handler(handlerRequest); + const responseIdToken = handlerResponse.response.providerInfo.idToken; + + expect(responseIdToken).toEqual(clusterScopedIdToken); + }); + it('request errors out with missing authorization_code parameter in the request_url', async () => { handlerRequest.url = 'https://test.com'; return expect(provider.handler(handlerRequest)).rejects.toThrow( diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts index de603b978b..e8ec21bf63 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.ts @@ -33,6 +33,7 @@ import { OAuthStartResponse } from '../types'; import express from 'express'; import { OAuthAdapter, OAuthEnvironmentHandler } from '../../lib/oauth'; import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; +import fetch from 'node-fetch'; type OidcImpl = { strategy: OidcStrategy; @@ -54,9 +55,15 @@ export type PinnipedOptions = OAuthProviderOptions & { export class PinnipedAuthProvider implements OAuthHandlers { private readonly implementation: Promise; + private readonly clientId: string; + private readonly clientSecret: string; + private readonly federationDomain: string; constructor(options: PinnipedOptions) { this.implementation = this.setupStrategy(options); + this.clientId = options.clientId; + this.clientSecret = options.clientSecret; + this.federationDomain = options.federationDomain; } async start(req: OAuthStartRequest): Promise { @@ -81,31 +88,66 @@ export class PinnipedAuthProvider implements OAuthHandlers { }); } + private async rfc8693TokenExchange({ accessToken, audience }) { + const tokenEndpoint = `${this.federationDomain}/oauth2/token`; + const authString: string = `${this.clientId}:${this.clientSecret}`; + const encodedAuthString = Buffer.from(authString, 'base64'); + + const requestOptions = { + method: 'POST', + headers: { + 'Content-Type': 'x-www-form-urlencoded', + Authorization: `Basic ${encodedAuthString}`, + }, + body: `grant_type=urn:ietf:params:oauth:grant-type:token-exchange + &subject_token=${accessToken} + &subject_token_type=urn:ietf:params:oauth:token-type:access_token + &requested_token_type=urn:ietf:params:oauth:token-type:jwt + &audience=${audience}`, + }; + const response = await fetch(tokenEndpoint, requestOptions); + return response.idToken; + } + async handler( req: express.Request, ): Promise<{ response: OAuthResponse; refreshToken?: string }> { - const { strategy } = await this.implementation; - - // the query string inside the req should contain a code and a state, we can change the stub to reject any auth code, + const { strategy, client } = await this.implementation; // if we dont add a base url our integration fails with invalid_url error in integration test const { searchParams } = new URL(req.url, 'https://pinniped.com'); const stateParam = searchParams.get('state'); - const audience = stateParam ? readState(stateParam).audience : 'none'; + const audience = stateParam ? readState(stateParam).audience : undefined; return new Promise((resolve, reject) => { strategy.success = user => { - resolve({ - response: { - providerInfo: { - accessToken: user.tokenset.access_token, - scope: user.tokenset.scope, + (audience + ? client + .grant({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token: user.tokenset.access_token, + audience, + subject_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + requested_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }) + .then(tokenset => tokenset.access_token) + : Promise.resolve(user.tokenset.id_token) + ).then(idToken => { + resolve({ + response: { + providerInfo: { + accessToken: user.tokenset.access_token, + scope: user.tokenset.scope, + idToken, + }, + profile: {}, }, - profile: {}, - }, - refreshToken: user.tokenset.refresh_token, + refreshToken: user.tokenset.refresh_token, + }); }); }; + strategy.fail = info => { reject(new Error(`Authentication rejected, ${info.message || ''}`)); }; From 5ee7c789adc44a2a1d53f6e3f1b7d30e224ea3ab Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Mon, 28 Aug 2023 18:34:04 -0400 Subject: [PATCH 41/59] refactor provider unit tests Signed-off-by: Ruben Vallejo --- .../src/providers/pinniped/index.test.ts | 392 ------------------ .../src/providers/pinniped/index.ts | 64 --- .../src/providers/pinniped/provider.test.ts | 212 +++++++++- .../src/providers/pinniped/provider.ts | 35 +- 4 files changed, 204 insertions(+), 499 deletions(-) delete mode 100644 plugins/auth-backend/src/providers/pinniped/index.test.ts diff --git a/plugins/auth-backend/src/providers/pinniped/index.test.ts b/plugins/auth-backend/src/providers/pinniped/index.test.ts deleted file mode 100644 index 19ba3c1b10..0000000000 --- a/plugins/auth-backend/src/providers/pinniped/index.test.ts +++ /dev/null @@ -1,392 +0,0 @@ -/* - * Copyright 2023 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 { pinniped } from '.'; -import { AuthProviderRouteHandlers } from '../types'; -import { getVoidLogger } from '@backstage/backend-common'; -import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; -import { ConfigReader } from '@backstage/config'; -import { setupServer } from 'msw/node'; -import { rest } from 'msw'; -import { Server } from 'http'; -import { AddressInfo } from 'net'; -import express from 'express'; -import request from 'supertest'; -import cookieParser from 'cookie-parser'; -import passport from 'passport'; -import session from 'express-session'; -import Router from 'express-promise-router'; -// import fetch from 'node-fetch'; -import { SignJWT, exportJWK, generateKeyPair, importJWK } from 'jose'; -import { v4 as uuid } from 'uuid'; - -describe('pinniped.create', () => { - const fakePinnipedSupervisor = setupServer(); - setupRequestMockHandlers(fakePinnipedSupervisor); - const nonce = 'AAAAAAAAAAAAAAAAAAAAAA=='; // 16 bytes of zeros in base64 - const state = Buffer.from( - `nonce=${encodeURIComponent(nonce)}&env=development`, - ).toString('hex'); - - let app: express.Express; - let provider: AuthProviderRouteHandlers; - let backstageServer: Server; - let appUrl: string; - - beforeEach(async () => { - const secret = 'secret'; - app = express() - .use(cookieParser(secret)) - .use( - session({ - secret, - saveUninitialized: false, - resave: false, - cookie: { secure: false }, - }), - ) - .use(passport.initialize()) - .use(passport.session()); - await new Promise(resolve => { - backstageServer = app.listen(0, '0.0.0.0', () => { - appUrl = `http://127.0.0.1:${ - (backstageServer.address() as AddressInfo).port - }`; - resolve(null); - }); - }); - fakePinnipedSupervisor.use( - rest.all(`${appUrl}/*`, req => req.passthrough()), - rest.all( - 'https://pinniped.test/.well-known/openid-configuration', - (_, res, ctx) => - res( - ctx.json({ - issuer: 'https://pinniped.test', - authorization_endpoint: 'https://pinniped.test/oauth2/authorize', - token_endpoint: 'https://pinniped.test/oauth2/token', - jwks_uri: 'https://pinniped.test/jwks.json', - response_types_supported: ['code', 'access_token'], - response_modes_supported: ['query', 'form_post'], - subject_types_supported: ['public'], - token_endpoint_auth_methods_supported: ['client_secret_basic'], - id_token_signing_alg_values_supported: [ - 'RS256', - 'RS512', - 'HS256', - 'ES256', - ], - token_endpoint_auth_signing_alg_values_supported: [ - 'RS256', - 'RS512', - 'HS256', - ], - request_object_signing_alg_values_supported: [ - 'RS256', - 'RS512', - 'HS256', - ], - scopes_supported: [ - 'openid', - 'offline_access', - 'pinniped:request-audience', - 'username', - 'groups', - ], - claims_supported: [ - 'username', - 'groups', - 'additionalClaims', - 'sub', - ], - code_challenge_methods_supported: ['S256'], - 'discovery.supervisor.pinniped.dev/v1alpha1': { - pinniped_identity_providers_endpoint: - 'https://pinniped.test/v1alpha1/pinniped_identity_providers', - }, - }), - ), - ), - ); - - provider = pinniped.create()({ - providerId: 'pinniped', - globalConfig: { - baseUrl: `${appUrl}/api/auth`, - appUrl, - isOriginAllowed: _ => true, - }, - config: new ConfigReader({ - development: { - federationDomain: 'https://pinniped.test', - clientId: 'clientId', - clientSecret: 'clientSecret', - }, - }), - logger: getVoidLogger(), - resolverContext: { - issueToken: async _ => ({ token: '' }), - findCatalogUser: async _ => ({ - entity: { - apiVersion: '', - kind: '', - metadata: { name: '' }, - }, - }), - signInWithCatalogUser: async _ => ({ token: '' }), - }, - }); - const router = Router(); - router - .use('/api/auth/pinniped/start', provider.start.bind(provider)) - .use( - '/api/auth/pinniped/handler/frame', - provider.frameHandler.bind(provider), - ); - app.use(router); - }); - - afterEach(() => { - backstageServer.close(); - }); - - // also include an audience parameter and only assert on the id_token - - // repurpose this test - it('/handler/frame exchanges authorization codes from /start for access tokens', async () => { - const agent = request.agent(''); - // make a /start request - // add query parameter for the audience - const startResponse = await agent.get( - `${appUrl}/api/auth/pinniped/start?env=development`, - ); - // follow the redirect to pinniped authorization endpoint - fakePinnipedSupervisor.use( - rest.get( - 'https://pinniped.test/oauth2/authorize', - async (req, res, ctx) => { - const callbackUrl = new URL( - req.url.searchParams.get('redirect_uri')!, - ); - callbackUrl.searchParams.set('code', 'authorization_code'); - callbackUrl.searchParams.set( - 'state', - req.url.searchParams.get('state')!, - ); - callbackUrl.searchParams.set('scope', 'test-scope'); - return res( - ctx.status(302), - ctx.set('Location', callbackUrl.toString()), - ); - }, - ), - ); - const authorizationResponse = await agent.get( - startResponse.header.location, - ); - - // follow the redirect back to /handler/frame - const testTokenMetadata = { - sub: 'test', - iss: 'https://pinniped.test', - iat: Date.now(), - aud: 'clientId', - exp: Date.now() + 10000, - }; - - const key = await generateKeyPair('ES256'); - const publicKey = await exportJWK(key.publicKey); - const privateKey = await exportJWK(key.privateKey); - publicKey.kid = privateKey.kid = uuid(); - publicKey.alg = privateKey.alg = 'ES256'; - - const jwt = await new SignJWT(testTokenMetadata) - .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) - .setIssuer(testTokenMetadata.iss) - .setAudience(testTokenMetadata.aud) - .setSubject(testTokenMetadata.sub) - .setIssuedAt(testTokenMetadata.iat) - .setExpirationTime(testTokenMetadata.exp) - .sign(await importJWK(privateKey)); - - fakePinnipedSupervisor.use( - rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => - res( - // TODO verify client ID + secret, etc -- real token endpoint - new URLSearchParams(await req.text()).get('code') === - 'authorization_code' - ? ctx.json({ - access_token: 'accessToken', - id_token: jwt, - scope: 'testScope', - }) - : ctx.status(401), - ), - ), - rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) => - res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), - ), - ); - - const handlerResponse = await agent.get( - authorizationResponse.header.location, - ); - // our assertion doesnt include a scope but the return type does...might need to change our assertion? - expect(handlerResponse.text).toContain( - encodeURIComponent( - JSON.stringify({ - type: 'authorization_response', - response: { - providerInfo: { - idToken: 'accessToken', - scope: 'testScope', - }, - profile: {}, - }, - }), - ), - ); - }, 70000); - - describe('#frameHandler', () => { - it.skip('performs an rfc 8693 token exchange after getting access token', async () => { - const agent = request.agent(''); - - // make a start request with an audience query - const startResponse = await agent.get( - `${appUrl}/api/auth/pinniped/start?env=development&aud=testCluster`, - ); - - const testTokenMetadata = { - sub: 'test', - iss: 'https://pinniped.test', - iat: Date.now(), - aud: 'clientId', - exp: Date.now() + 10000, - }; - - const key = await generateKeyPair('ES256'); - const publicKey = await exportJWK(key.publicKey); - const privateKey = await exportJWK(key.privateKey); - publicKey.kid = privateKey.kid = uuid(); - publicKey.alg = privateKey.alg = 'ES256'; - - const jwt = await new SignJWT(testTokenMetadata) - .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) - .setIssuer(testTokenMetadata.iss) - .setAudience(testTokenMetadata.aud) - .setSubject(testTokenMetadata.sub) - .setIssuedAt(testTokenMetadata.iat) - .setExpirationTime(testTokenMetadata.exp) - .sign(await importJWK(privateKey)); - - // follow the redirect to pinniped authorization endpoint - fakePinnipedSupervisor.use( - rest.get( - 'https://pinniped.test/oauth2/authorize', - async (req, res, ctx) => { - const callbackUrl = new URL( - req.url.searchParams.get('redirect_uri')!, - ); - callbackUrl.searchParams.set('code', 'authorization_code'); - callbackUrl.searchParams.set( - 'state', - req.url.searchParams.get('state')!, - ); - callbackUrl.searchParams.set('scope', 'test-scope'); - return res( - ctx.status(302), - ctx.set('Location', callbackUrl.toString()), - ); - }, - ), - rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => - res( - ctx.json( - new URLSearchParams(await req.text()).get('grant_type') === - 'urn:ietf:params:oauth:grant-type:token-exchange' - ? { access_token: 'accessToken', scope: 'test-scope' } - : { - accessToken: 'accessToken', - scope: 'test-scope', - idToken: jwt, - }, - ), - ), - ), - rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) => - res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), - ), - ); - - // fakePinnipedSupervisor.use( - // rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => - // res( - // ctx.json( - // new URLSearchParams(await req.text()).get('grant_type') === - // 'urn:ietf:params:oauth:grant-type:token-exchange' - // ? { access_token: 'accessToken' } - // : { id_token: 'clusterToken' }, - // ), - // ), - // ), - // ); - - const authorizationResponse = await agent.get( - startResponse.header.location, - ); - - const handlerResponse = await agent.get( - authorizationResponse.header.location, - ); - - // const responsePromise = request(app) - // .get( - // '/api/auth/pinniped/handler/frame?' + - // 'code=pin_ac_xU69qZGejOCu8Loz5iOD6Bm25SgQewmT0VVE1hOAQzA.WzxrI9bCder5UJHtCOX_yEnsM2OVh8pVSFI7NPs5yUM&' + - // 'scope=openid+pinniped%3Arequest-audience+username&' + - // `state=${state}`, - // ) - // .set( - // 'Cookie', - // `pinniped-nonce=${nonce}; ` + - // 'connect.sid=s:p3_hKHiFr_i58jyTPIZxtWN9pejiOujD.SN2irLt6oIL18v0GzGCPO1sibEmzybiVlT9ca3ZjT68', - // ); - - // const reqUrl = new URL(responsePromise.url); - // reqUrl.search = ''; - - // fakePinnipedSupervisor.use( - // rest.all(reqUrl.toString(), req => req.passthrough()), - // ); - - expect(handlerResponse.text).toContain( - encodeURIComponent( - JSON.stringify({ - type: 'authorization_response', - response: { - providerInfo: { - accessToken: 'accessToken', - scope: 'test-scope', - idToken: jwt, - }, - profile: {}, - }, - }), - ), - ); - }); - }); -}); diff --git a/plugins/auth-backend/src/providers/pinniped/index.ts b/plugins/auth-backend/src/providers/pinniped/index.ts index 943b0549e8..a45064ad4d 100644 --- a/plugins/auth-backend/src/providers/pinniped/index.ts +++ b/plugins/auth-backend/src/providers/pinniped/index.ts @@ -13,69 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// import { OidcAuthResult } from '../oidc'; -// import { OidcAuthProvider } from '../oidc/provider'; -// import { OAuthAdapter, OAuthEnvironmentHandler } from '../../lib/oauth'; -// import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; -// import { AuthHandler, SignInResolver } from '../types'; -// import { PinnipedAuthProvider } from './provider'; - -/** - * Auth provider integration for Pinniped - * - * @public - */ -// export const pinniped = createAuthProviderIntegration({ -// create(options?: { -// authHandler?: AuthHandler; -// signIn?: { -// resolver: SignInResolver; -// }; -// }) { -// return ({ providerId, globalConfig, config, resolverContext }) => -// OAuthEnvironmentHandler.mapConfig(config, envConfig => { -// const clientId = envConfig.getString('clientId'); -// const clientSecret = envConfig.getString('clientSecret'); -// const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); -// const callbackUrl = -// customCallbackUrl || -// `${globalConfig.baseUrl}/${providerId}/handler/frame`; -// const metadataUrl = `${envConfig.getString( -// 'federationDomain', -// )}/.well-known/openid-configuration`; -// const federationDomain = envConfig.getString('federationDomain'); -// const tokenSignedResponseAlg = 'ES256'; -// const prompt = 'auto'; -// const authHandler: AuthHandler = async ({ -// userinfo, -// }) => ({ -// profile: {}, -// }); - -// // const provider = new OidcAuthProvider({ -// // clientId, -// // clientSecret, -// // callbackUrl, -// // tokenSignedResponseAlg, -// // metadataUrl, -// // prompt, -// // signInResolver: options?.signIn?.resolver, -// // authHandler, -// // resolverContext, -// // }); - -// const provider = new PinnipedAuthProvider({ -// federationDomain, -// clientId, -// clientSecret, -// }); - -// return OAuthAdapter.fromConfig(globalConfig, provider, { -// providerId, -// callbackUrl, -// }); -// }); -// }, -// }); export { pinniped } from './provider'; diff --git a/plugins/auth-backend/src/providers/pinniped/provider.test.ts b/plugins/auth-backend/src/providers/pinniped/provider.test.ts index 9fbe0ff3e7..82312266ff 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.test.ts @@ -26,14 +26,27 @@ import { rest } from 'msw'; import express from 'express'; import { UnsecuredJWT } from 'jose'; import { OAuthState } from '../../lib/oauth'; +import { Server } from 'http'; +import cookieParser from 'cookie-parser'; +import session from 'express-session'; +import passport from 'passport'; +import { ConfigReader } from '@backstage/config'; +import Router from 'express-promise-router'; +import { pinniped } from '.'; +import { AuthProviderRouteHandlers } from '../types'; +import { getVoidLogger } from '@backstage/backend-common'; +import { AddressInfo } from 'net'; +import request from 'supertest'; +import { SignJWT, exportJWK, generateKeyPair, importJWK } from 'jose'; +import { v4 as uuid } from 'uuid'; describe('PinnipedAuthProvider', () => { let provider: PinnipedAuthProvider; let startRequest: OAuthStartRequest; let fakeSession: Record; - const worker = setupServer(); - setupRequestMockHandlers(worker); + const fakePinnipedSupervisor = setupServer(); + setupRequestMockHandlers(fakePinnipedSupervisor); const issuerMetadata = { issuer: 'https://pinniped.test', @@ -63,8 +76,8 @@ describe('PinnipedAuthProvider', () => { const clientMetadata: PinnipedOptions = { federationDomain: 'https://federationDomain.test', - clientId: 'clientId.test', - clientSecret: 'secret.test', + clientId: 'clientId', + clientSecret: 'secret', callbackUrl: 'https://federationDomain.test/callback', tokenSignedResponseAlg: 'none', }; @@ -96,7 +109,7 @@ describe('PinnipedAuthProvider', () => { beforeEach(() => { jest.clearAllMocks(); - worker.use( + fakePinnipedSupervisor.use( rest.all( 'https://federationDomain.test/.well-known/openid-configuration', (_req, res, ctx) => @@ -148,6 +161,24 @@ describe('PinnipedAuthProvider', () => { ctx.status(200), ), ), + rest.get( + 'https://pinniped.test/oauth2/authorize', + async (req, res, ctx) => { + const callbackUrl = new URL( + req.url.searchParams.get('redirect_uri')!, + ); + callbackUrl.searchParams.set('code', 'authorization_code'); + callbackUrl.searchParams.set( + 'state', + req.url.searchParams.get('state')!, + ); + callbackUrl.searchParams.set('scope', 'test-scope'); + return res( + ctx.status(302), + ctx.set('Location', callbackUrl.toString()), + ); + }, + ), ); fakeSession = {}; @@ -196,7 +227,7 @@ describe('PinnipedAuthProvider', () => { const startResponse = await provider.start(startRequest); const { searchParams } = new URL(startResponse.url); - expect(searchParams.get('client_id')).toBe('clientId.test'); + expect(searchParams.get('client_id')).toBe('clientId'); }); it('passes callback URL', async () => { @@ -259,7 +290,6 @@ describe('PinnipedAuthProvider', () => { let handlerRequest: express.Request; beforeEach(() => { - // we want to somehow pass an authentication header in this request for testing purposes handlerRequest = { method: 'GET', url: `https://test?code=authorization_code&state=${encodeState( @@ -345,9 +375,6 @@ describe('PinnipedAuthProvider', () => { } as unknown as OAuthStartRequest), ).rejects.toThrow('authentication requires session support'); }); - - // if no valid key is in the jwks array or even an unsigned jwt - // have pinniped reject your clientid and secret possibly as a unit test }); describe('#refresh', () => { @@ -370,5 +397,170 @@ describe('PinnipedAuthProvider', () => { expect(response.providerInfo.accessToken).toBe('accessToken'); }); + + it('gets an id_token', async () => { + const { response } = await provider.refresh(refreshRequest); + + expect(response.providerInfo.idToken).toBe(idToken); + }); + }); + + describe('pinniped.create', () => { + let app: express.Express; + let providerRouteHandler: AuthProviderRouteHandlers; + let backstageServer: Server; + let appUrl: string; + + beforeEach(async () => { + const secret = 'secret'; + app = express() + .use(cookieParser(secret)) + .use( + session({ + secret, + saveUninitialized: false, + resave: false, + cookie: { secure: false }, + }), + ) + .use(passport.initialize()) + .use(passport.session()); + await new Promise(resolve => { + backstageServer = app.listen(0, '0.0.0.0', () => { + appUrl = `http://127.0.0.1:${ + (backstageServer.address() as AddressInfo).port + }`; + resolve(null); + }); + }); + fakePinnipedSupervisor.use( + rest.all(`${appUrl}/*`, req => req.passthrough()), + ); + providerRouteHandler = pinniped.create()({ + providerId: 'pinniped', + globalConfig: { + baseUrl: `${appUrl}/api/auth`, + appUrl, + isOriginAllowed: _ => true, + }, + config: new ConfigReader({ + development: { + federationDomain: 'https://federationDomain.test', + clientId: 'clientId', + clientSecret: 'clientSecret', + }, + }), + logger: getVoidLogger(), + resolverContext: { + issueToken: async _ => ({ token: '' }), + findCatalogUser: async _ => ({ + entity: { + apiVersion: '', + kind: '', + metadata: { name: '' }, + }, + }), + signInWithCatalogUser: async _ => ({ token: '' }), + }, + }); + const router = Router(); + router + .use( + '/api/auth/pinniped/start', + providerRouteHandler.start.bind(providerRouteHandler), + ) + .use( + '/api/auth/pinniped/handler/frame', + providerRouteHandler.frameHandler.bind(providerRouteHandler), + ); + app.use(router); + }); + + afterEach(() => { + backstageServer.close(); + }); + + it('/handler/frame exchanges authorization codes from #start for Cluster Specific ID tokens', async () => { + const agent = request.agent(''); + const key = await generateKeyPair('ES256'); + const publicKey = await exportJWK(key.publicKey); + const privateKey = await exportJWK(key.privateKey); + publicKey.kid = privateKey.kid = uuid(); + publicKey.alg = privateKey.alg = 'ES256'; + + const signedJwt = await new SignJWT(testTokenMetadata) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .setIssuer(testTokenMetadata.iss) + .setAudience(testTokenMetadata.aud) + .setSubject(testTokenMetadata.sub) + .setIssuedAt(testTokenMetadata.iat) + .setExpirationTime(testTokenMetadata.exp) + .sign(await importJWK(privateKey)); + + // make a /start request with audience parameter + const startResponse = await agent.get( + `${appUrl}/api/auth/pinniped/start?env=development&audience=test_cluster`, + ); + // follow redirect to authorization endpoint + const authorizationResponse = await agent.get( + startResponse.header.location, + ); + // follow redirect to token_endpoint + fakePinnipedSupervisor.use( + rest.post( + 'https://pinniped.test/oauth2/token', + async (req, res, ctx) => { + const formBody = new URLSearchParams(await req.text()); + const isGrantTypeTokenExchange = + formBody.get('grant_type') === + 'urn:ietf:params:oauth:grant-type:token-exchange'; + const hasValidTokenExchangeParams = + formBody.get('subject_token') === 'accessToken' && + formBody.get('audience') === 'test_cluster' && + formBody.get('subject_token_type') === + 'urn:ietf:params:oauth:token-type:access_token' && + formBody.get('requested_token_type') === + 'urn:ietf:params:oauth:token-type:jwt'; + + return res( + req.headers.get('Authorization') && + (!isGrantTypeTokenExchange || hasValidTokenExchangeParams) + ? ctx.json({ + access_token: isGrantTypeTokenExchange + ? clusterScopedIdToken + : 'accessToken', + refresh_token: 'refreshToken', + ...(!isGrantTypeTokenExchange && { id_token: signedJwt }), + scope: 'testScope', + }) + : ctx.status(401), + ); + }, + ), + rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) => + res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), + ), + ); + + const handlerResponse = await agent.get( + authorizationResponse.header.location, + ); + + expect(handlerResponse.text).toContain( + encodeURIComponent( + JSON.stringify({ + type: 'authorization_response', + response: { + providerInfo: { + accessToken: 'accessToken', + scope: 'testScope', + idToken: clusterScopedIdToken, + }, + profile: {}, + }, + }), + ), + ); + }, 70000); }); }); diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts index e8ec21bf63..63776b5a81 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.ts @@ -33,7 +33,6 @@ import { OAuthStartResponse } from '../types'; import express from 'express'; import { OAuthAdapter, OAuthEnvironmentHandler } from '../../lib/oauth'; import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; -import fetch from 'node-fetch'; type OidcImpl = { strategy: OidcStrategy; @@ -55,23 +54,15 @@ export type PinnipedOptions = OAuthProviderOptions & { export class PinnipedAuthProvider implements OAuthHandlers { private readonly implementation: Promise; - private readonly clientId: string; - private readonly clientSecret: string; - private readonly federationDomain: string; constructor(options: PinnipedOptions) { this.implementation = this.setupStrategy(options); - this.clientId = options.clientId; - this.clientSecret = options.clientSecret; - this.federationDomain = options.federationDomain; } async start(req: OAuthStartRequest): Promise { const { strategy } = await this.implementation; - const stringifiedAudience = req.query?.audience as string; const state = { ...req.state, audience: stringifiedAudience }; - const options: Record = { scope: req.scope || 'openid pinniped:request-audience username offline_access', @@ -88,33 +79,10 @@ export class PinnipedAuthProvider implements OAuthHandlers { }); } - private async rfc8693TokenExchange({ accessToken, audience }) { - const tokenEndpoint = `${this.federationDomain}/oauth2/token`; - const authString: string = `${this.clientId}:${this.clientSecret}`; - const encodedAuthString = Buffer.from(authString, 'base64'); - - const requestOptions = { - method: 'POST', - headers: { - 'Content-Type': 'x-www-form-urlencoded', - Authorization: `Basic ${encodedAuthString}`, - }, - body: `grant_type=urn:ietf:params:oauth:grant-type:token-exchange - &subject_token=${accessToken} - &subject_token_type=urn:ietf:params:oauth:token-type:access_token - &requested_token_type=urn:ietf:params:oauth:token-type:jwt - &audience=${audience}`, - }; - const response = await fetch(tokenEndpoint, requestOptions); - return response.idToken; - } - async handler( req: express.Request, ): Promise<{ response: OAuthResponse; refreshToken?: string }> { const { strategy, client } = await this.implementation; - - // if we dont add a base url our integration fails with invalid_url error in integration test const { searchParams } = new URL(req.url, 'https://pinniped.com'); const stateParam = searchParams.get('state'); const audience = stateParam ? readState(stateParam).audience : undefined; @@ -179,7 +147,8 @@ export class PinnipedAuthProvider implements OAuthHandlers { response: { providerInfo: { accessToken: tokenset.access_token!, - scope: 'none', + scope: tokenset.scope!, + idToken: tokenset.id_token, }, profile: {}, }, From 70a3c2631f69c244186a40a8179a9951b7808364 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Wed, 30 Aug 2023 15:32:49 -0400 Subject: [PATCH 42/59] resolve rebase type/compilation errors Signed-off-by: Ruben Vallejo --- .../src/providers/pinniped/provider.test.ts | 16 ++++++++++++---- .../src/providers/pinniped/provider.ts | 8 +++++--- plugins/auth-node/src/oauth/state.ts | 1 + 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/plugins/auth-backend/src/providers/pinniped/provider.test.ts b/plugins/auth-backend/src/providers/pinniped/provider.test.ts index 82312266ff..6f7b9e92fa 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.test.ts @@ -20,11 +20,10 @@ import { encodeState, readState, } from '../../lib/oauth'; -import { PinnipedAuthProvider, PinnipedOptions } from './provider'; +import { PinnipedAuthProvider, PinnipedProviderOptions } from './provider'; import { setupServer } from 'msw/node'; import { rest } from 'msw'; import express from 'express'; -import { UnsecuredJWT } from 'jose'; import { OAuthState } from '../../lib/oauth'; import { Server } from 'http'; import cookieParser from 'cookie-parser'; @@ -37,7 +36,13 @@ import { AuthProviderRouteHandlers } from '../types'; import { getVoidLogger } from '@backstage/backend-common'; import { AddressInfo } from 'net'; import request from 'supertest'; -import { SignJWT, exportJWK, generateKeyPair, importJWK } from 'jose'; +import { + SignJWT, + exportJWK, + generateKeyPair, + importJWK, + UnsecuredJWT, +} from 'jose'; import { v4 as uuid } from 'uuid'; describe('PinnipedAuthProvider', () => { @@ -74,7 +79,7 @@ describe('PinnipedAuthProvider', () => { request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], }; - const clientMetadata: PinnipedOptions = { + const clientMetadata: PinnipedProviderOptions = { federationDomain: 'https://federationDomain.test', clientId: 'clientId', clientSecret: 'secret', @@ -462,6 +467,9 @@ describe('PinnipedAuthProvider', () => { }), signInWithCatalogUser: async _ => ({ token: '' }), }, + baseUrl: `${appUrl}/api/auth`, + appUrl, + isOriginAllowed: _ => true, }); const router = Router(); router diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts index 63776b5a81..dc1da84d69 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.ts @@ -43,7 +43,7 @@ type PrivateInfo = { refreshToken?: string; }; -export type PinnipedOptions = OAuthProviderOptions & { +export type PinnipedProviderOptions = OAuthProviderOptions & { federationDomain: string; clientId: string; clientSecret: string; @@ -55,7 +55,7 @@ export type PinnipedOptions = OAuthProviderOptions & { export class PinnipedAuthProvider implements OAuthHandlers { private readonly implementation: Promise; - constructor(options: PinnipedOptions) { + constructor(options: PinnipedProviderOptions) { this.implementation = this.setupStrategy(options); } @@ -157,7 +157,9 @@ export class PinnipedAuthProvider implements OAuthHandlers { }); } - private async setupStrategy(options: PinnipedOptions): Promise { + private async setupStrategy( + options: PinnipedProviderOptions, + ): Promise { const issuer = await Issuer.discover( `${options.federationDomain}/.well-known/openid-configuration`, ); diff --git a/plugins/auth-node/src/oauth/state.ts b/plugins/auth-node/src/oauth/state.ts index fc747d08a5..28f7d2fd2e 100644 --- a/plugins/auth-node/src/oauth/state.ts +++ b/plugins/auth-node/src/oauth/state.ts @@ -29,6 +29,7 @@ export type OAuthState = { scope?: string; redirectUrl?: string; flow?: string; + audience?: string; }; /** @public */ From 8d3e9c7277d37c61752c53b195596ef48a3bdda2 Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Wed, 30 Aug 2023 13:58:42 -0400 Subject: [PATCH 43/59] clean up tests and remove tokenSignedAlg option from pinniped, since it's not actually configurable by end users. This means that all the tests use ID tokens signed with a real JWK. Signed-off-by: Jamie Klassen --- .../src/providers/pinniped/provider.test.ts | 232 +++++++----------- .../src/providers/pinniped/provider.ts | 4 - 2 files changed, 83 insertions(+), 153 deletions(-) diff --git a/plugins/auth-backend/src/providers/pinniped/provider.test.ts b/plugins/auth-backend/src/providers/pinniped/provider.test.ts index 6f7b9e92fa..cd467df1e9 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.test.ts @@ -36,22 +36,16 @@ import { AuthProviderRouteHandlers } from '../types'; import { getVoidLogger } from '@backstage/backend-common'; import { AddressInfo } from 'net'; import request from 'supertest'; -import { - SignJWT, - exportJWK, - generateKeyPair, - importJWK, - UnsecuredJWT, -} from 'jose'; -import { v4 as uuid } from 'uuid'; +import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose'; describe('PinnipedAuthProvider', () => { let provider: PinnipedAuthProvider; - let startRequest: OAuthStartRequest; - let fakeSession: Record; + let idToken: string; + let publicKey: JWK; + let oauthState: OAuthState; - const fakePinnipedSupervisor = setupServer(); - setupRequestMockHandlers(fakePinnipedSupervisor); + const mswServer = setupServer(); + setupRequestMockHandlers(mswServer); const issuerMetadata = { issuer: 'https://pinniped.test', @@ -79,43 +73,37 @@ describe('PinnipedAuthProvider', () => { request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], }; - const clientMetadata: PinnipedProviderOptions = { + const pinnipedProviderOptions: PinnipedProviderOptions = { federationDomain: 'https://federationDomain.test', clientId: 'clientId', clientSecret: 'secret', - callbackUrl: 'https://federationDomain.test/callback', - tokenSignedResponseAlg: 'none', - }; - - const testTokenMetadata = { - sub: 'test', - iss: 'https://pinniped.test', - iat: Date.now(), - aud: clientMetadata.clientId, - exp: Date.now() + 10000, - }; - - const idToken = new UnsecuredJWT(testTokenMetadata) - .setIssuer(testTokenMetadata.iss) - .setAudience(testTokenMetadata.aud) - .setSubject(testTokenMetadata.sub) - .setIssuedAt(testTokenMetadata.iat) - .setExpirationTime(testTokenMetadata.exp) - .encode(); - - const oauthState: OAuthState = { - nonce: 'nonce', - env: 'env', - origin: 'undefined', + callbackUrl: 'https://backstage.test/callback', }; const clusterScopedIdToken = 'dummy-token'; - beforeEach(() => { + beforeAll(async () => { + const keyPair = await generateKeyPair('RS256'); + const privateKey = await exportJWK(keyPair.privateKey); + publicKey = await exportJWK(keyPair.publicKey); + publicKey.alg = privateKey.alg = 'RS256'; + + idToken = await new SignJWT({ + sub: 'test', + iss: 'https://pinniped.test', + iat: Date.now(), + aud: pinnipedProviderOptions.clientId, + exp: Date.now() + 10000, + }) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .sign(keyPair.privateKey); + }); + + beforeEach(async () => { jest.clearAllMocks(); - fakePinnipedSupervisor.use( - rest.all( + mswServer.use( + rest.get( 'https://federationDomain.test/.well-known/openid-configuration', (_req, res, ctx) => res( @@ -124,6 +112,27 @@ describe('PinnipedAuthProvider', () => { ctx.json(issuerMetadata), ), ), + rest.get( + 'https://pinniped.test/oauth2/authorize', + async (req, res, ctx) => { + const callbackUrl = new URL( + req.url.searchParams.get('redirect_uri')!, + ); + callbackUrl.searchParams.set('code', 'authorization_code'); + callbackUrl.searchParams.set( + 'state', + req.url.searchParams.get('state')!, + ); + callbackUrl.searchParams.set('scope', 'test-scope'); + return res( + ctx.status(302), + ctx.set('Location', callbackUrl.toString()), + ); + }, + ), + rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) => + res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), + ), rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => { const formBody = new URLSearchParams(await req.text()); const isGrantTypeTokenExchange = @@ -151,53 +160,30 @@ describe('PinnipedAuthProvider', () => { : ctx.status(401), ); }), - rest.get('https://pinniped.test/idp/userinfo.openid', (_req, res, ctx) => - res( - ctx.json({ - iss: 'https://pinniped.test', - sub: 'test', - aud: clientMetadata.clientId, - claims: { - given_name: 'Givenname', - family_name: 'Familyname', - email: 'user@example.com', - }, - }), - ctx.status(200), - ), - ), - rest.get( - 'https://pinniped.test/oauth2/authorize', - async (req, res, ctx) => { - const callbackUrl = new URL( - req.url.searchParams.get('redirect_uri')!, - ); - callbackUrl.searchParams.set('code', 'authorization_code'); - callbackUrl.searchParams.set( - 'state', - req.url.searchParams.get('state')!, - ); - callbackUrl.searchParams.set('scope', 'test-scope'); - return res( - ctx.status(302), - ctx.set('Location', callbackUrl.toString()), - ); - }, - ), ); - fakeSession = {}; - startRequest = { - session: fakeSession, - method: 'GET', - url: 'test', - state: oauthState, - } as unknown as OAuthStartRequest; + oauthState = { + nonce: 'nonce', + env: 'env', + }; - provider = new PinnipedAuthProvider(clientMetadata); + provider = new PinnipedAuthProvider(pinnipedProviderOptions); }); describe('#start', () => { + let fakeSession: Record; + let startRequest: OAuthStartRequest; + + beforeEach(() => { + fakeSession = {}; + startRequest = { + session: fakeSession, + method: 'GET', + url: 'test', + state: oauthState, + } as unknown as OAuthStartRequest; + }); + it('redirects to authorization endpoint returned from OIDC metadata endpoint', async () => { const startResponse = await provider.start(startRequest); const url = new URL(startResponse.url); @@ -207,14 +193,14 @@ describe('PinnipedAuthProvider', () => { expect(url.pathname).toBe('/oauth2/authorize'); }); - it('initiates an authorization code grant', async () => { + it('initiates authorization code grant', async () => { const startResponse = await provider.start(startRequest); const { searchParams } = new URL(startResponse.url); expect(searchParams.get('response_type')).toBe('code'); }); - it('passes audience query parameter into OAuthState in the redirect url when defined in the request', async () => { + it('persists audience parameter in oauth state', async () => { startRequest.query = { audience: 'test-cluster' }; const startResponse = await provider.start(startRequest); const { searchParams } = new URL(startResponse.url); @@ -235,12 +221,12 @@ describe('PinnipedAuthProvider', () => { expect(searchParams.get('client_id')).toBe('clientId'); }); - it('passes callback URL', async () => { + it('passes callback URL from config', async () => { const startResponse = await provider.start(startRequest); const { searchParams } = new URL(startResponse.url); expect(searchParams.get('redirect_uri')).toBe( - 'https://federationDomain.test/callback', + 'https://backstage.test/callback', ); }); @@ -308,21 +294,21 @@ describe('PinnipedAuthProvider', () => { } as unknown as express.Request; }); - it('exchanges authorization code for a access_token', async () => { + it('exchanges authorization code for access token', async () => { const handlerResponse = await provider.handler(handlerRequest); const accessToken = handlerResponse.response.providerInfo.accessToken; expect(accessToken).toEqual('accessToken'); }); - it('exchanges authorization code for a refresh_token', async () => { + it('exchanges authorization code for refresh token', async () => { const handlerResponse = await provider.handler(handlerRequest); const refreshToken = handlerResponse.refreshToken; expect(refreshToken).toEqual('refreshToken'); }); - it('exchanges authorization_code for a tokenset with a defined scope', async () => { + it('returns granted scope', async () => { const handlerResponse = await provider.handler(handlerRequest); const responseScope = handlerResponse.response.providerInfo.scope; @@ -349,14 +335,14 @@ describe('PinnipedAuthProvider', () => { expect(responseIdToken).toEqual(clusterScopedIdToken); }); - it('request errors out with missing authorization_code parameter in the request_url', async () => { + it('fails without authorization code', async () => { handlerRequest.url = 'https://test.com'; return expect(provider.handler(handlerRequest)).rejects.toThrow( 'Unexpected redirect', ); }); - it('fails when request has no state in req_url', async () => { + it('fails without oauth state', async () => { return expect( provider.handler({ method: 'GET', @@ -397,20 +383,20 @@ describe('PinnipedAuthProvider', () => { expect(refreshToken).toBe('refreshToken'); }); - it('gets an access_token', async () => { + it('gets access token', async () => { const { response } = await provider.refresh(refreshRequest); expect(response.providerInfo.accessToken).toBe('accessToken'); }); - it('gets an id_token', async () => { + it('gets id token', async () => { const { response } = await provider.refresh(refreshRequest); expect(response.providerInfo.idToken).toBe(idToken); }); }); - describe('pinniped.create', () => { + describe('integration', () => { let app: express.Express; let providerRouteHandler: AuthProviderRouteHandlers; let backstageServer: Server; @@ -438,9 +424,7 @@ describe('PinnipedAuthProvider', () => { resolve(null); }); }); - fakePinnipedSupervisor.use( - rest.all(`${appUrl}/*`, req => req.passthrough()), - ); + mswServer.use(rest.all(`${appUrl}/*`, req => req.passthrough())); providerRouteHandler = pinniped.create()({ providerId: 'pinniped', globalConfig: { @@ -488,24 +472,10 @@ describe('PinnipedAuthProvider', () => { backstageServer.close(); }); - it('/handler/frame exchanges authorization codes from #start for Cluster Specific ID tokens', async () => { + it('/handler/frame exchanges authorization code from #start for Cluster Specific ID token', async () => { const agent = request.agent(''); - const key = await generateKeyPair('ES256'); - const publicKey = await exportJWK(key.publicKey); - const privateKey = await exportJWK(key.privateKey); - publicKey.kid = privateKey.kid = uuid(); - publicKey.alg = privateKey.alg = 'ES256'; - const signedJwt = await new SignJWT(testTokenMetadata) - .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) - .setIssuer(testTokenMetadata.iss) - .setAudience(testTokenMetadata.aud) - .setSubject(testTokenMetadata.sub) - .setIssuedAt(testTokenMetadata.iat) - .setExpirationTime(testTokenMetadata.exp) - .sign(await importJWK(privateKey)); - - // make a /start request with audience parameter + // make /start request with audience parameter const startResponse = await agent.get( `${appUrl}/api/auth/pinniped/start?env=development&audience=test_cluster`, ); @@ -514,42 +484,6 @@ describe('PinnipedAuthProvider', () => { startResponse.header.location, ); // follow redirect to token_endpoint - fakePinnipedSupervisor.use( - rest.post( - 'https://pinniped.test/oauth2/token', - async (req, res, ctx) => { - const formBody = new URLSearchParams(await req.text()); - const isGrantTypeTokenExchange = - formBody.get('grant_type') === - 'urn:ietf:params:oauth:grant-type:token-exchange'; - const hasValidTokenExchangeParams = - formBody.get('subject_token') === 'accessToken' && - formBody.get('audience') === 'test_cluster' && - formBody.get('subject_token_type') === - 'urn:ietf:params:oauth:token-type:access_token' && - formBody.get('requested_token_type') === - 'urn:ietf:params:oauth:token-type:jwt'; - - return res( - req.headers.get('Authorization') && - (!isGrantTypeTokenExchange || hasValidTokenExchangeParams) - ? ctx.json({ - access_token: isGrantTypeTokenExchange - ? clusterScopedIdToken - : 'accessToken', - refresh_token: 'refreshToken', - ...(!isGrantTypeTokenExchange && { id_token: signedJwt }), - scope: 'testScope', - }) - : ctx.status(401), - ); - }, - ), - rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) => - res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), - ), - ); - const handlerResponse = await agent.get( authorizationResponse.header.location, ); @@ -569,6 +503,6 @@ describe('PinnipedAuthProvider', () => { }), ), ); - }, 70000); + }); }); }); diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts index dc1da84d69..221a526fe0 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.ts @@ -49,7 +49,6 @@ export type PinnipedProviderOptions = OAuthProviderOptions & { clientSecret: string; callbackUrl: string; scope?: string; - tokenSignedResponseAlg?: string; }; export class PinnipedAuthProvider implements OAuthHandlers { @@ -169,7 +168,6 @@ export class PinnipedAuthProvider implements OAuthHandlers { client_secret: options.clientSecret, redirect_uris: [options.callbackUrl], response_types: ['code'], - id_token_signed_response_alg: options.tokenSignedResponseAlg || 'ES256', scope: options.scope || '', }); @@ -205,14 +203,12 @@ export const pinniped = createAuthProviderIntegration({ const callbackUrl = customCallbackUrl || `${globalConfig.baseUrl}/${providerId}/handler/frame`; - const tokenSignedResponseAlg = 'ES256'; const provider = new PinnipedAuthProvider({ federationDomain, clientId, clientSecret, callbackUrl, - tokenSignedResponseAlg, }); return OAuthAdapter.fromConfig(globalConfig, provider, { From 501a8b5badf124f7cfb46965cd697ff5b812db65 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Thu, 31 Aug 2023 14:26:42 -0400 Subject: [PATCH 44/59] WIP: new auth pattern refactor, intro module and authenticator tests, #start refactor complete Signed-off-by: Ruben Vallejo --- packages/backend/package.json | 1 + .../.eslintrc.js | 1 + .../README.md | 5 + .../dev/index.ts | 26 ++ .../package.json | 36 +++ .../src/authenticator.test.ts | 232 ++++++++++++++++++ .../src/authenticator.ts | 138 +++++++++++ .../src/index.ts | 24 ++ .../src/module.test.ts | 142 +++++++++++ .../src/module.ts | 41 ++++ yarn.lock | 12 + 11 files changed, 658 insertions(+) create mode 100644 plugins/auth-backend-module-pinniped-provider/.eslintrc.js create mode 100644 plugins/auth-backend-module-pinniped-provider/README.md create mode 100644 plugins/auth-backend-module-pinniped-provider/dev/index.ts create mode 100644 plugins/auth-backend-module-pinniped-provider/package.json create mode 100644 plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts create mode 100644 plugins/auth-backend-module-pinniped-provider/src/authenticator.ts create mode 100644 plugins/auth-backend-module-pinniped-provider/src/index.ts create mode 100644 plugins/auth-backend-module-pinniped-provider/src/module.test.ts create mode 100644 plugins/auth-backend-module-pinniped-provider/src/module.ts diff --git a/packages/backend/package.json b/packages/backend/package.json index cd0191f7fa..4d10ab1507 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -35,6 +35,7 @@ "@backstage/plugin-adr-backend": "workspace:^", "@backstage/plugin-app-backend": "workspace:^", "@backstage/plugin-auth-backend": "workspace:^", + "@backstage/plugin-auth-backend-module-pinniped-provider": "^0.0.0", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-azure-devops-backend": "workspace:^", "@backstage/plugin-azure-sites-backend": "workspace:^", diff --git a/plugins/auth-backend-module-pinniped-provider/.eslintrc.js b/plugins/auth-backend-module-pinniped-provider/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/auth-backend-module-pinniped-provider/README.md b/plugins/auth-backend-module-pinniped-provider/README.md new file mode 100644 index 0000000000..f173b9b151 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/README.md @@ -0,0 +1,5 @@ +# @backstage/plugin-auth-backend-module-pinniped-provider + +The pinniped-provider backend module for the auth plugin. + +_This plugin was created through the Backstage CLI_ diff --git a/plugins/auth-backend-module-pinniped-provider/dev/index.ts b/plugins/auth-backend-module-pinniped-provider/dev/index.ts new file mode 100644 index 0000000000..0d29676cc4 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/dev/index.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2023 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 { authModulePinnipedProvider } from '../src'; + +const backend = createBackend(); + +backend.add(authPlugin); +backend.add(authModulePinnipedProvider); + +backend.start(); \ No newline at end of file diff --git a/plugins/auth-backend-module-pinniped-provider/package.json b/plugins/auth-backend-module-pinniped-provider/package.json new file mode 100644 index 0000000000..a0e6a51a9c --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/package.json @@ -0,0 +1,36 @@ +{ + "name": "@backstage/plugin-auth-backend-module-pinniped-provider", + "description": "The pinniped-provider backend module for the auth plugin.", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin-module" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^" + }, + "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", + "@backstage/cli": "workspace:^" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts b/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts new file mode 100644 index 0000000000..de92253f97 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts @@ -0,0 +1,232 @@ +import { + OAuthAuthenticator, + OAuthAuthenticatorStartInput, + OAuthState, + PassportOAuthAuthenticatorHelper, + PassportProfile, + decodeOAuthState, + encodeOAuthState, +} from '@backstage/plugin-auth-node'; +import { pinnipedAuthenticator } from './authenticator'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { ConfigReader } from '@backstage/config'; +import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose'; +import { rest } from 'msw'; +import express from 'express'; + +describe('pinnipedAuthenticator', () => { + let implementation: any; + let oauthState: OAuthState; + let idToken: string; + let publicKey: JWK; + + const mswServer = setupServer(); + setupRequestMockHandlers(mswServer); + + const issuerMetadata = { + issuer: 'https://pinniped.test', + authorization_endpoint: 'https://pinniped.test/oauth2/authorize', + token_endpoint: 'https://pinniped.test/oauth2/token', + revocation_endpoint: 'https://pinniped.test/oauth2/revoke_token', + userinfo_endpoint: 'https://pinniped.test/idp/userinfo.openid', + introspection_endpoint: 'https://pinniped.test/introspect.oauth2', + jwks_uri: 'https://pinniped.test/jwks.json', + scopes_supported: [ + 'openid', + 'offline_access', + 'pinniped:request-audience', + 'username', + 'groups', + ], + claims_supported: ['email', 'username', 'groups', 'additionalClaims'], + response_types_supported: ['code'], + id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + token_endpoint_auth_signing_alg_values_supported: [ + 'RS256', + 'RS512', + 'HS256', + ], + request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + }; + + const clusterScopedIdToken = 'dummy-token'; + + beforeAll(async () => { + const keyPair = await generateKeyPair('RS256'); + const privateKey = await exportJWK(keyPair.privateKey); + publicKey = await exportJWK(keyPair.publicKey); + publicKey.alg = privateKey.alg = 'RS256'; + + idToken = await new SignJWT({ + sub: 'test', + iss: 'https://pinniped.test', + iat: Date.now(), + aud: 'clientId', + exp: Date.now() + 10000, + }) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .sign(keyPair.privateKey); + }); + + + + beforeEach(() => { + jest.clearAllMocks(); + + mswServer.use( + rest.get( + 'https://federationDomain.test/.well-known/openid-configuration', + (_req, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(issuerMetadata), + ), + ), + ) + implementation = pinnipedAuthenticator.initialize({ + callbackUrl: 'https://backstage.test/callback', + config: new ConfigReader({ + federationDomain: 'https://federationDomain.test', + clientId: 'clientId', + clientSecret: 'clientSecret', + }) + }) + + oauthState = { + nonce: 'nonce', + env: 'env', + } + }); + + describe('#start', () => { + let fakeSession: Record; + let startRequest: OAuthAuthenticatorStartInput; + + beforeEach(() => { + fakeSession = {}; + startRequest = { + state: encodeOAuthState(oauthState), + req: { + method: 'GET', + url: 'test', + session: fakeSession, + }, + } as unknown as OAuthAuthenticatorStartInput; + }); + + it('redirects to authorization endpoint returned from OIDC metadata endpoint', async () => { + const startResponse = await pinnipedAuthenticator.start(startRequest, implementation); + const url = new URL(startResponse.url); + + expect(url.protocol).toBe('https:'); + expect(url.hostname).toBe('pinniped.test'); + expect(url.pathname).toBe('/oauth2/authorize'); + }); + + it('initiates authorization code grant', async () => { + const startResponse = await pinnipedAuthenticator.start(startRequest, implementation); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('response_type')).toBe('code'); + }); + + it('persists audience parameter in oauth state', async () => { + startRequest.req.query = { audience: 'test-cluster' }; + const startResponse = await pinnipedAuthenticator.start(startRequest, implementation); + const { searchParams } = new URL(startResponse.url); + const stateParam = searchParams.get('state'); + const decodedState = decodeOAuthState(stateParam!); + + expect(decodedState).toMatchObject({ + nonce: 'nonce', + env: 'env', + audience: 'test-cluster', + }); + }); + + it('passes client ID from config', async () => { + const startResponse = await pinnipedAuthenticator.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 pinnipedAuthenticator.start(startRequest, implementation); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('redirect_uri')).toBe( + 'https://backstage.test/callback', + ); + }); + + it('generates PKCE challenge', async () => { + const startResponse = await pinnipedAuthenticator.start(startRequest, implementation); + const { searchParams } = new URL(startResponse.url); + + expect(searchParams.get('code_challenge_method')).toBe('S256'); + expect(searchParams.get('code_challenge')).not.toBeNull(); + }); + + it('stores PKCE verifier in session', async () => { + await pinnipedAuthenticator.start(startRequest, implementation); + expect(fakeSession['oidc:pinniped.test'].code_verifier).toBeDefined(); + }); + + it('requests sufficient scopes for token exchange by default', async () => { + const startResponse = await pinnipedAuthenticator.start(startRequest, implementation); + const { searchParams } = new URL(startResponse.url); + const scopes = searchParams.get('scope')?.split(' ') ?? []; + + expect(scopes).toEqual( + expect.arrayContaining([ + 'openid', + 'pinniped:request-audience', + 'username', + 'offline_access', + ]), + ); + }); + + it('encodes OAuth state in query param', async () => { + const startResponse = await pinnipedAuthenticator.start(startRequest, implementation); + const { searchParams } = new URL(startResponse.url); + const stateParam = searchParams.get('state'); + const decodedState = decodeOAuthState(stateParam!); + + expect(decodedState).toMatchObject(oauthState); + }); + + it('fails when request has no session', async () => { + return expect( + pinnipedAuthenticator.start({state: encodeOAuthState(oauthState),req: { + method: 'GET', + url: 'test', + }} as unknown as OAuthAuthenticatorStartInput, + implementation) + ).rejects.toThrow('authentication requires session support'); + }); + + }); + + // describe('#authenticate', () => { + // let handlerRequest: express.Request; + + // beforeEach(() => { + // handlerRequest = { + // method: 'GET', + // url: `https://test?code=authorization_code&state=${encodeOAuthState( + // oauthState, + // )}`, + // session: { + // 'oidc:pinniped.test': { + // state: encodeOAuthState(oauthState), + // }, + // }, + // } as unknown as express.Request; + // }); + + // }) +}) diff --git a/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts b/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts new file mode 100644 index 0000000000..e6e9ca4a76 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts @@ -0,0 +1,138 @@ +//bunch of new authenticator logic for our provider goes in here + +import { PassportDoneCallback } from "@backstage/plugin-auth-backend/src/lib/passport"; +import { PassportOAuthAuthenticatorHelper, createOAuthAuthenticator, decodeOAuthState, encodeOAuthState } from "@backstage/plugin-auth-node"; +import { Issuer, TokenSet, Strategy as OidcStrategy } from 'openid-client' + +export const pinnipedAuthenticator = createOAuthAuthenticator({ + defaultProfileTransform: + PassportOAuthAuthenticatorHelper.defaultProfileTransform, + async initialize({ callbackUrl, config }) { + + const issuer = await Issuer.discover( + `${config.getString('federationDomain')}/.well-known/openid-configuration`, + ) + + const client = new issuer.Client({ + access_type: 'offline', // this option must be passed to provider to receive a refresh token + client_id: config.getString('clientId'), + client_secret: config.getString('clientSecret'), + redirect_uris: [callbackUrl], + response_types: ['code'], + scope: config.getOptionalString('scope') || '', + }); + + const strategy = new OidcStrategy({ + client, + passReqToCallback: false, + },( + tokenset: TokenSet, + done: PassportDoneCallback<{ tokenset: TokenSet }, { + refreshToken?: string; + }>, + ) => { + done(undefined, { tokenset }, {}); + },) + + return ({ strategy, client }) + + + }, + + //how does this helper get defined in other providers an what is the reason i get void type for it in my implementation + async start(input, implementation) { + const { strategy } = await implementation + const stringifiedAudience = input.req.query?.audience as string; + const decodedState = decodeOAuthState(input.state) + const state = { ...decodedState, audience: stringifiedAudience } + const options: Record = { + scope: + input.scope || 'openid pinniped:request-audience username offline_access', + state: encodeOAuthState(state), + }; + + return new Promise((resolve, reject) => { + strategy.redirect = (url: string) => { + resolve({ url }); + }; + strategy.error = (error: Error) => { + reject(error); + }; + strategy.authenticate(input.req, { ...options }); + }); + }, + + async authenticate(input, implementation) { + const { strategy, client } = await implementation; + const { req } = input + const { searchParams } = new URL(req.url, 'https://pinniped.com'); + const stateParam = searchParams.get('state'); + const audience = stateParam ? decodeOAuthState(stateParam).audience : undefined; + + return new Promise((resolve, reject) => { + strategy.success = user => { + (audience + ? client + .grant({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token: user.tokenset.access_token, + audience, + subject_token_type: + 'urn:ietf:params:oauth:token-type:access_token', + inputuested_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }) + .then(tokenset => tokenset.access_token) + : Promise.resolve(user.tokenset.id_token) + ).then(idToken => { + resolve({ + fullProfile: {provider: " ",id: " ",displayName: " "}, + session: { + accessToken: user.tokenset.access_token!, + tokenType: "random", + scope: user.tokenset.scope!, + idToken, + refreshToken: user.tokenset.refresh_token + } + }); + }); + }; + + strategy.fail = info => { + reject(new Error(`Authentication rejected, ${info.message || ''}`)); + }; + + strategy.error = (error: Error) => { + reject(error); + }; + + strategy.redirect = () => { + reject(new Error('Unexpected redirect')); + }; + + strategy.authenticate(req); + }); + }, + + async refresh(input, implementation) { + const { client } = await implementation; + const tokenset = await client.refresh(input.refreshToken); + + return new Promise((resolve, reject) => { + if (!tokenset.access_token) { + reject(new Error('Refresh Failed')); + } + + resolve({ + fullProfile: {provider: " ",id: " ",displayName: " "}, + session: { + accessToken: tokenset.access_token!, + tokenType: "random", + scope: tokenset.scope!, + idToken: tokenset.id_token, + refreshToken: tokenset.refresh_token + } + }); + }); + }, + +}) \ No newline at end of file diff --git a/plugins/auth-backend-module-pinniped-provider/src/index.ts b/plugins/auth-backend-module-pinniped-provider/src/index.ts new file mode 100644 index 0000000000..9a2ca6727e --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/src/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2023 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 pinniped-provider backend module for the auth plugin. + * + * @packageDocumentation + */ + +export { pinnipedAuthenticator } from './authenticator'; +export { authModulePinnipedProvider } from './module'; diff --git a/plugins/auth-backend-module-pinniped-provider/src/module.test.ts b/plugins/auth-backend-module-pinniped-provider/src/module.test.ts new file mode 100644 index 0000000000..e4dd80b9f9 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/src/module.test.ts @@ -0,0 +1,142 @@ +import { setupRequestMockHandlers } from "@backstage/backend-test-utils" +import { authModulePinnipedProvider } from "./module" +import request from 'supertest'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; +import { Server } from "http"; +import express from 'express'; +import cookieParser from 'cookie-parser'; +import session from 'express-session'; +import passport from 'passport'; +import { AddressInfo } from 'net'; +import { AuthProviderRouteHandlers, createOAuthRouteHandlers } from "@backstage/plugin-auth-node"; +import Router from 'express-promise-router'; +import { pinnipedAuthenticator } from "./authenticator"; +import { ConfigReader } from "@backstage/config"; + +describe('authModulePinnipedProvider', () => { + let app: express.Express; + let backstageServer: Server; + let appUrl: string; + let providerRouteHandler: AuthProviderRouteHandlers + + const mswServer = setupServer(); + setupRequestMockHandlers(mswServer); + + const issuerMetadata = { + issuer: 'https://pinniped.test', + authorization_endpoint: 'https://pinniped.test/oauth2/authorize', + token_endpoint: 'https://pinniped.test/oauth2/token', + revocation_endpoint: 'https://pinniped.test/oauth2/revoke_token', + userinfo_endpoint: 'https://pinniped.test/idp/userinfo.openid', + introspection_endpoint: 'https://pinniped.test/introspect.oauth2', + jwks_uri: 'https://pinniped.test/jwks.json', + scopes_supported: [ + 'openid', + 'offline_access', + 'pinniped:request-audience', + 'username', + 'groups', + ], + claims_supported: ['email', 'username', 'groups', 'additionalClaims'], + response_types_supported: ['code'], + id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + token_endpoint_auth_signing_alg_values_supported: [ + 'RS256', + 'RS512', + 'HS256', + ], + request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], + }; + + beforeEach(async () => { + // jest.clearAllMocks(); + + mswServer.use( + rest.get( + 'https://federationDomain.test/.well-known/openid-configuration', + (_req, res, ctx) => + res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(issuerMetadata), + ), + ), + ) + + const secret = 'secret'; + app = express() + .use(cookieParser(secret)) + .use( + session({ + secret, + saveUninitialized: false, + resave: false, + cookie: { secure: false }, + }), + ) + .use(passport.initialize()) + .use(passport.session()); + await new Promise(resolve => { + backstageServer = app.listen(0, '0.0.0.0', () => { + appUrl = `http://127.0.0.1:${ + (backstageServer.address() as AddressInfo).port + }`; + resolve(null); + }); + }); + + mswServer.use(rest.all(`${appUrl}/*`, req => req.passthrough())); + + providerRouteHandler = createOAuthRouteHandlers({ + authenticator: pinnipedAuthenticator, + appUrl, + baseUrl: `${appUrl}/api/auth`, + isOriginAllowed: _ => true, + providerId: 'pinniped', + config: new ConfigReader({ + federationDomain: 'https://federationDomain.test', + clientId: 'clientId', + clientSecret: 'clientSecret', + }), + resolverContext: { + issueToken: async _ => ({ token: '' }), + findCatalogUser: async _ => ({ + entity: { + apiVersion: '', + kind: '', + metadata: { name: '' }, + }, + }), + signInWithCatalogUser: async _ => ({ token: '' }), + }, + }) + + const router = Router(); + router + .use( + '/api/auth/pinniped/start', + providerRouteHandler.start.bind(providerRouteHandler), + ) + .use( + '/api/auth/pinniped/handler/frame', + providerRouteHandler.frameHandler.bind(providerRouteHandler), + ); + app.use(router); + }) + + afterEach(() => { + backstageServer.close(); + }); + //TODO: are we actually testing the auth module here since our setup makes use of creating the Oauthfactory directly and not through the module, how can we attach it using the module instead???? + + + it('should start', async () => { + + const agent = request.agent(backstageServer) + + const startResponse = await agent.get(`/api/auth/pinniped/start?env=development&audience=test_cluster`); + + expect(startResponse.status).toBe(302) + }, 70000) +}) \ No newline at end of file diff --git a/plugins/auth-backend-module-pinniped-provider/src/module.ts b/plugins/auth-backend-module-pinniped-provider/src/module.ts new file mode 100644 index 0000000000..0b7ca9e293 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/src/module.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2023 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, commonSignInResolvers, createOAuthProviderFactory } from '@backstage/plugin-auth-node'; +import { pinnipedAuthenticator } from './authenticator'; + +export const authModulePinnipedProvider = createBackendModule({ + pluginId: 'auth', + moduleId: 'pinniped-provider', + register(reg) { + reg.registerInit({ + deps: { + providers: authProvidersExtensionPoint, + }, + async init({ providers }) { + providers.registerProvider({ + providerId: 'pinniped', + factory: createOAuthProviderFactory({ + authenticator: pinnipedAuthenticator, + signInResolverFactories: { + ...commonSignInResolvers + } + }) + }) + }, + }); + }, +}); diff --git a/yarn.lock b/yarn.lock index cdbbe10ced..bab1a52aad 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4979,6 +4979,17 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-auth-backend-module-pinniped-provider@^0.0.0, @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" + dependencies: + "@backstage/backend-common": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-test-utils": "workspace:^" + "@backstage/cli": "workspace:^" + languageName: unknown + linkType: soft + "@backstage/plugin-auth-backend@workspace:^, @backstage/plugin-auth-backend@workspace:plugins/auth-backend": version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend@workspace:plugins/auth-backend" @@ -25897,6 +25908,7 @@ __metadata: "@backstage/plugin-adr-backend": "workspace:^" "@backstage/plugin-app-backend": "workspace:^" "@backstage/plugin-auth-backend": "workspace:^" + "@backstage/plugin-auth-backend-module-pinniped-provider": ^0.0.0 "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-azure-devops-backend": "workspace:^" "@backstage/plugin-azure-sites-backend": "workspace:^" From 362a5e293fec50c3f772e55eac89cd88419a49a8 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Wed, 6 Sep 2023 15:57:45 -0400 Subject: [PATCH 45/59] Completed Pinniped Authenticator refactor with passing unit tests Signed-off-by: Ruben Vallejo --- packages/backend/package.json | 1 - .../README.md | 8 +- .../package.json | 17 +- .../src/authenticator.test.ts | 361 +++++++++++-- .../src/authenticator.ts | 118 +++-- .../src/module.test.ts | 174 +++++- plugins/auth-backend/package.json | 1 + plugins/auth-backend/src/lib/oauth/types.ts | 17 +- .../src/providers/oidc/provider.ts | 24 +- .../src/providers/pinniped/provider.test.ts | 498 +----------------- .../src/providers/pinniped/provider.ts | 200 +------ yarn.lock | 30 +- 12 files changed, 613 insertions(+), 836 deletions(-) diff --git a/packages/backend/package.json b/packages/backend/package.json index 4d10ab1507..cd0191f7fa 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -35,7 +35,6 @@ "@backstage/plugin-adr-backend": "workspace:^", "@backstage/plugin-app-backend": "workspace:^", "@backstage/plugin-auth-backend": "workspace:^", - "@backstage/plugin-auth-backend-module-pinniped-provider": "^0.0.0", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-azure-devops-backend": "workspace:^", "@backstage/plugin-azure-sites-backend": "workspace:^", diff --git a/plugins/auth-backend-module-pinniped-provider/README.md b/plugins/auth-backend-module-pinniped-provider/README.md index f173b9b151..bdb340d426 100644 --- a/plugins/auth-backend-module-pinniped-provider/README.md +++ b/plugins/auth-backend-module-pinniped-provider/README.md @@ -1,5 +1,7 @@ -# @backstage/plugin-auth-backend-module-pinniped-provider +# Auth Module: Pinniped Provider -The pinniped-provider backend module for the auth plugin. +This module provides an Pinniped auth provider implementation for `@backstage/plugin-auth-backend`. -_This plugin was created through the Backstage CLI_ +## Links + +- [Backstage](https://backstage.io) diff --git a/plugins/auth-backend-module-pinniped-provider/package.json b/plugins/auth-backend-module-pinniped-provider/package.json index a0e6a51a9c..3d7dcf97d9 100644 --- a/plugins/auth-backend-module-pinniped-provider/package.json +++ b/plugins/auth-backend-module-pinniped-provider/package.json @@ -24,11 +24,24 @@ }, "dependencies": { "@backstage/backend-common": "workspace:^", - "@backstage/backend-plugin-api": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^", + "@backstage/plugin-auth-node": "workspace:^", + "openid-client": "^5.4.3" }, "devDependencies": { + "@backstage/backend-defaults": "workspace:^", "@backstage/backend-test-utils": "workspace:^", - "@backstage/cli": "workspace:^" + "@backstage/cli": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/plugin-auth-backend": "workspace:^", + "cookie-parser": "^1.4.6", + "express": "^4.18.2", + "express-promise-router": "^4.1.1", + "express-session": "^1.17.3", + "jose": "^4.14.6", + "msw": "^1.3.0", + "passport": "^0.6.0", + "supertest": "^6.3.3" }, "files": [ "dist" diff --git a/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts b/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts index de92253f97..87d443a11a 100644 --- a/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts +++ b/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts @@ -1,9 +1,23 @@ +/* + * Copyright 2023 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 { - OAuthAuthenticator, + OAuthAuthenticatorAuthenticateInput, + OAuthAuthenticatorRefreshInput, OAuthAuthenticatorStartInput, OAuthState, - PassportOAuthAuthenticatorHelper, - PassportProfile, decodeOAuthState, encodeOAuthState, } from '@backstage/plugin-auth-node'; @@ -53,10 +67,10 @@ describe('pinnipedAuthenticator', () => { const clusterScopedIdToken = 'dummy-token'; beforeAll(async () => { - const keyPair = await generateKeyPair('RS256'); + const keyPair = await generateKeyPair('ES256'); const privateKey = await exportJWK(keyPair.privateKey); publicKey = await exportJWK(keyPair.publicKey); - publicKey.alg = privateKey.alg = 'RS256'; + publicKey.alg = privateKey.alg = 'ES256'; idToken = await new SignJWT({ sub: 'test', @@ -69,8 +83,6 @@ describe('pinnipedAuthenticator', () => { .sign(keyPair.privateKey); }); - - beforeEach(() => { jest.clearAllMocks(); @@ -84,20 +96,51 @@ describe('pinnipedAuthenticator', () => { ctx.json(issuerMetadata), ), ), - ) - implementation = pinnipedAuthenticator.initialize({ + rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) => + res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), + ), + rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => { + const formBody = new URLSearchParams(await req.text()); + const isGrantTypeTokenExchange = + formBody.get('grant_type') === + 'urn:ietf:params:oauth:grant-type:token-exchange'; + const hasValidTokenExchangeParams = + formBody.get('subject_token') === 'accessToken' && + formBody.get('audience') === 'test_cluster' && + formBody.get('subject_token_type') === + 'urn:ietf:params:oauth:token-type:access_token' && + formBody.get('requested_token_type') === + 'urn:ietf:params:oauth:token-type:jwt'; + + return res( + req.headers.get('Authorization') && + (!isGrantTypeTokenExchange || hasValidTokenExchangeParams) + ? ctx.json({ + access_token: isGrantTypeTokenExchange + ? clusterScopedIdToken + : 'accessToken', + refresh_token: 'refreshToken', + ...(!isGrantTypeTokenExchange && { id_token: idToken }), + scope: 'testScope', + }) + : ctx.status(401), + ); + }), + ); + + implementation = pinnipedAuthenticator.initialize({ callbackUrl: 'https://backstage.test/callback', config: new ConfigReader({ federationDomain: 'https://federationDomain.test', clientId: 'clientId', clientSecret: 'clientSecret', - }) - }) + }), + }); oauthState = { nonce: 'nonce', env: 'env', - } + }; }); describe('#start', () => { @@ -108,7 +151,7 @@ describe('pinnipedAuthenticator', () => { fakeSession = {}; startRequest = { state: encodeOAuthState(oauthState), - req: { + req: { method: 'GET', url: 'test', session: fakeSession, @@ -117,16 +160,22 @@ describe('pinnipedAuthenticator', () => { }); it('redirects to authorization endpoint returned from OIDC metadata endpoint', async () => { - const startResponse = await pinnipedAuthenticator.start(startRequest, implementation); + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); const url = new URL(startResponse.url); - + expect(url.protocol).toBe('https:'); expect(url.hostname).toBe('pinniped.test'); expect(url.pathname).toBe('/oauth2/authorize'); }); it('initiates authorization code grant', async () => { - const startResponse = await pinnipedAuthenticator.start(startRequest, implementation); + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); const { searchParams } = new URL(startResponse.url); expect(searchParams.get('response_type')).toBe('code'); @@ -134,7 +183,10 @@ describe('pinnipedAuthenticator', () => { it('persists audience parameter in oauth state', async () => { startRequest.req.query = { audience: 'test-cluster' }; - const startResponse = await pinnipedAuthenticator.start(startRequest, implementation); + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); const { searchParams } = new URL(startResponse.url); const stateParam = searchParams.get('state'); const decodedState = decodeOAuthState(stateParam!); @@ -147,14 +199,20 @@ describe('pinnipedAuthenticator', () => { }); it('passes client ID from config', async () => { - const startResponse = await pinnipedAuthenticator.start(startRequest, implementation); + const startResponse = await pinnipedAuthenticator.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 pinnipedAuthenticator.start(startRequest, implementation); + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); const { searchParams } = new URL(startResponse.url); expect(searchParams.get('redirect_uri')).toBe( @@ -163,7 +221,10 @@ describe('pinnipedAuthenticator', () => { }); it('generates PKCE challenge', async () => { - const startResponse = await pinnipedAuthenticator.start(startRequest, implementation); + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); const { searchParams } = new URL(startResponse.url); expect(searchParams.get('code_challenge_method')).toBe('S256'); @@ -176,7 +237,10 @@ describe('pinnipedAuthenticator', () => { }); it('requests sufficient scopes for token exchange by default', async () => { - const startResponse = await pinnipedAuthenticator.start(startRequest, implementation); + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); const { searchParams } = new URL(startResponse.url); const scopes = searchParams.get('scope')?.split(' ') ?? []; @@ -191,7 +255,10 @@ describe('pinnipedAuthenticator', () => { }); it('encodes OAuth state in query param', async () => { - const startResponse = await pinnipedAuthenticator.start(startRequest, implementation); + const startResponse = await pinnipedAuthenticator.start( + startRequest, + implementation, + ); const { searchParams } = new URL(startResponse.url); const stateParam = searchParams.get('state'); const decodedState = decodeOAuthState(stateParam!); @@ -201,32 +268,236 @@ describe('pinnipedAuthenticator', () => { it('fails when request has no session', async () => { return expect( - pinnipedAuthenticator.start({state: encodeOAuthState(oauthState),req: { - method: 'GET', - url: 'test', - }} as unknown as OAuthAuthenticatorStartInput, - implementation) + pinnipedAuthenticator.start( + { + state: encodeOAuthState(oauthState), + req: { + method: 'GET', + url: 'test', + }, + } as unknown as OAuthAuthenticatorStartInput, + implementation, + ), ).rejects.toThrow('authentication requires session support'); }); - }); - // describe('#authenticate', () => { - // let handlerRequest: express.Request; + describe('#authenticate', () => { + let handlerRequest: OAuthAuthenticatorAuthenticateInput; - // beforeEach(() => { - // handlerRequest = { - // method: 'GET', - // url: `https://test?code=authorization_code&state=${encodeOAuthState( - // oauthState, - // )}`, - // session: { - // 'oidc:pinniped.test': { - // state: encodeOAuthState(oauthState), - // }, - // }, - // } as unknown as express.Request; - // }); + beforeEach(() => { + handlerRequest = { + req: { + method: 'GET', + url: `https://test?code=authorization_code&state=${encodeOAuthState( + oauthState, + )}`, + session: { + 'oidc:pinniped.test': { + state: encodeOAuthState(oauthState), + }, + }, + } as unknown as express.Request, + }; + }); - // }) -}) + it('exchanges authorization code for access token', async () => { + const handlerResponse = await pinnipedAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const accessToken = handlerResponse.session.accessToken; + + expect(accessToken).toEqual('accessToken'); + }); + + it('exchanges authorization code for refresh token', async () => { + const handlerResponse = await pinnipedAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const refreshToken = handlerResponse.session.refreshToken; + + expect(refreshToken).toEqual('refreshToken'); + }); + + it('returns granted scope', async () => { + const handlerResponse = await pinnipedAuthenticator.authenticate( + handlerRequest, + implementation, + ); + const responseScope = handlerResponse.session.scope; + + expect(responseScope).toEqual('testScope'); + }); + + it('returns cluster-scoped ID token when audience is specified', async () => { + oauthState.audience = 'test_cluster'; + handlerRequest = { + req: { + method: 'GET', + url: `https://test?code=authorization_code&state=${encodeOAuthState( + oauthState, + )}`, + session: { + 'oidc:pinniped.test': { + state: encodeOAuthState(oauthState), + }, + }, + } as unknown as express.Request, + }; + + const handlerResponse = await pinnipedAuthenticator.authenticate( + handlerRequest, + implementation, + ); + + expect(handlerResponse.session.idToken).toEqual(clusterScopedIdToken); + }, 70000); + + it('fails on network error during token exchange', async () => { + mswServer.use( + rest.post( + 'https://pinniped.test/oauth2/token', + async (req, res, ctx) => { + const formBody = new URLSearchParams(await req.text()); + const isGrantTypeTokenExchange = + formBody.get('grant_type') === + 'urn:ietf:params:oauth:grant-type:token-exchange'; + const hasValidTokenExchangeParams = + formBody.get('subject_token') === 'accessToken' && + formBody.get('audience') === 'test_cluster' && + formBody.get('subject_token_type') === + 'urn:ietf:params:oauth:token-type:access_token' && + formBody.get('requested_token_type') === + 'urn:ietf:params:oauth:token-type:jwt'; + + mswServer.use( + rest.post( + 'https://pinniped.test/oauth2/token', + async (_req, response, _ctx) => + response.networkError('Connection timed out'), + ), + ); + + return res( + req.headers.get('Authorization') && + (!isGrantTypeTokenExchange || hasValidTokenExchangeParams) + ? ctx.json({ + access_token: isGrantTypeTokenExchange + ? clusterScopedIdToken + : 'accessToken', + refresh_token: 'refreshToken', + ...(!isGrantTypeTokenExchange && { id_token: idToken }), + scope: 'testScope', + }) + : ctx.status(401), + ); + }, + ), + ); + + oauthState.audience = 'test_cluster'; + handlerRequest = { + req: { + method: 'GET', + url: `https://test?code=authorization_code&state=${encodeOAuthState( + oauthState, + )}`, + session: { + 'oidc:pinniped.test': { + state: encodeOAuthState(oauthState), + }, + }, + } as unknown as express.Request, + }; + + await expect( + pinnipedAuthenticator.authenticate(handlerRequest, implementation), + ).rejects.toThrow( + `Failed to get cluster specific ID token for "test_cluster", RFC8693 token exchange failed with error: NetworkError: Connection timed out`, + ); + }); + + it('fails without authorization code', async () => { + handlerRequest.req.url = 'https://test.com'; + return expect( + pinnipedAuthenticator.authenticate(handlerRequest, implementation), + ).rejects.toThrow('Unexpected redirect'); + }); + + it('fails without oauth state', async () => { + return expect( + pinnipedAuthenticator.authenticate( + { + req: { + method: 'GET', + url: `https://test?code=authorization_code}`, + session: { + ['oidc:pinniped.test']: { + state: { handle: 'sessionid', code_verifier: 'foo' }, + }, + }, + } as unknown as express.Request, + }, + implementation, + ), + ).rejects.toThrow( + 'Authentication rejected, state missing from the response', + ); + }); + + it('fails when request has no session', async () => { + return expect( + pinnipedAuthenticator.authenticate( + { + req: { + method: 'GET', + url: 'https://test.com', + } as unknown as express.Request, + }, + implementation, + ), + ).rejects.toThrow('authentication requires session support'); + }); + }); + + describe('#refresh', () => { + let refreshRequest: OAuthAuthenticatorRefreshInput; + + beforeEach(() => { + refreshRequest = { + scope: '', + refreshToken: 'otherRefreshToken', + req: {} as express.Request, + }; + }); + + it('gets new refresh token', async () => { + const refreshResponse = await pinnipedAuthenticator.refresh( + refreshRequest, + implementation, + ); + + expect(refreshResponse.session.refreshToken).toBe('refreshToken'); + }); + + it('gets access token', async () => { + const refreshResponse = await pinnipedAuthenticator.refresh( + refreshRequest, + implementation, + ); + + expect(refreshResponse.session.accessToken).toBe('accessToken'); + }); + + it('gets id token', async () => { + const refreshResponse = await pinnipedAuthenticator.refresh( + refreshRequest, + implementation, + ); + + expect(refreshResponse.session.idToken).toBe(idToken); + }); + }); +}); diff --git a/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts b/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts index e6e9ca4a76..0194ed03de 100644 --- a/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts @@ -1,18 +1,34 @@ -//bunch of new authenticator logic for our provider goes in here - -import { PassportDoneCallback } from "@backstage/plugin-auth-backend/src/lib/passport"; -import { PassportOAuthAuthenticatorHelper, createOAuthAuthenticator, decodeOAuthState, encodeOAuthState } from "@backstage/plugin-auth-node"; -import { Issuer, TokenSet, Strategy as OidcStrategy } from 'openid-client' +/* + * Copyright 2023 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 { PassportDoneCallback } from '@backstage/plugin-auth-node'; +import { + createOAuthAuthenticator, + decodeOAuthState, + encodeOAuthState, +} from '@backstage/plugin-auth-node'; +import { Issuer, TokenSet, Strategy as OidcStrategy } from 'openid-client'; export const pinnipedAuthenticator = createOAuthAuthenticator({ - defaultProfileTransform: - PassportOAuthAuthenticatorHelper.defaultProfileTransform, + defaultProfileTransform: async (_r, _c) => ({ profile: {} }), async initialize({ callbackUrl, config }) { - - const issuer = await Issuer.discover( - `${config.getString('federationDomain')}/.well-known/openid-configuration`, - ) - + const issuer = await Issuer.discover( + `${config.getString( + 'federationDomain', + )}/.well-known/openid-configuration`, + ); const client = new issuer.Client({ access_type: 'offline', // this option must be passed to provider to receive a refresh token client_id: config.getString('clientId'), @@ -20,34 +36,38 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({ redirect_uris: [callbackUrl], response_types: ['code'], scope: config.getOptionalString('scope') || '', + id_token_signed_response_alg: 'ES256', }); + const strategy = new OidcStrategy( + { + client, + passReqToCallback: false, + }, + ( + tokenset: TokenSet, + done: PassportDoneCallback< + { tokenset: TokenSet }, + { + refreshToken?: string; + } + >, + ) => { + done(undefined, { tokenset }, {}); + }, + ); - const strategy = new OidcStrategy({ - client, - passReqToCallback: false, - },( - tokenset: TokenSet, - done: PassportDoneCallback<{ tokenset: TokenSet }, { - refreshToken?: string; - }>, - ) => { - done(undefined, { tokenset }, {}); - },) - - return ({ strategy, client }) - - + return { strategy, client }; }, - //how does this helper get defined in other providers an what is the reason i get void type for it in my implementation async start(input, implementation) { - const { strategy } = await implementation + const { strategy } = await implementation; const stringifiedAudience = input.req.query?.audience as string; - const decodedState = decodeOAuthState(input.state) - const state = { ...decodedState, audience: stringifiedAudience } + const decodedState = decodeOAuthState(input.state); + const state = { ...decodedState, audience: stringifiedAudience }; const options: Record = { scope: - input.scope || 'openid pinniped:request-audience username offline_access', + input.scope || + 'openid pinniped:request-audience username offline_access', state: encodeOAuthState(state), }; @@ -64,10 +84,12 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({ async authenticate(input, implementation) { const { strategy, client } = await implementation; - const { req } = input + const { req } = input; const { searchParams } = new URL(req.url, 'https://pinniped.com'); const stateParam = searchParams.get('state'); - const audience = stateParam ? decodeOAuthState(stateParam).audience : undefined; + const audience = stateParam + ? decodeOAuthState(stateParam).audience + : undefined; return new Promise((resolve, reject) => { strategy.success = user => { @@ -79,20 +101,27 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({ audience, subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', - inputuested_token_type: 'urn:ietf:params:oauth:token-type:jwt', + requested_token_type: 'urn:ietf:params:oauth:token-type:jwt', }) .then(tokenset => tokenset.access_token) + .catch(err => + reject( + new Error( + `Failed to get cluster specific ID token for "${audience}", RFC8693 token exchange failed with error: ${err}`, + ), + ), + ) : Promise.resolve(user.tokenset.id_token) ).then(idToken => { resolve({ - fullProfile: {provider: " ",id: " ",displayName: " "}, + fullProfile: { provider: ' ', id: ' ', displayName: ' ' }, session: { accessToken: user.tokenset.access_token!, - tokenType: "random", + tokenType: user.tokenset.token_type ?? 'bearer', scope: user.tokenset.scope!, idToken, - refreshToken: user.tokenset.refresh_token - } + refreshToken: user.tokenset.refresh_token, + }, }); }); }; @@ -123,16 +152,15 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({ } resolve({ - fullProfile: {provider: " ",id: " ",displayName: " "}, + fullProfile: { provider: ' ', id: ' ', displayName: ' ' }, session: { accessToken: tokenset.access_token!, - tokenType: "random", + tokenType: tokenset.token_type ?? 'bearer', scope: tokenset.scope!, idToken: tokenset.id_token, - refreshToken: tokenset.refresh_token - } + refreshToken: tokenset.refresh_token, + }, }); }); }, - -}) \ No newline at end of file +}); diff --git a/plugins/auth-backend-module-pinniped-provider/src/module.test.ts b/plugins/auth-backend-module-pinniped-provider/src/module.test.ts index e4dd80b9f9..ed285cff86 100644 --- a/plugins/auth-backend-module-pinniped-provider/src/module.test.ts +++ b/plugins/auth-backend-module-pinniped-provider/src/module.test.ts @@ -1,24 +1,44 @@ -import { setupRequestMockHandlers } from "@backstage/backend-test-utils" -import { authModulePinnipedProvider } from "./module" +/* + * Copyright 2023 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 { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import request from 'supertest'; import { setupServer } from 'msw/node'; import { rest } from 'msw'; -import { Server } from "http"; +import { Server } from 'http'; import express from 'express'; import cookieParser from 'cookie-parser'; import session from 'express-session'; import passport from 'passport'; import { AddressInfo } from 'net'; -import { AuthProviderRouteHandlers, createOAuthRouteHandlers } from "@backstage/plugin-auth-node"; +import { + AuthProviderRouteHandlers, + createOAuthRouteHandlers, +} from '@backstage/plugin-auth-node'; import Router from 'express-promise-router'; -import { pinnipedAuthenticator } from "./authenticator"; -import { ConfigReader } from "@backstage/config"; +import { pinnipedAuthenticator } from './authenticator'; +import { ConfigReader } from '@backstage/config'; +import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose'; describe('authModulePinnipedProvider', () => { let app: express.Express; let backstageServer: Server; let appUrl: string; - let providerRouteHandler: AuthProviderRouteHandlers + let providerRouteHandler: AuthProviderRouteHandlers; + let idToken: string; + let publicKey: JWK; const mswServer = setupServer(); setupRequestMockHandlers(mswServer); @@ -49,8 +69,27 @@ describe('authModulePinnipedProvider', () => { request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], }; + const clusterScopedIdToken = 'dummy-token'; + + beforeAll(async () => { + const keyPair = await generateKeyPair('ES256'); + const privateKey = await exportJWK(keyPair.privateKey); + publicKey = await exportJWK(keyPair.publicKey); + publicKey.alg = privateKey.alg = 'ES256'; + + idToken = await new SignJWT({ + sub: 'test', + iss: 'https://pinniped.test', + iat: Date.now(), + aud: 'clientId', + exp: Date.now() + 10000, + }) + .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) + .sign(keyPair.privateKey); + }); + beforeEach(async () => { - // jest.clearAllMocks(); + jest.clearAllMocks(); mswServer.use( rest.get( @@ -62,7 +101,55 @@ describe('authModulePinnipedProvider', () => { ctx.json(issuerMetadata), ), ), - ) + rest.get( + 'https://pinniped.test/oauth2/authorize', + async (req, res, ctx) => { + const callbackUrl = new URL( + req.url.searchParams.get('redirect_uri')!, + ); + callbackUrl.searchParams.set('code', 'authorization_code'); + callbackUrl.searchParams.set( + 'state', + req.url.searchParams.get('state')!, + ); + callbackUrl.searchParams.set('scope', 'test-scope'); + return res( + ctx.status(302), + ctx.set('Location', callbackUrl.toString()), + ); + }, + ), + rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) => + res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), + ), + rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => { + const formBody = new URLSearchParams(await req.text()); + const isGrantTypeTokenExchange = + formBody.get('grant_type') === + 'urn:ietf:params:oauth:grant-type:token-exchange'; + const hasValidTokenExchangeParams = + formBody.get('subject_token') === 'accessToken' && + formBody.get('audience') === 'test_cluster' && + formBody.get('subject_token_type') === + 'urn:ietf:params:oauth:token-type:access_token' && + formBody.get('requested_token_type') === + 'urn:ietf:params:oauth:token-type:jwt'; + + return res( + req.headers.get('Authorization') && + (!isGrantTypeTokenExchange || hasValidTokenExchangeParams) + ? ctx.json({ + access_token: isGrantTypeTokenExchange + ? clusterScopedIdToken + : 'accessToken', + refresh_token: 'refreshToken', + ...(!isGrantTypeTokenExchange && { id_token: idToken }), + scope: 'testScope', + }) + : ctx.status(401), + ); + }), + ); const secret = 'secret'; app = express() @@ -110,33 +197,64 @@ describe('authModulePinnipedProvider', () => { }), signInWithCatalogUser: async _ => ({ token: '' }), }, - }) + }); const router = Router(); - router - .use( - '/api/auth/pinniped/start', - providerRouteHandler.start.bind(providerRouteHandler), - ) - .use( - '/api/auth/pinniped/handler/frame', - providerRouteHandler.frameHandler.bind(providerRouteHandler), - ); - app.use(router); - }) + router + .use( + '/api/auth/pinniped/start', + providerRouteHandler.start.bind(providerRouteHandler), + ) + .use( + '/api/auth/pinniped/handler/frame', + providerRouteHandler.frameHandler.bind(providerRouteHandler), + ); + app.use(router); + }); afterEach(() => { backstageServer.close(); }); - //TODO: are we actually testing the auth module here since our setup makes use of creating the Oauthfactory directly and not through the module, how can we attach it using the module instead???? - it('should start', async () => { + const agent = request.agent(backstageServer); + const startResponse = await agent.get( + `/api/auth/pinniped/start?env=development&audience=test_cluster`, + ); - const agent = request.agent(backstageServer) + expect(startResponse.status).toBe(302); + }); - const startResponse = await agent.get(`/api/auth/pinniped/start?env=development&audience=test_cluster`); + it('/handler/frame exchanges authorization code from #start for Cluster Specific ID token', async () => { + const agent = request.agent(''); - expect(startResponse.status).toBe(302) - }, 70000) -}) \ No newline at end of file + // make /start request with audience parameter + const startResponse = await agent.get( + `${appUrl}/api/auth/pinniped/start?env=development&audience=test_cluster`, + ); + // follow redirect to authorization endpoint + const authorizationResponse = await agent.get( + startResponse.header.location, + ); + // follow redirect to token_endpoint + const handlerResponse = await agent.get( + authorizationResponse.header.location, + ); + + expect(handlerResponse.text).toContain( + encodeURIComponent( + JSON.stringify({ + type: 'authorization_response', + response: { + profile: {}, + providerInfo: { + idToken: clusterScopedIdToken, + accessToken: 'accessToken', + scope: 'testScope', + }, + }, + }), + ), + ); + }); +}); diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 0d9fc96ce8..dab3e6d883 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -45,6 +45,7 @@ "@backstage/plugin-auth-backend-module-google-provider": "workspace:^", "@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^", "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^", + "@backstage/plugin-auth-backend-module-pinniped-provider": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", "@backstage/types": "workspace:^", diff --git a/plugins/auth-backend/src/lib/oauth/types.ts b/plugins/auth-backend/src/lib/oauth/types.ts index 49398ab279..b7205c9b85 100644 --- a/plugins/auth-backend/src/lib/oauth/types.ts +++ b/plugins/auth-backend/src/lib/oauth/types.ts @@ -92,18 +92,11 @@ export type OAuthProviderInfo = { scope: string; }; -/** @public */ -export type OAuthState = { - /* A type for the serialized value in the `state` parameter of the OAuth authorization flow - */ - nonce: string; - env: string; - origin?: string; - scope?: string; - redirectUrl?: string; - flow?: string; - audience? : string; -}; +/** + * @public + * @deprecated import from `@backstage/plugin-auth-node` instead + */ +export type OAuthState = _OAuthState; /** * @public diff --git a/plugins/auth-backend/src/providers/oidc/provider.ts b/plugins/auth-backend/src/providers/oidc/provider.ts index 96bcbf6e00..7638027b01 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.ts @@ -130,9 +130,7 @@ export class OidcAuthProvider implements OAuthHandlers { if (!tokenset.access_token) { throw new Error('Refresh failed'); } - const userinfo = client.issuer.userinfo_endpoint - ? await client.userinfo(tokenset.access_token) - : { sub: '' }; + const userinfo = await client.userinfo(tokenset.access_token); return { response: await this.handleResult({ tokenset, userinfo }), @@ -161,23 +159,17 @@ export class OidcAuthProvider implements OAuthHandlers { }, ( tokenset: TokenSet, - userinfo: - | UserinfoResponse - | PassportDoneCallback, - done?: PassportDoneCallback, + userinfo: UserinfoResponse, + done: PassportDoneCallback, ) => { - if (typeof userinfo === 'function') { - userinfo( - undefined, - { tokenset, userinfo: { sub: '' } }, - { - refreshToken: tokenset.refresh_token, - }, + if (typeof done !== 'function') { + throw new Error( + 'OIDC IdP must provide a userinfo_endpoint in the metadata response', ); } - done!( + done( undefined, - { tokenset, userinfo: userinfo as UserinfoResponse }, + { tokenset, userinfo }, { refreshToken: tokenset.refresh_token, }, diff --git a/plugins/auth-backend/src/providers/pinniped/provider.test.ts b/plugins/auth-backend/src/providers/pinniped/provider.test.ts index cd467df1e9..60458e187a 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.test.ts @@ -13,496 +13,24 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; -import { - OAuthRefreshRequest, - OAuthStartRequest, - encodeState, - readState, -} from '../../lib/oauth'; -import { PinnipedAuthProvider, PinnipedProviderOptions } from './provider'; -import { setupServer } from 'msw/node'; -import { rest } from 'msw'; -import express from 'express'; -import { OAuthState } from '../../lib/oauth'; -import { Server } from 'http'; -import cookieParser from 'cookie-parser'; -import session from 'express-session'; -import passport from 'passport'; -import { ConfigReader } from '@backstage/config'; -import Router from 'express-promise-router'; -import { pinniped } from '.'; -import { AuthProviderRouteHandlers } from '../types'; -import { getVoidLogger } from '@backstage/backend-common'; -import { AddressInfo } from 'net'; -import request from 'supertest'; -import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose'; -describe('PinnipedAuthProvider', () => { - let provider: PinnipedAuthProvider; - let idToken: string; - let publicKey: JWK; - let oauthState: OAuthState; +import { pinnipedAuthenticator } from '@backstage/plugin-auth-backend-module-pinniped-provider'; +import { createOAuthProviderFactory } from '@backstage/plugin-auth-node'; +import { pinniped } from './provider'; - const mswServer = setupServer(); - setupRequestMockHandlers(mswServer); +jest.mock('@backstage/plugin-auth-node', () => ({ + ...jest.requireActual('@backstage/plugin-auth-node'), + createOAuthProviderFactory: jest.fn(() => 'provider-factory'), +})); - const issuerMetadata = { - issuer: 'https://pinniped.test', - authorization_endpoint: 'https://pinniped.test/oauth2/authorize', - token_endpoint: 'https://pinniped.test/oauth2/token', - revocation_endpoint: 'https://pinniped.test/oauth2/revoke_token', - userinfo_endpoint: 'https://pinniped.test/idp/userinfo.openid', - introspection_endpoint: 'https://pinniped.test/introspect.oauth2', - jwks_uri: 'https://pinniped.test/jwks.json', - scopes_supported: [ - 'openid', - 'offline_access', - 'pinniped:request-audience', - 'username', - 'groups', - ], - claims_supported: ['email', 'username', 'groups', 'additionalClaims'], - response_types_supported: ['code'], - id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], - token_endpoint_auth_signing_alg_values_supported: [ - 'RS256', - 'RS512', - 'HS256', - ], - request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'], - }; +describe('createPinnipedAuthProvider', () => { + afterEach(() => jest.clearAllMocks()); - const pinnipedProviderOptions: PinnipedProviderOptions = { - federationDomain: 'https://federationDomain.test', - clientId: 'clientId', - clientSecret: 'secret', - callbackUrl: 'https://backstage.test/callback', - }; + it('should be created', async () => { + expect(pinniped.create()).toBe('provider-factory'); - const clusterScopedIdToken = 'dummy-token'; - - beforeAll(async () => { - const keyPair = await generateKeyPair('RS256'); - const privateKey = await exportJWK(keyPair.privateKey); - publicKey = await exportJWK(keyPair.publicKey); - publicKey.alg = privateKey.alg = 'RS256'; - - idToken = await new SignJWT({ - sub: 'test', - iss: 'https://pinniped.test', - iat: Date.now(), - aud: pinnipedProviderOptions.clientId, - exp: Date.now() + 10000, - }) - .setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid }) - .sign(keyPair.privateKey); - }); - - beforeEach(async () => { - jest.clearAllMocks(); - - mswServer.use( - rest.get( - 'https://federationDomain.test/.well-known/openid-configuration', - (_req, res, ctx) => - res( - ctx.status(200), - ctx.set('Content-Type', 'application/json'), - ctx.json(issuerMetadata), - ), - ), - rest.get( - 'https://pinniped.test/oauth2/authorize', - async (req, res, ctx) => { - const callbackUrl = new URL( - req.url.searchParams.get('redirect_uri')!, - ); - callbackUrl.searchParams.set('code', 'authorization_code'); - callbackUrl.searchParams.set( - 'state', - req.url.searchParams.get('state')!, - ); - callbackUrl.searchParams.set('scope', 'test-scope'); - return res( - ctx.status(302), - ctx.set('Location', callbackUrl.toString()), - ); - }, - ), - rest.get('https://pinniped.test/jwks.json', async (_req, res, ctx) => - res(ctx.status(200), ctx.json({ keys: [{ ...publicKey }] })), - ), - rest.post('https://pinniped.test/oauth2/token', async (req, res, ctx) => { - const formBody = new URLSearchParams(await req.text()); - const isGrantTypeTokenExchange = - formBody.get('grant_type') === - 'urn:ietf:params:oauth:grant-type:token-exchange'; - const hasValidTokenExchangeParams = - formBody.get('subject_token') === 'accessToken' && - formBody.get('audience') === 'test_cluster' && - formBody.get('subject_token_type') === - 'urn:ietf:params:oauth:token-type:access_token' && - formBody.get('requested_token_type') === - 'urn:ietf:params:oauth:token-type:jwt'; - - return res( - req.headers.get('Authorization') && - (!isGrantTypeTokenExchange || hasValidTokenExchangeParams) - ? ctx.json({ - access_token: isGrantTypeTokenExchange - ? clusterScopedIdToken - : 'accessToken', - refresh_token: 'refreshToken', - ...(!isGrantTypeTokenExchange && { id_token: idToken }), - scope: 'testScope', - }) - : ctx.status(401), - ); - }), - ); - - oauthState = { - nonce: 'nonce', - env: 'env', - }; - - provider = new PinnipedAuthProvider(pinnipedProviderOptions); - }); - - describe('#start', () => { - let fakeSession: Record; - let startRequest: OAuthStartRequest; - - beforeEach(() => { - fakeSession = {}; - startRequest = { - session: fakeSession, - method: 'GET', - url: 'test', - state: oauthState, - } as unknown as OAuthStartRequest; - }); - - it('redirects to authorization endpoint returned from OIDC metadata endpoint', async () => { - const startResponse = await provider.start(startRequest); - const url = new URL(startResponse.url); - - expect(url.protocol).toBe('https:'); - expect(url.hostname).toBe('pinniped.test'); - expect(url.pathname).toBe('/oauth2/authorize'); - }); - - it('initiates authorization code grant', async () => { - const startResponse = await provider.start(startRequest); - const { searchParams } = new URL(startResponse.url); - - expect(searchParams.get('response_type')).toBe('code'); - }); - - it('persists audience parameter in oauth state', async () => { - startRequest.query = { audience: 'test-cluster' }; - const startResponse = await provider.start(startRequest); - const { searchParams } = new URL(startResponse.url); - const stateParam = searchParams.get('state'); - const decodedState = readState(stateParam!); - - expect(decodedState).toMatchObject({ - nonce: 'nonce', - env: 'env', - audience: 'test-cluster', - }); - }); - - it('passes client ID from config', async () => { - const startResponse = await provider.start(startRequest); - const { searchParams } = new URL(startResponse.url); - - expect(searchParams.get('client_id')).toBe('clientId'); - }); - - it('passes callback URL from config', async () => { - const startResponse = await provider.start(startRequest); - const { searchParams } = new URL(startResponse.url); - - expect(searchParams.get('redirect_uri')).toBe( - 'https://backstage.test/callback', - ); - }); - - it('generates PKCE challenge', async () => { - const startResponse = await provider.start(startRequest); - const { searchParams } = new URL(startResponse.url); - - expect(searchParams.get('code_challenge_method')).toBe('S256'); - expect(searchParams.get('code_challenge')).not.toBeNull(); - }); - - it('stores PKCE verifier in session', async () => { - await provider.start(startRequest); - expect(fakeSession['oidc:pinniped.test'].code_verifier).toBeDefined(); - }); - - it('requests sufficient scopes for token exchange', async () => { - const startResponse = await provider.start(startRequest); - const { searchParams } = new URL(startResponse.url); - const scopes = searchParams.get('scope')?.split(' ') ?? []; - - expect(scopes).toEqual( - expect.arrayContaining([ - 'openid', - 'pinniped:request-audience', - 'username', - 'offline_access', - ]), - ); - }); - - it('encodes OAuth state in query param', async () => { - const startResponse = await provider.start(startRequest); - const { searchParams } = new URL(startResponse.url); - const stateParam = searchParams.get('state'); - const decodedState = readState(stateParam!); - - expect(decodedState).toMatchObject(oauthState); - }); - - it('fails when request has no session', async () => { - return expect( - provider.start({ - method: 'GET', - url: 'test', - } as unknown as OAuthStartRequest), - ).rejects.toThrow('authentication requires session support'); - }); - }); - - describe('#handler', () => { - let handlerRequest: express.Request; - - beforeEach(() => { - handlerRequest = { - method: 'GET', - url: `https://test?code=authorization_code&state=${encodeState( - oauthState, - )}`, - session: { - 'oidc:pinniped.test': { - state: encodeState(oauthState), - }, - }, - } as unknown as express.Request; - }); - - it('exchanges authorization code for access token', async () => { - const handlerResponse = await provider.handler(handlerRequest); - const accessToken = handlerResponse.response.providerInfo.accessToken; - - expect(accessToken).toEqual('accessToken'); - }); - - it('exchanges authorization code for refresh token', async () => { - const handlerResponse = await provider.handler(handlerRequest); - const refreshToken = handlerResponse.refreshToken; - - expect(refreshToken).toEqual('refreshToken'); - }); - - it('returns granted scope', async () => { - const handlerResponse = await provider.handler(handlerRequest); - const responseScope = handlerResponse.response.providerInfo.scope; - - expect(responseScope).toEqual('testScope'); - }); - - it('returns cluster-scoped ID token when audience is specified', async () => { - oauthState.audience = 'test_cluster'; - handlerRequest = { - method: 'GET', - url: `https://test?code=authorization_code&state=${encodeState( - oauthState, - )}`, - session: { - 'oidc:pinniped.test': { - state: encodeState(oauthState), - }, - }, - } as unknown as express.Request; - - const handlerResponse = await provider.handler(handlerRequest); - const responseIdToken = handlerResponse.response.providerInfo.idToken; - - expect(responseIdToken).toEqual(clusterScopedIdToken); - }); - - it('fails without authorization code', async () => { - handlerRequest.url = 'https://test.com'; - return expect(provider.handler(handlerRequest)).rejects.toThrow( - 'Unexpected redirect', - ); - }); - - it('fails without oauth state', async () => { - return expect( - provider.handler({ - method: 'GET', - url: `https://test?code=authorization_code}`, - session: { - ['oidc:pinniped.test']: { - state: { handle: 'sessionid', code_verifier: 'foo' }, - }, - }, - } as unknown as express.Request), - ).rejects.toThrow( - 'Authentication rejected, state missing from the response', - ); - }); - - it('fails when request has no session', async () => { - return expect( - provider.handler({ - method: 'GET', - url: 'https://test.com', - } as unknown as OAuthStartRequest), - ).rejects.toThrow('authentication requires session support'); - }); - }); - - describe('#refresh', () => { - let refreshRequest: OAuthRefreshRequest; - - beforeEach(() => { - refreshRequest = { - refreshToken: 'otherRefreshToken', - } as unknown as OAuthRefreshRequest; - }); - - it('gets new refresh token', async () => { - const { refreshToken } = await provider.refresh(refreshRequest); - - expect(refreshToken).toBe('refreshToken'); - }); - - it('gets access token', async () => { - const { response } = await provider.refresh(refreshRequest); - - expect(response.providerInfo.accessToken).toBe('accessToken'); - }); - - it('gets id token', async () => { - const { response } = await provider.refresh(refreshRequest); - - expect(response.providerInfo.idToken).toBe(idToken); - }); - }); - - describe('integration', () => { - let app: express.Express; - let providerRouteHandler: AuthProviderRouteHandlers; - let backstageServer: Server; - let appUrl: string; - - beforeEach(async () => { - const secret = 'secret'; - app = express() - .use(cookieParser(secret)) - .use( - session({ - secret, - saveUninitialized: false, - resave: false, - cookie: { secure: false }, - }), - ) - .use(passport.initialize()) - .use(passport.session()); - await new Promise(resolve => { - backstageServer = app.listen(0, '0.0.0.0', () => { - appUrl = `http://127.0.0.1:${ - (backstageServer.address() as AddressInfo).port - }`; - resolve(null); - }); - }); - mswServer.use(rest.all(`${appUrl}/*`, req => req.passthrough())); - providerRouteHandler = pinniped.create()({ - providerId: 'pinniped', - globalConfig: { - baseUrl: `${appUrl}/api/auth`, - appUrl, - isOriginAllowed: _ => true, - }, - config: new ConfigReader({ - development: { - federationDomain: 'https://federationDomain.test', - clientId: 'clientId', - clientSecret: 'clientSecret', - }, - }), - logger: getVoidLogger(), - resolverContext: { - issueToken: async _ => ({ token: '' }), - findCatalogUser: async _ => ({ - entity: { - apiVersion: '', - kind: '', - metadata: { name: '' }, - }, - }), - signInWithCatalogUser: async _ => ({ token: '' }), - }, - baseUrl: `${appUrl}/api/auth`, - appUrl, - isOriginAllowed: _ => true, - }); - const router = Router(); - router - .use( - '/api/auth/pinniped/start', - providerRouteHandler.start.bind(providerRouteHandler), - ) - .use( - '/api/auth/pinniped/handler/frame', - providerRouteHandler.frameHandler.bind(providerRouteHandler), - ); - app.use(router); - }); - - afterEach(() => { - backstageServer.close(); - }); - - it('/handler/frame exchanges authorization code from #start for Cluster Specific ID token', async () => { - const agent = request.agent(''); - - // make /start request with audience parameter - const startResponse = await agent.get( - `${appUrl}/api/auth/pinniped/start?env=development&audience=test_cluster`, - ); - // follow redirect to authorization endpoint - const authorizationResponse = await agent.get( - startResponse.header.location, - ); - // follow redirect to token_endpoint - const handlerResponse = await agent.get( - authorizationResponse.header.location, - ); - - expect(handlerResponse.text).toContain( - encodeURIComponent( - JSON.stringify({ - type: 'authorization_response', - response: { - providerInfo: { - accessToken: 'accessToken', - scope: 'testScope', - idToken: clusterScopedIdToken, - }, - profile: {}, - }, - }), - ), - ); + expect(createOAuthProviderFactory).toHaveBeenCalledWith({ + authenticator: pinnipedAuthenticator, }); }); }); diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts index 221a526fe0..433c49a6cd 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.ts @@ -13,179 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - Client, - Issuer, - Strategy as OidcStrategy, - TokenSet, -} from 'openid-client'; -import { - OAuthHandlers, - OAuthProviderOptions, - OAuthRefreshRequest, - OAuthResponse, - OAuthStartRequest, - encodeState, - readState, -} from '../../lib/oauth'; -import { PassportDoneCallback } from '../../lib/passport'; -import { OAuthStartResponse } from '../types'; -import express from 'express'; -import { OAuthAdapter, OAuthEnvironmentHandler } from '../../lib/oauth'; + +import { pinnipedAuthenticator } from '@backstage/plugin-auth-backend-module-pinniped-provider'; import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; - -type OidcImpl = { - strategy: OidcStrategy; - client: Client; -}; - -type PrivateInfo = { - refreshToken?: string; -}; - -export type PinnipedProviderOptions = OAuthProviderOptions & { - federationDomain: string; - clientId: string; - clientSecret: string; - callbackUrl: string; - scope?: string; -}; - -export class PinnipedAuthProvider implements OAuthHandlers { - private readonly implementation: Promise; - - constructor(options: PinnipedProviderOptions) { - this.implementation = this.setupStrategy(options); - } - - async start(req: OAuthStartRequest): Promise { - const { strategy } = await this.implementation; - const stringifiedAudience = req.query?.audience as string; - const state = { ...req.state, audience: stringifiedAudience }; - const options: Record = { - scope: - req.scope || 'openid pinniped:request-audience username offline_access', - state: encodeState(state), - }; - return new Promise((resolve, reject) => { - strategy.redirect = (url: string) => { - resolve({ url }); - }; - strategy.error = (error: Error) => { - reject(error); - }; - strategy.authenticate(req, { ...options }); - }); - } - - async handler( - req: express.Request, - ): Promise<{ response: OAuthResponse; refreshToken?: string }> { - const { strategy, client } = await this.implementation; - const { searchParams } = new URL(req.url, 'https://pinniped.com'); - const stateParam = searchParams.get('state'); - const audience = stateParam ? readState(stateParam).audience : undefined; - - return new Promise((resolve, reject) => { - strategy.success = user => { - (audience - ? client - .grant({ - grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', - subject_token: user.tokenset.access_token, - audience, - subject_token_type: - 'urn:ietf:params:oauth:token-type:access_token', - requested_token_type: 'urn:ietf:params:oauth:token-type:jwt', - }) - .then(tokenset => tokenset.access_token) - : Promise.resolve(user.tokenset.id_token) - ).then(idToken => { - resolve({ - response: { - providerInfo: { - accessToken: user.tokenset.access_token, - scope: user.tokenset.scope, - idToken, - }, - profile: {}, - }, - refreshToken: user.tokenset.refresh_token, - }); - }); - }; - - strategy.fail = info => { - reject(new Error(`Authentication rejected, ${info.message || ''}`)); - }; - - strategy.error = (error: Error) => { - reject(error); - }; - - strategy.redirect = () => { - reject(new Error('Unexpected redirect')); - }; - - strategy.authenticate(req); - }); - } - - async refresh( - req: OAuthRefreshRequest, - ): Promise<{ response: OAuthResponse; refreshToken?: string }> { - const { client } = await this.implementation; - const tokenset = await client.refresh(req.refreshToken); - - return new Promise((resolve, reject) => { - if (!tokenset.access_token) { - reject(new Error('Refresh Failed')); - } - - resolve({ - response: { - providerInfo: { - accessToken: tokenset.access_token!, - scope: tokenset.scope!, - idToken: tokenset.id_token, - }, - profile: {}, - }, - refreshToken: tokenset.refresh_token, - }); - }); - } - - private async setupStrategy( - options: PinnipedProviderOptions, - ): Promise { - const issuer = await Issuer.discover( - `${options.federationDomain}/.well-known/openid-configuration`, - ); - const client = new issuer.Client({ - access_type: 'offline', // this option must be passed to provider to receive a refresh token - client_id: options.clientId, - client_secret: options.clientSecret, - redirect_uris: [options.callbackUrl], - response_types: ['code'], - scope: options.scope || '', - }); - - const strategy = new OidcStrategy( - { - client, - passReqToCallback: false, - }, - ( - tokenset: TokenSet, - done: PassportDoneCallback<{ tokenset: TokenSet }, PrivateInfo>, - ) => { - done(undefined, { tokenset }, {}); - }, - ); - return { strategy, client }; - } -} +import { createOAuthProviderFactory } from '@backstage/plugin-auth-node'; /** * Auth provider integration for Pinniped auth @@ -194,27 +25,8 @@ export class PinnipedAuthProvider implements OAuthHandlers { */ export const pinniped = createAuthProviderIntegration({ create() { - return ({ providerId, globalConfig, config }) => - OAuthEnvironmentHandler.mapConfig(config, envConfig => { - const clientId = envConfig.getString('clientId'); - const clientSecret = envConfig.getString('clientSecret'); - const federationDomain = envConfig.getString('federationDomain'); - const customCallbackUrl = envConfig.getOptionalString('callbackUrl'); - const callbackUrl = - customCallbackUrl || - `${globalConfig.baseUrl}/${providerId}/handler/frame`; - - const provider = new PinnipedAuthProvider({ - federationDomain, - clientId, - clientSecret, - callbackUrl, - }); - - return OAuthAdapter.fromConfig(globalConfig, provider, { - providerId, - callbackUrl, - }); - }); + return createOAuthProviderFactory({ + authenticator: pinnipedAuthenticator, + }); }, }); diff --git a/yarn.lock b/yarn.lock index bab1a52aad..c68ddea50e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4979,14 +4979,27 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-auth-backend-module-pinniped-provider@^0.0.0, @backstage/plugin-auth-backend-module-pinniped-provider@workspace:plugins/auth-backend-module-pinniped-provider": +"@backstage/plugin-auth-backend-module-pinniped-provider@workspace:^, @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" dependencies: "@backstage/backend-common": "workspace:^" + "@backstage/backend-defaults": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" "@backstage/backend-test-utils": "workspace:^" "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/plugin-auth-backend": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" + cookie-parser: ^1.4.6 + express: ^4.18.2 + express-promise-router: ^4.1.1 + express-session: ^1.17.3 + jose: ^4.14.6 + msw: ^1.3.0 + openid-client: ^5.4.3 + passport: ^0.6.0 + supertest: ^6.3.3 languageName: unknown linkType: soft @@ -5010,6 +5023,7 @@ __metadata: "@backstage/plugin-auth-backend-module-google-provider": "workspace:^" "@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^" "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^" + "@backstage/plugin-auth-backend-module-pinniped-provider": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" "@backstage/types": "workspace:^" @@ -25908,7 +25922,6 @@ __metadata: "@backstage/plugin-adr-backend": "workspace:^" "@backstage/plugin-app-backend": "workspace:^" "@backstage/plugin-auth-backend": "workspace:^" - "@backstage/plugin-auth-backend-module-pinniped-provider": ^0.0.0 "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-azure-devops-backend": "workspace:^" "@backstage/plugin-azure-sites-backend": "workspace:^" @@ -26117,7 +26130,7 @@ __metadata: languageName: node linkType: hard -"express-session@npm:^1.17.1": +"express-session@npm:^1.17.1, express-session@npm:^1.17.3": version: 1.17.3 resolution: "express-session@npm:1.17.3" dependencies: @@ -30330,6 +30343,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^4.14.6": + version: 4.14.6 + resolution: "jose@npm:4.14.6" + checksum: eae81a234e7bf1446b1bd80722b3462b014e3835b155c3a7799c1c5043163a53a0dc28d347004151b031e6b7b863403aabf8814d9cc217ce21f8c2f3ebd4b335 + languageName: node + linkType: hard + "jose@npm:^4.15.1, jose@npm:^4.6.0": version: 4.15.3 resolution: "jose@npm:4.15.3" @@ -33457,7 +33477,7 @@ __metadata: languageName: node linkType: hard -"msw@npm:^1.0.0, msw@npm:^1.0.1, msw@npm:^1.2.1, msw@npm:^1.2.3, msw@npm:^1.3.1": +"msw@npm:^1.0.0, msw@npm:^1.0.1, msw@npm:^1.2.1, msw@npm:^1.2.3, msw@npm:^1.3.0, msw@npm:^1.3.1": version: 1.3.2 resolution: "msw@npm:1.3.2" dependencies: @@ -34531,7 +34551,7 @@ __metadata: languageName: node linkType: hard -"openid-client@npm:^5.2.1, openid-client@npm:^5.3.0": +"openid-client@npm:^5.2.1, openid-client@npm:^5.3.0, openid-client@npm:^5.4.3": version: 5.6.1 resolution: "openid-client@npm:5.6.1" dependencies: From ae3425583626fb2621753dfd5514f04e3614694d Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Thu, 7 Sep 2023 15:15:34 -0400 Subject: [PATCH 46/59] PR chores: changeset, api-report, cleaning, add catalog-info entry Signed-off-by: Ruben Vallejo --- .changeset/short-ears-rescue.md | 5 + .changeset/tiny-peaches-brake.md | 5 + .changeset/young-ducks-heal.md | 5 + .../api-report.md | 28 +++ .../catalog-info.yaml | 10 + .../dev/index.ts | 2 +- .../src/authenticator.test.ts | 2 +- .../src/authenticator.ts | 5 +- .../src/config.d.ts | 34 ++++ .../src/module.ts | 15 +- plugins/auth-backend/api-report.md | 13 +- plugins/auth-backend/package.json | 7 +- plugins/auth-node/api-report.md | 1 + yarn.lock | 188 +----------------- 14 files changed, 112 insertions(+), 208 deletions(-) create mode 100644 .changeset/short-ears-rescue.md create mode 100644 .changeset/tiny-peaches-brake.md create mode 100644 .changeset/young-ducks-heal.md create mode 100644 plugins/auth-backend-module-pinniped-provider/api-report.md create mode 100644 plugins/auth-backend-module-pinniped-provider/catalog-info.yaml create mode 100644 plugins/auth-backend-module-pinniped-provider/src/config.d.ts diff --git a/.changeset/short-ears-rescue.md b/.changeset/short-ears-rescue.md new file mode 100644 index 0000000000..41968f3da4 --- /dev/null +++ b/.changeset/short-ears-rescue.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend-module-pinniped-provider': minor +--- + +Add new Pinniped auth module and authenticator to be used alongside the new Pinniped auth provider. diff --git a/.changeset/tiny-peaches-brake.md b/.changeset/tiny-peaches-brake.md new file mode 100644 index 0000000000..e6d979fca3 --- /dev/null +++ b/.changeset/tiny-peaches-brake.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Add Pinniped Auth Provider to list of default auth providers diff --git a/.changeset/young-ducks-heal.md b/.changeset/young-ducks-heal.md new file mode 100644 index 0000000000..28614e47bc --- /dev/null +++ b/.changeset/young-ducks-heal.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-node': patch +--- + +Adding optional audience parameter to OAuthState type declaration diff --git a/plugins/auth-backend-module-pinniped-provider/api-report.md b/plugins/auth-backend-module-pinniped-provider/api-report.md new file mode 100644 index 0000000000..b9b993bd1e --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/api-report.md @@ -0,0 +1,28 @@ +## API Report File for "@backstage/plugin-auth-backend-module-pinniped-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 { BaseClient } from 'openid-client'; +import { OAuthAuthenticator } from '@backstage/plugin-auth-node'; +import { Strategy } from 'openid-client'; +import { TokenSet } from 'openid-client'; + +// @public (undocumented) +export const authModulePinnipedProvider: () => BackendFeature; + +// @public (undocumented) +export const pinnipedAuthenticator: OAuthAuthenticator< + Promise<{ + providerStrategy: Strategy< + { + tokenset: TokenSet; + }, + BaseClient + >; + client: BaseClient; + }>, + unknown +>; +``` diff --git a/plugins/auth-backend-module-pinniped-provider/catalog-info.yaml b/plugins/auth-backend-module-pinniped-provider/catalog-info.yaml new file mode 100644 index 0000000000..9d1ef1c299 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-auth-backend-module-pinniped-provider + title: '@backstage/plugin-auth-backend-module-pinniped-provider' + description: The pinniped-provider backend module for the auth plugin. +spec: + lifecycle: experimental + type: backstage-backend-plugin-module + owner: maintainers diff --git a/plugins/auth-backend-module-pinniped-provider/dev/index.ts b/plugins/auth-backend-module-pinniped-provider/dev/index.ts index 0d29676cc4..cf0a6ebac9 100644 --- a/plugins/auth-backend-module-pinniped-provider/dev/index.ts +++ b/plugins/auth-backend-module-pinniped-provider/dev/index.ts @@ -23,4 +23,4 @@ const backend = createBackend(); backend.add(authPlugin); backend.add(authModulePinnipedProvider); -backend.start(); \ No newline at end of file +backend.start(); diff --git a/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts b/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts index 87d443a11a..ea2ca451b5 100644 --- a/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts +++ b/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts @@ -353,7 +353,7 @@ describe('pinnipedAuthenticator', () => { ); expect(handlerResponse.session.idToken).toEqual(clusterScopedIdToken); - }, 70000); + }); it('fails on network error during token exchange', async () => { mswServer.use( diff --git a/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts b/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts index 0194ed03de..0b178cc080 100644 --- a/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts @@ -21,6 +21,7 @@ import { } from '@backstage/plugin-auth-node'; import { Issuer, TokenSet, Strategy as OidcStrategy } from 'openid-client'; +/** @public */ export const pinnipedAuthenticator = createOAuthAuthenticator({ defaultProfileTransform: async (_r, _c) => ({ profile: {} }), async initialize({ callbackUrl, config }) { @@ -114,7 +115,7 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({ : Promise.resolve(user.tokenset.id_token) ).then(idToken => { resolve({ - fullProfile: { provider: ' ', id: ' ', displayName: ' ' }, + fullProfile: { provider: '', id: '', displayName: '' }, session: { accessToken: user.tokenset.access_token!, tokenType: user.tokenset.token_type ?? 'bearer', @@ -152,7 +153,7 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({ } resolve({ - fullProfile: { provider: ' ', id: ' ', displayName: ' ' }, + fullProfile: { provider: '', id: '', displayName: '' }, session: { accessToken: tokenset.access_token!, tokenType: tokenset.token_type ?? 'bearer', diff --git a/plugins/auth-backend-module-pinniped-provider/src/config.d.ts b/plugins/auth-backend-module-pinniped-provider/src/config.d.ts new file mode 100644 index 0000000000..50685abfb0 --- /dev/null +++ b/plugins/auth-backend-module-pinniped-provider/src/config.d.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2020 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 interface Config { + /** Configuration options for the auth plugin */ + auth?: { + providers?: { + pinniped?: { + [authEnv: string]: { + clientId: string; + federationDomain: string; + /** + * @visibility secret + */ + clientSecret: string; + scope?: string; + }; + }; + }; + }; +} diff --git a/plugins/auth-backend-module-pinniped-provider/src/module.ts b/plugins/auth-backend-module-pinniped-provider/src/module.ts index 0b7ca9e293..5fff30e82e 100644 --- a/plugins/auth-backend-module-pinniped-provider/src/module.ts +++ b/plugins/auth-backend-module-pinniped-provider/src/module.ts @@ -14,9 +14,14 @@ * limitations under the License. */ import { createBackendModule } from '@backstage/backend-plugin-api'; -import { authProvidersExtensionPoint, commonSignInResolvers, createOAuthProviderFactory } from '@backstage/plugin-auth-node'; +import { + authProvidersExtensionPoint, + commonSignInResolvers, + createOAuthProviderFactory, +} from '@backstage/plugin-auth-node'; import { pinnipedAuthenticator } from './authenticator'; +/** @public */ export const authModulePinnipedProvider = createBackendModule({ pluginId: 'auth', moduleId: 'pinniped-provider', @@ -31,10 +36,10 @@ export const authModulePinnipedProvider = createBackendModule({ factory: createOAuthProviderFactory({ authenticator: pinnipedAuthenticator, signInResolverFactories: { - ...commonSignInResolvers - } - }) - }) + ...commonSignInResolvers, + }, + }), + }); }, }); }, diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index c320d16abc..889b2b385e 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -620,18 +620,7 @@ export const providers: Readonly<{ resolvers: never; }>; pinniped: Readonly<{ - create: ( - options?: - | { - authHandler?: AuthHandler | undefined; - signIn?: - | { - resolver: SignInResolver; - } - | undefined; - } - | undefined, - ) => AuthProviderFactory; + create: () => AuthProviderFactory_2; resolvers: never; }>; saml: Readonly<{ diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index dab3e6d883..8cc8cf6b98 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -32,7 +32,6 @@ "clean": "backstage-cli package clean" }, "dependencies": { - "-": "^0.0.1", "@backstage/backend-common": "workspace:^", "@backstage/backend-plugin-api": "workspace:^", "@backstage/catalog-client": "workspace:^", @@ -59,21 +58,18 @@ "cookie-parser": "^1.4.5", "cookie-signature": "^1.2.1", "cors": "^2.8.5", - "d": "^1.0.1", - "e": "^0.2.32", "express": "^4.17.1", "express-promise-router": "^4.1.0", "express-session": "^1.17.1", "fs-extra": "10.1.0", "google-auth-library": "^8.0.0", "jose": "^4.6.0", - "jwt-decode": "^3.1.2", + "jwt-decode": "^3.1.0", "knex": "^2.0.0", "lodash": "^4.17.21", "luxon": "^3.0.0", "minimatch": "^5.0.0", "morgan": "^1.10.0", - "njwt": "^2.0.0", "node-cache": "^5.1.2", "node-fetch": "^2.6.7", "openid-client": "^5.2.1", @@ -88,7 +84,6 @@ "passport-onelogin-oauth": "^0.0.1", "passport-saml": "^3.1.2", "uuid": "^8.0.0", - "v": "^0.3.0", "winston": "^3.2.1", "yn": "^4.0.0" }, diff --git a/plugins/auth-node/api-report.md b/plugins/auth-node/api-report.md index a01de2cf46..4b1a8a1f27 100644 --- a/plugins/auth-node/api-report.md +++ b/plugins/auth-node/api-report.md @@ -399,6 +399,7 @@ export type OAuthState = { scope?: string; redirectUrl?: string; flow?: string; + audience?: string; }; // @public (undocumented) diff --git a/yarn.lock b/yarn.lock index c68ddea50e..d28d24fc14 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,13 +5,6 @@ __metadata: version: 6 cacheKey: 8 -"-@npm:^0.0.1": - version: 0.0.1 - resolution: "-@npm:0.0.1" - checksum: 33786d96a8c404f3ce4db242b50d9a8f6013b3a0673bba52186b92f016429135ec2d9b9f77abf85c2a2b757c85e9b33a1f44d5dbf9740fd6294de87198681fd6 - languageName: node - linkType: hard - "@aashutoshrathi/word-wrap@npm:^1.2.3": version: 1.2.6 resolution: "@aashutoshrathi/word-wrap@npm:1.2.6" @@ -5007,7 +5000,6 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/plugin-auth-backend@workspace:plugins/auth-backend" dependencies: - "-": ^0.0.1 "@backstage/backend-common": "workspace:^" "@backstage/backend-defaults": "workspace:^" "@backstage/backend-plugin-api": "workspace:^" @@ -5048,22 +5040,19 @@ __metadata: cookie-parser: ^1.4.5 cookie-signature: ^1.2.1 cors: ^2.8.5 - d: ^1.0.1 - e: ^0.2.32 express: ^4.17.1 express-promise-router: ^4.1.0 express-session: ^1.17.1 fs-extra: 10.1.0 google-auth-library: ^8.0.0 jose: ^4.6.0 - jwt-decode: ^3.1.2 + jwt-decode: ^3.1.0 knex: ^2.0.0 lodash: ^4.17.21 luxon: ^3.0.0 minimatch: ^5.0.0 morgan: ^1.10.0 msw: ^1.0.0 - njwt: ^2.0.0 node-cache: ^5.1.2 node-fetch: ^2.6.7 openid-client: ^5.2.1 @@ -5079,7 +5068,6 @@ __metadata: passport-saml: ^3.1.2 supertest: ^6.1.3 uuid: ^8.0.0 - v: ^0.3.0 winston: ^3.2.1 yn: ^4.0.0 languageName: unknown @@ -18310,7 +18298,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^15.0.1, @types/node@npm:^15.6.1": +"@types/node@npm:^15.6.1": version: 15.14.9 resolution: "@types/node@npm:15.14.9" checksum: 49f7f0522a3af4b8389aee660e88426490cd54b86356672a1fedb49919a8797c00d090ec2dcc4a5df34edc2099d57fc2203d796c4e7fbd382f2022ccd789eee7 @@ -20431,13 +20419,6 @@ __metadata: languageName: node linkType: hard -"async-limiter@npm:~1.0.0": - version: 1.0.1 - resolution: "async-limiter@npm:1.0.1" - checksum: 2b849695b465d93ad44c116220dee29a5aeb63adac16c1088983c339b0de57d76e82533e8e364a93a9f997f28bbfc6a92948cefc120652bd07f3b59f8d75cf2b - languageName: node - linkType: hard - "async-lock@npm:^1.1.0": version: 1.2.4 resolution: "async-lock@npm:1.2.4" @@ -23581,16 +23562,6 @@ __metadata: languageName: node linkType: hard -"d@npm:1, d@npm:^1.0.1": - version: 1.0.1 - resolution: "d@npm:1.0.1" - dependencies: - es5-ext: ^0.10.50 - type: ^1.0.1 - checksum: 49ca0639c7b822db670de93d4fbce44b4aa072cd848c76292c9978a8cd0fff1028763020ff4b0f147bd77bfe29b4c7f82e0f71ade76b2a06100543cdfd948d19 - languageName: node - linkType: hard - "dagre@npm:^0.8.5": version: 0.8.5 resolution: "dagre@npm:0.8.5" @@ -23676,16 +23647,6 @@ __metadata: languageName: node linkType: hard -"deasync@npm:^0.1.9": - version: 0.1.28 - resolution: "deasync@npm:0.1.28" - dependencies: - bindings: ^1.5.0 - node-addon-api: ^1.7.1 - checksum: e0c1ef427875c897e0d903a08410df1d0a3dfd0d2a0a1e43fb6c2824dfbc504b810bd08a0d30653117259316e1aa65409c96dbed40101c934f75bac7499e1265 - languageName: node - linkType: hard - "debounce@npm:^1.1.0, debounce@npm:^1.2.0": version: 1.2.1 resolution: "debounce@npm:1.2.1" @@ -23693,7 +23654,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:2.6.9, debug@npm:^2.6.0, debug@npm:^2.6.1": +"debug@npm:2.6.9, debug@npm:^2.6.0": version: 2.6.9 resolution: "debug@npm:2.6.9" dependencies: @@ -23714,7 +23675,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:^3.1.0, debug@npm:^3.2.7": +"debug@npm:^3.2.7": version: 3.2.7 resolution: "debug@npm:3.2.7" dependencies: @@ -24407,13 +24368,6 @@ __metadata: languageName: unknown linkType: soft -"e@npm:^0.2.32": - version: 0.2.32 - resolution: "e@npm:0.2.32" - checksum: 6fcebe65c37d44e69b03d1db3ea1a949855aaae227a3a7f80e807983d6f31f9dc4c9420c02ec6d37046ca0c5523fb836215c10e1cd9d8f438e6df701dc24de35 - languageName: node - linkType: hard - "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -24452,7 +24406,7 @@ __metadata: languageName: node linkType: hard -"ecdsa-sig-formatter@npm:1.0.11, ecdsa-sig-formatter@npm:^1.0.11, ecdsa-sig-formatter@npm:^1.0.5": +"ecdsa-sig-formatter@npm:1.0.11, ecdsa-sig-formatter@npm:^1.0.11": version: 1.0.11 resolution: "ecdsa-sig-formatter@npm:1.0.11" dependencies: @@ -24818,17 +24772,6 @@ __metadata: languageName: node linkType: hard -"es5-ext@npm:^0.10.35, es5-ext@npm:^0.10.50": - version: 0.10.62 - resolution: "es5-ext@npm:0.10.62" - dependencies: - es6-iterator: ^2.0.3 - es6-symbol: ^3.1.3 - next-tick: ^1.1.0 - checksum: 25f42f6068cfc6e393cf670bc5bba249132c5f5ec2dd0ed6e200e6274aca2fed8e9aec8a31c76031744c78ca283c57f0b41c7e737804c6328c7b8d3fbcba7983 - languageName: node - linkType: hard - "es6-error@npm:^4.1.1": version: 4.1.1 resolution: "es6-error@npm:4.1.1" @@ -24836,17 +24779,6 @@ __metadata: languageName: node linkType: hard -"es6-iterator@npm:^2.0.3": - version: 2.0.3 - resolution: "es6-iterator@npm:2.0.3" - dependencies: - d: 1 - es5-ext: ^0.10.35 - es6-symbol: ^3.1.1 - checksum: 6e48b1c2d962c21dee604b3d9f0bc3889f11ed5a8b33689155a2065d20e3107e2a69cc63a71bd125aeee3a589182f8bbcb5c8a05b6a8f38fa4205671b6d09697 - languageName: node - linkType: hard - "es6-object-assign@npm:^1.1.0": version: 1.1.0 resolution: "es6-object-assign@npm:1.1.0" @@ -24854,16 +24786,6 @@ __metadata: languageName: node linkType: hard -"es6-symbol@npm:^3.1.1, es6-symbol@npm:^3.1.3": - version: 3.1.3 - resolution: "es6-symbol@npm:3.1.3" - dependencies: - d: ^1.0.1 - ext: ^1.1.2 - checksum: cd49722c2a70f011eb02143ef1c8c70658d2660dead6641e160b94619f408b9cf66425515787ffe338affdf0285ad54f4eae30ea5bd510e33f8659ec53bcaa70 - languageName: node - linkType: hard - "esbuild-loader@npm:^2.18.0": version: 2.21.0 resolution: "esbuild-loader@npm:2.21.0" @@ -26185,15 +26107,6 @@ __metadata: languageName: node linkType: hard -"ext@npm:^1.1.2": - version: 1.7.0 - resolution: "ext@npm:1.7.0" - dependencies: - type: ^2.7.2 - checksum: ef481f9ef45434d8c867cfd09d0393b60945b7c8a1798bedc4514cb35aac342ccb8d8ecb66a513e6a2b4ec1e294a338e3124c49b29736f8e7c735721af352c31 - languageName: node - linkType: hard - "extend@npm:3.0.2, extend@npm:^3.0.0, extend@npm:^3.0.2, extend@npm:~3.0.2": version: 3.0.2 resolution: "extend@npm:3.0.2" @@ -31210,7 +31123,7 @@ __metadata: languageName: node linkType: hard -"jwt-decode@npm:*, jwt-decode@npm:^3.1.0, jwt-decode@npm:^3.1.2": +"jwt-decode@npm:*, jwt-decode@npm:^3.1.0": version: 3.1.2 resolution: "jwt-decode@npm:3.1.2" checksum: 20a4b072d44ce3479f42d0d2c8d3dabeb353081ba4982e40b83a779f2459a70be26441be6c160bfc8c3c6eadf9f6380a036fbb06ac5406b5674e35d8c4205eeb @@ -33698,13 +33611,6 @@ __metadata: languageName: node linkType: hard -"next-tick@npm:^1.1.0": - version: 1.1.0 - resolution: "next-tick@npm:1.1.0" - checksum: 83b5cf36027a53ee6d8b7f9c0782f2ba87f4858d977342bfc3c20c21629290a2111f8374d13a81221179603ffc4364f38374b5655d17b6a8f8a8c77bdea4fe8b - languageName: node - linkType: hard - "nimma@npm:0.2.2": version: 0.2.2 resolution: "nimma@npm:0.2.2" @@ -33737,17 +33643,6 @@ __metadata: languageName: node linkType: hard -"njwt@npm:^2.0.0": - version: 2.0.0 - resolution: "njwt@npm:2.0.0" - dependencies: - "@types/node": ^15.0.1 - ecdsa-sig-formatter: ^1.0.5 - uuid: ^8.3.2 - checksum: 3c6c33b2fd044bca7468171f5dca064f5a4f59ce0e63b567df62c1a8d720e3c3d65921d5e99ae72eb22fde3285ef42b6009b4c4469f06e3a0e66d88a6f393373 - languageName: node - linkType: hard - "no-case@npm:^3.0.4": version: 3.0.4 resolution: "no-case@npm:3.0.4" @@ -33774,15 +33669,6 @@ __metadata: languageName: node linkType: hard -"node-addon-api@npm:^1.7.1": - version: 1.7.2 - resolution: "node-addon-api@npm:1.7.2" - dependencies: - node-gyp: latest - checksum: 938922b3d7cb34ee137c5ec39df6289a3965e8cab9061c6848863324c21a778a81ae3bc955554c56b6b86962f6ccab2043dd5fa3f33deab633636bd28039333f - languageName: node - linkType: hard - "node-addon-api@npm:^3.2.1": version: 3.2.1 resolution: "node-addon-api@npm:3.2.1" @@ -36789,7 +36675,7 @@ __metadata: languageName: node linkType: hard -"randombytes@npm:^2.0.0, randombytes@npm:^2.0.1, randombytes@npm:^2.0.3, randombytes@npm:^2.0.5, randombytes@npm:^2.1.0": +"randombytes@npm:^2.0.0, randombytes@npm:^2.0.1, randombytes@npm:^2.0.5, randombytes@npm:^2.1.0": version: 2.1.0 resolution: "randombytes@npm:2.1.0" dependencies: @@ -39338,20 +39224,6 @@ __metadata: languageName: node linkType: hard -"simple-websocket@npm:^5.0.0": - version: 5.1.1 - resolution: "simple-websocket@npm:5.1.1" - dependencies: - debug: ^3.1.0 - inherits: ^2.0.1 - randombytes: ^2.0.3 - readable-stream: ^2.0.5 - safe-buffer: ^5.0.1 - ws: ^3.3.1 - checksum: 846ba5a4e8419a4b3186e8618ed55969d498d494c8c78002a7f6adfef51edfb1f673380ad28cd92d59c8a70d2247395ad8ad1117c1597839500e6c5efd1c3f30 - languageName: node - linkType: hard - "sinon@npm:^14.0.2": version: 14.0.2 resolution: "sinon@npm:14.0.2" @@ -41463,20 +41335,6 @@ __metadata: languageName: node linkType: hard -"type@npm:^1.0.1": - version: 1.2.0 - resolution: "type@npm:1.2.0" - checksum: dae8c64f82c648b985caf321e9dd6e8b7f4f2e2d4f846fc6fd2c8e9dc7769382d8a52369ddbaccd59aeeceb0df7f52fb339c465be5f2e543e81e810e413451ee - languageName: node - linkType: hard - -"type@npm:^2.7.2": - version: 2.7.2 - resolution: "type@npm:2.7.2" - checksum: 0f42379a8adb67fe529add238a3e3d16699d95b42d01adfe7b9a7c5da297f5c1ba93de39265ba30ffeb37dfd0afb3fb66ae09f58d6515da442219c086219f6f4 - languageName: node - linkType: hard - "typed-array-buffer@npm:^1.0.0": version: 1.0.0 resolution: "typed-array-buffer@npm:1.0.0" @@ -41664,13 +41522,6 @@ __metadata: languageName: node linkType: hard -"ultron@npm:~1.1.0": - version: 1.1.1 - resolution: "ultron@npm:1.1.1" - checksum: aa7b5ebb1b6e33287b9d873c6756c4b7aa6d1b23d7162ff25b0c0ce5c3c7e26e2ab141a5dc6e96c10ac4d00a372e682ce298d784f06ffcd520936590b4bc0653 - languageName: node - linkType: hard - "unbox-primitive@npm:^1.0.2": version: 1.0.2 resolution: "unbox-primitive@npm:1.0.2" @@ -42240,20 +42091,6 @@ __metadata: languageName: node linkType: hard -"v@npm:^0.3.0": - version: 0.3.0 - resolution: "v@npm:0.3.0" - dependencies: - deasync: ^0.1.9 - debug: ^2.6.1 - simple-websocket: ^5.0.0 - dependenciesMeta: - deasync: - optional: true - checksum: 55a52287b7d417f348516d50e2103f3ef46db371e736da94d55ae2fdc61bdeb3f58eea689ffa69aebe2e93a1abd7a9424eabc552f6060884e434b872229b2045 - languageName: node - linkType: hard - "valid-url@npm:^1.0.9": version: 1.0.9 resolution: "valid-url@npm:1.0.9" @@ -43082,17 +42919,6 @@ __metadata: languageName: node linkType: hard -"ws@npm:^3.3.1": - version: 3.3.3 - resolution: "ws@npm:3.3.3" - dependencies: - async-limiter: ~1.0.0 - safe-buffer: ~5.1.0 - ultron: ~1.1.0 - checksum: 20b7bf34bb88715b9e2d435b76088d770e063641e7ee697b07543815fabdb752335261c507a973955e823229d0af8549f39cc669825e5c8404aa0422615c81d9 - languageName: node - linkType: hard - "ws@npm:^5.2.0 || ^6.0.0 || ^7.0.0, ws@npm:^7.4.6": version: 7.5.9 resolution: "ws@npm:7.5.9" From b497b6e9f3456cc6def12a44d2dd8b80cef3eb60 Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Tue, 12 Sep 2023 15:05:37 -0400 Subject: [PATCH 47/59] Extract rfc8693 tokenexchange logic to a helper function Signed-off-by: Ruben Vallejo Co-authored-by: Jamie Klassen --- .../dev/index.ts | 2 +- .../src/authenticator.test.ts | 2 +- .../src/authenticator.ts | 82 +++++++++++++------ plugins/auth-backend/package.json | 2 - yarn.lock | 17 +--- 5 files changed, 61 insertions(+), 44 deletions(-) diff --git a/plugins/auth-backend-module-pinniped-provider/dev/index.ts b/plugins/auth-backend-module-pinniped-provider/dev/index.ts index cf0a6ebac9..bd09f77a1f 100644 --- a/plugins/auth-backend-module-pinniped-provider/dev/index.ts +++ b/plugins/auth-backend-module-pinniped-provider/dev/index.ts @@ -15,7 +15,7 @@ */ import { createBackend } from '@backstage/backend-defaults'; -import { authPlugin } from '@backstage/plugin-auth-backend'; +import authPlugin from '@backstage/plugin-auth-backend'; import { authModulePinnipedProvider } from '../src'; const backend = createBackend(); diff --git a/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts b/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts index ea2ca451b5..f908c1ea13 100644 --- a/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts +++ b/plugins/auth-backend-module-pinniped-provider/src/authenticator.test.ts @@ -415,7 +415,7 @@ describe('pinnipedAuthenticator', () => { await expect( pinnipedAuthenticator.authenticate(handlerRequest, implementation), ).rejects.toThrow( - `Failed to get cluster specific ID token for "test_cluster", RFC8693 token exchange failed with error: NetworkError: Connection timed out`, + `Failed to get cluster specific ID token for "test_cluster": Error: RFC8693 token exchange failed with error: NetworkError: Connection timed out`, ); }); diff --git a/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts b/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts index 0b178cc080..83c4204ad0 100644 --- a/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts +++ b/plugins/auth-backend-module-pinniped-provider/src/authenticator.ts @@ -19,7 +19,39 @@ import { decodeOAuthState, encodeOAuthState, } from '@backstage/plugin-auth-node'; -import { Issuer, TokenSet, Strategy as OidcStrategy } from 'openid-client'; +import { + Client, + Issuer, + TokenSet, + Strategy as OidcStrategy, +} from 'openid-client'; + +const rfc8693TokenExchange = async ({ + subject_token, + target_audience, + ctx, +}: { + subject_token: string; + target_audience: string; + ctx: Promise<{ + providerStrategy: OidcStrategy<{}>; + client: Client; + }>; +}): Promise => { + const { client } = await ctx; + return client + .grant({ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token, + audience: target_audience, + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', + requested_token_type: 'urn:ietf:params:oauth:token-type:jwt', + }) + .then(tokenset => tokenset.access_token) + .catch(err => { + throw new Error(`RFC8693 token exchange failed with error: ${err}`); + }); +}; /** @public */ export const pinnipedAuthenticator = createOAuthAuthenticator({ @@ -39,7 +71,7 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({ scope: config.getOptionalString('scope') || '', id_token_signed_response_alg: 'ES256', }); - const strategy = new OidcStrategy( + const providerStrategy = new OidcStrategy( { client, passReqToCallback: false, @@ -57,11 +89,11 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({ }, ); - return { strategy, client }; + return { providerStrategy, client }; }, - async start(input, implementation) { - const { strategy } = await implementation; + async start(input, ctx) { + const { providerStrategy } = await ctx; const stringifiedAudience = input.req.query?.audience as string; const decodedState = decodeOAuthState(input.state); const state = { ...decodedState, audience: stringifiedAudience }; @@ -73,6 +105,7 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({ }; return new Promise((resolve, reject) => { + const strategy = Object.create(providerStrategy); strategy.redirect = (url: string) => { resolve({ url }); }; @@ -83,8 +116,8 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({ }); }, - async authenticate(input, implementation) { - const { strategy, client } = await implementation; + async authenticate(input, ctx) { + const { providerStrategy } = await ctx; const { req } = input; const { searchParams } = new URL(req.url, 'https://pinniped.com'); const stateParam = searchParams.get('state'); @@ -93,25 +126,20 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({ : undefined; return new Promise((resolve, reject) => { - strategy.success = user => { + const strategy = Object.create(providerStrategy); + strategy.success = (user: any) => { (audience - ? client - .grant({ - grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', - subject_token: user.tokenset.access_token, - audience, - subject_token_type: - 'urn:ietf:params:oauth:token-type:access_token', - requested_token_type: 'urn:ietf:params:oauth:token-type:jwt', - }) - .then(tokenset => tokenset.access_token) - .catch(err => - reject( - new Error( - `Failed to get cluster specific ID token for "${audience}", RFC8693 token exchange failed with error: ${err}`, - ), + ? rfc8693TokenExchange({ + subject_token: user.tokenset.access_token, + target_audience: audience, + ctx, + }).catch(err => + reject( + new Error( + `Failed to get cluster specific ID token for "${audience}": ${err}`, ), - ) + ), + ) : Promise.resolve(user.tokenset.id_token) ).then(idToken => { resolve({ @@ -127,7 +155,7 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({ }); }; - strategy.fail = info => { + strategy.fail = (info: any) => { reject(new Error(`Authentication rejected, ${info.message || ''}`)); }; @@ -143,8 +171,8 @@ export const pinnipedAuthenticator = createOAuthAuthenticator({ }); }, - async refresh(input, implementation) { - const { client } = await implementation; + async refresh(input, ctx) { + const { client } = await ctx; const tokenset = await client.refresh(input.refreshToken); return new Promise((resolve, reject) => { diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 8cc8cf6b98..360ea52785 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -54,9 +54,7 @@ "@types/passport": "^1.0.3", "compression": "^1.7.4", "connect-session-knex": "^3.0.1", - "cookie": "^0.5.0", "cookie-parser": "^1.4.5", - "cookie-signature": "^1.2.1", "cors": "^2.8.5", "express": "^4.17.1", "express-promise-router": "^4.1.0", diff --git a/yarn.lock b/yarn.lock index d28d24fc14..186c06cad5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5036,9 +5036,7 @@ __metadata: "@types/xml2js": ^0.4.7 compression: ^1.7.4 connect-session-knex: ^3.0.1 - cookie: ^0.5.0 cookie-parser: ^1.4.5 - cookie-signature: ^1.2.1 cors: ^2.8.5 express: ^4.17.1 express-promise-router: ^4.1.0 @@ -22775,13 +22773,6 @@ __metadata: languageName: node linkType: hard -"cookie-signature@npm:^1.2.1": - version: 1.2.1 - resolution: "cookie-signature@npm:1.2.1" - checksum: bb464aacac390b5d7d8ead2d6fff7c1c3b7378c7d0250921f48923fe889688e081ab33950448929db5f24d4f9f1506589a7ee1c685de8f12a3fdb30c49667ec5 - languageName: node - linkType: hard - "cookie@npm:0.4.1": version: 0.4.1 resolution: "cookie@npm:0.4.1" @@ -22796,7 +22787,7 @@ __metadata: languageName: node linkType: hard -"cookie@npm:0.5.0, cookie@npm:^0.5.0, cookie@npm:~0.5.0": +"cookie@npm:0.5.0, cookie@npm:~0.5.0": version: 0.5.0 resolution: "cookie@npm:0.5.0" checksum: 1f4bd2ca5765f8c9689a7e8954183f5332139eb72b6ff783d8947032ec1fdf43109852c178e21a953a30c0dd42257828185be01b49d1eb1a67fd054ca588a180 @@ -30257,9 +30248,9 @@ __metadata: linkType: hard "jose@npm:^4.14.6": - version: 4.14.6 - resolution: "jose@npm:4.14.6" - checksum: eae81a234e7bf1446b1bd80722b3462b014e3835b155c3a7799c1c5043163a53a0dc28d347004151b031e6b7b863403aabf8814d9cc217ce21f8c2f3ebd4b335 + version: 4.15.2 + resolution: "jose@npm:4.15.2" + checksum: 8f0cab1eef31243abe14a935b2b330cd95f10f9b69808fd642088ae5000e50e566664934537d2c6413ab2f6b54acd8265a5033da05157aa1260c5f1d7e57fab0 languageName: node linkType: hard From 2f172ba9272d9fec54317912099a9259a488809d Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Wed, 11 Oct 2023 12:26:50 -0400 Subject: [PATCH 48/59] remove pinniped provider from default providers in auth-backend Signed-off-by: Ruben Vallejo --- .changeset/tiny-peaches-brake.md | 5 --- plugins/auth-backend/api-report.md | 4 --- plugins/auth-backend/package.json | 1 - .../src/providers/pinniped/index.ts | 17 --------- .../src/providers/pinniped/provider.test.ts | 36 ------------------- .../src/providers/pinniped/provider.ts | 32 ----------------- .../auth-backend/src/providers/providers.ts | 3 -- yarn.lock | 12 ++----- 8 files changed, 2 insertions(+), 108 deletions(-) delete mode 100644 .changeset/tiny-peaches-brake.md delete mode 100644 plugins/auth-backend/src/providers/pinniped/index.ts delete mode 100644 plugins/auth-backend/src/providers/pinniped/provider.test.ts delete mode 100644 plugins/auth-backend/src/providers/pinniped/provider.ts diff --git a/.changeset/tiny-peaches-brake.md b/.changeset/tiny-peaches-brake.md deleted file mode 100644 index e6d979fca3..0000000000 --- a/.changeset/tiny-peaches-brake.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-auth-backend': patch ---- - -Add Pinniped Auth Provider to list of default auth providers diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index 889b2b385e..41536f2a3f 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -619,10 +619,6 @@ export const providers: Readonly<{ ) => AuthProviderFactory_2; resolvers: never; }>; - pinniped: Readonly<{ - create: () => AuthProviderFactory_2; - resolvers: never; - }>; saml: Readonly<{ create: ( options?: diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 360ea52785..355c8eb554 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -44,7 +44,6 @@ "@backstage/plugin-auth-backend-module-google-provider": "workspace:^", "@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^", "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^", - "@backstage/plugin-auth-backend-module-pinniped-provider": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-catalog-node": "workspace:^", "@backstage/types": "workspace:^", diff --git a/plugins/auth-backend/src/providers/pinniped/index.ts b/plugins/auth-backend/src/providers/pinniped/index.ts deleted file mode 100644 index a45064ad4d..0000000000 --- a/plugins/auth-backend/src/providers/pinniped/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2023 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 { pinniped } from './provider'; diff --git a/plugins/auth-backend/src/providers/pinniped/provider.test.ts b/plugins/auth-backend/src/providers/pinniped/provider.test.ts deleted file mode 100644 index 60458e187a..0000000000 --- a/plugins/auth-backend/src/providers/pinniped/provider.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2023 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 { pinnipedAuthenticator } from '@backstage/plugin-auth-backend-module-pinniped-provider'; -import { createOAuthProviderFactory } from '@backstage/plugin-auth-node'; -import { pinniped } from './provider'; - -jest.mock('@backstage/plugin-auth-node', () => ({ - ...jest.requireActual('@backstage/plugin-auth-node'), - createOAuthProviderFactory: jest.fn(() => 'provider-factory'), -})); - -describe('createPinnipedAuthProvider', () => { - afterEach(() => jest.clearAllMocks()); - - it('should be created', async () => { - expect(pinniped.create()).toBe('provider-factory'); - - expect(createOAuthProviderFactory).toHaveBeenCalledWith({ - authenticator: pinnipedAuthenticator, - }); - }); -}); diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts deleted file mode 100644 index 433c49a6cd..0000000000 --- a/plugins/auth-backend/src/providers/pinniped/provider.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2023 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 { pinnipedAuthenticator } from '@backstage/plugin-auth-backend-module-pinniped-provider'; -import { createAuthProviderIntegration } from '../createAuthProviderIntegration'; -import { createOAuthProviderFactory } from '@backstage/plugin-auth-node'; - -/** - * Auth provider integration for Pinniped auth - * - * @public - */ -export const pinniped = createAuthProviderIntegration({ - create() { - return createOAuthProviderFactory({ - authenticator: pinnipedAuthenticator, - }); - }, -}); diff --git a/plugins/auth-backend/src/providers/providers.ts b/plugins/auth-backend/src/providers/providers.ts index cf5a6df68d..36a24f4f6c 100644 --- a/plugins/auth-backend/src/providers/providers.ts +++ b/plugins/auth-backend/src/providers/providers.ts @@ -33,7 +33,6 @@ import { saml } from './saml'; import { AuthProviderFactory } from './types'; import { bitbucketServer } from './bitbucketServer'; import { easyAuth } from './azure-easyauth'; -import { pinniped } from './pinniped'; /** * All built-in auth provider integrations. @@ -57,7 +56,6 @@ export const providers = Object.freeze({ oidc, okta, onelogin, - pinniped, saml, easyAuth, }); @@ -85,5 +83,4 @@ export const defaultAuthProviderFactories: { bitbucket: bitbucket.create(), bitbucketServer: bitbucketServer.create(), atlassian: atlassian.create(), - pinniped: pinniped.create(), }; diff --git a/yarn.lock b/yarn.lock index 186c06cad5..57ca1c478d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4972,7 +4972,7 @@ __metadata: languageName: unknown linkType: soft -"@backstage/plugin-auth-backend-module-pinniped-provider@workspace:^, @backstage/plugin-auth-backend-module-pinniped-provider@workspace:plugins/auth-backend-module-pinniped-provider": +"@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" dependencies: @@ -5015,7 +5015,6 @@ __metadata: "@backstage/plugin-auth-backend-module-google-provider": "workspace:^" "@backstage/plugin-auth-backend-module-microsoft-provider": "workspace:^" "@backstage/plugin-auth-backend-module-oauth2-provider": "workspace:^" - "@backstage/plugin-auth-backend-module-pinniped-provider": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" "@backstage/plugin-catalog-node": "workspace:^" "@backstage/types": "workspace:^" @@ -30247,14 +30246,7 @@ __metadata: languageName: node linkType: hard -"jose@npm:^4.14.6": - version: 4.15.2 - resolution: "jose@npm:4.15.2" - checksum: 8f0cab1eef31243abe14a935b2b330cd95f10f9b69808fd642088ae5000e50e566664934537d2c6413ab2f6b54acd8265a5033da05157aa1260c5f1d7e57fab0 - languageName: node - linkType: hard - -"jose@npm:^4.15.1, jose@npm:^4.6.0": +"jose@npm:^4.14.6, jose@npm:^4.15.1, jose@npm:^4.6.0": version: 4.15.3 resolution: "jose@npm:4.15.3" checksum: b76eeccc1d40d0eaf26dfaadc0f88fc15802c9105ab66a24ee223bd84369f7cb217f4a2cb852f5080ff6996170b3a73db2b2d26878b8905d99c36ca432628134 From c58f8264f1d646faaf97ebd6647ec5a9d19df553 Mon Sep 17 00:00:00 2001 From: Markus Date: Thu, 12 Oct 2023 17:55:27 +0200 Subject: [PATCH 49/59] fix: empty scope in oidc client response Signed-off-by: Markus --- plugins/auth-backend/src/providers/oidc/provider.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/auth-backend/src/providers/oidc/provider.ts b/plugins/auth-backend/src/providers/oidc/provider.ts index 7638027b01..b6608aebbd 100644 --- a/plugins/auth-backend/src/providers/oidc/provider.ts +++ b/plugins/auth-backend/src/providers/oidc/provider.ts @@ -130,6 +130,9 @@ export class OidcAuthProvider implements OAuthHandlers { if (!tokenset.access_token) { throw new Error('Refresh failed'); } + if (!tokenset.scope) { + tokenset.scope = req.scope; + } const userinfo = await client.userinfo(tokenset.access_token); return { From 9ff79351527bef6d09bd5cff9e4eb651c666cee2 Mon Sep 17 00:00:00 2001 From: Markus Date: Thu, 12 Oct 2023 18:16:04 +0200 Subject: [PATCH 50/59] chore: add changeset Signed-off-by: Markus --- .changeset/five-spiders-listen.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/five-spiders-listen.md diff --git a/.changeset/five-spiders-listen.md b/.changeset/five-spiders-listen.md new file mode 100644 index 0000000000..a7968964e2 --- /dev/null +++ b/.changeset/five-spiders-listen.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': patch +--- + +Fixed bug in oidc refresh handler, if token endpoints response on refresh request does not contain a scope, the requested scope is used. From 0296f272b40d18bd1df8c0922737df8eb8473187 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 13 Oct 2023 00:05:25 +0200 Subject: [PATCH 51/59] type fixes for React 18 Signed-off-by: Patrik Oldsberg --- .changeset/healthy-shirts-fold.md | 6 ++++ .changeset/popular-bikes-do.md | 5 +++ .changeset/pretty-swans-worry.md | 5 +++ .changeset/sweet-buckets-fry.md | 5 +++ .changeset/thick-dolphins-boil.md | 5 +++ .changeset/wet-cows-brake.md | 5 +++ .changeset/wild-geese-occur.md | 5 +++ .../src/routing/RouteResolver.beta.test.ts | 3 +- .../src/routing/RouteResolver.compat.test.ts | 3 +- .../src/routing/RouteResolver.stable.test.ts | 3 +- .../src/routing/RoutingProvider.beta.test.tsx | 4 ++- .../routing/RoutingProvider.stable.test.tsx | 4 ++- packages/core-components/api-report.md | 2 +- .../DependencyGraph/DependencyGraph.tsx | 2 +- .../src/components/Table/Table.tsx | 4 +-- .../src/layout/Sidebar/Items.tsx | 16 +++++++-- .../src/layout/Sidebar/MobileSidebar.tsx | 5 ++- .../src/layout/Sidebar/Sidebar.stories.tsx | 2 +- .../src/layout/TabbedCard/TabbedCard.tsx | 4 ++- .../src/extensions/useElementFilter.test.tsx | 7 +++- .../translation/useTranslationRef.test.tsx | 7 +++- .../CatalogGraphPage/CurveFilter.tsx | 7 ++-- .../CatalogGraphPage/DirectionFilter.tsx | 7 ++-- .../DefaultImportPage/DefaultImportPage.tsx | 2 +- .../ImportInfoCard/ImportInfoCard.tsx | 2 +- .../StepReviewLocation/StepReviewLocation.tsx | 2 +- .../EntityPeekAheadPopover.tsx | 2 +- .../components/OverviewPage.tsx | 5 ++- .../useUnregisterEntityDialogState.test.tsx | 22 +++++++++--- .../components/EntityLayout/EntityLayout.tsx | 5 ++- .../components/FileExplorer/FileExplorer.tsx | 6 ++-- .../BarChart/BarChartTooltip.test.tsx | 5 +-- .../ProjectSelect/ProjectSelect.test.tsx | 4 +-- .../src/components/AuditList/index.tsx | 4 ++- .../GroupListPicker/GroupListPicker.tsx | 2 +- plugins/playlist/package.json | 1 + .../PlaylistPage/AddEntitiesDrawer.tsx | 6 ++-- .../PlaylistPage/PlaylistEntitiesTable.tsx | 2 +- .../MultistepJsonForm/MultistepJsonForm.tsx | 4 +-- .../CustomFieldExplorer.tsx | 14 +++++--- .../TemplateFormPreviewer.tsx | 3 +- .../CustomFieldExplorer.tsx | 8 +++-- .../TemplateFormPreviewer.tsx | 3 +- .../SearchFilter.Autocomplete.test.tsx | 2 +- .../SearchResultGroup/SearchResultGroup.tsx | 4 +-- plugins/search/src/alpha.tsx | 34 ++++++++++--------- .../HomePageComponent/HomePageSearchBar.tsx | 9 +---- .../SentryIssuesTable/SentryIssuesTable.tsx | 15 ++++---- .../TechDocsReaderPageHeader.tsx | 4 ++- yarn.lock | 1 + 50 files changed, 193 insertions(+), 94 deletions(-) create mode 100644 .changeset/healthy-shirts-fold.md create mode 100644 .changeset/popular-bikes-do.md create mode 100644 .changeset/pretty-swans-worry.md create mode 100644 .changeset/sweet-buckets-fry.md create mode 100644 .changeset/thick-dolphins-boil.md create mode 100644 .changeset/wet-cows-brake.md create mode 100644 .changeset/wild-geese-occur.md diff --git a/.changeset/healthy-shirts-fold.md b/.changeset/healthy-shirts-fold.md new file mode 100644 index 0000000000..f2ac0a9e83 --- /dev/null +++ b/.changeset/healthy-shirts-fold.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-catalog': patch +'@backstage/plugin-techdocs': patch +--- + +The `spec.lifecycle' field in entities will now always be rendered as a string. diff --git a/.changeset/popular-bikes-do.md b/.changeset/popular-bikes-do.md new file mode 100644 index 0000000000..b1cc5c2cdd --- /dev/null +++ b/.changeset/popular-bikes-do.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-code-coverage': patch +--- + +The warning for missing code coverage will now render the entity as a reference. diff --git a/.changeset/pretty-swans-worry.md b/.changeset/pretty-swans-worry.md new file mode 100644 index 0000000000..621ae98fe5 --- /dev/null +++ b/.changeset/pretty-swans-worry.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Fixed the type declaration of `DependencyGraphProps`, the `defs` prop now expects `JSX.Element`s. diff --git a/.changeset/sweet-buckets-fry.md b/.changeset/sweet-buckets-fry.md new file mode 100644 index 0000000000..8f17617645 --- /dev/null +++ b/.changeset/sweet-buckets-fry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-react': patch +--- + +The `spec.type` field in entities will now always be rendered as a string. diff --git a/.changeset/thick-dolphins-boil.md b/.changeset/thick-dolphins-boil.md new file mode 100644 index 0000000000..dd562e620c --- /dev/null +++ b/.changeset/thick-dolphins-boil.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog-import': patch +--- + +The `app.title` configuration is now properly required to be a string. diff --git a/.changeset/wet-cows-brake.md b/.changeset/wet-cows-brake.md new file mode 100644 index 0000000000..9e694d8452 --- /dev/null +++ b/.changeset/wet-cows-brake.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-react': patch +--- + +The filter options passed to `SearchResultGroupLayout` are now always explicitly rendered as strings by default. diff --git a/.changeset/wild-geese-occur.md b/.changeset/wild-geese-occur.md new file mode 100644 index 0000000000..b97620e7f5 --- /dev/null +++ b/.changeset/wild-geese-occur.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search': patch +--- + +Minor internal code cleanup. diff --git a/packages/core-app-api/src/routing/RouteResolver.beta.test.ts b/packages/core-app-api/src/routing/RouteResolver.beta.test.ts index c0ee452d28..09e6389e7f 100644 --- a/packages/core-app-api/src/routing/RouteResolver.beta.test.ts +++ b/packages/core-app-api/src/routing/RouteResolver.beta.test.ts @@ -31,9 +31,8 @@ jest.mock('react-router-dom', () => jest.requireActual('react-router-dom-beta'), ); -const element = () => null; const rest = { - element, + element: null, caseSensitive: false, children: [MATCH_ALL_ROUTE], plugins: new Set(), diff --git a/packages/core-app-api/src/routing/RouteResolver.compat.test.ts b/packages/core-app-api/src/routing/RouteResolver.compat.test.ts index 0b4bf588a2..39949b4cd6 100644 --- a/packages/core-app-api/src/routing/RouteResolver.compat.test.ts +++ b/packages/core-app-api/src/routing/RouteResolver.compat.test.ts @@ -25,9 +25,8 @@ import { } from '@backstage/core-plugin-api'; import { MATCH_ALL_ROUTE } from './collectors'; -const element = () => null; const rest = { - element, + element: null, caseSensitive: false, children: [MATCH_ALL_ROUTE], plugins: new Set(), diff --git a/packages/core-app-api/src/routing/RouteResolver.stable.test.ts b/packages/core-app-api/src/routing/RouteResolver.stable.test.ts index 05f7678078..f1f9ec7137 100644 --- a/packages/core-app-api/src/routing/RouteResolver.stable.test.ts +++ b/packages/core-app-api/src/routing/RouteResolver.stable.test.ts @@ -31,9 +31,8 @@ jest.mock('react-router-dom', () => jest.requireActual('react-router-dom-stable'), ); -const element = () => null; const rest = { - element, + element: null, caseSensitive: false, children: [MATCH_ALL_ROUTE], plugins: new Set(), diff --git a/packages/core-app-api/src/routing/RoutingProvider.beta.test.tsx b/packages/core-app-api/src/routing/RoutingProvider.beta.test.tsx index c3a5bab89e..0f7a5cd3ad 100644 --- a/packages/core-app-api/src/routing/RoutingProvider.beta.test.tsx +++ b/packages/core-app-api/src/routing/RoutingProvider.beta.test.tsx @@ -353,7 +353,9 @@ describe('v1 consumer', () => { initialProps: { routeRef: routeRef1 as AnyRouteRef, }, - wrapper: ({ children }: React.PropsWithChildren<{}>) => ( + wrapper: ({ + children, + }: React.PropsWithChildren<{ routeRef: AnyRouteRef }>) => ( , string>([ diff --git a/packages/core-app-api/src/routing/RoutingProvider.stable.test.tsx b/packages/core-app-api/src/routing/RoutingProvider.stable.test.tsx index 84a33842c9..495fc20b15 100644 --- a/packages/core-app-api/src/routing/RoutingProvider.stable.test.tsx +++ b/packages/core-app-api/src/routing/RoutingProvider.stable.test.tsx @@ -385,7 +385,9 @@ describe('v1 consumer', () => { initialProps: { routeRef: routeRef1 as AnyRouteRef, }, - wrapper: ({ children }: React.PropsWithChildren<{}>) => ( + wrapper: ({ + children, + }: React.PropsWithChildren<{ routeRef: AnyRouteRef }>) => ( , string>([ diff --git a/packages/core-components/api-report.md b/packages/core-components/api-report.md index 44a8b33e04..d40179dc62 100644 --- a/packages/core-components/api-report.md +++ b/packages/core-components/api-report.md @@ -249,7 +249,7 @@ export interface DependencyGraphProps acyclicer?: 'greedy'; align?: DependencyGraphTypes.Alignment; curve?: 'curveStepBefore' | 'curveMonotoneX'; - defs?: SVGDefsElement | SVGDefsElement[]; + defs?: JSX.Element | JSX.Element[]; direction?: DependencyGraphTypes.Direction; edgeMargin?: number; edgeRanks?: number; diff --git a/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx b/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx index 4928292424..3dd1025b8f 100644 --- a/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx +++ b/packages/core-components/src/components/DependencyGraph/DependencyGraph.tsx @@ -144,7 +144,7 @@ export interface DependencyGraphProps * {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs | Defs} shared by rendered SVG to be used by * {@link DependencyGraphProps.renderNode} and/or {@link DependencyGraphProps.renderLabel} */ - defs?: SVGDefsElement | SVGDefsElement[]; + defs?: JSX.Element | JSX.Element[]; /** * Controls zoom behavior of graph * diff --git a/packages/core-components/src/components/Table/Table.tsx b/packages/core-components/src/components/Table/Table.tsx index 9f2b04c214..e4ee9b5995 100644 --- a/packages/core-components/src/components/Table/Table.tsx +++ b/packages/core-components/src/components/Table/Table.tsx @@ -455,7 +455,7 @@ export function Table(props: TableProps) { const hasFilters = !!filters?.length; const Toolbar = useCallback( - toolbarProps => { + (toolbarProps: any /* no type for this in material-table */) => { return ( (props: TableProps) { const hasNoRows = typeof data !== 'function' && data.length === 0; const columnCount = columns.length; const Body = useCallback( - bodyProps => { + (bodyProps: any /* no type for this in material-table */) => { if (isLoading) { return ( diff --git a/packages/core-components/src/layout/Sidebar/Items.tsx b/packages/core-components/src/layout/Sidebar/Items.tsx index c40d45f8b4..aba4c79181 100644 --- a/packages/core-components/src/layout/Sidebar/Items.tsx +++ b/packages/core-components/src/layout/Sidebar/Items.tsx @@ -312,7 +312,11 @@ const sidebarSubmenuType = React.createElement(SidebarSubmenu).type; // properly yet, matching for example /foobar with /foo. export const WorkaroundNavLink = React.forwardRef< HTMLAnchorElement, - NavLinkProps & { activeStyle?: CSSProperties; activeClassName?: string } + NavLinkProps & { + children?: ReactNode; + activeStyle?: CSSProperties; + activeClassName?: string; + } >(function WorkaroundNavLinkWithRef( { to, @@ -361,7 +365,10 @@ export const WorkaroundNavLink = React.forwardRef< /** * Common component used by SidebarItem & SidebarItemWithSubmenu */ -const SidebarItemBase = forwardRef((props, ref) => { +const SidebarItemBase = forwardRef< + any, + SidebarItemProps & { children: ReactNode } +>((props, ref) => { const { icon: Icon, text, @@ -553,7 +560,10 @@ const SidebarItemWithSubmenu = ({ * @remarks * If children contain a `SidebarSubmenu` component the `SidebarItem` will have a expandable submenu */ -export const SidebarItem = forwardRef((props, ref) => { +export const SidebarItem = forwardRef< + any, + SidebarItemProps & { children: ReactNode } +>((props, ref) => { // Filter children for SidebarSubmenu components const [submenu] = useElementFilter(props.children, elements => // Directly comparing child.type with SidebarSubmenu will not work with in diff --git a/packages/core-components/src/layout/Sidebar/MobileSidebar.tsx b/packages/core-components/src/layout/Sidebar/MobileSidebar.tsx index e124036648..88d653cea5 100644 --- a/packages/core-components/src/layout/Sidebar/MobileSidebar.tsx +++ b/packages/core-components/src/layout/Sidebar/MobileSidebar.tsx @@ -25,7 +25,7 @@ import Typography from '@material-ui/core/Typography'; import CloseIcon from '@material-ui/icons/Close'; import MenuIcon from '@material-ui/icons/Menu'; import { orderBy } from 'lodash'; -import React, { useEffect, useState, useContext } from 'react'; +import React, { useEffect, useState, useContext, ReactNode } from 'react'; import { useLocation } from 'react-router-dom'; import { SidebarOpenStateProvider } from './SidebarOpenStateContext'; import { SidebarGroup } from './SidebarGroup'; @@ -206,8 +206,7 @@ export const MobileSidebar = (props: MobileSidebarProps) => { onClose={() => setSelectedMenuItemIndex(-1)} > {sidebarGroups[selectedMenuItemIndex] && - (sidebarGroups[selectedMenuItemIndex].props - .children as React.ReactChildren)} + (sidebarGroups[selectedMenuItemIndex].props.children as ReactNode)} { export const SampleSidebar = () => ( - + }> diff --git a/packages/core-components/src/layout/TabbedCard/TabbedCard.tsx b/packages/core-components/src/layout/TabbedCard/TabbedCard.tsx index a09ea11b3d..4e75878a7d 100644 --- a/packages/core-components/src/layout/TabbedCard/TabbedCard.tsx +++ b/packages/core-components/src/layout/TabbedCard/TabbedCard.tsx @@ -96,7 +96,9 @@ export function TabbedCard(props: PropsWithChildren) { } else { React.Children.map(children, child => { if ( - React.isValidElement<{ children?: unknown; value?: unknown }>(child) && + React.isValidElement<{ children?: ReactNode; value?: unknown }>( + child, + ) && child?.props.value === value ) { selectedTabContent = child?.props.children; diff --git a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx index 8795ecdded..9a3760d019 100644 --- a/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx +++ b/packages/core-plugin-api/src/extensions/useElementFilter.test.tsx @@ -41,7 +41,12 @@ const FeatureFlagComponent = (_props: { }) => null; attachComponentData(FeatureFlagComponent, 'core.featureFlagged', true); const mockFeatureFlagsApi = new LocalStorageFeatureFlags(); -const Wrapper = ({ children }: { children?: React.ReactNode }) => ( +const Wrapper = ({ + children, +}: { + children?: React.ReactNode; + tree?: ReactNode; +}) => ( {children} diff --git a/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx b/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx index a8dbdc0402..19f0516906 100644 --- a/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx +++ b/packages/core-plugin-api/src/translation/useTranslationRef.test.tsx @@ -303,7 +303,12 @@ describe('useTranslationRef', () => { const translationApi = I18nextTranslationApi.create({ languageApi }); const { result, rerender } = renderHook( - ({ translationRef }) => useTranslationRef(translationRef), + ({ + translationRef, + }: { + translationRef: TranslationRef; + children?: ReactNode; + }) => useTranslationRef(translationRef), { wrapper: ({ children }) => ( = ['curveMonotoneX', 'curveStepBefore']; export const CurveFilter = ({ value, onChange }: Props) => { - const handleChange = useCallback(v => onChange(v as Curve), [onChange]); + const handleChange = useCallback( + (v: SelectedItems) => onChange(v as Curve), + [onChange], + ); return ( diff --git a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx index 815ed1f292..c614ed2b58 100644 --- a/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx +++ b/plugins/catalog-graph/src/components/CatalogGraphPage/DirectionFilter.tsx @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Select } from '@backstage/core-components'; +import { Select, SelectedItems } from '@backstage/core-components'; import { Box } from '@material-ui/core'; import React, { useCallback } from 'react'; import { Direction } from '../EntityRelationsGraph'; @@ -31,7 +31,10 @@ export type Props = { }; export const DirectionFilter = ({ value, onChange }: Props) => { - const handleChange = useCallback(v => onChange(v as Direction), [onChange]); + const handleChange = useCallback( + (v: SelectedItems) => onChange(v as Direction), + [onChange], + ); return ( diff --git a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx index c777953808..16000465b9 100644 --- a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx +++ b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.tsx @@ -36,7 +36,7 @@ export const DefaultImportPage = () => { const theme = useTheme(); const configApi = useApi(configApiRef); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); - const appTitle = configApi.getOptional('app.title') || 'Backstage'; + const appTitle = configApi.getOptionalString('app.title') || 'Backstage'; const contentItems = [ diff --git a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx index f7086fce19..26b6b8eb52 100644 --- a/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx +++ b/plugins/catalog-import/src/components/ImportInfoCard/ImportInfoCard.tsx @@ -43,7 +43,7 @@ export const ImportInfoCard = (props: ImportInfoCardProps) => { } = props; const configApi = useApi(configApiRef); - const appTitle = configApi.getOptional('app.title') || 'Backstage'; + const appTitle = configApi.getOptionalString('app.title') || 'Backstage'; const catalogImportApi = useApi(catalogImportApiRef); const hasGithubIntegration = configApi.has('integrations.github'); diff --git a/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx b/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx index 74ebd973d0..b0db174787 100644 --- a/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx +++ b/plugins/catalog-import/src/components/StepReviewLocation/StepReviewLocation.tsx @@ -42,7 +42,7 @@ export const StepReviewLocation = ({ const configApi = useApi(configApiRef); const analytics = useAnalytics(); - const appTitle = configApi.getOptional('app.title') || 'Backstage'; + const appTitle = configApi.getOptionalString('app.title') || 'Backstage'; const [submitted, setSubmitted] = useState(false); const [error, setError] = useState(); diff --git a/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.tsx b/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.tsx index 91d60d4f13..077274a6e9 100644 --- a/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.tsx +++ b/plugins/catalog-react/src/components/EntityPeekAheadPopover/EntityPeekAheadPopover.tsx @@ -164,7 +164,7 @@ export const EntityPeekAheadPopover = (props: EntityPeekAheadPopoverProps) => { {entity.metadata.description} )} - {entity.spec?.type} + {entity.spec?.type?.toString()} {(entity.metadata.tags || []) .slice(0, maxTagChips) diff --git a/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx b/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx index 09b944605f..d2eb6d4266 100644 --- a/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx +++ b/plugins/catalog-react/src/components/InspectEntityDialog/components/OverviewPage.tsx @@ -74,7 +74,10 @@ export function OverviewPage(props: { entity: AlphaEntity }) { {spec?.type && ( - + )} {metadata.uid && ( diff --git a/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx b/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx index e458e7d157..8591cdad93 100644 --- a/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx +++ b/plugins/catalog-react/src/components/UnregisterEntityDialog/useUnregisterEntityDialogState.test.tsx @@ -22,7 +22,7 @@ import { renderHook, RenderHookResult, } from '@testing-library/react-hooks'; -import React from 'react'; +import React, { ReactNode } from 'react'; import { UseUnregisterEntityDialogState, useUnregisterEntityDialogState, @@ -85,7 +85,10 @@ describe('useUnregisterEntityDialogState', () => { }); it('goes through the happy unregister path', async () => { - let rendered: RenderHookResult; + let rendered: RenderHookResult< + { children?: ReactNode }, + UseUnregisterEntityDialogState + >; act(() => { rendered = renderHook(() => useUnregisterEntityDialogState(entity), { wrapper: Wrapper, @@ -114,7 +117,10 @@ describe('useUnregisterEntityDialogState', () => { entity.metadata.annotations![ANNOTATION_ORIGIN_LOCATION] = 'bootstrap:bootstrap'; - let rendered: RenderHookResult; + let rendered: RenderHookResult< + { children?: ReactNode }, + UseUnregisterEntityDialogState + >; act(() => { rendered = renderHook(() => useUnregisterEntityDialogState(entity), { wrapper: Wrapper, @@ -137,7 +143,10 @@ describe('useUnregisterEntityDialogState', () => { it('chooses only-delete when there was no location annotation', async () => { delete entity.metadata.annotations![ANNOTATION_ORIGIN_LOCATION]; - let rendered: RenderHookResult; + let rendered: RenderHookResult< + { children?: ReactNode }, + UseUnregisterEntityDialogState + >; act(() => { rendered = renderHook(() => useUnregisterEntityDialogState(entity), { wrapper: Wrapper, @@ -157,7 +166,10 @@ describe('useUnregisterEntityDialogState', () => { }); it('chooses only-delete when the location could not be found', async () => { - let rendered: RenderHookResult; + let rendered: RenderHookResult< + { children?: ReactNode }, + UseUnregisterEntityDialogState + >; act(() => { rendered = renderHook(() => useUnregisterEntityDialogState(entity), { wrapper: Wrapper, diff --git a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx index a190984ffe..9505dad889 100644 --- a/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/components/EntityLayout/EntityLayout.tsx @@ -128,7 +128,10 @@ function EntityLabels(props: { entity: Entity }) { /> )} {entity.spec?.lifecycle && ( - + )} ); diff --git a/plugins/code-coverage/src/components/FileExplorer/FileExplorer.tsx b/plugins/code-coverage/src/components/FileExplorer/FileExplorer.tsx index 4017ba8264..57bb9a2be0 100644 --- a/plugins/code-coverage/src/components/FileExplorer/FileExplorer.tsx +++ b/plugins/code-coverage/src/components/FileExplorer/FileExplorer.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useEntity } from '@backstage/plugin-catalog-react'; +import { humanizeEntityRef, useEntity } from '@backstage/plugin-catalog-react'; import { Box, Modal, makeStyles } from '@material-ui/core'; import FolderIcon from '@material-ui/icons/Folder'; import FileOutlinedIcon from '@material-ui/icons/InsertDriveFileOutlined'; @@ -181,7 +181,9 @@ export const FileExplorer = () => { } if (!value) { return ( - No code coverage found for ${entity} + + No code coverage found for {humanizeEntityRef(entity)} + ); } diff --git a/plugins/cost-insights/src/components/BarChart/BarChartTooltip.test.tsx b/plugins/cost-insights/src/components/BarChart/BarChartTooltip.test.tsx index 3a6dca9417..4e255917d8 100644 --- a/plugins/cost-insights/src/components/BarChart/BarChartTooltip.test.tsx +++ b/plugins/cost-insights/src/components/BarChart/BarChartTooltip.test.tsx @@ -33,8 +33,9 @@ const items = [ }, ]; -const tooltipItems = () => - items.map(item => ); +const tooltipItems = items.map(item => ( + +)); describe('', () => { it('formats label and tooltip item text correctly', async () => { diff --git a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx index 7dd5b4253f..3a2f30bbfe 100644 --- a/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx +++ b/plugins/cost-insights/src/components/ProjectSelect/ProjectSelect.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import React, { ComponentType } from 'react'; import { getByRole, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ProjectSelect } from './ProjectSelect'; @@ -28,7 +28,7 @@ const mockProjects = [ ]; describe('', () => { - let Component: React.ReactNode; + let Component: ComponentType; beforeEach(() => { Component = () => ( diff --git a/plugins/lighthouse/src/components/AuditList/index.tsx b/plugins/lighthouse/src/components/AuditList/index.tsx index c433bb1900..f030ec4708 100644 --- a/plugins/lighthouse/src/components/AuditList/index.tsx +++ b/plugins/lighthouse/src/components/AuditList/index.tsx @@ -44,7 +44,9 @@ import { useApi } from '@backstage/core-plugin-api'; export const LIMIT = 10; const AuditList = () => { - const [dismissedStored] = useLocalStorage(LIGHTHOUSE_INTRO_LOCAL_STORAGE); + const [dismissedStored] = useLocalStorage( + LIGHTHOUSE_INTRO_LOCAL_STORAGE, + ); const [dismissed, setDismissed] = useState(dismissedStored); const query = useQuery(); diff --git a/plugins/org-react/src/components/GroupListPicker/GroupListPicker.tsx b/plugins/org-react/src/components/GroupListPicker/GroupListPicker.tsx index a47c346390..57a93ce0fe 100644 --- a/plugins/org-react/src/components/GroupListPicker/GroupListPicker.tsx +++ b/plugins/org-react/src/components/GroupListPicker/GroupListPicker.tsx @@ -74,7 +74,7 @@ export const GroupListPicker = (props: GroupListPickerProps) => { }, [catalogApi, groupTypes]); const handleChange = useCallback( - (_, v: GroupEntity | null) => { + (_: unknown, v: GroupEntity | null) => { onChange(v ?? undefined); setAnchorEl(null); }, diff --git a/plugins/playlist/package.json b/plugins/playlist/package.json index 5169e4828d..a479c2946d 100644 --- a/plugins/playlist/package.json +++ b/plugins/playlist/package.json @@ -59,6 +59,7 @@ "@backstage/cli": "workspace:^", "@backstage/core-app-api": "workspace:^", "@backstage/dev-utils": "workspace:^", + "@backstage/plugin-search-common": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/dom": "^9.0.0", "@testing-library/jest-dom": "^6.0.0", diff --git a/plugins/playlist/src/components/PlaylistPage/AddEntitiesDrawer.tsx b/plugins/playlist/src/components/PlaylistPage/AddEntitiesDrawer.tsx index d5a52dc83c..529327776e 100644 --- a/plugins/playlist/src/components/PlaylistPage/AddEntitiesDrawer.tsx +++ b/plugins/playlist/src/components/PlaylistPage/AddEntitiesDrawer.tsx @@ -15,6 +15,7 @@ */ import { + CompoundEntityRef, Entity, getCompoundEntityRef, stringifyEntityRef, @@ -22,6 +23,7 @@ import { import { useApi, useRouteRef } from '@backstage/core-plugin-api'; import { CatalogEntityDocument } from '@backstage/plugin-catalog-common'; import { catalogApiRef, entityRouteRef } from '@backstage/plugin-catalog-react'; +import type { SearchDocument } from '@backstage/plugin-search-common'; import { SearchBar, SearchContextProvider, @@ -131,13 +133,13 @@ export const AddEntitiesDrawer = ({ }; const addEntity = useCallback( - entityResult => { + (entityResult: SearchDocument) => { // TODO(kuangp): this parsing of the location is not great. Ideally `CatalogEntityDocument` // contains the `metadata.name` field so we can derive the full ref and we only fall back to // parsing location if it's missing (ie. for older versions) const match = entityResult.location.match(entityLocationRegex); if (match?.groups) { - onAdd(stringifyEntityRef(match?.groups)); + onAdd(stringifyEntityRef(match?.groups as CompoundEntityRef)); } else { // eslint-disable-next-line no-console console.error( diff --git a/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx b/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx index c74859ce20..72d50506b0 100644 --- a/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx +++ b/plugins/playlist/src/components/PlaylistPage/PlaylistEntitiesTable.tsx @@ -72,7 +72,7 @@ export const PlaylistEntitiesTable = ({ ); const removeEntity = useCallback( - async (_, entity: Entity | Entity[]) => { + async (_: unknown, entity: Entity | Entity[]) => { try { const entityArray = [entity].flat(); const entityNames = entityArray.map( diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index ec95bad0e5..d8299a9b5f 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -29,7 +29,7 @@ import { useRouteRefParams, useApi, } from '@backstage/core-plugin-api'; -import { FormProps, IChangeEvent, withTheme } from '@rjsf/core'; +import { FormProps, IChangeEvent, ISubmitEvent, withTheme } from '@rjsf/core'; import { Theme as MuiTheme } from '@rjsf/material-ui'; import React, { ComponentType, useState } from 'react'; import { transformSchemaToProps } from './schema'; @@ -185,7 +185,7 @@ export const MultistepJsonForm = (props: MultistepJsonFormProps) => { formData={formData} formContext={{ formData }} onChange={onChange} - onSubmit={e => { + onSubmit={(e: ISubmitEvent) => { if (e.errors.length === 0) handleNext(); }} {...formProps} diff --git a/plugins/scaffolder/src/components/TemplateEditorPage/CustomFieldExplorer.tsx b/plugins/scaffolder/src/components/TemplateEditorPage/CustomFieldExplorer.tsx index 43b3b49bcf..e470267b23 100644 --- a/plugins/scaffolder/src/components/TemplateEditorPage/CustomFieldExplorer.tsx +++ b/plugins/scaffolder/src/components/TemplateEditorPage/CustomFieldExplorer.tsx @@ -28,7 +28,7 @@ import { Select, } from '@material-ui/core'; import CloseIcon from '@material-ui/icons/Close'; -import { withTheme } from '@rjsf/core'; +import { ISubmitEvent, withTheme } from '@rjsf/core'; import { Theme as MuiTheme } from '@rjsf/material-ui'; import CodeMirror from '@uiw/react-codemirror'; import React, { useCallback, useMemo, useState } from 'react'; @@ -104,7 +104,7 @@ export const CustomFieldExplorer = ({ }, [customFieldExtensions]); const handleSelectionChange = useCallback( - selection => { + (selection: FieldExtensionOptions) => { setSelectedField(selection); setFieldFormState({}); setFormState({}); @@ -113,7 +113,7 @@ export const CustomFieldExplorer = ({ ); const handleFieldConfigChange = useCallback( - state => { + (state: {}) => { setFieldFormState(state); setFormState({}); // Force TemplateEditorForm to re-render since some fields @@ -134,7 +134,9 @@ export const CustomFieldExplorer = ({ value={selectedField} label="Choose Custom Field Extension" labelId="select-field-label" - onChange={e => handleSelectionChange(e.target.value)} + onChange={e => + handleSelectionChange(e.target.value as FieldExtensionOptions) + } > {fieldOptions.map((option, idx) => ( @@ -158,7 +160,9 @@ export const CustomFieldExplorer = ({ noHtml5Validate formData={fieldFormState} formContext={{ fieldFormState }} - onSubmit={e => handleFieldConfigChange(e.formData)} + onSubmit={(e: ISubmitEvent) => + handleFieldConfigChange(e.formData) + } schema={selectedField.schema?.uiOptions || {}} >