plugin/auth-backend: add gitlab oauth

This commit is contained in:
danztran
2020-06-28 22:24:47 +07:00
parent 9ddfa56b44
commit f524bbdea2
18 changed files with 497 additions and 9 deletions
+11
View File
@@ -27,11 +27,13 @@ import {
GoogleAuth,
GithubAuth,
OktaAuth,
GitlabAuth,
oauthRequestApiRef,
OAuthRequestManager,
googleAuthApiRef,
githubAuthApiRef,
oktaAuthApiRef,
gitlabAuthApiRef,
storageApiRef,
WebStorage,
} from '@backstage/core';
@@ -102,6 +104,15 @@ export const apis = (config: ConfigApi) => {
}),
);
builder.add(
gitlabAuthApiRef,
GitlabAuth.create({
apiOrigin: 'http://localhost:7000',
basePath: '/auth/',
oauthRequestApi,
}),
);
builder.add(
techRadarApiRef,
new TechRadar({
+18 -5
View File
@@ -242,11 +242,24 @@ export const githubAuthApiRef = createApiRef<
*/
export const oktaAuthApiRef = createApiRef<
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionStateApi
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionStateApi
>({
id: 'core.auth.okta',
description: 'Provides authentication towards Okta APIs',
});
});
/**
* Provides authentication towards Gitlab APIs.
*
* See https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#limiting-scopes-of-a-personal-access-token
* for a full list of supported scopes.
*/
export const gitlabAuthApiRef = createApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionStateApi
>({
id: 'core.auth.gitlab',
description: 'Provides authentication towards Gitlab APIs',
});
@@ -0,0 +1,46 @@
/*
* Copyright 2020 Spotify AB
*
* 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 GitlabAuth from './GitlabAuth';
describe('GitlabAuth', () => {
it('should get access token', async () => {
const getSession = jest
.fn()
.mockResolvedValue({ providerInfo: { accessToken: 'access-token' } });
const gitlabAuth = new GitlabAuth({ getSession } as any);
expect(await gitlabAuth.getAccessToken()).toBe('access-token');
expect(getSession).toBeCalledTimes(1);
});
it('should normalize scope', () => {
const tests = [
{
arguments: ['read_user api write_repository'],
expect: new Set(['read_user', 'api', 'write_repository']),
},
{
arguments: ['read_repository sudo'],
expect: new Set(['read_repository', 'sudo']),
},
];
for (const test of tests) {
expect(GitlabAuth.normalizeScope(...test.arguments)).toEqual(test.expect);
}
});
});
@@ -0,0 +1,135 @@
/*
* Copyright 2020 Spotify AB
*
* 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 GitlabIcon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { GitlabSession } from './types';
import {
OAuthApi,
SessionStateApi,
SessionState,
ProfileInfo,
BackstageIdentity,
AuthRequestOptions,
} from '../../../definitions/auth';
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
type CreateOptions = {
apiOrigin: string;
basePath: string;
oauthRequestApi: OAuthRequestApi;
environment?: string;
provider?: AuthProvider & { id: string };
};
export type GitlabAuthResponse = {
providerInfo: {
accessToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'gitlab',
title: 'Gitlab',
icon: GitlabIcon,
};
class GitlabAuth implements OAuthApi, SessionStateApi {
static create({
apiOrigin,
basePath,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
apiOrigin,
basePath,
environment,
provider,
oauthRequestApi,
sessionTransform(res: GitlabAuthResponse): GitlabSession {
return {
...res,
providerInfo: {
accessToken: res.providerInfo.accessToken,
scopes: GitlabAuth.normalizeScope(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
});
const sessionManager = new StaticAuthSessionManager({
connector,
defaultScopes: new Set(['read_user']),
sessionScopes: (session: GitlabSession) => session.providerInfo.scopes,
});
return new GitlabAuth(sessionManager);
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<GitlabSession>) {}
async getAccessToken(scope?: string, options?: AuthRequestOptions) {
const session = await this.sessionManager.getSession({
...options,
scopes: GitlabAuth.normalizeScope(scope),
});
return session?.providerInfo.accessToken ?? '';
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
async logout() {
await this.sessionManager.removeSession();
}
static normalizeScope(scope?: string): Set<string> {
if (!scope) {
return new Set();
}
const scopeList = Array.isArray(scope) ? scope : scope.split(' ');
return new Set(scopeList);
}
}
export default GitlabAuth;
@@ -0,0 +1,18 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export * from './types';
export { default as GitlabAuth } from './GitlabAuth';
@@ -0,0 +1,27 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type GitlabSession = {
providerInfo: {
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -16,4 +16,5 @@
export * from './google';
export * from './github';
export * from './gitlab';
export * from './okta';
@@ -22,6 +22,7 @@ import { SidebarContext } from './config';
import {
googleAuthApiRef,
githubAuthApiRef,
gitlabAuthApiRef,
identityApiRef,
oktaAuthApiRef,
useApi,
@@ -57,6 +58,11 @@ export function SidebarUserSettings() {
apiRef={githubAuthApiRef}
icon={Star}
/>
<OAuthProviderSettings
title="Gitlab"
apiRef={gitlabAuthApiRef}
icon={Star}
/>
<OIDCProviderSettings
title="Okta"
apiRef={oktaAuthApiRef}
@@ -28,6 +28,8 @@ import {
googleAuthApiRef,
GithubAuth,
githubAuthApiRef,
GitlabAuth,
gitlabAuthApiRef,
} from '@backstage/core';
// TODO(rugvip): We should likely figure out how to reuse all of these between apps
@@ -75,3 +77,14 @@ export const githubAuthApiFactory = createApiFactory({
oauthRequestApi,
}),
});
export const gitlabAuthApiFactory = createApiFactory({
implements: gitlabAuthApiRef,
deps: { oauthRequestApi: oauthRequestApiRef },
factory: ({ oauthRequestApi }) =>
GitlabAuth.create({
apiOrigin: 'http://localhost:7000',
basePath: '/auth/',
oauthRequestApi,
}),
});
+10
View File
@@ -6,6 +6,7 @@ import {
OAuthRequestManager,
googleAuthApiRef,
githubAuthApiRef,
gitlabAuthApiRef,
oktaAuthApiRef,
AlertApiForwarder,
ErrorApiForwarder,
@@ -52,6 +53,15 @@ builder.add(
}),
);
builder.add(
gitlabAuthApiRef,
GitlabAuth.create({
apiOrigin: 'http://localhost:7000',
basePath: '/auth/',
oauthRequestApi,
}),
);
builder.add(
oktaAuthApiRef,
OktaAuth.create({
+1
View File
@@ -39,6 +39,7 @@
"morgan": "^1.10.0",
"passport": "^0.4.1",
"passport-github2": "^0.1.12",
"passport-gitlab2": "^5.0.0",
"passport-google-oauth20": "^2.0.0",
"passport-oauth2": "^1.5.0",
"passport-okta-oauth": "^0.0.1",
@@ -137,7 +137,9 @@ export const executeRefreshTokenStrategy = async (
params: any,
) => {
if (err) {
reject(new Error(`Failed to refresh access token ${err}`));
reject(
new Error(`Failed to refresh access token: ${JSON.stringify(err)}`),
);
}
if (!accessToken) {
reject(
@@ -17,6 +17,7 @@
import Router from 'express-promise-router';
import { createGithubProvider } from './github';
import { createGoogleProvider } from './google';
import { createGitlabProvider } from './gitlab';
import { createSamlProvider } from './saml';
import { createOktaProvider } from './okta';
import { AuthProviderFactory, AuthProviderConfig } from './types';
@@ -26,6 +27,7 @@ import { TokenIssuer } from '../identity';
const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
github: createGithubProvider,
gitlab: createGitlabProvider,
saml: createSamlProvider,
okta: createOktaProvider,
};
@@ -0,0 +1,17 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export { createGitlabProvider } from './provider';
@@ -0,0 +1,166 @@
/*
* Copyright 2020 Spotify AB
*
* 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 express from 'express';
import { Strategy as GitlabStrategy } from 'passport-gitlab2';
import {
executeFrameHandlerStrategy,
executeRedirectStrategy,
makeProfileInfo,
} from '../../lib/PassportStrategyHelper';
import {
OAuthProviderHandlers,
OAuthPrivateInfo,
AuthProviderConfig,
RedirectInfo,
EnvironmentProviderConfig,
OAuthProviderOptions,
OAuthProviderConfig,
OAuthResponse,
PassportDoneCallback,
} from '../types';
import { OAuthProvider } from '../../lib/OAuthProvider';
import {
EnvironmentHandlers,
EnvironmentHandler,
} from '../../lib/EnvironmentHandler';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
import passport from 'passport';
export class GitlabAuthProvider implements OAuthProviderHandlers {
private readonly _strategy: GitlabStrategy;
private static _defaultExpiresInSeconds = 2 * 3600;
static transformPassportProfile(rawProfile: any): passport.Profile {
const profile: passport.Profile = {
id: rawProfile.id,
username: rawProfile.username,
provider: rawProfile.provider,
displayName: rawProfile.displayName,
};
if (rawProfile.emails && rawProfile.emails.length > 0) {
profile.emails = rawProfile.emails;
}
if (rawProfile.avatarUrl) {
profile.photos = [{ value: rawProfile.avatarUrl }];
}
return profile;
}
static transformOAuthResponse(
accessToken: string,
rawProfile: any,
params: any = {},
): OAuthResponse {
const passportProfile = GitlabAuthProvider.transformPassportProfile(
rawProfile,
);
const profile = makeProfileInfo(passportProfile, params.id_token);
return {
providerInfo: {
accessToken,
scope: params.scope,
expiresInSeconds:
params.expires_in || GitlabAuthProvider._defaultExpiresInSeconds,
},
profile,
};
}
constructor(options: OAuthProviderOptions) {
this._strategy = new GitlabStrategy(
{ ...options },
(
accessToken: any,
_: any,
params: any,
rawProfile: any,
done: PassportDoneCallback<OAuthResponse, OAuthPrivateInfo>,
) => {
const oauthResponse = GitlabAuthProvider.transformOAuthResponse(
accessToken,
rawProfile,
params,
);
done(undefined, oauthResponse);
},
);
}
async start(
req: express.Request,
options: Record<string, string>,
): Promise<RedirectInfo> {
return await executeRedirectStrategy(req, this._strategy, options);
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
const { response } = await executeFrameHandlerStrategy<
OAuthResponse,
OAuthPrivateInfo
>(req, this._strategy);
return { response };
}
}
export function createGitlabProvider(
{ baseUrl }: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
tokenIssuer: TokenIssuer,
) {
const envProviders: EnvironmentHandlers = {};
for (const [env, envConfig] of Object.entries(providerConfig)) {
const config = (envConfig as unknown) as OAuthProviderConfig;
const { secure, appOrigin, baseUrl: oauthBaseUrl } = config;
const callbackURLParam = `?env=${env}`;
const opts = {
clientID: config.clientId,
clientSecret: config.clientSecret,
callbackURL: `${baseUrl}/gitlab/handler/frame${callbackURLParam}`,
baseURL: oauthBaseUrl,
};
if (!opts.clientID || !opts.clientSecret) {
if (process.env.NODE_ENV !== 'development') {
throw new Error(
'Failed to initialize Gitlab auth provider, set AUTH_GITLAB_CLIENT_ID and AUTH_GITLAB_CLIENT_SECRET env vars',
);
}
logger.warn(
'Gitlab auth provider disabled, set AUTH_GITLAB_CLIENT_ID and AUTH_GITLAB_CLIENT_SECRET env vars to enable',
);
continue;
}
envProviders[env] = new OAuthProvider(new GitlabAuthProvider(opts), {
disableRefresh: true,
providerId: 'gitlab',
secure,
baseUrl,
appOrigin,
tokenIssuer,
});
}
return new EnvironmentHandler(envProviders);
}
@@ -47,6 +47,10 @@ export type OAuthProviderConfig = {
* to the window that initiates an auth request.
*/
appOrigin: string;
/**
* Base URL of the auth provider.
*/
baseUrl: string;
/**
* Client ID of the auth provider.
*/
+11 -2
View File
@@ -76,6 +76,15 @@ export async function createRouter(
clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!,
},
},
gitlab: {
development: {
appOrigin: 'http://localhost:3000',
secure: false,
baseUrl: process.env.GITLAB_BASE_URL || 'https://gitlab.com',
clientId: process.env.AUTH_GITLAB_CLIENT_ID!,
clientSecret: process.env.AUTH_GITLAB_CLIENT_SECRET!,
},
},
saml: {
development: {
entryPoint: 'http://localhost:7001/',
@@ -89,8 +98,8 @@ export async function createRouter(
clientId: process.env.AUTH_OKTA_CLIENT_ID!,
clientSecret: process.env.AUTH_OKTA_CLIENT_SECRET!,
audience: process.env.AUTH_OKTA_AUDIENCE,
}
}
},
},
},
},
};
+8 -1
View File
@@ -14019,6 +14019,13 @@ passport-github2@^0.1.12:
dependencies:
passport-oauth2 "1.x.x"
passport-gitlab2@^5.0.0:
version "5.0.0"
resolved "https://registry.npmjs.org/passport-gitlab2/-/passport-gitlab2-5.0.0.tgz#ea37e5285321c026a02671e87469cac28cce9b69"
integrity sha512-cXQMgM6JQx9wHVh7JLH30D8fplfwjsDwRz+zS0pqC8JS+4bNmc1J04NGp5g2M4yfwylH9kQRrMN98GxMw7q7cg==
dependencies:
passport-oauth2 "^1.4.0"
passport-google-oauth20@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz#0d241b2d21ebd3dc7f2b60669ec4d587e3a674ef"
@@ -14035,7 +14042,7 @@ passport-oauth1@1.x.x:
passport-strategy "1.x.x"
utils-merge "1.x.x"
passport-oauth2@1.x.x, passport-oauth2@^1.5.0:
passport-oauth2@1.x.x, passport-oauth2@^1.4.0, passport-oauth2@^1.5.0:
version "1.5.0"
resolved "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.5.0.tgz#64babbb54ac46a4dcab35e7f266ed5294e3c4108"
integrity sha512-kqBt6vR/5VlCK8iCx1/KpY42kQ+NEHZwsSyt4Y6STiNjU+wWICG1i8ucc1FapXDGO15C5O5VZz7+7vRzrDPXXQ==