From f5be2ec692920d82ecc04af24668a18c1c168fad Mon Sep 17 00:00:00 2001 From: Ruben Vallejo Date: Mon, 28 Aug 2023 18:30:09 -0400 Subject: [PATCH] Add rfc token exchange logic to #handler success Signed-off-by: Ruben Vallejo --- .../src/providers/pinniped/index.test.ts | 155 ++++++++++++++---- .../src/providers/pinniped/provider.test.ts | 51 +++++- .../src/providers/pinniped/provider.ts | 66 ++++++-- 3 files changed, 217 insertions(+), 55 deletions(-) diff --git a/plugins/auth-backend/src/providers/pinniped/index.test.ts b/plugins/auth-backend/src/providers/pinniped/index.test.ts index f9b5a01fc5..19ba3c1b10 100644 --- a/plugins/auth-backend/src/providers/pinniped/index.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/index.test.ts @@ -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: {}, }, diff --git a/plugins/auth-backend/src/providers/pinniped/provider.test.ts b/plugins/auth-backend/src/providers/pinniped/provider.test.ts index a9e7d34824..9fbe0ff3e7 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.test.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.test.ts @@ -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( diff --git a/plugins/auth-backend/src/providers/pinniped/provider.ts b/plugins/auth-backend/src/providers/pinniped/provider.ts index de603b978b..e8ec21bf63 100644 --- a/plugins/auth-backend/src/providers/pinniped/provider.ts +++ b/plugins/auth-backend/src/providers/pinniped/provider.ts @@ -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; @@ -54,9 +55,15 @@ export type PinnipedOptions = OAuthProviderOptions & { export class PinnipedAuthProvider implements OAuthHandlers { private readonly implementation: Promise; + 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 { @@ -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 || ''}`)); };