Add rfc token exchange logic to #handler success

Signed-off-by: Ruben Vallejo <rvallejo@vmware.com>
This commit is contained in:
Ruben Vallejo
2023-08-28 18:30:09 -04:00
parent eb1dac4d84
commit f5be2ec692
3 changed files with 217 additions and 55 deletions
@@ -162,6 +162,9 @@ describe('pinniped.create', () => {
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
@@ -182,10 +185,7 @@ describe('pinniped.create', () => {
'state',
req.url.searchParams.get('state')!,
);
// callbackUrl.searchParams.set(
// 'scope',
// 'test-scope',
// );
callbackUrl.searchParams.set('scope', 'test-scope');
return res(
ctx.status(302),
ctx.set('Location', callbackUrl.toString()),
@@ -198,11 +198,13 @@ describe('pinniped.create', () => {
);
// follow the redirect back to /handler/frame
const sub = 'test';
const iss = 'https://pinniped.test';
const iat = Date.now();
const aud = 'clientId';
const exp = Date.now() + 10000;
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);
@@ -210,13 +212,13 @@ describe('pinniped.create', () => {
publicKey.kid = privateKey.kid = uuid();
publicKey.alg = privateKey.alg = 'ES256';
const jwt = await new SignJWT({ iss, sub, aud, iat, exp })
const jwt = await new SignJWT(testTokenMetadata)
.setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid })
.setIssuer(iss)
.setAudience(aud)
.setSubject(sub)
.setIssuedAt(iat)
.setExpirationTime(exp)
.setIssuer(testTokenMetadata.iss)
.setAudience(testTokenMetadata.aud)
.setSubject(testTokenMetadata.sub)
.setIssuedAt(testTokenMetadata.iat)
.setExpirationTime(testTokenMetadata.exp)
.sign(await importJWK(privateKey));
fakePinnipedSupervisor.use(
@@ -248,7 +250,7 @@ describe('pinniped.create', () => {
type: 'authorization_response',
response: {
providerInfo: {
accessToken: 'accessToken',
idToken: 'accessToken',
scope: 'testScope',
},
profile: {},
@@ -260,44 +262,125 @@ describe('pinniped.create', () => {
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' }
: { id_token: 'clusterToken' },
? { 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 }] })),
),
);
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()),
// 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,
);
expect((await responsePromise).text).toContain(
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: {
idToken: 'clusterToken',
accessToken: 'accessToken',
scope: 'test-scope',
idToken: jwt,
},
profile: {},
},
@@ -91,6 +91,8 @@ describe('PinnipedAuthProvider', () => {
origin: 'undefined',
};
const clusterScopedIdToken = 'dummy-token';
beforeEach(() => {
jest.clearAllMocks();
@@ -104,18 +106,33 @@ describe('PinnipedAuthProvider', () => {
ctx.json(issuerMetadata),
),
),
rest.post('https://pinniped.test/oauth2/token', (req, res, ctx) =>
res(
req.headers.get('Authorization')
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: 'accessToken',
access_token: isGrantTypeTokenExchange
? clusterScopedIdToken
: 'accessToken',
refresh_token: 'refreshToken',
id_token: idToken,
...(!isGrantTypeTokenExchange && { id_token: idToken }),
scope: 'testScope',
})
: ctx.status(401),
),
),
);
}),
rest.get('https://pinniped.test/idp/userinfo.openid', (_req, res, ctx) =>
res(
ctx.json({
@@ -277,6 +294,26 @@ describe('PinnipedAuthProvider', () => {
expect(responseScope).toEqual('testScope');
});
it('returns cluster-scoped ID token when audience is specified', async () => {
oauthState.audience = 'test_cluster';
handlerRequest = {
method: 'GET',
url: `https://test?code=authorization_code&state=${encodeState(
oauthState,
)}`,
session: {
'oidc:pinniped.test': {
state: encodeState(oauthState),
},
},
} as unknown as express.Request;
const handlerResponse = await provider.handler(handlerRequest);
const responseIdToken = handlerResponse.response.providerInfo.idToken;
expect(responseIdToken).toEqual(clusterScopedIdToken);
});
it('request errors out with missing authorization_code parameter in the request_url', async () => {
handlerRequest.url = 'https://test.com';
return expect(provider.handler(handlerRequest)).rejects.toThrow(
@@ -33,6 +33,7 @@ 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>;
@@ -54,9 +55,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> {
@@ -81,31 +88,66 @@ 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 } = await this.implementation;
// the query string inside the req should contain a code and a state, we can change the stub to reject any auth code,
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 : 'none';
const audience = stateParam ? readState(stateParam).audience : undefined;
return new Promise((resolve, reject) => {
strategy.success = user => {
resolve({
response: {
providerInfo: {
accessToken: user.tokenset.access_token,
scope: user.tokenset.scope,
(audience
? client
.grant({
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
subject_token: user.tokenset.access_token,
audience,
subject_token_type:
'urn:ietf:params:oauth:token-type:access_token',
requested_token_type: 'urn:ietf:params:oauth:token-type:jwt',
})
.then(tokenset => tokenset.access_token)
: Promise.resolve(user.tokenset.id_token)
).then(idToken => {
resolve({
response: {
providerInfo: {
accessToken: user.tokenset.access_token,
scope: user.tokenset.scope,
idToken,
},
profile: {},
},
profile: {},
},
refreshToken: user.tokenset.refresh_token,
refreshToken: user.tokenset.refresh_token,
});
});
};
strategy.fail = info => {
reject(new Error(`Authentication rejected, ${info.message || ''}`));
};