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 <jklassen@vmware.com>
This commit is contained in:
Jamie Klassen
2023-08-30 13:58:42 -04:00
committed by Ruben Vallejo
parent 70a3c2631f
commit 8d3e9c7277
2 changed files with 83 additions and 153 deletions
@@ -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<string, any>;
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<string, any>;
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);
});
});
});
@@ -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, {