implement consent redirect in new PinnipedAuthProvider
Signed-off-by: Ruben Vallejo <rvallejo@vmware.com>
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user