diff --git a/.changeset/brave-shoes-push.md b/.changeset/brave-shoes-push.md new file mode 100644 index 0000000000..3c6a8d7e0e --- /dev/null +++ b/.changeset/brave-shoes-push.md @@ -0,0 +1,40 @@ +--- +'@backstage/create-app': patch +--- + +Added the default `ScmAuth` implementation to the app. + +To apply this change to an existing app, head to `packages/app/apis.ts`, import `ScmAuth` from `@backstage/integration-react`, and add a `ScmAuth.createDefaultApiFactory()` to your list of APIs: + +```diff + import { + ScmIntegrationsApi, + scmIntegrationsApiRef, ++ ScmAuth, + } from '@backstage/integration-react'; + + export const apis: AnyApiFactory[] = [ +... ++ ScmAuth.createDefaultApiFactory(), +... + ]; +``` + +If you have integrations towards SCM providers other than the default ones (github.com, gitlab.com, etc.), you will want to create a custom `ScmAuth` factory instead, for example like this: + +```ts +createApiFactory({ + api: scmAuthApiRef, + deps: { + gheAuthApi: gheAuthApiRef, + githubAuthApi: githubAuthApiRef, + }, + factory: ({ githubAuthApi, gheAuthApi }) => + ScmAuth.merge( + ScmAuth.forGithub(githubAuthApi), + ScmAuth.forGithub(gheAuthApi, { + host: 'ghe.example.com', + }), + ), +}); +``` diff --git a/.changeset/great-rabbits-juggle.md b/.changeset/great-rabbits-juggle.md new file mode 100644 index 0000000000..649af14aeb --- /dev/null +++ b/.changeset/great-rabbits-juggle.md @@ -0,0 +1,36 @@ +--- +'@backstage/plugin-catalog-import': minor +--- + +Switched to using the `ScmAuthApi` for authentication rather than GitHub auth. If you are instantiating your `CatalogImportClient` manually you now need to pass in an instance of `ScmAuthApi` instead. + +Also be sure to register the `scmAuthApiRef` from the `@backstage/integration-react` in your app: + +```ts +import { ScmAuth } from '@backstage/integration-react'; + +// in packages/app/apis.ts + +const apis = [ +// ... other APIs + +ScmAuth.createDefaultApiFactory(); + +// OR + +createApiFactory({ + api: scmAuthApiRef, + deps: { + gheAuthApi: gheAuthApiRef, + githubAuthApi: githubAuthApiRef, + }, + factory: ({ githubAuthApi, gheAuthApi }) => + ScmAuth.merge( + ScmAuth.forGithub(githubAuthApi), + ScmAuth.forGithub(gheAuthApi, { + host: 'ghe.example.com', + }), + ), +}); +] +``` diff --git a/.changeset/purple-scissors-allow.md b/.changeset/purple-scissors-allow.md new file mode 100644 index 0000000000..39d70d4c2a --- /dev/null +++ b/.changeset/purple-scissors-allow.md @@ -0,0 +1,89 @@ +--- +'@backstage/integration-react': patch +--- + +Added `ScmAuthApi` along with the implementation `ScmAuth`. The `ScmAuthApi` provides methods for client-side authentication towards multiple different source code management services simultaneously. + +When requesting credentials you supply a URL along with the same options as the other `OAuthApi`s, and optionally a request for additional high-level scopes. + +For example like this: + +```ts +const { token } = await scmAuthApi.getCredentials({ + url: 'https://ghe.example.com/backstage/backstage', + additionalScope: { + repoWrite: true, + }, +}); +``` + +The instantiation of the API can either be done with a default factory that adds support for the public providers (github.com, gitlab.com, etc.): + +```ts +// in packages/app/apis.ts +ScmAuth.createDefaultApiFactory(); +``` + +Or with a more custom setup that can add support for additional providers, for example like this: + +```ts +createApiFactory({ + api: scmAuthApiRef, + deps: { + gheAuthApi: gheAuthApiRef, + githubAuthApi: githubAuthApiRef, + }, + factory: ({ githubAuthApi, gheAuthApi }) => + ScmAuth.merge( + ScmAuth.forGithub(githubAuthApi), + ScmAuth.forGithub(gheAuthApi, { + host: 'ghe.example.com', + }), + ), +}); +``` + +The additional `gheAuthApiRef` utility API can be defined either inside the app itself if it's only used for this purpose, for inside an internal common package for APIs, such as `@internal/apis`: + +```ts +const gheAuthApiRef: ApiRef = + createApiRef({ + id: 'internal.auth.ghe', + }); +``` + +And then implemented using the `GithubAuth` class from `@backstage/core-app-api`: + +```ts +createApiFactory({ + api: githubAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, + configApi: configApiRef, + }, + factory: ({ discoveryApi, oauthRequestApi, configApi }) => + GithubAuth.create({ + provider: { + id: 'ghe', + icon: ..., + title: 'GHE' + }, + discoveryApi, + oauthRequestApi, + defaultScopes: ['read:user'], + environment: configApi.getOptionalString('auth.environment'), + }), +}) +``` + +Finally you also need to add and configure another GitHub provider to the `auth-backend` using the provider ID `ghe`: + +```ts +// Add the following options to `createRouter` in packages/backend/src/plugins/auth.ts +providerFactories: { + ghe: createGithubProvider(), +}, +``` + +Other providers follow the same steps, but you will want to use the appropriate auth API implementation in the frontend, such as for example `GitlabAuth`. diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 08ff781319..d587a88402 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -17,6 +17,7 @@ import { ScmIntegrationsApi, scmIntegrationsApiRef, + ScmAuth, } from '@backstage/integration-react'; import { costInsightsApiRef, @@ -41,6 +42,8 @@ export const apis: AnyApiFactory[] = [ factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi), }), + ScmAuth.createDefaultApiFactory(), + createApiFactory({ api: graphQlBrowseApiRef, deps: { errorApi: errorApiRef, githubAuthApi: githubAuthApiRef }, diff --git a/packages/create-app/templates/default-app/packages/app/src/apis.ts b/packages/create-app/templates/default-app/packages/app/src/apis.ts index f2cd272c87..c89753aae8 100644 --- a/packages/create-app/templates/default-app/packages/app/src/apis.ts +++ b/packages/create-app/templates/default-app/packages/app/src/apis.ts @@ -1,6 +1,7 @@ import { ScmIntegrationsApi, scmIntegrationsApiRef, + ScmAuth, } from '@backstage/integration-react'; import { AnyApiFactory, @@ -14,4 +15,5 @@ export const apis: AnyApiFactory[] = [ deps: { configApi: configApiRef }, factory: ({ configApi }) => ScmIntegrationsApi.fromConfig(configApi), }), + ScmAuth.createDefaultApiFactory(), ]; diff --git a/packages/integration-react/api-report.md b/packages/integration-react/api-report.md index 88057fda74..b7feb6b6a9 100644 --- a/packages/integration-react/api-report.md +++ b/packages/integration-react/api-report.md @@ -5,9 +5,95 @@ ```ts /// +import { ApiFactory } from '@backstage/core-plugin-api'; import { ApiRef } from '@backstage/core-plugin-api'; +import { AuthRequestOptions } from '@backstage/core-plugin-api'; +import { BackstageIdentityApi } from '@backstage/core-plugin-api'; import { Config } from '@backstage/config'; +import { OAuthApi } from '@backstage/core-plugin-api'; +import { OpenIdConnectApi } from '@backstage/core-plugin-api'; +import { ProfileInfoApi } from '@backstage/core-plugin-api'; import { ScmIntegrationRegistry } from '@backstage/integration'; +import { SessionApi } from '@backstage/core-plugin-api'; + +// @public +export class ScmAuth implements ScmAuthApi { + static createDefaultApiFactory(): ApiFactory< + ScmAuthApi, + ScmAuthApi, + { + github: OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi; + gitlab: OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionApi; + azure: OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionApi; + } + >; + static forAuthApi( + authApi: OAuthApi, + options: { + host: string; + scopeMapping: { + default: string[]; + repoWrite: string[]; + }; + }, + ): ScmAuth; + static forAzure( + microsoftAuthApi: OAuthApi, + options?: { + host?: string; + }, + ): ScmAuth; + static forBitbucket( + bitbucketAuthApi: OAuthApi, + options?: { + host?: string; + }, + ): ScmAuth; + static forGithub( + githubAuthApi: OAuthApi, + options?: { + host?: string; + }, + ): ScmAuth; + static forGitlab( + gitlabAuthApi: OAuthApi, + options?: { + host?: string; + }, + ): ScmAuth; + // (undocumented) + getCredentials(options: ScmAuthTokenOptions): Promise; + isUrlSupported(url: URL): boolean; + static merge(...providers: ScmAuth[]): ScmAuthApi; +} + +// @public +export interface ScmAuthApi { + getCredentials(options: ScmAuthTokenOptions): Promise; +} + +// @public +export const scmAuthApiRef: ApiRef; + +// @public (undocumented) +export interface ScmAuthTokenOptions extends AuthRequestOptions { + additionalScope?: { + repoWrite?: boolean; + }; + url: string; +} + +// @public (undocumented) +export interface ScmAuthTokenResponse { + headers: { + [name: string]: string; + }; + token: string; +} // Warning: (ae-missing-release-tag) "ScmIntegrationIcon" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // diff --git a/packages/integration-react/src/api/ScmAuth.test.ts b/packages/integration-react/src/api/ScmAuth.test.ts new file mode 100644 index 0000000000..2d0ea9b97d --- /dev/null +++ b/packages/integration-react/src/api/ScmAuth.test.ts @@ -0,0 +1,215 @@ +/* + * 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 { ScmAuth } from './ScmAuth'; + +class MockOAuthApi implements OAuthApi { + constructor(private readonly accessToken: string) {} + + getAccessToken = jest.fn(async () => { + return this.accessToken; + }); +} + +describe('ScmAuth', () => { + it('should provide credentials for GitHub and GHE', async () => { + const mockGithubAuth = new MockOAuthApi('github-access-token'); + const mockGheAuth = new MockOAuthApi('ghe-access-token'); + + const api = ScmAuth.merge( + ScmAuth.forGithub(mockGithubAuth), + ScmAuth.forGithub(mockGheAuth, { + host: 'ghe.example.com', + }), + ); + + await expect( + api.getCredentials({ url: 'https://github.com/backstage/backstage' }), + ).resolves.toEqual({ + token: 'github-access-token', + headers: { + Authorization: 'Bearer github-access-token', + }, + }); + await expect( + api.getCredentials({ + url: 'https://ghe.example.com/backstage/backstage', + additionalScope: { + repoWrite: true, + }, + }), + ).resolves.toEqual({ + token: 'ghe-access-token', + headers: { + Authorization: 'Bearer ghe-access-token', + }, + }); + + expect(mockGithubAuth.getAccessToken).toHaveBeenCalledTimes(1); + expect(mockGithubAuth.getAccessToken).toHaveBeenCalledWith( + ['repo', 'read:org', 'read:user'], + {}, + ); + expect(mockGheAuth.getAccessToken).toHaveBeenCalledTimes(1); + expect(mockGheAuth.getAccessToken).toHaveBeenCalledWith( + ['repo', 'read:org', 'read:user', 'gist'], + {}, + ); + }); + + it('should use correct scopes for each provider', async () => { + const mockAuthApi = { + getAccessToken: async (scopes: string[]) => { + return scopes.join(' '); + }, + }; + + const githubAuth = ScmAuth.forGithub(mockAuthApi); + await expect( + githubAuth.getCredentials({ url: 'http://example.com' }), + ).resolves.toMatchObject({ + token: 'repo read:org read:user', + }); + await expect( + githubAuth.getCredentials({ + url: 'http://example.com', + additionalScope: { repoWrite: true }, + }), + ).resolves.toMatchObject({ + token: 'repo read:org read:user gist', + }); + + const gitlabAuth = ScmAuth.forGitlab(mockAuthApi); + await expect( + gitlabAuth.getCredentials({ url: 'http://example.com' }), + ).resolves.toMatchObject({ + token: 'read_user read_api read_repository', + }); + await expect( + gitlabAuth.getCredentials({ + url: 'http://example.com', + additionalScope: { repoWrite: true }, + }), + ).resolves.toMatchObject({ + token: 'read_user read_api read_repository write_repository api', + }); + + const azureAuth = ScmAuth.forAzure(mockAuthApi); + await expect( + azureAuth.getCredentials({ url: 'http://example.com' }), + ).resolves.toMatchObject({ + token: 'vso.build vso.code vso.graph vso.project vso.profile', + }); + await expect( + azureAuth.getCredentials({ + url: 'http://example.com', + additionalScope: { repoWrite: true }, + }), + ).resolves.toMatchObject({ + token: + 'vso.build vso.code vso.graph vso.project vso.profile vso.code_manage', + }); + + const bitbucketAuth = ScmAuth.forBitbucket(mockAuthApi); + await expect( + bitbucketAuth.getCredentials({ url: 'http://example.com' }), + ).resolves.toMatchObject({ + token: 'account team pullrequest snippet issue', + }); + await expect( + bitbucketAuth.getCredentials({ + url: 'http://example.com', + additionalScope: { repoWrite: true }, + }), + ).resolves.toMatchObject({ + token: + 'account team pullrequest snippet issue pullrequest:write snippet:write issue:write', + }); + }); + + it('should handle host option', () => { + const mockAuthApi = { + getAccessToken: jest.fn(), + }; + + const expectUrlSupport = (scm: ScmAuth, url: string) => { + expect(scm.isUrlSupported(new URL(url))).toBe(true); + expect(scm.isUrlSupported(new URL('https://not.supported.com'))).toBe( + false, + ); + }; + + expectUrlSupport(ScmAuth.forGithub(mockAuthApi), 'https://github.com'); + expectUrlSupport(ScmAuth.forGitlab(mockAuthApi), 'https://gitlab.com'); + expectUrlSupport( + ScmAuth.forAzure(mockAuthApi, {}), + 'https://dev.azure.com', + ); + expectUrlSupport( + ScmAuth.forBitbucket(mockAuthApi, {}), + 'https://bitbucket.org', + ); + expectUrlSupport( + ScmAuth.forGithub(mockAuthApi, { host: 'example.com' }), + 'https://example.com/abc', + ); + expectUrlSupport( + ScmAuth.forGitlab(mockAuthApi, { host: 'example.com' }), + 'http://example.com', + ); + expectUrlSupport( + ScmAuth.forAzure(mockAuthApi, { host: 'example.com' }), + 'https://example.com', + ); + expectUrlSupport( + ScmAuth.forBitbucket(mockAuthApi, { host: 'example.com:8080' }), + 'https://example.com:8080', + ); + }); + + it('should throw an error for unknown URLs', async () => { + const emptyMux = ScmAuth.merge(); + await expect( + emptyMux.getCredentials({ url: 'http://example.com' }), + ).rejects.toThrow( + "No authentication provider available for access to 'http://example.com'", + ); + + const scmAuth = ScmAuth.merge( + ScmAuth.forAuthApi(new MockOAuthApi('token'), { + host: 'example.com', + scopeMapping: { + default: ['a'], + repoWrite: ['b'], + }, + }), + ); + await expect( + scmAuth.getCredentials({ url: 'http://example.com' }), + ).resolves.toMatchObject({ token: 'token' }); + await expect( + scmAuth.getCredentials({ url: 'http://not.example.com' }), + ).rejects.toThrow( + "No authentication provider available for access to 'http://not.example.com'", + ); + await expect( + scmAuth.getCredentials({ url: 'http://example.com:8080' }), + ).rejects.toThrow( + "No authentication provider available for access to 'http://example.com:8080'", + ); + }); +}); diff --git a/packages/integration-react/src/api/ScmAuth.ts b/packages/integration-react/src/api/ScmAuth.ts new file mode 100644 index 0000000000..c34c4da758 --- /dev/null +++ b/packages/integration-react/src/api/ScmAuth.ts @@ -0,0 +1,275 @@ +/* + * 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 { + createApiFactory, + githubAuthApiRef, + gitlabAuthApiRef, + microsoftAuthApiRef, + OAuthApi, +} from '@backstage/core-plugin-api'; +import { + ScmAuthApi, + scmAuthApiRef, + ScmAuthTokenOptions, + ScmAuthTokenResponse, +} from './ScmAuthApi'; + +type ScopeMapping = { + /** The base scopes used for all requests */ + default: string[]; + /** Additional scopes added if `repoWrite` is requested */ + repoWrite: string[]; +}; + +class ScmAuthMux implements ScmAuthApi { + #providers: Array; + + constructor(providers: ScmAuth[]) { + this.#providers = providers; + } + + async 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 access to '${options.url}'`, + ); + } + + return provider.getCredentials(options); + } +} + +/** + * An implementation of the ScmAuthApi that merges together OAuthApi instances + * to form a single instance that can handles authentication for multiple providers. + * + * @public + * + * @example + * ``` + * // Supports authentication towards both public GitHub and GHE: + * createApiFactory({ + * api: scmAuthApiRef, + * deps: { + * gheAuthApi: gheAuthApiRef, + * githubAuthApi: githubAuthApiRef, + * }, + * factory: ({ githubAuthApi, gheAuthApi }) => + * ScmAuth.merge( + * ScmAuth.forGithub(githubAuthApi), + * ScmAuth.forGithub(gheAuthApi, { + * host: 'ghe.example.com', + * }), + * ) + * }) + * ``` + */ +export class ScmAuth implements ScmAuthApi { + /** + * Creates an API factory that enables auth for each of the default SCM providers. + */ + static createDefaultApiFactory() { + return createApiFactory({ + api: scmAuthApiRef, + deps: { + github: githubAuthApiRef, + gitlab: gitlabAuthApiRef, + azure: microsoftAuthApiRef, + }, + factory: ({ github, gitlab, azure }) => + ScmAuth.merge( + ScmAuth.forGithub(github), + ScmAuth.forGitlab(gitlab), + ScmAuth.forAzure(azure), + ), + }); + } + + /** + * Creates a general purpose ScmAuth instance with a custom scope mapping. + */ + static forAuthApi( + authApi: OAuthApi, + options: { + host: string; + scopeMapping: { + default: string[]; + repoWrite: string[]; + }; + }, + ): ScmAuth { + return new ScmAuth(authApi, options.host, options.scopeMapping); + } + + /** + * Creates a new ScmAuth instance that handles authentication towards GitHub. + * + * The host option determines which URLs that are handled by this instance and defaults to `github.com`. + * + * The default scopes are: + * + * `repo read:org read:user` + * + * If the additional `repoWrite` permission is requested, these scopes are added: + * + * `gist` + */ + static forGithub( + githubAuthApi: OAuthApi, + options?: { + host?: string; + }, + ): ScmAuth { + const host = options?.host ?? 'github.com'; + return new ScmAuth(githubAuthApi, host, { + default: ['repo', 'read:org', 'read:user'], + repoWrite: ['gist'], + }); + } + + /** + * Creates a new ScmAuth instance that handles authentication towards GitLab. + * + * The host option determines which URLs that are handled by this instance and defaults to `gitlab.com`. + * + * The default scopes are: + * + * `read_user read_api read_repository` + * + * If the additional `repoWrite` permission is requested, these scopes are added: + * + * `write_repository api` + */ + static forGitlab( + gitlabAuthApi: OAuthApi, + options?: { + host?: string; + }, + ): ScmAuth { + const host = options?.host ?? 'gitlab.com'; + return new ScmAuth(gitlabAuthApi, host, { + default: ['read_user', 'read_api', 'read_repository'], + repoWrite: ['write_repository', 'api'], + }); + } + + /** + * Creates a new ScmAuth instance that handles authentication towards Azure. + * + * The host option determines which URLs that are handled by this instance and defaults to `dev.azure.com`. + * + * The default scopes are: + * + * `vso.build vso.code vso.graph vso.project vso.profile` + * + * If the additional `repoWrite` permission is requested, these scopes are added: + * + * `vso.code_manage` + */ + static forAzure( + microsoftAuthApi: OAuthApi, + options?: { + host?: string; + }, + ): ScmAuth { + const host = options?.host ?? 'dev.azure.com'; + return new ScmAuth(microsoftAuthApi, host, { + default: [ + 'vso.build', + 'vso.code', + 'vso.graph', + 'vso.project', + 'vso.profile', + ], + repoWrite: ['vso.code_manage'], + }); + } + + /** + * Creates a new ScmAuth instance that handles authentication towards Bitbucket. + * + * The host option determines which URLs that are handled by this instance and defaults to `bitbucket.org`. + * + * The default scopes are: + * + * `account team pullrequest snippet issue` + * + * If the additional `repoWrite` permission is requested, these scopes are added: + * + * `pullrequest:write snippet:write issue:write` + */ + static forBitbucket( + bitbucketAuthApi: OAuthApi, + options?: { + host?: string; + }, + ): ScmAuth { + const host = options?.host ?? 'bitbucket.org'; + return new ScmAuth(bitbucketAuthApi, host, { + default: ['account', 'team', 'pullrequest', 'snippet', 'issue'], + repoWrite: ['pullrequest:write', 'snippet:write', 'issue:write'], + }); + } + + /** + * Merges together multiple ScmAuth instances into one that + * routes requests to the correct instance based on the URL. + */ + static merge(...providers: ScmAuth[]): ScmAuthApi { + return new ScmAuthMux(providers); + } + + #api: OAuthApi; + #host: string; + #scopeMapping: ScopeMapping; + + private constructor(api: OAuthApi, host: string, scopeMapping: ScopeMapping) { + this.#api = api; + this.#host = host; + this.#scopeMapping = scopeMapping; + } + + /** + * Checks whether the implementation is able to provide authentication for the given URL. + */ + isUrlSupported(url: URL): boolean { + return url.host === this.#host; + } + + async getCredentials( + options: ScmAuthTokenOptions, + ): Promise { + const { url, additionalScope, ...restOptions } = options; + + const scopes = this.#scopeMapping.default.slice(); + if (additionalScope?.repoWrite) { + scopes.push(...this.#scopeMapping.repoWrite); + } + + const token = await this.#api.getAccessToken(scopes, restOptions); + return { + token, + headers: { + Authorization: `Bearer ${token}`, + }, + }; + } +} diff --git a/packages/integration-react/src/api/ScmAuthApi.ts b/packages/integration-react/src/api/ScmAuthApi.ts new file mode 100644 index 0000000000..d8fc413620 --- /dev/null +++ b/packages/integration-react/src/api/ScmAuthApi.ts @@ -0,0 +1,81 @@ +/* + * 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 { + ApiRef, + createApiRef, + AuthRequestOptions, +} from '@backstage/core-plugin-api'; + +/** @public */ +export interface ScmAuthTokenOptions extends AuthRequestOptions { + /** + * The URL of the SCM resource to be accessed. + * + * @example https://github.com/backstage/backstage + */ + url: string; + + /** + * Whether to request additional access scope. + * + * Read access to user, organization, and repositories is always included. + */ + additionalScope?: { + /** + * Requests access to be able to write repository content, including + * the ability to create things like issues and pull requests. + */ + repoWrite?: boolean; + }; +} + +/** @public */ +export interface ScmAuthTokenResponse { + /** + * An authorization token that can be used to authenticate requests. + */ + token: string; + + /** + * The set of HTTP headers that are needed to authenticate requests. + */ + headers: { [name: string]: string }; +} + +/** + * ScmAuthApi provides methods for authenticating towards source code management services. + * + * As opposed to using the GitHub, GitLab and other auth APIs + * directly, this API allows for more generic access to SCM services. + * + * @public + */ +export interface ScmAuthApi { + /** + * Requests credentials for accessing an SCM resource. + */ + getCredentials(options: ScmAuthTokenOptions): Promise; +} + +/** + * The ApiRef for the ScmAuthApi. + * + * @public + */ +export const scmAuthApiRef: ApiRef = createApiRef({ + id: 'core.scmauth', +}); diff --git a/packages/integration-react/src/api/index.ts b/packages/integration-react/src/api/index.ts index 7417bd0c7b..2847804b63 100644 --- a/packages/integration-react/src/api/index.ts +++ b/packages/integration-react/src/api/index.ts @@ -14,6 +14,13 @@ * limitations under the License. */ +export { scmAuthApiRef } from './ScmAuthApi'; +export { ScmAuth } from './ScmAuth'; +export type { + ScmAuthApi, + ScmAuthTokenOptions, + ScmAuthTokenResponse, +} from './ScmAuthApi'; export { ScmIntegrationsApi, scmIntegrationsApiRef, diff --git a/plugins/catalog-import/api-report.md b/plugins/catalog-import/api-report.md index 4deb66e01b..9874956b5f 100644 --- a/plugins/catalog-import/api-report.md +++ b/plugins/catalog-import/api-report.md @@ -16,9 +16,9 @@ import { EntityName } from '@backstage/catalog-model'; import { FieldErrors } from 'react-hook-form'; import { IdentityApi } from '@backstage/core-plugin-api'; import { InfoCardVariants } from '@backstage/core-components'; -import { OAuthApi } from '@backstage/core-plugin-api'; import { default as React_2 } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; +import { ScmAuthApi } from '@backstage/integration-react'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { SubmitHandler } from 'react-hook-form'; import { TextFieldProps } from '@material-ui/core/TextField/TextField'; @@ -96,7 +96,7 @@ export const catalogImportApiRef: ApiRef; export class CatalogImportClient implements CatalogImportApi { constructor(options: { discoveryApi: DiscoveryApi; - githubAuthApi: OAuthApi; + scmAuthApi: ScmAuthApi; identityApi: IdentityApi; scmIntegrationsApi: ScmIntegrationRegistry; catalogApi: CatalogApi; diff --git a/plugins/catalog-import/src/api/CatalogImportClient.test.ts b/plugins/catalog-import/src/api/CatalogImportClient.test.ts index a87587c9c5..373acd4b5f 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.test.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.test.ts @@ -47,8 +47,8 @@ jest.doMock('@octokit/rest', () => { }); import { ConfigReader, UrlPatternDiscovery } from '@backstage/core-app-api'; -import { OAuthApi } from '@backstage/core-plugin-api'; import { ScmIntegrations } from '@backstage/integration'; +import { ScmAuthApi } from '@backstage/integration-react'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { msw } from '@backstage/test-utils'; import { Octokit } from '@octokit/rest'; @@ -63,8 +63,8 @@ describe('CatalogImportClient', () => { const mockBaseUrl = 'http://backstage:9191/api/catalog'; const discoveryApi = UrlPatternDiscovery.compile(mockBaseUrl); - const githubAuthApi: jest.Mocked = { - getAccessToken: jest.fn(), + const scmAuthApi: jest.Mocked = { + getCredentials: jest.fn().mockResolvedValue({ token: 'token' }), }; const identityApi = { getUserId: () => { @@ -112,7 +112,7 @@ describe('CatalogImportClient', () => { beforeEach(() => { catalogImportClient = new CatalogImportClient({ discoveryApi, - githubAuthApi, + scmAuthApi, scmIntegrationsApi, identityApi, catalogApi, diff --git a/plugins/catalog-import/src/api/CatalogImportClient.ts b/plugins/catalog-import/src/api/CatalogImportClient.ts index c804d1193d..752899e6e8 100644 --- a/plugins/catalog-import/src/api/CatalogImportClient.ts +++ b/plugins/catalog-import/src/api/CatalogImportClient.ts @@ -20,12 +20,12 @@ import { ConfigApi, DiscoveryApi, IdentityApi, - OAuthApi, } from '@backstage/core-plugin-api'; import { GitHubIntegrationConfig, ScmIntegrationRegistry, } from '@backstage/integration'; +import { ScmAuthApi } from '@backstage/integration-react'; import { Octokit } from '@octokit/rest'; import { Base64 } from 'js-base64'; import { PartialEntity } from '../types'; @@ -36,21 +36,21 @@ import { trimEnd } from 'lodash'; export class CatalogImportClient implements CatalogImportApi { private readonly discoveryApi: DiscoveryApi; private readonly identityApi: IdentityApi; - private readonly githubAuthApi: OAuthApi; + private readonly scmAuthApi: ScmAuthApi; private readonly scmIntegrationsApi: ScmIntegrationRegistry; private readonly catalogApi: CatalogApi; private readonly configApi: ConfigApi; constructor(options: { discoveryApi: DiscoveryApi; - githubAuthApi: OAuthApi; + scmAuthApi: ScmAuthApi; identityApi: IdentityApi; scmIntegrationsApi: ScmIntegrationRegistry; catalogApi: CatalogApi; configApi: ConfigApi; }) { this.discoveryApi = options.discoveryApi; - this.githubAuthApi = options.githubAuthApi; + this.scmAuthApi = options.scmAuthApi; this.identityApi = options.identityApi; this.scmIntegrationsApi = options.scmIntegrationsApi; this.catalogApi = options.catalogApi; @@ -157,6 +157,7 @@ the component will become available.\n\nFor more information, read an \ if (ghConfig) { return await this.submitGitHubPrToRepo({ ...ghConfig, + repositoryUrl, fileContent, title, body, @@ -215,7 +216,7 @@ the component will become available.\n\nFor more information, read an \ entities: EntityName[]; }> > { - const token = await this.githubAuthApi.getAccessToken(['repo']); + const { token } = await this.scmAuthApi.getCredentials({ url }); const octo = new Octokit({ auth: token, baseUrl: githubIntegrationConfig.apiBaseUrl, @@ -270,6 +271,7 @@ the component will become available.\n\nFor more information, read an \ title, body, fileContent, + repositoryUrl, githubIntegrationConfig, }: { owner: string; @@ -277,9 +279,15 @@ the component will become available.\n\nFor more information, read an \ title: string; body: string; fileContent: string; + repositoryUrl: string; githubIntegrationConfig: GitHubIntegrationConfig; }): Promise<{ link: string; location: string }> { - const token = await this.githubAuthApi.getAccessToken(['repo']); + const { token } = await this.scmAuthApi.getCredentials({ + url: repositoryUrl, + additionalScope: { + repoWrite: true, + }, + }); const octo = new Octokit({ auth: token, diff --git a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx index 57376c33f2..69ae38207d 100644 --- a/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx +++ b/plugins/catalog-import/src/components/DefaultImportPage/DefaultImportPage.test.tsx @@ -55,8 +55,8 @@ describe('', () => { catalogImportApiRef, new CatalogImportClient({ discoveryApi: {} as any, - githubAuthApi: { - getAccessToken: async () => 'token', + scmAuthApi: { + getCredentials: async () => ({ token: 'token', headers: {} }), }, identityApi, scmIntegrationsApi: {} as any, diff --git a/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx b/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx index fe3c3673f8..92a6077792 100644 --- a/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx +++ b/plugins/catalog-import/src/components/ImportPage/ImportPage.test.tsx @@ -61,10 +61,8 @@ describe('', () => { catalogImportApiRef, new CatalogImportClient({ discoveryApi: {} as any, - githubAuthApi: { - getAccessToken: async () => 'token', - }, identityApi, + scmAuthApi: {} as any, scmIntegrationsApi: {} as any, catalogApi: {} as any, configApi: new ConfigReader({}), diff --git a/plugins/catalog-import/src/plugin.ts b/plugins/catalog-import/src/plugin.ts index d7d6370de3..2dd90d36bc 100644 --- a/plugins/catalog-import/src/plugin.ts +++ b/plugins/catalog-import/src/plugin.ts @@ -21,10 +21,12 @@ import { createRoutableExtension, createRouteRef, discoveryApiRef, - githubAuthApiRef, identityApiRef, } from '@backstage/core-plugin-api'; -import { scmIntegrationsApiRef } from '@backstage/integration-react'; +import { + scmAuthApiRef, + scmIntegrationsApiRef, +} from '@backstage/integration-react'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { catalogImportApiRef, CatalogImportClient } from './api'; @@ -40,7 +42,7 @@ export const catalogImportPlugin = createPlugin({ api: catalogImportApiRef, deps: { discoveryApi: discoveryApiRef, - githubAuthApi: githubAuthApiRef, + scmAuthApi: scmAuthApiRef, identityApi: identityApiRef, scmIntegrationsApi: scmIntegrationsApiRef, catalogApi: catalogApiRef, @@ -48,7 +50,7 @@ export const catalogImportPlugin = createPlugin({ }, factory: ({ discoveryApi, - githubAuthApi, + scmAuthApi, identityApi, scmIntegrationsApi, catalogApi, @@ -56,7 +58,7 @@ export const catalogImportPlugin = createPlugin({ }) => new CatalogImportClient({ discoveryApi, - githubAuthApi, + scmAuthApi, scmIntegrationsApi, identityApi, catalogApi,