implement consent redirect in new PinnipedAuthProvider

Signed-off-by: Ruben Vallejo <rvallejo@vmware.com>
This commit is contained in:
Ruben Vallejo
2023-07-11 12:27:48 -04:00
parent 7b4b8a0034
commit 07df9ce153
3 changed files with 430 additions and 48 deletions
@@ -13,59 +13,69 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { OidcAuthResult } from '../oidc';
import { OidcAuthProvider } from '../oidc/provider';
import { OAuthAdapter, OAuthEnvironmentHandler } from '../../lib/oauth';
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
import { AuthHandler, SignInResolver } from '../types';
// import { OidcAuthResult } from '../oidc';
// import { OidcAuthProvider } from '../oidc/provider';
// import { OAuthAdapter, OAuthEnvironmentHandler } from '../../lib/oauth';
// import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
// import { AuthHandler, SignInResolver } from '../types';
// import { PinnipedAuthProvider } from './provider';
/**
* Auth provider integration for Pinniped
*
* @public
*/
export const pinniped = createAuthProviderIntegration({
create(options?: {
authHandler?: AuthHandler<OidcAuthResult>;
signIn?: {
resolver: SignInResolver<OidcAuthResult>;
};
}) {
return ({ providerId, globalConfig, config, resolverContext }) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const customCallbackUrl = envConfig.getOptionalString('callbackUrl');
const callbackUrl =
customCallbackUrl ||
`${globalConfig.baseUrl}/${providerId}/handler/frame`;
const metadataUrl = `${envConfig.getString(
'federationDomain',
)}/.well-known/openid-configuration`;
const tokenSignedResponseAlg = 'ES256';
const prompt = 'auto';
const authHandler: AuthHandler<OidcAuthResult> = async ({
userinfo,
}) => ({
profile: {},
});
// export const pinniped = createAuthProviderIntegration({
// create(options?: {
// authHandler?: AuthHandler<OidcAuthResult>;
// signIn?: {
// resolver: SignInResolver<OidcAuthResult>;
// };
// }) {
// return ({ providerId, globalConfig, config, resolverContext }) =>
// OAuthEnvironmentHandler.mapConfig(config, envConfig => {
// const clientId = envConfig.getString('clientId');
// const clientSecret = envConfig.getString('clientSecret');
// const customCallbackUrl = envConfig.getOptionalString('callbackUrl');
// const callbackUrl =
// customCallbackUrl ||
// `${globalConfig.baseUrl}/${providerId}/handler/frame`;
// const metadataUrl = `${envConfig.getString(
// 'federationDomain',
// )}/.well-known/openid-configuration`;
// const federationDomain = envConfig.getString('federationDomain');
// const tokenSignedResponseAlg = 'ES256';
// const prompt = 'auto';
// const authHandler: AuthHandler<OidcAuthResult> = async ({
// userinfo,
// }) => ({
// profile: {},
// });
const provider = new OidcAuthProvider({
clientId,
clientSecret,
callbackUrl,
tokenSignedResponseAlg,
metadataUrl,
prompt,
signInResolver: options?.signIn?.resolver,
authHandler,
resolverContext,
});
// // const provider = new OidcAuthProvider({
// // clientId,
// // clientSecret,
// // callbackUrl,
// // tokenSignedResponseAlg,
// // metadataUrl,
// // prompt,
// // signInResolver: options?.signIn?.resolver,
// // authHandler,
// // resolverContext,
// // });
return OAuthAdapter.fromConfig(globalConfig, provider, {
providerId,
callbackUrl,
});
});
},
});
// const provider = new PinnipedAuthProvider({
// federationDomain,
// clientId,
// clientSecret,
// });
// return OAuthAdapter.fromConfig(globalConfig, provider, {
// providerId,
// callbackUrl,
// });
// });
// },
// });
export { pinniped } from './provider';
@@ -0,0 +1,174 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { setupRequestMockHandlers } from '@backstage/backend-test-utils';
import { OAuthStartRequest } from '../../lib/oauth';
import { AuthResolverContext } from '../types';
import { PinnipedAuthProvider, PinnipedOptions } from './provider';
import { setupServer } from 'msw/node';
import { rest } from 'msw';
import { ClientMetadata, IssuerMetadata } from 'openid-client';
describe('PinnipedAuthProvider', () => {
let startRequest: OAuthStartRequest;
let fakeSession: Record<string, any>;
let provider: PinnipedAuthProvider;
const worker = setupServer();
setupRequestMockHandlers(worker);
const issuerMetadata = {
issuer: 'https://pinniped.test',
authorization_endpoint: 'https://pinniped.test/oauth2/authorize',
token_endpoint: 'https://pinniped.test/oauth2/token',
revocation_endpoint: 'https://pinniped.test/oauth2/revoke_token',
userinfo_endpoint: 'https://pinniped.test/idp/userinfo.openid',
introspection_endpoint: 'https://pinniped.test/introspect.oauth2',
jwks_uri: 'https://pinniped.test/pf/JWKS',
scopes_supported: [
'openid',
'offline_access',
'pinniped:request-audience',
'username',
'groups',
],
claims_supported: ['email', 'username', 'groups', 'additionalClaims'],
response_types_supported: ['code'],
id_token_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'],
token_endpoint_auth_signing_alg_values_supported: [
'RS256',
'RS512',
'HS256',
],
request_object_signing_alg_values_supported: ['RS256', 'RS512', 'HS256'],
};
const clientMetadata: PinnipedOptions = {
federationDomain: 'https://federationDomain.test',
clientId: 'clientId.test',
clientSecret: 'secret.test',
callbackUrl: 'https://federationDomain.test/callback',
resolverContext: {} as AuthResolverContext,
authHandler: async () => ({
profile: {},
}),
};
beforeEach(() => {
jest.clearAllMocks();
fakeSession = {};
startRequest = {
session: fakeSession,
method: 'GET',
url: 'test',
} 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);
});
it('hits the metadata url', async () => {
const handler = jest.fn((_req, res, ctx) => {
return res(
ctx.status(200),
ctx.set('Content-Type', 'application/json'),
ctx.json(issuerMetadata),
);
});
worker.use(
rest.get(
'https://federationDomain.test/.well-known/openid-configuration',
handler,
),
);
provider = new PinnipedAuthProvider(clientMetadata);
const { strategy } = (await (provider as any).implementation) as any as {
strategy: {
_client: ClientMetadata;
_issuer: IssuerMetadata;
};
};
expect(handler).toHaveBeenCalledTimes(1);
expect(strategy._client.client_id).toBe(clientMetadata.clientId);
expect(strategy._issuer.token_endpoint).toBe(issuerMetadata.token_endpoint);
});
describe('/start', () => {
it('redirects to authorization endpoint returned from federationDomain config value', async () => {
const startResponse = await provider.start(startRequest);
const url = new URL(startResponse.url);
expect(url.protocol).toBe('https:');
expect(url.hostname).toBe('pinniped.test');
expect(url.pathname).toBe('/oauth2/authorize');
});
it('passes client ID from config', async () => {
const startResponse = await provider.start(startRequest);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('client_id')).toBe('clientId.test');
});
it('passes callback URL', async () => {
const startResponse = await provider.start(startRequest);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('redirect_uri')).toBe(
'https://federationDomain.test/callback',
);
});
it('generates PKCE challenge', async () => {
const startResponse = await provider.start(startRequest);
const { searchParams } = new URL(startResponse.url);
expect(searchParams.get('code_challenge_method')).toBe('S256');
expect(searchParams.get('code_challenge')).not.toBeNull();
});
it('stores PKCE verifier in session', async () => {
await provider.start(startRequest);
expect(fakeSession['oidc:pinniped.test'].code_verifier).toBeDefined();
});
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');
});
});
});
@@ -0,0 +1,198 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Client,
Issuer,
Strategy as OidcStrategy,
TokenSet,
UserinfoResponse,
} from 'openid-client';
import {
OAuthHandlers,
OAuthProviderOptions,
OAuthResponse,
OAuthStartRequest,
encodeState,
} from '../../lib/oauth';
import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
PassportDoneCallback,
} from '../../lib/passport';
import { AuthResolverContext, OAuthStartResponse } from '../types';
import express from 'express';
import { OidcAuthResult } from '../oidc';
import { OAuthAdapter, OAuthEnvironmentHandler } from '../../lib/oauth';
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
import { AuthHandler, SignInResolver } from '../types';
type OidcImpl = {
strategy: OidcStrategy<UserinfoResponse, Client>;
client: Client;
};
type PrivateInfo = {
refreshToken?: string;
};
export type PinnipedOptions = OAuthProviderOptions & {
federationDomain: string;
clientId: string;
clientSecret: string;
callbackUrl: string;
scope?: string;
prompt?: string;
tokenSignedResponseAlg?: string;
signInResolver?: SignInResolver<OidcAuthResult>;
authHandler: AuthHandler<OidcAuthResult>;
resolverContext: AuthResolverContext;
};
export class PinnipedAuthProvider implements OAuthHandlers {
private readonly implementation: Promise<OidcImpl>;
private readonly federationDomain: string;
private readonly clientId: string;
private readonly clientSecret: string;
private readonly callbackUrl: string;
private readonly scope?: string;
private readonly prompt?: string;
private readonly signInResolver?: SignInResolver<OidcAuthResult>;
private readonly authHandler: AuthHandler<OidcAuthResult>;
private readonly resolverContext: AuthResolverContext;
constructor(options: PinnipedOptions) {
this.implementation = this.setupStrategy(options);
this.federationDomain = options.federationDomain;
this.clientId = options.clientId;
this.clientSecret = options.clientSecret;
this.callbackUrl = options.callbackUrl;
this.scope = options.scope;
this.prompt = options.prompt;
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
this.resolverContext = options.resolverContext;
}
async start(req: OAuthStartRequest): Promise<OAuthStartResponse> {
const { strategy } = await this.implementation;
const options: Record<string, string> = {
scope: req.scope || this.scope || 'openid profile email',
state: encodeState(req.state),
};
return new Promise((resolve, reject) => {
strategy.redirect = (url: string, status?: number) => {
resolve({ url, status: status ?? undefined });
};
strategy.error = (error: Error) => {
reject(error);
};
strategy.authenticate(req, { ...options });
});
}
private async setupStrategy(options: PinnipedOptions): Promise<OidcImpl> {
const issuer = await Issuer.discover(
`${options.federationDomain}/.well-known/openid-configuration`,
);
const client = new issuer.Client({
access_type: 'offline', // this option must be passed to provider to receive a refresh token
client_id: options.clientId,
client_secret: options.clientSecret,
redirect_uris: [options.callbackUrl],
response_types: ['code'],
id_token_signed_response_alg: options.tokenSignedResponseAlg || 'RS256',
scope: options.scope || '',
});
const strategy = new OidcStrategy(
{
client,
passReqToCallback: false,
},
(
tokenset: TokenSet,
userinfo:
| UserinfoResponse
| PassportDoneCallback<OidcAuthResult, PrivateInfo>,
done?: PassportDoneCallback<OidcAuthResult, PrivateInfo>,
) => {
if (typeof userinfo === 'function') {
userinfo(
undefined,
{ tokenset, userinfo: { sub: '' } },
{
refreshToken: tokenset.refresh_token,
},
);
}
done!(
undefined,
{ tokenset, userinfo: userinfo as UserinfoResponse },
{
refreshToken: tokenset.refresh_token,
},
);
},
);
return { strategy, client };
}
}
/**
* Auth provider integration for Pinniped auth
*
* @public
*/
export const pinniped = createAuthProviderIntegration({
create(options?: {
authHandler?: AuthHandler<OidcAuthResult>;
signIn?: {
resolver: SignInResolver<OidcAuthResult>;
};
}) {
return ({ providerId, globalConfig, config, resolverContext }) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const federationDomain = envConfig.getString('federationDomain');
const customCallbackUrl = envConfig.getOptionalString('callbackUrl');
const callbackUrl =
customCallbackUrl ||
`${globalConfig.baseUrl}/${providerId}/handler/frame`;
const tokenSignedResponseAlg = 'ES256';
const prompt = 'auto';
const authHandler: AuthHandler<OidcAuthResult> = async () => ({
profile: {},
});
const provider = new PinnipedAuthProvider({
federationDomain,
clientId,
clientSecret,
callbackUrl,
tokenSignedResponseAlg,
prompt,
authHandler,
resolverContext,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
providerId,
callbackUrl,
});
});
},
});