From db52779ad4e3ada17718a61792f25fca7298aa3d Mon Sep 17 00:00:00 2001 From: Antonio Musolino Date: Wed, 10 Apr 2024 14:36:33 +0200 Subject: [PATCH 1/3] feat: added oauth authorization --- packages/gitlab-backend/config.d.ts | 7 +++ packages/gitlab-backend/package.json | 2 +- packages/gitlab-backend/src/service/router.ts | 17 ++++- packages/gitlab/config.d.ts | 7 +++ packages/gitlab/src/api/GitlabCIClient.ts | 63 ++++++++++++------- packages/gitlab/src/plugin.ts | 11 +++- yarn.lock | 4 +- 7 files changed, 84 insertions(+), 27 deletions(-) diff --git a/packages/gitlab-backend/config.d.ts b/packages/gitlab-backend/config.d.ts index 0a2baf2c..73979d8f 100644 --- a/packages/gitlab-backend/config.d.ts +++ b/packages/gitlab-backend/config.d.ts @@ -13,5 +13,12 @@ export interface Config { * @visibility backend */ proxySecure?: boolean; + + /** + * Active Oauth + * @default "false" + * @visibility backend + */ + useOAuth?: boolean; }; } diff --git a/packages/gitlab-backend/package.json b/packages/gitlab-backend/package.json index 49ea3a10..1ccbdb80 100644 --- a/packages/gitlab-backend/package.json +++ b/packages/gitlab-backend/package.json @@ -40,7 +40,6 @@ "@backstage/backend-plugin-api": "^0.6.7", "@backstage/config": "^1.1.1", "@backstage/integration": "^1.7.0", - "@types/express": "*", "body-parser": "^1.20.2", "express": "^4.17.3", "express-promise-router": "^4.1.0", @@ -54,6 +53,7 @@ "@backstage/cli": "^0.22.8", "@backstage/plugin-catalog-common": "^1.0.14", "@backstage/plugin-catalog-node": "^1.3.7", + "@types/express": "^4.17.21", "@types/supertest": "^2.0.12", "msw": "^1.0.0", "supertest": "^6.2.4" diff --git a/packages/gitlab-backend/src/service/router.ts b/packages/gitlab-backend/src/service/router.ts index 3b18f659..e07adf77 100644 --- a/packages/gitlab-backend/src/service/router.ts +++ b/packages/gitlab-backend/src/service/router.ts @@ -29,6 +29,7 @@ export async function createRouter( ): Promise { const { logger, config } = options; const secure = config.getOptionalBoolean('gitlab.proxySecure'); + const useOAuth = config.getOptionalBoolean('gitlab.useOAuth'); const basePath = getBasePath(config) || ''; const gitlabIntegrations: GitLabIntegrationConfig[] = @@ -49,11 +50,21 @@ export async function createRouter( // target, causing a ERR_HTTP_HEADERS_SENT crash const filter = (_pathname: string, req: Request): boolean => { if (req.headers['authorization']) delete req.headers['authorization']; + // Forward authorization, this header is defined when gitlab.useOAuth is true + if (req.headers['gitlab-authorization']) + req.headers['authorization'] = req.headers[ + 'gitlab-authorization' + ] as string; return req.method === 'GET'; }; const graphqlFilter = (_pathname: string, req: Request): boolean => { if (req.headers['authorization']) delete req.headers['authorization']; + // Forward authorization, this header is defined when gitlab.useOAuth is true + if (req.headers['gitlab-authorization']) + req.headers['authorization'] = req.headers[ + 'gitlab-authorization' + ] as string; return req.method === 'POST' && !req.body.query?.includes('mutation'); }; @@ -66,7 +77,8 @@ export async function createRouter( target: apiUrl.origin, changeOrigin: true, headers: { - ...(token ? { 'PRIVATE-TOKEN': token } : {}), + // If useOAuth is true, we don't not add the token + ...(token && !useOAuth ? { 'PRIVATE-TOKEN': token } : {}), }, secure, onProxyReq: (proxyReq, req) => { @@ -96,7 +108,8 @@ export async function createRouter( target: apiUrl.origin, changeOrigin: true, headers: { - ...(token ? { 'PRIVATE-TOKEN': token } : {}), + // If useOAuth is true, we don't not add the token + ...(token && !useOAuth ? { 'PRIVATE-TOKEN': token } : {}), }, secure, logProvider: () => logger, diff --git a/packages/gitlab/config.d.ts b/packages/gitlab/config.d.ts index 90880e4c..0e86bcd1 100644 --- a/packages/gitlab/config.d.ts +++ b/packages/gitlab/config.d.ts @@ -13,5 +13,12 @@ export interface Config { * @visibility frontend */ defaultReadmePath?: string; + + /** + * Active Oauth + * @default "false" + * @visibility frontend + */ + useOAuth?: boolean; }; } diff --git a/packages/gitlab/src/api/GitlabCIClient.ts b/packages/gitlab/src/api/GitlabCIClient.ts index c8331934..29d33c9b 100644 --- a/packages/gitlab/src/api/GitlabCIClient.ts +++ b/packages/gitlab/src/api/GitlabCIClient.ts @@ -1,4 +1,8 @@ -import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; +import { + DiscoveryApi, + IdentityApi, + OAuthApi, +} from '@backstage/core-plugin-api'; import { PeopleCardEntityData } from '../components/types'; import { parseCodeOwners } from '../components/utils'; import { @@ -21,9 +25,20 @@ import type { } from '@gitbeaker/rest'; import dayjs from 'dayjs'; +export type APIOptions = { + discoveryApi: DiscoveryApi; + identityApi: IdentityApi; + codeOwnersPath?: string; + readmePath?: string; + gitlabAuthApi: OAuthApi; + useOAuth?: boolean; +}; + export class GitlabCIClient implements GitlabCIApi { discoveryApi: DiscoveryApi; identityApi: IdentityApi; + gitlabAuthApi: OAuthApi; + useOAth: boolean; codeOwnersPath: string; gitlabInstance: string; readmePath: string; @@ -33,19 +48,17 @@ export class GitlabCIClient implements GitlabCIApi { identityApi, codeOwnersPath, readmePath, + gitlabAuthApi, gitlabInstance, - }: { - discoveryApi: DiscoveryApi; - identityApi: IdentityApi; - codeOwnersPath?: string; - readmePath?: string; - gitlabInstance: string; - }) { + useOAuth, + }: APIOptions & { gitlabInstance: string }) { this.discoveryApi = discoveryApi; this.codeOwnersPath = codeOwnersPath || 'CODEOWNERS'; this.readmePath = readmePath || 'README.md'; this.gitlabInstance = gitlabInstance; this.identityApi = identityApi; + this.gitlabAuthApi = gitlabAuthApi; + this.useOAth = useOAuth ?? false; } static setupAPI({ @@ -53,12 +66,9 @@ export class GitlabCIClient implements GitlabCIApi { identityApi, codeOwnersPath, readmePath, - }: { - discoveryApi: DiscoveryApi; - identityApi: IdentityApi; - codeOwnersPath?: string; - readmePath?: string; - }) { + gitlabAuthApi, + useOAuth, + }: APIOptions) { return { build: (gitlabInstance: string) => new this({ @@ -67,6 +77,8 @@ export class GitlabCIClient implements GitlabCIApi { codeOwnersPath, readmePath, gitlabInstance, + gitlabAuthApi, + useOAuth, }), }; } @@ -82,16 +94,25 @@ export class GitlabCIClient implements GitlabCIApi { )}/${APIkind}/${this.gitlabInstance}`; const token = (await this.identityApi.getCredentials()).token; + const headers: Record = {}; if (token) { - options = { - ...options, - headers: { - ...options?.headers, - Authorization: `Bearer ${token}`, - }, - }; + headers.Authorization = `Bearer ${token}`; + } + if (this.useOAth) { + const oauthToken = await this.gitlabAuthApi.getAccessToken([ + 'read_api', + ]); + headers['gitlab-authorization'] = `Bearer ${oauthToken}`; } + options = { + ...options, + headers: { + ...options?.headers, + ...headers, + }, + }; + const response = await fetch( `${apiUrl}${path ? `/${path}` : ''}?${new URLSearchParams( query diff --git a/packages/gitlab/src/plugin.ts b/packages/gitlab/src/plugin.ts index 77e3f850..9591ad32 100644 --- a/packages/gitlab/src/plugin.ts +++ b/packages/gitlab/src/plugin.ts @@ -10,6 +10,7 @@ import { createApiFactory, createRouteRef, discoveryApiRef, + gitlabAuthApiRef, } from '@backstage/core-plugin-api'; import { GitlabCIApiRef, GitlabCIClient } from './api'; @@ -26,8 +27,14 @@ export const gitlabPlugin = createPlugin({ configApi: configApiRef, discoveryApi: discoveryApiRef, identityApi: identityApiRef, + gitlabAuthApi: gitlabAuthApiRef, }, - factory: ({ configApi, discoveryApi, identityApi }) => + factory: ({ + configApi, + discoveryApi, + identityApi, + gitlabAuthApi, + }) => GitlabCIClient.setupAPI({ discoveryApi, identityApi, @@ -37,6 +44,8 @@ export const gitlabPlugin = createPlugin({ readmePath: configApi.getOptionalString( 'gitlab.defaultReadmePath' ), + gitlabAuthApi, + useOAuth: configApi.getOptionalBoolean('gitlab.useOAuth'), }), }), ], diff --git a/yarn.lock b/yarn.lock index 9888f61e..1c278996 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4575,7 +4575,7 @@ __metadata: "@backstage/integration": ^1.7.0 "@backstage/plugin-catalog-common": ^1.0.14 "@backstage/plugin-catalog-node": ^1.3.7 - "@types/express": "*" + "@types/express": ^4.17.21 "@types/supertest": ^2.0.12 body-parser: ^1.20.2 express: ^4.17.3 @@ -8437,7 +8437,7 @@ __metadata: languageName: node linkType: hard -"@types/express@npm:*, @types/express@npm:^4.17.13, @types/express@npm:^4.17.6": +"@types/express@npm:*, @types/express@npm:^4.17.13, @types/express@npm:^4.17.21, @types/express@npm:^4.17.6": version: 4.17.21 resolution: "@types/express@npm:4.17.21" dependencies: From ce298508880328a20d8136e3134440e17f0c0918 Mon Sep 17 00:00:00 2001 From: Antonio Musolino Date: Wed, 10 Apr 2024 17:38:47 +0200 Subject: [PATCH 2/3] test: added oauth tests --- .../gitlab-backend/src/service/router.test.ts | 184 +++++++++++++----- packages/gitlab-backend/src/service/router.ts | 8 +- 2 files changed, 137 insertions(+), 55 deletions(-) diff --git a/packages/gitlab-backend/src/service/router.test.ts b/packages/gitlab-backend/src/service/router.test.ts index 04a3f845..a4b91bb2 100644 --- a/packages/gitlab-backend/src/service/router.test.ts +++ b/packages/gitlab-backend/src/service/router.test.ts @@ -7,9 +7,8 @@ import { setupServer } from 'msw/node'; import { createRouter } from './router'; -describe('createRouter', () => { - let app: express.Application; - const server = setupServer( +const buildServer = () => { + return setupServer( rest.get( 'https://non-existing-example.com/api/v4/projects/434', (req, res, ctx) => { @@ -59,6 +58,14 @@ describe('createRouter', () => { } ) ); +}; + +describe('createRouter', () => { + let app: express.Application; + const server = buildServer(); + + const token = 'Bearer iT6P7Ikla2zgBfGSPEWps'; + const token2 = `${token}other`; const config = new ConfigReader({ integrations: { @@ -66,10 +73,12 @@ describe('createRouter', () => { { host: 'non-existing-example.com', apiBaseUrl: 'https://non-existing-example.com/api/v4', + token, }, { host: 'non-existing-example-2.com', apiBaseUrl: 'https://non-existing-example-2.com/api/v4', + token: token2, }, ], }, @@ -113,6 +122,7 @@ describe('createRouter', () => { connection: 'close', host: 'non-existing-example.com', 'user-agent': 'supertest', + 'private-token': token, }, url: 'https://non-existing-example.com/api/v4/projects/434', }); @@ -132,6 +142,7 @@ describe('createRouter', () => { connection: 'close', host: 'non-existing-example-2.com', 'user-agent': 'supertest', + 'private-token': token2, }, url: 'https://non-existing-example-2.com/api/v4/projects/434', }); @@ -155,6 +166,7 @@ describe('createRouter', () => { 'content-type': 'application/json', host: 'non-existing-example.com', 'user-agent': 'supertest', + 'private-token': token, }, url: 'https://non-existing-example.com/api/graphql', }); @@ -176,6 +188,7 @@ describe('createRouter', () => { 'content-type': 'application/json', host: 'non-existing-example-2.com', 'user-agent': 'supertest', + 'private-token': token2, }, url: 'https://non-existing-example-2.com/api/graphql', }); @@ -256,56 +269,7 @@ describe('createRouter', () => { describe('createRouter with baseUrl', () => { let app: express.Application; - const server = setupServer( - rest.get( - 'https://non-existing-example.com/api/v4/projects/434', - (req, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - url: req.url.toString(), - headers: req.headers.all(), - }) - ); - } - ), - rest.get( - 'https://non-existing-example-2.com/api/v4/projects/434', - (req, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - url: req.url.toString(), - headers: req.headers.all(), - }) - ); - } - ), - rest.post( - 'https://non-existing-example.com/api/graphql', - (req, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - url: req.url.toString(), - headers: req.headers.all(), - }) - ); - } - ), - rest.post( - 'https://non-existing-example-2.com/api/graphql', - (req, res, ctx) => { - return res( - ctx.status(200), - ctx.json({ - url: req.url.toString(), - headers: req.headers.all(), - }) - ); - } - ) - ); + const server = buildServer(); const basePath = '/docs'; @@ -517,3 +481,117 @@ describe('createRouter with baseUrl', () => { }); }); }); + +describe('OAuth token authorizations', () => { + let app: express.Application; + const OAuthToken = 'Bearer iT6P7Ikla2zgBfGSPEWps'; + const server = setupServer( + rest.get( + 'https://example-gitlab.com/api/v4/projects/434', + (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + url: req.url.toString(), + headers: req.headers.all(), + }) + ); + } + ), + rest.post('https://example-gitlab.com/api/graphql', (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + url: req.url.toString(), + headers: req.headers.all(), + }) + ); + }) + ); + + const config = new ConfigReader({ + gitlab: { + useOAuth: true, + }, + integrations: { + gitlab: [ + { + host: 'example-gitlab.com', + apiBaseUrl: 'https://example-gitlab.com/api/v4', + token: 'Bearer different-from-oauth', + }, + ], + }, + }); + + beforeAll(async () => { + const router = await createRouter({ + logger: getVoidLogger(), + config, + }); + app = express().use('/api/gitlab', router); + server.listen({ + onUnhandledRequest: ({ headers }, print) => { + if (headers.get('User-Agent') === 'supertest') { + return; + } + print.error(); + }, + }); + }); + + afterAll(() => server.close()); + + beforeEach(async () => { + jest.resetAllMocks(); + server.resetHandlers(); + }); + + describe('GET Request', () => { + it('Oauth Token should work', async () => { + const agent = request.agent(app); + // this is set to let msw pass test requests through the mock server + agent.set('User-Agent', 'supertest'); + agent.set('gitlab-authorization', OAuthToken); + const response = await agent.get( + '/api/gitlab/rest/example-gitlab.com/projects/434' + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + headers: { + 'accept-encoding': 'gzip, deflate', + connection: 'close', + host: 'example-gitlab.com', + 'user-agent': 'supertest', + authorization: OAuthToken, + }, + url: 'https://example-gitlab.com/api/v4/projects/434', + }); + }); + }); + + describe('Graphql requests', () => { + it('Oauth Token should work', async () => { + const agent = request.agent(app); + // this is set to let msw pass test requests through the mock server + agent.set('User-Agent', 'supertest'); + agent.set('gitlab-authorization', OAuthToken); + const response = await agent.post( + '/api/gitlab/graphql/example-gitlab.com' + ); + expect(response.status).toEqual(200); + expect(response.body).toEqual({ + headers: { + 'accept-encoding': 'gzip, deflate', + connection: 'close', + 'content-length': '2', + 'content-type': 'application/json', + host: 'example-gitlab.com', + 'user-agent': 'supertest', + authorization: OAuthToken, + }, + url: 'https://example-gitlab.com/api/graphql', + }); + }); + }); +}); diff --git a/packages/gitlab-backend/src/service/router.ts b/packages/gitlab-backend/src/service/router.ts index e07adf77..adef0da1 100644 --- a/packages/gitlab-backend/src/service/router.ts +++ b/packages/gitlab-backend/src/service/router.ts @@ -51,20 +51,24 @@ export async function createRouter( const filter = (_pathname: string, req: Request): boolean => { if (req.headers['authorization']) delete req.headers['authorization']; // Forward authorization, this header is defined when gitlab.useOAuth is true - if (req.headers['gitlab-authorization']) + if (req.headers['gitlab-authorization']) { req.headers['authorization'] = req.headers[ 'gitlab-authorization' ] as string; + delete req.headers['gitlab-authorization']; + } return req.method === 'GET'; }; const graphqlFilter = (_pathname: string, req: Request): boolean => { if (req.headers['authorization']) delete req.headers['authorization']; // Forward authorization, this header is defined when gitlab.useOAuth is true - if (req.headers['gitlab-authorization']) + if (req.headers['gitlab-authorization']) { req.headers['authorization'] = req.headers[ 'gitlab-authorization' ] as string; + delete req.headers['gitlab-authorization']; + } return req.method === 'POST' && !req.body.query?.includes('mutation'); }; From 22849c559a44db95592e8f3c9e3555d1aa5bcdd6 Mon Sep 17 00:00:00 2001 From: Antonio Musolino Date: Wed, 10 Apr 2024 17:55:43 +0200 Subject: [PATCH 3/3] docs: OIDC/oauth --- README.md | 16 ++++++++++++++++ packages/gitlab-backend/config.d.ts | 2 +- packages/gitlab/config.d.ts | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ee783dee..44193963 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ - [Setup](#setup) - [Setup Frontend Plugin](#setup-frontend-plugin) - [Setup Backend Plugin](#setup-backend-plugin) + - [Extra OIDC/OAuth](#extra-oidcoauth) - [Register To The New Backend System](#register-to-the-new-backend-system) - [Annotations](#annotations) - [Code owners file](#code-owners-file) @@ -243,8 +244,23 @@ gitlab: # This parameter controls SSL Certs verification # Default: true proxySecure: false + # Activate Oauth/OIDC + # Default: false + useOAuth: false ``` +### Extra OIDC/OAuth + +By default, this plugin utilizes the token specified in the configuration file `app-config.yaml` under the key: `integrations.gitlab[i].token`. However, you can opt out of using this token by activating OIDC as shown below: + +```yaml +gitlab: + useOAuth: true +``` + +**Note:**: To use OIDC you have to configure the `@backstage/plugin-auth-backend-module-gitlab-provider` plugin. +**Note:**: OIDC does not allow multi GitLab instances! + ### Register To The New Backend System If you're already using the [New Backend System](https://backstage.io/docs/backend-system/), registering backend plugins will become much easier: diff --git a/packages/gitlab-backend/config.d.ts b/packages/gitlab-backend/config.d.ts index 73979d8f..cc9cde8d 100644 --- a/packages/gitlab-backend/config.d.ts +++ b/packages/gitlab-backend/config.d.ts @@ -15,7 +15,7 @@ export interface Config { proxySecure?: boolean; /** - * Active Oauth + * Activate Oauth/OIDC * @default "false" * @visibility backend */ diff --git a/packages/gitlab/config.d.ts b/packages/gitlab/config.d.ts index 0e86bcd1..e8fc7185 100644 --- a/packages/gitlab/config.d.ts +++ b/packages/gitlab/config.d.ts @@ -15,7 +15,7 @@ export interface Config { defaultReadmePath?: string; /** - * Active Oauth + * Activate Oauth/OIDC * @default "false" * @visibility frontend */