Add rfc token exchange logic to #handler success
Signed-off-by: Ruben Vallejo <rvallejo@vmware.com>
This commit is contained in:
@@ -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 || ''}`));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user