From b9ec93430e8d6959ce6153bd26c70cf3263ec6f7 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Wed, 11 Oct 2023 12:31:15 +0200 Subject: [PATCH 01/28] 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 d4cdf46e49f5be2e01949c33939f80d14fed8be7 Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Sun, 26 Mar 2023 21:57:09 -0400 Subject: [PATCH 02/28] 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 03/28] 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 04/28] 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 05/28] 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 06/28] 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 07/28] 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 08/28] 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 09/28] 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 10/28] 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 11/28] 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 12/28] 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 13/28] 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 14/28] 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 15/28] 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 16/28] 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 17/28] 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 18/28] 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 19/28] 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 20/28] 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 21/28] 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 22/28] 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 23/28] 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 24/28] 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 25/28] 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 49678f00020e496b7bdc0ebea90badb71f539db5 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 13 Oct 2023 10:15:01 +0200 Subject: [PATCH 26/28] feat: added scaffolder maintainers to the codeowners Signed-off-by: blam --- .github/CODEOWNERS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e788a6fe95..25073c9556 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -70,6 +70,8 @@ yarn.lock @backstage/maintainers @backst /plugins/playlist-* @backstage/maintainers @backstage/reviewers @kuangp /plugins/rollbar @backstage/maintainers @backstage/reviewers @andrewthauer /plugins/rollbar-backend @backstage/maintainers @backstage/reviewers @andrewthauer +/plugins/scaffolder @backstage/maintainers @backstage/reviewers @backstage/scaffolder-maintainers +/plugins/scaffolder-* @backstage/maintainers @backstage/reviewers @backstage/scaffolder-maintainers /plugins/search @backstage/discoverability-maintainers /plugins/search-* @backstage/discoverability-maintainers /plugins/sonarqube @backstage/maintainers @backstage/reviewers @backstage/sda-se-reviewers From 0296f272b40d18bd1df8c0922737df8eb8473187 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 13 Oct 2023 00:05:25 +0200 Subject: [PATCH 27/28] 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 || {}} >