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