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(), };