Merge pull request #6535 from DeveloperSHD/feature/gitlab-auth-provider
Feature/gitlab auth provider
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-auth-backend': patch
|
||||
---
|
||||
|
||||
Add Sign In and Handler resolver for GitLab provider
|
||||
+1
-1
@@ -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. |
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user