Merge pull request #7002 from backstage/rugvip/scm-auth
[PRFC] integration-react: add ScmAuthApi + implementation
This commit is contained in:
@@ -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',
|
||||
}),
|
||||
),
|
||||
});
|
||||
```
|
||||
@@ -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',
|
||||
}),
|
||||
),
|
||||
});
|
||||
]
|
||||
```
|
||||
@@ -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<OAuthApi & ProfileInfoApi & SessionApi> =
|
||||
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`.
|
||||
@@ -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 },
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
@@ -5,9 +5,95 @@
|
||||
```ts
|
||||
/// <reference types="react" />
|
||||
|
||||
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<ScmAuthTokenResponse>;
|
||||
isUrlSupported(url: URL): boolean;
|
||||
static merge(...providers: ScmAuth[]): ScmAuthApi;
|
||||
}
|
||||
|
||||
// @public
|
||||
export interface ScmAuthApi {
|
||||
getCredentials(options: ScmAuthTokenOptions): Promise<ScmAuthTokenResponse>;
|
||||
}
|
||||
|
||||
// @public
|
||||
export const scmAuthApiRef: ApiRef<ScmAuthApi>;
|
||||
|
||||
// @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)
|
||||
//
|
||||
|
||||
@@ -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'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<ScmAuth>;
|
||||
|
||||
constructor(providers: ScmAuth[]) {
|
||||
this.#providers = providers;
|
||||
}
|
||||
|
||||
async getCredentials(
|
||||
options: ScmAuthTokenOptions,
|
||||
): Promise<ScmAuthTokenResponse> {
|
||||
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<ScmAuthTokenResponse> {
|
||||
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}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<ScmAuthTokenResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ApiRef for the ScmAuthApi.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const scmAuthApiRef: ApiRef<ScmAuthApi> = createApiRef({
|
||||
id: 'core.scmauth',
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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<CatalogImportApi>;
|
||||
export class CatalogImportClient implements CatalogImportApi {
|
||||
constructor(options: {
|
||||
discoveryApi: DiscoveryApi;
|
||||
githubAuthApi: OAuthApi;
|
||||
scmAuthApi: ScmAuthApi;
|
||||
identityApi: IdentityApi;
|
||||
scmIntegrationsApi: ScmIntegrationRegistry;
|
||||
catalogApi: CatalogApi;
|
||||
|
||||
@@ -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<OAuthApi> = {
|
||||
getAccessToken: jest.fn(),
|
||||
const scmAuthApi: jest.Mocked<ScmAuthApi> = {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -55,8 +55,8 @@ describe('<DefaultImportPage />', () => {
|
||||
catalogImportApiRef,
|
||||
new CatalogImportClient({
|
||||
discoveryApi: {} as any,
|
||||
githubAuthApi: {
|
||||
getAccessToken: async () => 'token',
|
||||
scmAuthApi: {
|
||||
getCredentials: async () => ({ token: 'token', headers: {} }),
|
||||
},
|
||||
identityApi,
|
||||
scmIntegrationsApi: {} as any,
|
||||
|
||||
@@ -61,10 +61,8 @@ describe('<ImportPage />', () => {
|
||||
catalogImportApiRef,
|
||||
new CatalogImportClient({
|
||||
discoveryApi: {} as any,
|
||||
githubAuthApi: {
|
||||
getAccessToken: async () => 'token',
|
||||
},
|
||||
identityApi,
|
||||
scmAuthApi: {} as any,
|
||||
scmIntegrationsApi: {} as any,
|
||||
catalogApi: {} as any,
|
||||
configApi: new ConfigReader({}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user