feat: enable backwards compatability and write a simple test

Co-authored-by: Fredrik Adelöw <freben@users.noreply.github.com>
Co-authored-by: Johan Haals <johan.haals@gmail.com>
Co-authored-by: Patrik Oldsberg <poldsberg@gmail.com>

Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
blam
2021-06-16 10:34:48 +02:00
parent db1d558411
commit 8adb6f6bcd
3 changed files with 156 additions and 20 deletions
@@ -0,0 +1,89 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { GoogleAuthProvider } from './provider';
import * as helpers from '../../lib/passport/PassportStrategyHelper';
import { OAuthResult } from '../../lib/oauth';
import { getVoidLogger } from '@backstage/backend-common';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient } from '../../lib/catalog';
const mockFrameHandler = (jest.spyOn(
helpers,
'executeFrameHandlerStrategy',
) as unknown) as jest.MockedFunction<
() => Promise<{ result: OAuthResult; privateInfo: any }>
>;
describe('createGoogleProvider', () => {
it('should auth', async () => {
const tokenIssuer = {
issueToken: jest.fn(),
listPublicKeys: jest.fn(),
};
const catalogIdentityClient = {
findUser: jest.fn(),
};
const provider = new GoogleAuthProvider({
logger: getVoidLogger(),
catalogIdentityClient: (catalogIdentityClient as unknown) as CatalogIdentityClient,
tokenIssuer: (tokenIssuer as unknown) as TokenIssuer,
profileTransform: async ({ fullProfile }) => ({
email: fullProfile.emails![0]!.value,
displayName: fullProfile.displayName,
picture: 'http://google.com/lols',
}),
clientId: 'mock',
clientSecret: 'mock',
callbackUrl: 'mock',
});
mockFrameHandler.mockResolvedValueOnce({
result: {
fullProfile: {
emails: [{ value: 'conrad@example.com' }],
displayName: 'Conrad',
id: 'conrad',
provider: 'google',
},
params: {
id_token: 'idToken',
scope: 'scope',
expires_in: 123,
},
accessToken: 'accessToken',
},
privateInfo: {
refreshToken: 'wacka',
},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual({
providerInfo: {
accessToken: 'accessToken',
expiresInSeconds: 123,
idToken: 'idToken',
scope: 'scope',
},
profile: {
email: 'conrad@example.com',
displayName: 'Conrad',
picture: 'http://google.com/lols',
},
});
});
});
@@ -44,6 +44,7 @@ import {
RedirectInfo,
SignInResolver,
} from '../types';
import { Logger } from 'winston';
type PrivateInfo = {
refreshToken: string;
@@ -54,6 +55,7 @@ type Options = OAuthProviderOptions & {
profileTransform: ProfileTransform<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
};
export class GoogleAuthProvider implements OAuthHandlers {
@@ -62,12 +64,14 @@ export class GoogleAuthProvider implements OAuthHandlers {
private readonly profileTransform: ProfileTransform<OAuthResult>;
private readonly tokenIssuer: TokenIssuer;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly logger: Logger;
constructor(options: Options) {
this.signInResolver = options.signInResolver;
this.profileTransform = options.profileTransform;
this.tokenIssuer = options.tokenIssuer;
this.catalogIdentityClient = options.catalogIdentityClient;
this.logger = options.logger;
this._strategy = new GoogleStrategy(
{
clientID: options.clientId,
@@ -163,6 +167,7 @@ export class GoogleAuthProvider implements OAuthHandlers {
{
tokenIssuer: this.tokenIssuer,
catalogIdentityClient: this.catalogIdentityClient,
logger: this.logger,
},
);
}
@@ -171,7 +176,10 @@ export class GoogleAuthProvider implements OAuthHandlers {
}
}
const emailSignInResolver: SignInResolver<OAuthResult> = async (info, ctx) => {
export const googleEmailSignInResolver: SignInResolver<OAuthResult> = async (
info,
ctx,
) => {
const { profile } = info;
if (!profile.email) {
@@ -190,6 +198,38 @@ const emailSignInResolver: SignInResolver<OAuthResult> = async (info, ctx) => {
return { id: entity.metadata.name, entity, token };
};
export const googleDefaultSignInResolver: SignInResolver<OAuthResult> = async (
info,
ctx,
) => {
const { profile } = info;
if (!profile.email) {
throw new Error('Google profile contained no email');
}
let userId: string;
try {
const entity = await ctx.catalogIdentityClient.findUser({
annotations: {
'google.com/email': profile.email,
},
});
userId = entity.metadata.name;
} catch (error) {
ctx.logger.warn(
`Failed to look up user, ${error}, falling back to allowing login based on email pattern, this will probably break in the future`,
);
userId = profile.email.split('@')[0];
}
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: userId, ent: [`user:default/${userId}`] },
});
return { id: userId, token };
};
export type GoogleProviderOptions = {
/**
* The profile transformation function used to verify and convert the auth response
@@ -200,21 +240,28 @@ export type GoogleProviderOptions = {
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
/**
* Maps an auth result to a Backstage identity for the user.
*
* Set to `'email'` to use the default email-based sign in resolver, which will search
* the catalog for a single user entity that has a matching `google.com/email` annotation.
*/
signIn?: {
/**
* Maps an auth result to a Backstage identity for the user.
*
* Set to `'email'` to use the default email-based sign in resolver, which will search
* the catalog for a single user entity that has a matching `google.com/email` annotation.
*/
resolver?: 'email' | SignInResolver<OAuthResult>;
resolver?: SignInResolver<OAuthResult>;
};
};
export const createGoogleProvider = (
options?: GoogleProviderOptions,
): AuthProviderFactory => {
return ({ providerId, globalConfig, config, tokenIssuer, catalogApi }) =>
return ({
providerId,
globalConfig,
config,
tokenIssuer,
catalogApi,
logger,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
@@ -233,17 +280,15 @@ export const createGoogleProvider = (
profileTransform = options.profileTransform;
}
let signInResolver: SignInResolver<OAuthResult> | undefined = undefined;
const resolver = options?.signIn?.resolver;
if (resolver === 'email') {
signInResolver = emailSignInResolver;
} else if (typeof resolver === 'function') {
signInResolver = info =>
resolver(info, {
catalogIdentityClient,
tokenIssuer,
});
}
const signInResolverFn =
options?.signIn?.resolver ?? googleDefaultSignInResolver;
const signInResolver: SignInResolver<OAuthResult> = info =>
signInResolverFn(info, {
catalogIdentityClient,
tokenIssuer,
logger,
});
const provider = new GoogleAuthProvider({
clientId,
@@ -253,6 +298,7 @@ export const createGoogleProvider = (
profileTransform,
tokenIssuer,
catalogIdentityClient,
logger,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
@@ -215,6 +215,7 @@ export type SignInResolver<AuthResult> = (
context: {
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
},
) => Promise<BackstageIdentity>;