From df99f264b3cc40b0b797ba8b19eb035988cfbc9e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Sat, 28 Aug 2021 18:55:38 +0200 Subject: [PATCH] integration-react: rework ScmAuthApi scopes and avoid a layer in the ScmAuthMux Signed-off-by: Patrik Oldsberg --- packages/integration-react/src/api/ScmAuth.ts | 178 ++++++++++++++++++ .../integration-react/src/api/ScmAuthApi.ts | 16 +- .../integration-react/src/api/ScmAuthMux.ts | 110 ----------- packages/integration-react/src/api/index.ts | 2 +- 4 files changed, 185 insertions(+), 121 deletions(-) create mode 100644 packages/integration-react/src/api/ScmAuth.ts delete mode 100644 packages/integration-react/src/api/ScmAuthMux.ts diff --git a/packages/integration-react/src/api/ScmAuth.ts b/packages/integration-react/src/api/ScmAuth.ts new file mode 100644 index 0000000000..46f458d871 --- /dev/null +++ b/packages/integration-react/src/api/ScmAuth.ts @@ -0,0 +1,178 @@ +/* + * Copyright 2021 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 { OAuthApi } from '@backstage/core-plugin-api'; +import { + ScmAuthApi, + ScmAuthTokenOptions, + ScmAuthTokenResponse, +} from './ScmAuthApi'; + +type ScopeMapping = { + default: string[]; + repoWrite: string[]; +}; + +class ScmAuthMux implements ScmAuthApi { + #providers = new Array(); + + constructor(providers: ScmAuth[]) { + this.#providers = providers; + } + + getCredentials( + options: ScmAuthTokenOptions, + ): Promise { + const url = new URL(options.url); + const provider = this.#providers.find(p => p.isUrlSupported(url)); + if (!provider) { + throw new Error( + `No authentication provider available for SCM access to '${options.url}'`, + ); + } + + return provider.getCredentials(options); + } +} + +export class ScmAuth implements ScmAuthApi { + static forAuthApi( + authApi: OAuthApi, + options: { + hostname: string; + scopeMapping: { + default: string[]; + repoWrite: string[]; + }; + }, + ): ScmAuthApi { + return new ScmAuth(authApi, options.hostname, options.scopeMapping); + } + + static forGithub( + githubAuthApi: OAuthApi, + options?: { + hostname?: string; + }, + ): ScmAuth { + const hostname = options?.hostname ?? 'github.com'; + return new ScmAuth(githubAuthApi, hostname, { + default: ['repo', 'read:org', 'read:user'], + repoWrite: ['repo', 'read:org', 'read:user', 'gist'], + }); + } + + static forGitlab( + gitlabAuthApi: OAuthApi, + options?: { + hostname?: string; + }, + ): ScmAuth { + const hostname = options?.hostname ?? 'gitlab.com'; + return new ScmAuth(gitlabAuthApi, hostname, { + default: ['read_user', 'read_api', 'read_repository'], + repoWrite: ['read_user', 'read_api', 'write_repository', 'api'], + }); + } + + static forAzure( + microsoftAuthApiRef: OAuthApi, + options?: { + hostname?: string; + }, + ): ScmAuth { + const hostname = options?.hostname ?? 'dev.azure.com'; + return new ScmAuth(microsoftAuthApiRef, hostname, { + default: [ + 'vso.build', + 'vso.code', + 'vso.graph', + 'vso.project', + 'vso.profile', + ], + repoWrite: [ + 'vso.build', + 'vso.code_manage', + 'vso.graph', + 'vso.project', + 'vso.profile', + ], + }); + } + + static forBitbucket( + bitbucketAuthApi: OAuthApi, + options?: { + hostname?: string; + }, + ): ScmAuth { + const hostname = options?.hostname ?? 'bitbucket.org'; + return new ScmAuth(bitbucketAuthApi, hostname, { + default: ['account', 'team', 'pullrequest', 'snippet', 'issue'], + repoWrite: [ + 'account', + 'team', + 'pullrequest:write', + 'snippet:write', + 'issue:write', + ], + }); + } + + static mux(providers: ScmAuth[]): ScmAuthApi { + return new ScmAuthMux(providers); + } + + #api: OAuthApi; + #hostname: string; + #scopeMapping: ScopeMapping; + + private constructor( + api: OAuthApi, + hostname: string, + scopeMapping: ScopeMapping, + ) { + this.#api = api; + this.#hostname = hostname; + this.#scopeMapping = scopeMapping; + } + + /** + * Checks whether the implementation is able to provide authentication for the given URL. + */ + isUrlSupported(url: URL): boolean { + return url.hostname === this.#hostname; + } + + async getCredentials( + options: ScmAuthTokenOptions, + ): Promise { + const scopes = options.additionalScope?.repoWrite + ? this.#scopeMapping.repoWrite + : this.#scopeMapping.default; + + const token = await this.#api.getAccessToken(scopes, { + instantPopup: options.instantPopup, + optional: options.optional, + }); + return { + token, + headers: { + Authorization: `Bearer ${token}`, + }, + }; + } +} diff --git a/packages/integration-react/src/api/ScmAuthApi.ts b/packages/integration-react/src/api/ScmAuthApi.ts index 9a20ae9a9e..427b1e2119 100644 --- a/packages/integration-react/src/api/ScmAuthApi.ts +++ b/packages/integration-react/src/api/ScmAuthApi.ts @@ -29,18 +29,14 @@ export interface ScmAuthTokenOptions extends AuthRequestOptions { url: string; /** - * The type of access to be granted. + * Whether to request additional access scope. + * + * Read access to user, organization, and repositories is always included. */ - scope: { + additionalScope?: { /** - * Whether to request access to be able to read repository content, including - * read access to management features like issues and pull requests. - */ - repoRead?: boolean; - - /** - * Whether to request access to be able to write repository content, including - * the ability to create management features like issues and pull requests. + * Requests access to be able to write repository content, including + * the ability to create things like issues and pull requests. */ repoWrite?: boolean; }; diff --git a/packages/integration-react/src/api/ScmAuthMux.ts b/packages/integration-react/src/api/ScmAuthMux.ts deleted file mode 100644 index ff0355b3df..0000000000 --- a/packages/integration-react/src/api/ScmAuthMux.ts +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright 2021 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 { OAuthApi } from '@backstage/core-plugin-api'; -import { - ScmAuthApi, - ScmAuthTokenOptions, - ScmAuthTokenResponse, -} from './ScmAuthApi'; - -type Provider = { - api: ScmAuthApi; - predicate: (url: URL) => boolean; -}; - -class OAuthApiWrapper { - constructor( - private readonly oauthApi: OAuthApi, - private readonly scopeMapping: { - [scope in keyof Required]: string[]; - }, - ) {} - - async getCredentials( - options: ScmAuthTokenOptions, - ): Promise { - const scopes = Object.entries(options.scope).flatMap( - ([scope, requested]) => { - if (requested) { - return this.scopeMapping[scope as keyof ScmAuthTokenOptions['scope']]; - } - return []; - }, - ); - const token = await this.oauthApi.getAccessToken(scopes, { - instantPopup: options.instantPopup, - optional: options.optional, - }); - return { - token, - headers: { - Authorization: `Bearer ${token}`, - }, - }; - } -} - -export class ScmAuthMux implements ScmAuthApi { - static fromProviders(providers: Provider[]): ScmAuthMux { - return new ScmAuthMux(providers); - } - - static fromAuthApi( - authApi: OAuthApi, - options: { - scopeMapping: { - [scope in keyof Required]: string[]; - }; - }, - ): ScmAuthApi { - return new OAuthApiWrapper(authApi, options.scopeMapping); - } - - static providerForGithub( - githubAuthApi: OAuthApi, - options?: { - hostname?: string; - }, - ): Provider { - const hostname = options?.hostname ?? 'github.com'; - return { - api: this.fromAuthApi(githubAuthApi, { - scopeMapping: { - repoRead: ['repo'], - repoWrite: ['repo'], - }, - }), - predicate: url => url.hostname === hostname, - }; - } - - private constructor(private readonly providers: Provider[]) {} - - getCredentials( - options: ScmAuthTokenOptions, - ): Promise { - const url = new URL(options.url); - const provider = this.providers.find(p => p.predicate(url)); - if (!provider) { - throw new Error( - `No authentication provider available for SCM access to '${options.url}'`, - ); - } - - return provider.api.getCredentials(options); - } -} diff --git a/packages/integration-react/src/api/index.ts b/packages/integration-react/src/api/index.ts index 84ed186ae2..2847804b63 100644 --- a/packages/integration-react/src/api/index.ts +++ b/packages/integration-react/src/api/index.ts @@ -15,7 +15,7 @@ */ export { scmAuthApiRef } from './ScmAuthApi'; -export { ScmAuthMux } from './ScmAuthMux'; +export { ScmAuth } from './ScmAuth'; export type { ScmAuthApi, ScmAuthTokenOptions,