refactor and add #refresh to pinnipedAuth provider

Signed-off-by: Ruben Vallejo <rvallejo@vmware.com>
This commit is contained in:
Ruben Vallejo
2023-08-22 16:50:10 -04:00
parent 9fedd45be6
commit 44483b9a0a
2 changed files with 129 additions and 106 deletions
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { OAuthStartRequest, encodeState, readState } from '../../lib/oauth';
import { OAuthRefreshRequest, OAuthStartRequest, encodeState, readState } from '../../lib/oauth';
import { PinnipedAuthProvider, PinnipedOptions } from './provider';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
@@ -64,25 +64,31 @@ describe('PinnipedAuthProvider', () => {
tokenSignedResponseAlg: 'none',
};
const sub = 'test';
const iss = 'https://pinniped.test';
const iat = Date.now();
const aud = clientMetadata.clientId;
const exp = Date.now() + 10000;
const idToken = new UnsecuredJWT({ iss, sub, aud, iat, exp })
.setIssuer(iss)
.setAudience(aud)
.setSubject(sub)
.setIssuedAt(iat)
.setExpirationTime(exp)
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',
};
beforeEach(() => {
jest.clearAllMocks();
worker.use(
rest.post('https://pinniped.test/oauth2/token', (req, res, ctx) =>
res(
@@ -95,7 +101,45 @@ describe('PinnipedAuthProvider', () => {
: ctx.status(401),
),
),
rest.all(
'https://federationDomain.test/.well-known/openid-configuration', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(issuerMetadata),
)
),
rest.post('https://pinniped.test/oauth2/token', (req, res, ctx) =>
res(
req.headers.get('Authorization')
? ctx.json({
access_token: 'accessToken',
refresh_token: 'refreshToken',
id_token: idToken,
})
: 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),
),
),
);
fakeSession = {};
startRequest = {
session: fakeSession,
@@ -103,19 +147,7 @@ describe('PinnipedAuthProvider', () => {
url: 'test',
state: oauthState,
} as unknown as OAuthStartRequest;
const handler = jest.fn((_req, res, ctx) => {
return res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(issuerMetadata),
);
});
worker.use(
rest.all(
'https://federationDomain.test/.well-known/openid-configuration',
handler,
),
);
provider = new PinnipedAuthProvider(clientMetadata);
});
@@ -186,15 +218,6 @@ describe('PinnipedAuthProvider', () => {
);
});
it('fails when request has no session', async () => {
return expect(
provider.start({
method: 'GET',
url: 'test',
} as unknown as OAuthStartRequest),
).rejects.toThrow('authentication requires session support');
});
it('encodes OAuth state in query param', async () => {
const startResponse = await provider.start(startRequest);
const { searchParams } = new URL(startResponse.url);
@@ -203,65 +226,46 @@ describe('PinnipedAuthProvider', () => {
expect(decodedState).toMatchObject(oauthState);
});
it('fails when request has no session', async () => {
return expect(
provider.start({
method: 'GET',
url: 'test',
} as unknown as OAuthStartRequest),
).rejects.toThrow('authentication requires session support');
});
});
describe('#handler', () => {
let handlerRequest: express.Request;
const testState = {
nonce: 'nonce',
env: 'development',
origin: 'undefined',
audience: 'test-cluster'
};
beforeEach(() => {
provider = new PinnipedAuthProvider(clientMetadata);
//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(testState)}`,
url: `https://test?code=authorization_code&state=${encodeState(oauthState)}`,
session: {
'oidc:pinniped.test': {
state: encodeState(testState),
state: encodeState(oauthState),
},
},
} as unknown as express.Request;
worker.use(
rest.post('https://pinniped.test/oauth2/token', (req, res, ctx) =>
res(
req.headers.get('Authorization')
? ctx.json({
access_token: 'accessToken',
refresh_token: 'refreshToken',
id_token: idToken,
})
: 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),
),
),
);
});
it('exchanges authorization code for a valid access_token', async() => {
const handlerResponse = await provider.handler(handlerRequest);
const accessToken = handlerResponse.response.providerInfo.accessToken
expect(accessToken).toEqual('accessToken')
})
it('fails when request has no state', async () => {
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('Unexpected redirect')
})
it('fails when request has no state in req_url', async () => {
return expect(
provider.handler({
method: 'GET',
@@ -276,26 +280,6 @@ describe('PinnipedAuthProvider', () => {
'Authentication rejected, state missing from the response',
);
});
it('exchanges authorization code for a valid access_token', async() => {
const handlerResponse = await provider.handler(handlerRequest);
const accessToken = handlerResponse.response.providerInfo.accessToken
expect(accessToken).toEqual('accessToken')
})
//scope cannot be passed along in the redirect url and is different from audience
it.skip('responds with the correct audience as scope', async() => {
const handlerResponse = await provider.handler(handlerRequest);
const audience = handlerResponse.response.providerInfo.scope
expect(audience).toEqual('pinniped:request-audience username')
})
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('Unexpected redirect')
})
it('fails when request has no session', async () => {
return expect(
@@ -309,4 +293,30 @@ describe('PinnipedAuthProvider', () => {
//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', () => {
let refreshRequest: OAuthRefreshRequest;
beforeEach(() => {
refreshRequest = {
refreshToken: 'otherRefreshToken'
} as unknown as OAuthRefreshRequest;
});
it('gets new refresh token', async() => {
const { refreshToken } = await provider.refresh(refreshRequest);
expect(refreshToken).toBe('refreshToken')
})
it('gets an access_token', async() => {
const { response } = await provider.refresh(refreshRequest)
expect(response.providerInfo.accessToken).toBe("accessToken")
})
//find out when refresh requests are even made?
//so far looks like the response should be exactly like the one returned by the handler
//what are we exchanging exactly to get this refresh token??
})
});
@@ -22,6 +22,7 @@ import {
import {
OAuthHandlers,
OAuthProviderOptions,
OAuthRefreshRequest,
OAuthResponse,
OAuthStartRequest,
encodeState,
@@ -61,14 +62,6 @@ export class PinnipedAuthProvider implements OAuthHandlers {
async start(req: OAuthStartRequest): Promise<OAuthStartResponse> {
const { strategy } = await this.implementation;
// we are practicing with this scope and it works openid pinniped:request-audience username
// whats the bare minimum needed for our request to fail?
// http://127.0.0.1:7007/api/auth/pinniped/start?env=development&audience=host-cluster
// having scope seperated by + results in an error
// `{"type":"authorization_response","error":{"name":"OPError","message":"invalid_scope (The requested scope is invalid, unknown, or malformed. The OAuth 2.0 Client is not allowed to request scope 'openid+pinniped:request-audience+username'.)"}}`
const stringifiedAudience = req.query?.audience as string
const state = {...req.state, audience: stringifiedAudience }
@@ -127,7 +120,27 @@ export class PinnipedAuthProvider implements OAuthHandlers {
});
}
// will need a refresh method that covers our happy path
async refresh(req: OAuthRefreshRequest): Promise<{ response: OAuthResponse; refreshToken?: string }> {
const { client } = await this.implementation;
const tokenset = await client.refresh(req.refreshToken);
return new Promise((resolve, reject) => {
if(!tokenset.access_token){
reject(new Error('Refresh Failed'))
}
resolve({
response: {
providerInfo: {
accessToken: tokenset.access_token!,
scope: 'none',
},
profile: {},
},
refreshToken: tokenset.refresh_token,
});
})
}
private async setupStrategy(options: PinnipedOptions): Promise<OidcImpl> {
const issuer = await Issuer.discover(