diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index d2b2706464..40a8c9f275 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -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({ diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index a944e85a7e..c9a17f0153 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -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', +}); diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts new file mode 100644 index 0000000000..18346b3208 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts @@ -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); + } + }); +}); diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts new file mode 100644 index 0000000000..20e13f8a03 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts @@ -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 { + return this.sessionManager.sessionState$(); + } + + constructor(private readonly sessionManager: SessionManager) {} + + 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 { + 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 { + if (!scope) { + return new Set(); + } + + const scopeList = Array.isArray(scope) ? scope : scope.split(' '); + return new Set(scopeList); + } +} +export default GitlabAuth; diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/index.ts b/packages/core-api/src/apis/implementations/auth/gitlab/index.ts new file mode 100644 index 0000000000..ee17b7de06 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/gitlab/index.ts @@ -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'; diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/types.ts b/packages/core-api/src/apis/implementations/auth/gitlab/types.ts new file mode 100644 index 0000000000..b03dfec084 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/gitlab/types.ts @@ -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; + expiresAt: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; diff --git a/packages/core-api/src/apis/implementations/auth/index.ts b/packages/core-api/src/apis/implementations/auth/index.ts index 4fa992dfb5..9d89ba5b04 100644 --- a/packages/core-api/src/apis/implementations/auth/index.ts +++ b/packages/core-api/src/apis/implementations/auth/index.ts @@ -16,4 +16,5 @@ export * from './google'; export * from './github'; +export * from './gitlab'; export * from './okta'; diff --git a/packages/core/src/layout/Sidebar/UserSettings.tsx b/packages/core/src/layout/Sidebar/UserSettings.tsx index 668cb1354a..937169c8bf 100644 --- a/packages/core/src/layout/Sidebar/UserSettings.tsx +++ b/packages/core/src/layout/Sidebar/UserSettings.tsx @@ -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} /> + + GitlabAuth.create({ + apiOrigin: 'http://localhost:7000', + basePath: '/auth/', + oauthRequestApi, + }), +}); diff --git a/packages/storybook/.storybook/apis.js b/packages/storybook/.storybook/apis.js index 0d0cf74e8f..4851b872a8 100644 --- a/packages/storybook/.storybook/apis.js +++ b/packages/storybook/.storybook/apis.js @@ -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({ diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index a336441dc2..d756704a62 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -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", diff --git a/plugins/auth-backend/src/lib/PassportStrategyHelper.ts b/plugins/auth-backend/src/lib/PassportStrategyHelper.ts index 1167946560..1936e2ff05 100644 --- a/plugins/auth-backend/src/lib/PassportStrategyHelper.ts +++ b/plugins/auth-backend/src/lib/PassportStrategyHelper.ts @@ -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( diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 9feefdac9f..7fa375bb6b 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -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, }; diff --git a/plugins/auth-backend/src/providers/gitlab/index.ts b/plugins/auth-backend/src/providers/gitlab/index.ts new file mode 100644 index 0000000000..7fba2dd95c --- /dev/null +++ b/plugins/auth-backend/src/providers/gitlab/index.ts @@ -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'; diff --git a/plugins/auth-backend/src/providers/gitlab/provider.ts b/plugins/auth-backend/src/providers/gitlab/provider.ts new file mode 100644 index 0000000000..ffc24751d3 --- /dev/null +++ b/plugins/auth-backend/src/providers/gitlab/provider.ts @@ -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, + ) => { + const oauthResponse = GitlabAuthProvider.transformOAuthResponse( + accessToken, + rawProfile, + params, + ); + done(undefined, oauthResponse); + }, + ); + } + + async start( + req: express.Request, + options: Record, + ): Promise { + 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); +} diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index aec4f9dfd0..8dff5bb60d 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -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. */ diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index b9c0ff4496..1ba821247f 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -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, - } - } + }, + }, }, }, }; diff --git a/yarn.lock b/yarn.lock index f4fa34b993..d442d3ce80 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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==