auth-backend: implement sign-in and profile options for google provider

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2021-02-15 12:35:25 +01:00
committed by Fredrik Adelöw
parent 48a5dcf9d5
commit 1111c0753c
2 changed files with 114 additions and 76 deletions
@@ -17,8 +17,8 @@
import express from 'express';
import passport from 'passport';
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
import { Logger } from 'winston';
import { CatalogIdentityClient } from '../../lib/catalog';
import { TokenIssuer } from '../../identity/types';
import { CatalogIdentityClient, getEntityClaims } from '../../lib/catalog';
import {
encodeState,
OAuthAdapter,
@@ -27,8 +27,8 @@ import {
OAuthProviderOptions,
OAuthRefreshRequest,
OAuthResponse,
OAuthStartRequest,
OAuthResult,
OAuthStartRequest,
} from '../../lib/oauth';
import {
executeFetchUserProfileStrategy,
@@ -38,30 +38,36 @@ import {
makeProfileInfo,
PassportDoneCallback,
} from '../../lib/passport';
import { AuthProviderFactory, RedirectInfo } from '../types';
import { TokenIssuer } from '../../identity/types';
import {
AuthProviderFactory,
ProfileTransform,
RedirectInfo,
SignInResolver,
} from '../types';
type PrivateInfo = {
refreshToken: string;
};
type Options = OAuthProviderOptions & {
logger: Logger;
identityClient: CatalogIdentityClient;
signInResolver?: SignInResolver<OAuthResult>;
profileTransform: ProfileTransform<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
};
export class GoogleAuthProvider implements OAuthHandlers {
private readonly _strategy: GoogleStrategy;
private readonly logger: Logger;
private readonly identityClient: CatalogIdentityClient;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly profileTransform: ProfileTransform<OAuthResult>;
private readonly tokenIssuer: TokenIssuer;
private readonly catalogIdentityClient: CatalogIdentityClient;
constructor(options: Options) {
this.logger = options.logger;
this.identityClient = options.identityClient;
this.signInResolver = options.signInResolver;
this.profileTransform = options.profileTransform;
this.tokenIssuer = options.tokenIssuer;
// TODO: throw error if env variables not set?
this.catalogIdentityClient = options.catalogIdentityClient;
this._strategy = new GoogleStrategy(
{
clientID: options.clientId,
@@ -111,18 +117,8 @@ export class GoogleAuthProvider implements OAuthHandlers {
PrivateInfo
>(req, this._strategy);
const profile = makeProfileInfo(result.fullProfile, result.params.id_token);
return {
response: await this.populateIdentity({
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
profile,
}),
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
@@ -133,89 +129,130 @@ export class GoogleAuthProvider implements OAuthHandlers {
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
const profile = makeProfileInfo(fullProfile, params.id_token);
return this.populateIdentity({
providerInfo: {
accessToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
},
profile,
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: req.refreshToken,
});
}
private async populateIdentity(
response: OAuthResponse,
): Promise<OAuthResponse> {
const { profile } = response;
private async handleResult(result: OAuthResult) {
const profile = await this.profileTransform(result);
if (!profile.email) {
throw new Error('Google profile contained no email');
}
const response: OAuthResponse = {
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
profile,
};
try {
const token = await this.tokenIssuer.issueToken({
claims: { sub: 'backstage.io/auth-backend' },
});
const user = await this.identityClient.findUser(
if (this.signInResolver) {
response.backstageIdentity = await this.signInResolver(
{
annotations: {
'google.com/email': profile.email,
},
result,
profile,
},
{ token },
);
return {
...response,
backstageIdentity: {
id: user.metadata.name,
{
tokenIssuer: this.tokenIssuer,
catalogIdentityClient: this.catalogIdentityClient,
},
};
} catch (error) {
this.logger.warn(
`Failed to look up user, ${error}, falling back to allowing login based on email pattern, this will probably break in the future`,
);
return {
...response,
backstageIdentity: { id: profile.email.split('@')[0] },
};
}
return response;
}
}
export type GoogleProviderOptions = {};
const emailSignInResolver: SignInResolver<OAuthResult> = async (info, ctx) => {
const { profile } = info;
if (!profile.email) {
throw new Error('Google profile contained no email');
}
const entity = await ctx.catalogIdentityClient.findUser({
annotations: {
'google.com/email': profile.email,
},
});
const claims = getEntityClaims(entity);
const token = await ctx.tokenIssuer.issueToken({ claims });
return { id: entity.metadata.name, entity, token };
};
export type GoogleProviderOptions = {
/**
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
profileTransform?: ProfileTransform<OAuthResult>;
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
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
* for the catalog for a single user entity that has a matching `google.com/email` annotation.
*/
resolver?: 'email' | SignInResolver<OAuthResult>;
};
};
export const createGoogleProvider = (
_options?: GoogleProviderOptions,
options?: GoogleProviderOptions,
): AuthProviderFactory => {
return ({
providerId,
globalConfig,
config,
logger,
tokenIssuer,
catalogApi,
}) =>
return ({ providerId, globalConfig, config, tokenIssuer, catalogApi }) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
let profileTransform: ProfileTransform<OAuthResult> = async ({
fullProfile,
params,
}) => makeProfileInfo(fullProfile, params.id_token);
if (options?.profileTransform) {
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 provider = new GoogleAuthProvider({
clientId,
clientSecret,
callbackUrl,
logger,
signInResolver,
profileTransform,
tokenIssuer,
identityClient: new CatalogIdentityClient({ catalogApi }),
catalogIdentityClient,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
@@ -14,6 +14,7 @@
* limitations under the License.
*/
export * from './google';
export { factories as defaultAuthProviderFactories } from './factories';
// Export the minimal interface required for implementing a