Merge pull request #6535 from DeveloperSHD/feature/gitlab-auth-provider

Feature/gitlab auth provider
This commit is contained in:
Johan Haals
2021-08-23 16:54:50 +02:00
committed by GitHub
8 changed files with 215 additions and 56 deletions
@@ -17,9 +17,11 @@
import express from 'express';
import passport from 'passport';
import jwtDecoder from 'jwt-decode';
import { ProfileInfo, RedirectInfo } from '../../providers/types';
import { InternalOAuthError } from 'passport-oauth2';
import { PassportProfile } from './types';
import { ProfileInfo, RedirectInfo } from '../../providers/types';
export type PassportDoneCallback<Res, Private = never> = (
err?: Error,
response?: Res,
@@ -27,7 +29,7 @@ export type PassportDoneCallback<Res, Private = never> = (
) => void;
export const makeProfileInfo = (
profile: passport.Profile,
profile: PassportProfile,
idToken?: string,
): ProfileInfo => {
let email: string | undefined = undefined;
@@ -37,7 +39,9 @@ export const makeProfileInfo = (
}
let picture: string | undefined = undefined;
if (profile.photos && profile.photos.length > 0) {
if (profile.avatarUrl) {
picture = profile.avatarUrl;
} else if (profile.photos && profile.photos.length > 0) {
const [firstPhoto] = profile.photos;
picture = firstPhoto.value;
}
@@ -194,12 +198,12 @@ type ProviderStrategy = {
export const executeFetchUserProfileStrategy = async (
providerStrategy: passport.Strategy,
accessToken: string,
): Promise<passport.Profile> => {
): Promise<PassportProfile> => {
return new Promise((resolve, reject) => {
const anyStrategy = providerStrategy as unknown as ProviderStrategy;
anyStrategy.userProfile(
accessToken,
(error: Error, rawProfile: passport.Profile) => {
(error: Error, rawProfile: PassportProfile) => {
if (error) {
reject(error);
} else {
@@ -0,0 +1,20 @@
/*
* Copyright 2020 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 passport from 'passport';
export type PassportProfile = passport.Profile & {
avatarUrl?: string;
};
@@ -14,9 +14,12 @@
* limitations under the License.
*/
import { GitlabAuthProvider } from './provider';
import { GitlabAuthProvider, gitlabDefaultSignInResolver } from './provider';
import * as helpers from '../../lib/passport/PassportStrategyHelper';
import { OAuthResult } from '../../lib/oauth';
import { getVoidLogger } from '../../../../../packages/backend-common/src';
import { TokenIssuer } from '../../identity';
import { CatalogIdentityClient } from '../../lib/catalog';
const mockFrameHandler = jest.spyOn(
helpers,
@@ -60,12 +63,12 @@ describe('GitlabAuthProvider', () => {
accessToken: '19xasczxcm9n7gacn9jdgm19me',
expiresInSeconds: 100,
scope: 'user_read write_repository',
idToken: undefined,
},
profile: {
email: 'jimmymarkum@gmail.com',
displayName: 'Jimmy Markum',
picture:
'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg',
picture: 'http://gitlab.com/lols',
},
},
},
@@ -102,21 +105,43 @@ describe('GitlabAuthProvider', () => {
accessToken:
'ajakljsdoiahoawxbrouawucmbawe.awkxjemaneasdxwe.sodijxqeqwexeqwxe',
expiresInSeconds: 200,
idToken: undefined,
scope: 'read_repository',
},
profile: {
displayName: 'Dave Boyle',
email: 'daveboyle@gitlab.org',
picture: 'http://gitlab.com/lols',
},
},
},
];
const tokenIssuer = {
issueToken: jest.fn(),
listPublicKeys: jest.fn(),
};
const catalogIdentityClient = {
findUser: jest.fn(),
};
const provider = new GitlabAuthProvider({
clientId: 'mock',
clientSecret: 'mock',
callbackUrl: 'mock',
baseUrl: 'mock',
catalogIdentityClient:
catalogIdentityClient as unknown as CatalogIdentityClient,
tokenIssuer: tokenIssuer as unknown as TokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: {
email: fullProfile.emails![0]!.value,
displayName: fullProfile.displayName,
picture: 'http://gitlab.com/lols',
},
}),
signInResolver: gitlabDefaultSignInResolver,
logger: getVoidLogger(),
});
for (const test of tests) {
mockFrameHandler.mockResolvedValueOnce(test.input);
@@ -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,10 +43,8 @@ import {
encodeState,
OAuthResult,
} from '../../lib/oauth';
type FullProfile = OAuthResult['fullProfile'] & {
avatarUrl?: string;
};
import { TokenIssuer } from '../../identity';
import { CatalogIdentityClient } from '../../lib/catalog';
type PrivateInfo = {
refreshToken: string;
@@ -47,29 +52,54 @@ type PrivateInfo = {
export type GitlabAuthProviderOptions = OAuthProviderOptions & {
baseUrl: string;
signInResolver?: SignInResolver<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
};
function transformProfile(fullProfile: FullProfile) {
const profile = makeProfileInfo({
...fullProfile,
photos: [
...(fullProfile.photos ?? []),
...(fullProfile.avatarUrl ? [{ value: fullProfile.avatarUrl }] : []),
],
});
export const gitlabDefaultSignInResolver: SignInResolver<OAuthResult> = async (
info,
ctx,
) => {
const { profile, result } = info;
let id = result.fullProfile.id;
let id = fullProfile.id;
if (profile.email) {
id = profile.email.split('@')[0];
}
return { id, profile };
}
const token = await ctx.tokenIssuer.issueToken({
claims: { sub: id, ent: [`user:default/${id}`] },
});
return { id, token };
};
export const gitlabDefaultAuthHandler: AuthHandler<OAuthResult> = async ({
fullProfile,
params,
}) => ({
profile: makeProfileInfo(fullProfile, params.id_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.catalogIdentityClient = options.catalogIdentityClient;
this.logger = options.logger;
this.tokenIssuer = options.tokenIssuer;
this.authHandler = options.authHandler;
this.signInResolver = options.signInResolver;
this._strategy = new GitlabStrategy(
{
clientID: options.clientId,
@@ -109,23 +139,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 +161,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(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 +240,34 @@ 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 ?? gitlabDefaultAuthHandler;
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 * from './okta';