authHandler/signInResolver backwards compatibility

Signed-off-by: Jamie Klassen <jamie.klassen@broadcom.com>
This commit is contained in:
Jamie Klassen
2024-01-05 18:56:32 -05:00
parent 5d2fcba064
commit 002010c8f6
11 changed files with 337 additions and 155 deletions
@@ -7,10 +7,10 @@ import { BackendFeature } from '@backstage/backend-plugin-api';
import { BaseClient } from 'openid-client';
import { OAuthAuthenticator } from '@backstage/plugin-auth-node';
import { PassportOAuthAuthenticatorHelper } from '@backstage/plugin-auth-node';
import { PassportOAuthResult } from '@backstage/plugin-auth-node';
import { PassportProfile } from '@backstage/plugin-auth-node';
import { SignInResolverFactory } from '@backstage/plugin-auth-node';
import { Strategy } from 'openid-client';
import { TokenSet } from 'openid-client';
import { UserinfoResponse } from 'openid-client';
// @public (undocumented)
const authModuleOidcProvider: () => BackendFeature;
@@ -18,16 +18,24 @@ export default authModuleOidcProvider;
// @public (undocumented)
export const oidcAuthenticator: OAuthAuthenticator<
Promise<{
helper: PassportOAuthAuthenticatorHelper;
client: BaseClient;
{
initializedScope: string | undefined;
initializedPrompt: string | undefined;
strategy: Strategy<PassportOAuthResult, BaseClient>;
}>,
PassportProfile
promise: Promise<{
helper: PassportOAuthAuthenticatorHelper;
client: BaseClient;
strategy: Strategy<OidcAuthResult, BaseClient>;
}>;
},
OidcAuthResult
>;
// @public
export type OidcAuthResult = {
tokenset: TokenSet;
userinfo: UserinfoResponse;
};
// @public
export namespace oidcSignInResolvers {
const emailLocalPartMatchingUserEntityName: SignInResolverFactory<
@@ -281,81 +281,75 @@ describe('oidcAuthenticator', () => {
});
it('exchanges authorization code for access token', async () => {
const handlerResponse = await oidcAuthenticator.authenticate(
const authenticatorResult = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
const accessToken = handlerResponse.session.accessToken;
const accessToken = authenticatorResult.session.accessToken;
expect(accessToken).toEqual('accessToken');
});
it('exchanges authorization code for refresh token', async () => {
const handlerResponse = await oidcAuthenticator.authenticate(
const authenticatorResult = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
const refreshToken = handlerResponse.session.refreshToken;
const refreshToken = authenticatorResult.session.refreshToken;
expect(refreshToken).toEqual('refreshToken');
});
it('returns granted scope', async () => {
const handlerResponse = await oidcAuthenticator.authenticate(
const authenticatorResult = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
const responseScope = handlerResponse.session.scope;
const responseScope = authenticatorResult.session.scope;
expect(responseScope).toEqual('testScope');
});
it('returns a default session.tokentype field', async () => {
const handlerResponse = await oidcAuthenticator.authenticate(
const authenticatorResult = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
const tokenType = handlerResponse.session.tokenType;
const tokenType = authenticatorResult.session.tokenType;
expect(tokenType).toEqual('bearer');
});
it('returns defined fullProfile with picture and email', async () => {
const handlerResponse = await oidcAuthenticator.authenticate(
it('returns picture and email', async () => {
const authenticatorResult = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
const displayName = handlerResponse.fullProfile.displayName;
const email = handlerResponse.fullProfile.emails![0].value;
const picture = handlerResponse.fullProfile.photos![0].value;
expect(displayName).toEqual('Alice Adams');
expect(email).toEqual('alice@test.com');
expect(picture).toEqual('http://testPictureUrl/photo.jpg');
expect(authenticatorResult).toMatchObject({
fullProfile: {
userinfo: {
email: 'alice@test.com',
picture: 'http://testPictureUrl/photo.jpg',
name: 'Alice Adams',
},
},
});
});
it('returns defined response with an idToken', async () => {
const handlerResponse = await oidcAuthenticator.authenticate(
it('returns idToken', async () => {
const authenticatorResult = await oidcAuthenticator.authenticate(
handlerRequest,
implementation,
);
expect(handlerResponse).toMatchObject({
fullProfile: {
displayName: 'Alice Adams',
id: 'test',
provider: 'oidc',
},
expect(authenticatorResult).toMatchObject({
session: {
accessToken: 'accessToken',
idToken,
refreshToken: 'refreshToken',
scope: 'testScope',
tokenType: 'bearer',
},
});
expect(
Math.abs(handlerResponse.session.expiresInSeconds! - 3600),
Math.abs(authenticatorResult.session.expiresInSeconds! - 3600),
).toBeLessThan(5);
});
@@ -23,15 +23,35 @@ import {
} from 'openid-client';
import {
createOAuthAuthenticator,
OAuthAuthenticatorResult,
PassportDoneCallback,
PassportHelpers,
PassportOAuthAuthenticatorHelper,
PassportOAuthDoneCallback,
PassportOAuthPrivateInfo,
} from '@backstage/plugin-auth-node';
/**
* authentication result for the OIDC which includes the token set and user
* profile response
* @public
*/
export type OidcAuthResult = {
tokenset: TokenSet;
userinfo: UserinfoResponse;
};
/** @public */
export const oidcAuthenticator = createOAuthAuthenticator({
defaultProfileTransform:
PassportOAuthAuthenticatorHelper.defaultProfileTransform,
async initialize({ callbackUrl, config }) {
defaultProfileTransform: async (
input: OAuthAuthenticatorResult<OidcAuthResult>,
) => ({
profile: {
email: input.fullProfile.userinfo.email,
picture: input.fullProfile.userinfo.picture,
displayName: input.fullProfile.userinfo.name,
},
}),
initialize({ callbackUrl, config }) {
const clientId = config.getString('clientId');
const clientSecret = config.getString('clientSecret');
const metadataUrl = config.getString('metadataUrl');
@@ -45,81 +65,53 @@ export const oidcAuthenticator = createOAuthAuthenticator({
const initializedScope = config.getOptionalString('scope');
const initializedPrompt = config.getOptionalString('prompt');
const issuer = await Issuer.discover(metadataUrl);
const client = new issuer.Client({
access_type: 'offline', // this option must be passed to provider to receive a refresh token
client_id: clientId,
client_secret: clientSecret,
redirect_uris: [customCallbackUrl || callbackUrl],
response_types: ['code'],
token_endpoint_auth_method:
tokenEndpointAuthMethod || 'client_secret_basic',
id_token_signed_response_alg: tokenSignedResponseAlg || 'RS256',
scope: initializedScope || '',
const promise = Issuer.discover(metadataUrl).then(issuer => {
const client = new issuer.Client({
access_type: 'offline', // this option must be passed to provider to receive a refresh token
client_id: clientId,
client_secret: clientSecret,
redirect_uris: [customCallbackUrl || callbackUrl],
response_types: ['code'],
token_endpoint_auth_method:
tokenEndpointAuthMethod || 'client_secret_basic',
id_token_signed_response_alg: tokenSignedResponseAlg || 'RS256',
scope: initializedScope || '',
});
const strategy = new OidcStrategy(
{
client,
passReqToCallback: false,
},
(
tokenset: TokenSet,
userinfo: UserinfoResponse,
done: PassportDoneCallback<OidcAuthResult, PassportOAuthPrivateInfo>,
) => {
if (typeof done !== 'function') {
throw new Error(
'OIDC IdP must provide a userinfo_endpoint in the metadata response',
);
}
done(
undefined,
{ tokenset, userinfo },
{ refreshToken: tokenset.refresh_token },
);
},
);
const helper = PassportOAuthAuthenticatorHelper.from(strategy);
return { helper, client, strategy };
});
const strategy = new OidcStrategy(
{
client,
passReqToCallback: false,
},
(
tokenset: TokenSet,
userinfo: UserinfoResponse,
done: PassportOAuthDoneCallback,
) => {
if (typeof done !== 'function') {
throw new Error(
'OIDC IdP must provide a userinfo_endpoint in the metadata response',
);
}
const emails = userinfo.email ? [{ value: userinfo.email }] : undefined;
const photos = userinfo.picture
? [{ value: userinfo.picture }]
: undefined;
const name =
userinfo.family_name && userinfo.given_name
? {
familyName: userinfo.family_name,
givenName: userinfo.given_name,
}
: undefined;
done(
undefined,
{
fullProfile: {
provider: 'oidc',
id: userinfo.sub,
displayName: userinfo.name!,
username: userinfo.preferred_username,
name,
emails,
photos,
},
accessToken: tokenset.access_token!,
params: {
id_token: tokenset.id_token,
scope: tokenset.scope!,
expires_in: tokenset.expires_in!,
token_type: tokenset.token_type,
},
},
{
refreshToken: tokenset.refresh_token,
},
);
},
);
const helper = PassportOAuthAuthenticatorHelper.from(strategy);
return { helper, client, initializedScope, initializedPrompt, strategy };
return { initializedScope, initializedPrompt, promise };
},
async start(input, ctx) {
const { initializedScope, initializedPrompt, helper, strategy } = await ctx;
const { initializedScope, initializedPrompt, promise } = ctx;
const { helper, strategy } = await promise;
const options: Record<string, string> = {
scope: input.scope || initializedScope || 'openid profile email',
state: input.state,
@@ -140,12 +132,32 @@ export const oidcAuthenticator = createOAuthAuthenticator({
});
},
async authenticate(input, ctx) {
return (await ctx).helper.authenticate(input);
async authenticate(
input,
ctx,
): Promise<OAuthAuthenticatorResult<OidcAuthResult>> {
const { strategy } = await ctx.promise;
const { result, privateInfo } =
await PassportHelpers.executeFrameHandlerStrategy<
OidcAuthResult,
PassportOAuthPrivateInfo
>(input.req, strategy);
return {
fullProfile: result,
session: {
accessToken: result.tokenset.access_token!,
tokenType: result.tokenset.token_type ?? 'bearer',
scope: result.tokenset.scope!,
expiresInSeconds: result.tokenset.expires_in,
idToken: result.tokenset.id_token,
refreshToken: privateInfo.refreshToken,
},
};
},
async refresh(input, ctx) {
const { client } = await ctx;
const { client } = await ctx.promise;
const tokenset = await client.refresh(input.refreshToken);
if (!tokenset.access_token) {
throw new Error('Refresh failed');
@@ -160,11 +172,7 @@ export const oidcAuthenticator = createOAuthAuthenticator({
reject(new Error('Refresh Failed'));
}
resolve({
fullProfile: {
provider: 'oidc',
id: userinfo.sub,
displayName: userinfo.name!,
},
fullProfile: { userinfo, tokenset },
session: {
accessToken: tokenset.access_token!,
tokenType: tokenset.token_type ?? 'bearer',
@@ -21,5 +21,6 @@
*/
export { oidcAuthenticator } from './authenticator';
export type { OidcAuthResult } from './authenticator';
export { authModuleOidcProvider as default } from './module';
export { oidcSignInResolvers } from './resolvers';
+3 -10
View File
@@ -25,6 +25,7 @@ import { LoggerService } from '@backstage/backend-plugin-api';
import { OAuth2ProxyResult as OAuth2ProxyResult_2 } from '@backstage/plugin-auth-backend-module-oauth2-proxy-provider';
import { OAuthEnvironmentHandler as OAuthEnvironmentHandler_2 } from '@backstage/plugin-auth-node';
import { OAuthState as OAuthState_2 } from '@backstage/plugin-auth-node';
import { OidcAuthResult } from '@backstage/plugin-auth-backend-module-oidc-provider';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { prepareBackstageIdentityResponse as prepareBackstageIdentityResponse_2 } from '@backstage/plugin-auth-node';
@@ -34,9 +35,7 @@ import { SignInInfo as SignInInfo_2 } from '@backstage/plugin-auth-node';
import { SignInResolver as SignInResolver_2 } from '@backstage/plugin-auth-node';
import { TokenManager } from '@backstage/backend-common';
import { TokenParams as TokenParams_2 } from '@backstage/plugin-auth-node';
import { TokenSet } from 'openid-client';
import { UserEntity } from '@backstage/catalog-model';
import { UserinfoResponse } from 'openid-client';
import { WebMessageResponse as WebMessageResponse_2 } from '@backstage/plugin-auth-node';
// @public @deprecated
@@ -340,12 +339,6 @@ export type OAuthStartResponse = {
// @public @deprecated (undocumented)
export type OAuthState = OAuthState_2;
// @public @deprecated
export type OidcAuthResult = {
tokenset: TokenSet;
userinfo: UserinfoResponse;
};
// @public @deprecated (undocumented)
export const postMessageResponse: (
res: express.Response,
@@ -564,10 +557,10 @@ export const providers: Readonly<{
create: (
options?:
| {
authHandler?: AuthHandler<OAuthResult> | undefined;
authHandler?: AuthHandler<OidcAuthResult> | undefined;
signIn?:
| {
resolver: SignInResolver<OAuthResult>;
resolver: SignInResolver<OidcAuthResult>;
}
| undefined;
}
-1
View File
@@ -75,7 +75,6 @@
"passport-auth0": "^1.4.3",
"passport-bitbucket-oauth2": "^0.1.2",
"passport-github2": "^0.1.12",
"passport-gitlab2": "^5.0.0",
"passport-google-oauth20": "^2.0.0",
"passport-microsoft": "^1.0.0",
"passport-oauth2": "^1.6.1",
@@ -29,7 +29,6 @@ export type {
} from './cloudflare-access';
export type { GithubOAuthResult } from './github';
export type { OAuth2ProxyResult } from './oauth2-proxy';
export type { OidcAuthResult } from './oidc';
export type { SamlAuthResult } from './saml';
export type { GcpIapResult, GcpIapTokenInfo } from './gcp-iap';
@@ -15,4 +15,3 @@
*/
export { oidc } from './provider';
export type { OidcAuthResult } from './provider';
@@ -0,0 +1,169 @@
/*
* Copyright 2024 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 { getVoidLogger } from '@backstage/backend-common';
import { LoggerService } from '@backstage/backend-plugin-api';
import { Config, ConfigReader } from '@backstage/config';
import {
AuthProviderConfig,
AuthResolverContext,
CookieConfigurer,
} from '@backstage/plugin-auth-node';
import express from 'express';
import { JWK, SignJWT, exportJWK, generateKeyPair } from 'jose';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { oidc } from './provider';
describe('oidc.create', () => {
const userinfo = {
sub: 'test',
iss: 'https://oidc.test',
aud: 'clientId',
nonce: 'foo',
};
const server = setupServer();
setupRequestMockHandlers(server);
let publicKey: JWK;
let tokenset: object;
let providerFactoryOptions: {
providerId: string;
globalConfig: AuthProviderConfig;
config: Config;
logger: LoggerService;
resolverContext: AuthResolverContext;
baseUrl: string;
appUrl: string;
isOriginAllowed: (origin: string) => boolean;
cookieConfigurer?: CookieConfigurer;
};
beforeAll(async () => {
const keyPair = await generateKeyPair('RS256');
const privateKey = await exportJWK(keyPair.privateKey);
publicKey = await exportJWK(keyPair.publicKey);
publicKey.alg = privateKey.alg = 'RS256';
tokenset = {
id_token: await new SignJWT({
iat: Date.now(),
exp: Date.now() + 10000,
...userinfo,
})
.setProtectedHeader({ alg: privateKey.alg, kid: privateKey.kid })
.sign(keyPair.privateKey),
access_token: 'accessToken',
};
});
beforeEach(() => {
server.use(
rest.get(
'https://oidc.test/.well-known/openid-configuration',
(_req, res, ctx) =>
res(
ctx.json({
issuer: 'https://oidc.test',
token_endpoint: 'https://oidc.test/oauth2/token',
userinfo_endpoint: 'https://oidc.test/idp/userinfo.openid',
jwks_uri: 'https://oidc.test/jwks.json',
}),
),
),
rest.post('https://oidc.test/oauth2/token', (_req, res, ctx) =>
res(ctx.json(tokenset)),
),
rest.get('https://oidc.test/jwks.json', async (_req, res, ctx) =>
res(ctx.json({ keys: [{ ...publicKey }] })),
),
rest.get(
'https://oidc.test/idp/userinfo.openid',
async (_req, res, ctx) => res(ctx.json(userinfo)),
),
);
providerFactoryOptions = {
providerId: 'myoidc',
baseUrl: 'http://backstage.test/api/auth',
appUrl: 'http://backstage.test',
isOriginAllowed: _ => true,
globalConfig: {
baseUrl: 'http://backstage.test/api/auth',
appUrl: 'http://backstage.test',
isOriginAllowed: _ => true,
},
config: new ConfigReader({
development: {
metadataUrl: 'https://oidc.test/.well-known/openid-configuration',
clientId: 'clientId',
clientSecret: 'clientSecret',
},
}),
logger: getVoidLogger(),
resolverContext: {
issueToken: jest.fn(),
findCatalogUser: jest.fn(),
signInWithCatalogUser: jest.fn(),
},
};
});
it('invokes authHandler with tokenset and userinfo response', async () => {
const authHandler = jest.fn();
const provider = oidc.create({ authHandler })(providerFactoryOptions);
const state = Buffer.from('nonce=foo&env=development').toString('hex');
await provider.frameHandler(
{
method: 'GET',
url: `http://backstage.test/api/auth/myoidc/handler/frame?code=blahblah&state=${state}`,
query: { state },
cookies: { 'myoidc-nonce': 'foo' },
session: { 'oidc:oidc.test': { state, nonce: 'foo' } },
} as unknown as express.Request,
{ setHeader: jest.fn(), end: jest.fn() } as unknown as express.Response,
);
expect(authHandler).toHaveBeenCalledWith(
{ tokenset, userinfo },
providerFactoryOptions.resolverContext,
);
});
it('invokes sign-in resolver with tokenset and userinfo response', async () => {
const resolver = jest.fn();
const provider = oidc.create({ signIn: { resolver } })(
providerFactoryOptions,
);
const state = Buffer.from('nonce=foo&env=development').toString('hex');
await provider.frameHandler(
{
method: 'GET',
url: `http://backstage.test/api/auth/myoidc/handler/frame?code=blahblah&state=${state}`,
query: { state },
cookies: { 'myoidc-nonce': 'foo' },
session: { 'oidc:oidc.test': { state, nonce: 'foo' } },
} as unknown as express.Request,
{ setHeader: jest.fn(), end: jest.fn() } as unknown as express.Response,
);
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ result: { tokenset, userinfo } }),
providerFactoryOptions.resolverContext,
);
});
});
@@ -15,30 +15,23 @@
*/
import { AuthHandler, SignInResolver } from '../types';
import { OAuthResult } from '../../lib/oauth';
import { createAuthProviderIntegration } from '../createAuthProviderIntegration';
import { createOAuthProviderFactory } from '@backstage/plugin-auth-node';
import {
adaptLegacyOAuthHandler,
adaptLegacyOAuthSignInResolver,
} from '../../lib/legacy';
import { oidcAuthenticator } from '@backstage/plugin-auth-backend-module-oidc-provider';
import { TokenSet, UserinfoResponse } from 'openid-client';
createOAuthProviderFactory,
AuthResolverContext,
BackstageSignInResult,
OAuthAuthenticatorResult,
SignInInfo,
} from '@backstage/plugin-auth-node';
import {
oidcAuthenticator,
OidcAuthResult,
} from '@backstage/plugin-auth-backend-module-oidc-provider';
import {
commonByEmailLocalPartResolver,
commonByEmailResolver,
} from '../resolvers';
/**
* authentication result for the OIDC which includes the token set and user information (a profile response sent by OIDC server)
* @public
* @deprecated No longer used
*/
export type OidcAuthResult = {
tokenset: TokenSet;
userinfo: UserinfoResponse;
};
/**
* Auth provider integration for generic OpenID Connect auth
*
@@ -50,19 +43,39 @@ export const oidc = createAuthProviderIntegration({
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
authHandler?: AuthHandler<OAuthResult>;
authHandler?: AuthHandler<OidcAuthResult>;
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
* Configure sign-in for this provider; convert user profile respones into
* Backstage identities.
*/
signIn?: {
resolver: SignInResolver<OAuthResult>;
resolver: SignInResolver<OidcAuthResult>;
};
}) {
const authHandler = options?.authHandler;
const signInResolver = options?.signIn?.resolver;
return createOAuthProviderFactory({
authenticator: oidcAuthenticator,
profileTransform: adaptLegacyOAuthHandler(options?.authHandler),
signInResolver: adaptLegacyOAuthSignInResolver(options?.signIn?.resolver),
profileTransform:
authHandler &&
((
result: OAuthAuthenticatorResult<OidcAuthResult>,
context: AuthResolverContext,
) => authHandler(result.fullProfile, context)),
signInResolver:
signInResolver &&
((
info: SignInInfo<OAuthAuthenticatorResult<OidcAuthResult>>,
context: AuthResolverContext,
): Promise<BackstageSignInResult> =>
signInResolver(
{
result: info.result.fullProfile,
profile: info.profile,
},
context,
)),
});
},
resolvers: {
-1
View File
@@ -4819,7 +4819,6 @@ __metadata:
passport-auth0: ^1.4.3
passport-bitbucket-oauth2: ^0.1.2
passport-github2: ^0.1.12
passport-gitlab2: ^5.0.0
passport-google-oauth20: ^2.0.0
passport-microsoft: ^1.0.0
passport-oauth2: ^1.6.1