refactor provider unit tests

Signed-off-by: Ruben Vallejo <rvallejo@vmware.com>
This commit is contained in:
Ruben Vallejo
2023-08-28 18:34:04 -04:00
parent f5be2ec692
commit 5ee7c789ad
4 changed files with 204 additions and 499 deletions
@@ -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: {},
},
}),
),
);
});
});
});
@@ -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<OidcAuthResult>;
// signIn?: {
// resolver: SignInResolver<OidcAuthResult>;
// };
// }) {
// 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<OidcAuthResult> = 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';
@@ -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<string, any>;
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);
});
});
@@ -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<undefined, Client>;
@@ -55,23 +54,15 @@ export type PinnipedOptions = OAuthProviderOptions & {
export class PinnipedAuthProvider implements OAuthHandlers {
private readonly implementation: Promise<OidcImpl>;
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<OAuthStartResponse> {
const { strategy } = await this.implementation;
const stringifiedAudience = req.query?.audience as string;
const state = { ...req.state, audience: stringifiedAudience };
const options: Record<string, string> = {
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: {},
},