diff --git a/packages/integration-react/src/api/ScmAuth.test.ts b/packages/integration-react/src/api/ScmAuth.test.ts index 2d0ea9b97d..4ebfef749e 100644 --- a/packages/integration-react/src/api/ScmAuth.test.ts +++ b/packages/integration-react/src/api/ScmAuth.test.ts @@ -141,6 +141,58 @@ describe('ScmAuth', () => { }); }); + it('should support additional provided scopes from the caller', async () => { + const mockAuthApi = { + getAccessToken: async (scopes: string[]) => { + return scopes.join(' '); + }, + }; + + const githubAuth = ScmAuth.forGithub(mockAuthApi); + await expect( + githubAuth.getCredentials({ + url: 'http://example.com', + additionalScope: { + customScopes: { github: ['org:read', 'workflow:write'] }, + }, + }), + ).resolves.toMatchObject({ + token: 'repo read:org read:user org:read workflow:write', + }); + + const gitlabAuth = ScmAuth.forGitlab(mockAuthApi); + await expect( + gitlabAuth.getCredentials({ + url: 'http://example.com', + additionalScope: { customScopes: { gitlab: ['write_repository'] } }, + }), + ).resolves.toMatchObject({ + token: 'read_user read_api read_repository write_repository', + }); + + const azureAuth = ScmAuth.forAzure(mockAuthApi); + await expect( + azureAuth.getCredentials({ + url: 'http://example.com', + additionalScope: { customScopes: { azure: ['vso.org'] } }, + }), + ).resolves.toMatchObject({ + token: 'vso.build vso.code vso.graph vso.project vso.profile vso.org', + }); + + const bitbucketAuth = ScmAuth.forBitbucket(mockAuthApi); + await expect( + bitbucketAuth.getCredentials({ + url: 'http://example.com', + additionalScope: { + customScopes: { bitbucket: ['snippet:write', 'issue:write'] }, + }, + }), + ).resolves.toMatchObject({ + token: 'account team pullrequest snippet issue snippet:write issue:write', + }); + }); + it('should handle host option', () => { const mockAuthApi = { getAccessToken: jest.fn(), diff --git a/packages/integration-react/src/api/ScmAuth.ts b/packages/integration-react/src/api/ScmAuth.ts index be3216962d..f450d63cec 100644 --- a/packages/integration-react/src/api/ScmAuth.ts +++ b/packages/integration-react/src/api/ScmAuth.ts @@ -35,6 +35,9 @@ type ScopeMapping = { repoWrite: string[]; }; +// An enum of all supported providers +type ProviderName = 'generic' | 'github' | 'azure' | 'bitbucket' | 'gitlab'; + class ScmAuthMux implements ScmAuthApi { #providers: Array; @@ -93,12 +96,14 @@ export class ScmAuth implements ScmAuthApi { github: githubAuthApiRef, gitlab: gitlabAuthApiRef, azure: microsoftAuthApiRef, + bitbucket: bitbucketAuthApiRef, }, - factory: ({ github, gitlab, azure }) => + factory: ({ github, gitlab, azure, bitbucket }) => ScmAuth.merge( ScmAuth.forGithub(github), ScmAuth.forGitlab(gitlab), ScmAuth.forAzure(azure), + ScmAuth.forBitbucket(bitbucket), ), }); } @@ -116,7 +121,7 @@ export class ScmAuth implements ScmAuthApi { }; }, ): ScmAuth { - return new ScmAuth(authApi, options.host, options.scopeMapping); + return new ScmAuth('generic', authApi, options.host, options.scopeMapping); } /** @@ -139,7 +144,7 @@ export class ScmAuth implements ScmAuthApi { }, ): ScmAuth { const host = options?.host ?? 'github.com'; - return new ScmAuth(githubAuthApi, host, { + return new ScmAuth('github', githubAuthApi, host, { default: ['repo', 'read:org', 'read:user'], repoWrite: ['gist'], }); @@ -165,7 +170,7 @@ export class ScmAuth implements ScmAuthApi { }, ): ScmAuth { const host = options?.host ?? 'gitlab.com'; - return new ScmAuth(gitlabAuthApi, host, { + return new ScmAuth('gitlab', gitlabAuthApi, host, { default: ['read_user', 'read_api', 'read_repository'], repoWrite: ['write_repository', 'api'], }); @@ -191,7 +196,7 @@ export class ScmAuth implements ScmAuthApi { }, ): ScmAuth { const host = options?.host ?? 'dev.azure.com'; - return new ScmAuth(microsoftAuthApi, host, { + return new ScmAuth('azure', microsoftAuthApi, host, { default: [ 'vso.build', 'vso.code', @@ -223,7 +228,7 @@ export class ScmAuth implements ScmAuthApi { }, ): ScmAuth { const host = options?.host ?? 'bitbucket.org'; - return new ScmAuth(bitbucketAuthApi, host, { + return new ScmAuth('bitbucket', bitbucketAuthApi, host, { default: ['account', 'team', 'pullrequest', 'snippet', 'issue'], repoWrite: ['pullrequest:write', 'snippet:write', 'issue:write'], }); @@ -240,11 +245,18 @@ export class ScmAuth implements ScmAuthApi { #api: OAuthApi; #host: string; #scopeMapping: ScopeMapping; + #providerName: ProviderName; - private constructor(api: OAuthApi, host: string, scopeMapping: ScopeMapping) { + private constructor( + providerName: ProviderName, + api: OAuthApi, + host: string, + scopeMapping: ScopeMapping, + ) { this.#api = api; this.#host = host; this.#scopeMapping = scopeMapping; + this.#providerName = providerName; } /** @@ -254,6 +266,16 @@ export class ScmAuth implements ScmAuthApi { return url.host === this.#host; } + private getAdditionalScopesForProvider( + additionalScopes: ScmAuthTokenOptions['additionalScope'], + ): string[] { + if (!additionalScopes?.customScopes || this.#providerName === 'generic') { + return []; + } + + return additionalScopes.customScopes?.[this.#providerName] ?? []; + } + /** * Fetches credentials for the given resource. */ @@ -267,7 +289,17 @@ export class ScmAuth implements ScmAuthApi { scopes.push(...this.#scopeMapping.repoWrite); } - const token = await this.#api.getAccessToken(scopes, restOptions); + const additionalScopes = + this.getAdditionalScopesForProvider(additionalScope); + + if (additionalScopes.length) { + scopes.push(...additionalScopes); + } + + const uniqueScopes = [...new Set(scopes)]; + + const token = await this.#api.getAccessToken(uniqueScopes, restOptions); + return { token, headers: { diff --git a/packages/integration-react/src/api/ScmAuthApi.ts b/packages/integration-react/src/api/ScmAuthApi.ts index 44b007c4a2..be65842bc1 100644 --- a/packages/integration-react/src/api/ScmAuthApi.ts +++ b/packages/integration-react/src/api/ScmAuthApi.ts @@ -44,6 +44,16 @@ export interface ScmAuthTokenOptions extends AuthRequestOptions { * the ability to create things like issues and pull requests. */ repoWrite?: boolean; + /** + * Allow an arbitrary list of scopes provided from the user + * to request from the provider. + */ + customScopes?: { + github?: string[]; + azure?: string[]; + bitbucket?: string[]; + gitlab?: string[]; + }; }; }