feat: setup gitlab auth provider factory

* add username to ProfileInfo
* expose gitlab provider
* add signIn handler
* refactor resultHandler usage

Signed-off-by: Tom Opdebeeck <tom.opdebeeck@studiohyperdrive.be>
This commit is contained in:
Tom Opdebeeck
2021-07-19 16:35:00 +02:00
parent 5b9e6a78b5
commit cd5932bc66
4 changed files with 156 additions and 34 deletions
@@ -30,7 +30,7 @@ export const makeProfileInfo = (
profile: passport.Profile,
idToken?: string,
): ProfileInfo => {
let { displayName } = profile;
let { displayName, username } = profile;
let email: string | undefined = undefined;
if (profile.emails && profile.emails.length > 0) {
@@ -56,6 +56,10 @@ export const makeProfileInfo = (
if (!displayName && decoded.name) {
displayName = decoded.name;
}
if (!username && decoded.username) {
username = decoded.username;
}
} catch (e) {
throw new Error(`Failed to parse id token and get profile info, ${e}`);
}
@@ -65,6 +69,7 @@ export const makeProfileInfo = (
email,
picture,
displayName,
username,
};
};
@@ -16,6 +16,8 @@
import express from 'express';
import { Strategy as GitlabStrategy } from 'passport-gitlab2';
import { Logger } from 'winston';
import {
executeRedirectStrategy,
executeFrameHandlerStrategy,
@@ -24,7 +26,12 @@ import {
makeProfileInfo,
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
RedirectInfo,
AuthProviderFactory,
SignInResolver,
AuthHandler,
} from '../types';
import {
OAuthAdapter,
OAuthProviderOptions,
@@ -36,6 +43,8 @@ import {
encodeState,
OAuthResult,
} from '../../lib/oauth';
import { TokenIssuer } from '../../identity';
import { CatalogIdentityClient } from '../../lib/catalog';
type FullProfile = OAuthResult['fullProfile'] & {
avatarUrl?: string;
@@ -47,29 +56,72 @@ type PrivateInfo = {
export type GitlabAuthProviderOptions = OAuthProviderOptions & {
baseUrl: string;
signInResolver?: SignInResolver<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
};
function transformProfile(fullProfile: FullProfile) {
function transformResult(result: OAuthResult): OAuthResult {
const { fullProfile, ...authResult } = result;
const profile = makeProfileInfo({
...fullProfile,
photos: [
...(fullProfile.photos ?? []),
...(fullProfile.avatarUrl ? [{ value: fullProfile.avatarUrl }] : []),
...((fullProfile as FullProfile).avatarUrl
? [{ value: (fullProfile as FullProfile).avatarUrl as string }]
: []),
],
});
let id = fullProfile.id;
if (profile.email) {
id = profile.email.split('@')[0];
}
return { id, profile };
return {
...authResult,
fullProfile,
};
}
export const gitlabDefaultSignInResolver: SignInResolver<OAuthResult> = async (
info,
ctx,
) => {
const { profile } = info;
if (!profile.email) {
throw new Error('Profile contained no email');
}
const userId = profile.email.split('@')[0];
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: userId, ent: [`user:default/${userId}`] },
});
return { id: userId, token };
};
export class GitlabAuthProvider implements OAuthHandlers {
private readonly _strategy: GitlabStrategy;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
private readonly tokenIssuer: TokenIssuer;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly logger: Logger;
constructor(options: GitlabAuthProviderOptions) {
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
this.tokenIssuer = options.tokenIssuer;
this.logger = options.logger;
this.catalogIdentityClient = options.catalogIdentityClient;
this._strategy = new GitlabStrategy(
{
clientID: options.clientId,
@@ -109,23 +161,9 @@ export class GitlabAuthProvider implements OAuthHandlers {
OAuthResult,
PrivateInfo
>(req, this._strategy);
const { accessToken, params } = result;
const { id, profile } = transformProfile(result.fullProfile);
return {
response: {
profile,
providerInfo: {
accessToken,
scope: params.scope,
expiresInSeconds: params.expires_in,
idToken: params.id_token,
},
backstageIdentity: {
id,
},
},
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
@@ -145,30 +183,78 @@ export class GitlabAuthProvider implements OAuthHandlers {
this._strategy,
accessToken,
);
const { id, profile } = transformProfile(fullProfile);
return {
profile,
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: newRefreshToken,
});
}
private async handleResult(result: OAuthResult): Promise<OAuthResponse> {
const { profile } = await this.authHandler(transformResult(result));
const response: OAuthResponse = {
providerInfo: {
accessToken,
refreshToken: newRefreshToken, // GitLab expires the old refresh token when used
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
},
backstageIdentity: {
id,
idToken: result.params.id_token,
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
profile,
};
if (this.signInResolver) {
response.backstageIdentity = await this.signInResolver(
{
result,
profile,
},
{
tokenIssuer: this.tokenIssuer,
catalogIdentityClient: this.catalogIdentityClient,
logger: this.logger,
},
);
}
return response;
}
}
export type GitlabProviderOptions = {};
export type GitlabProviderOptions = {
/**
* 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>;
/**
* 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 `microsoft.com/email` annotation.
*/
signIn?: {
resolver?: SignInResolver<OAuthResult>;
};
};
export const createGitlabProvider = (
_options?: GitlabProviderOptions,
options?: GitlabProviderOptions,
): AuthProviderFactory => {
return ({ providerId, globalConfig, config, tokenIssuer }) =>
return ({
providerId,
globalConfig,
config,
tokenIssuer,
catalogApi,
logger,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
@@ -176,11 +262,37 @@ export const createGitlabProvider = (
const baseUrl = audience || 'https://gitlab.com';
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
});
const signInResolverFn =
options?.signIn?.resolver ?? gitlabDefaultSignInResolver;
const signInResolver: SignInResolver<OAuthResult> = info =>
signInResolverFn(info, {
catalogIdentityClient,
tokenIssuer,
logger,
});
const provider = new GitlabAuthProvider({
clientId,
clientSecret,
callbackUrl,
baseUrl,
authHandler,
signInResolver,
catalogIdentityClient,
logger,
tokenIssuer,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
@@ -14,6 +14,7 @@
* limitations under the License.
*/
export * from './gitlab';
export * from './google';
export * from './microsoft';
export { factories as defaultAuthProviderFactories } from './factories';
@@ -196,6 +196,10 @@ export type ProfileInfo = {
* signed in user.
*/
picture?: string;
/**
* Username of the signed in user.
*/
username?: string;
};
export type SignInInfo<AuthResult> = {