From d4cdf46e49f5be2e01949c33939f80d14fed8be7 Mon Sep 17 00:00:00 2001 From: Jamie Klassen Date: Sun, 26 Mar 2023 21:57:09 -0400 Subject: [PATCH 01/24] 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 02/24] 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 03/24] 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 04/24] 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 05/24] 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 06/24] 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 07/24] 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 08/24] 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 09/24] 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 10/24] 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 11/24] 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 12/24] 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 13/24] 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 14/24] 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 15/24] 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 16/24] 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 17/24] 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 18/24] 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 19/24] 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 20/24] 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 21/24] 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 22/24] 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 23/24] 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 24/24] 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