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, {