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
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Add Sign In and Handler resolver for GitLab provider
+1 -1
View File
@@ -42,4 +42,4 @@
| [empathy.co](https://empathy.co/) | [@guillermotti](https://github.com/guillermotti) | Developer portal for tech docs, service catalog, plugin discovery and much more. |
| [creditas.com](https://creditas.com/) | [@aureliosaraiva](https://github.com/aureliosaraiva) [@Creditas](https://github.com/creditas) | Centralization of all services, standards, documentation, etc. We started the deployment process. |
| [Prisjakt](https://www.prisjakt.nu) / [PriceSpy](https://pricespy.co.uk) | [@kennylindahl](https://github.com/kennylindahl) | Internal developer portal - Documentation, scaffolding, software catalog, TechRadar, Gitlab org data integration |
| [Powerspike](https://powerspike.tv/) | [@trelore](https://github.com/trelore) | Developer portal for documentation of core libraries and repositories. |
| [Powerspike](https://powerspike.tv/) | [@trelore](https://github.com/trelore) | Developer portal for documentation of core libraries and repositories. |
+19 -2
View File
@@ -84,6 +84,13 @@ export type BackstageIdentity = {
entity?: Entity;
};
// Warning: (ae-missing-release-tag) "createGitlabProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const createGitlabProvider: (
options?: GitlabProviderOptions | undefined,
) => AuthProviderFactory;
// Warning: (ae-missing-release-tag) "createGoogleProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -138,7 +145,16 @@ export const encodeState: (state: OAuthState) => string;
// @public (undocumented)
export const ensuresXRequestedWith: (req: express.Request) => boolean;
// Warning: (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "GitlabProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type GitlabProviderOptions = {
authHandler?: AuthHandler<OAuthResult>;
signIn?: {
resolver?: SignInResolver<OAuthResult>;
};
};
// Warning: (ae-missing-release-tag) "googleEmailSignInResolver" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -404,7 +420,8 @@ export type WebMessageResponse =
//
// src/identity/types.d.ts:25:5 - (ae-forgotten-export) The symbol "TokenParams" needs to be exported by the entry point index.d.ts
// src/identity/types.d.ts:31:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts
// src/providers/google/provider.d.ts:36:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts
// src/providers/gitlab/provider.d.ts:38:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts
// src/providers/gitlab/provider.d.ts:49:9 - (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:109:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:115:5 - (ae-forgotten-export) The symbol "ExperimentalIdentityResolver" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:132:8 - (tsdoc-missing-deprecation-message) The @deprecated block must include a deprecation message, e.g. describing the recommended alternative
@@ -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';