Loading desktop.package.json +12 −0 Original line number Diff line number Diff line Loading @@ -1018,6 +1018,18 @@ } } }, { "id": "authentication", "order": 4, "title": "Authentication", "properties": { "gitlab.authentication.oauthClientIds": { "markdownDescription": "Set up [OAuth authentication](https://docs.gitlab.com/integration/oauth_provider/) for a GitLab instance. Enter the GitLab instance's URL in the item column and the OAuth application's client ID in the value field.", "type": "object", "additionalProperties": { "type": "string" } } } }, { "id": "other", "properties": { Loading src/common/utils/type_predicates.ts +7 −0 Original line number Diff line number Diff line Loading @@ -13,4 +13,11 @@ export function isRecordOfStringBoolean(value: unknown): value is Record<string, return Object.values(value).every(val => isBoolean(val)); } export function isRecordOfStringString(value: unknown): value is Record<string, string> { if (!value) return false; if (!isPlainObject(value)) return false; return Object.values(value).every(val => isString(val)); } export { isBoolean, isString }; src/desktop/accounts/auth_flows/oauth_flow.test.ts +290 −8 Original line number Diff line number Diff line Loading @@ -8,20 +8,22 @@ import { currentUserRequest } from '../../../common/gitlab/api/get_current_user' import { doNotAwait } from '../../../common/utils/do_not_await'; import { GITLAB_COM_URL } from '../../../common/constants'; import { Credentials } from '../../../common/platform/gitlab_account'; import { BUNDLED_CLIENT_IDS, getAuthenticationConfiguration, } from '../../utils/extension_configuration'; import { OAuthFlow } from './oauth_flow'; jest.mock('../../commands/openers'); jest.mock('../../gitlab/gitlab_service'); jest.mock('../../utils/extension_configuration'); jest.useFakeTimers(); /* This method simulates the first response from GitLab OAuth, it accepts the authentication URL and returns redirect URL */ const fakeOAuthService = (urlString: string): string => { const url = new URL(urlString); const params = url.searchParams; assert.strictEqual( params.get('client_id'), '36f2a70cddeb5a0889d4fd8295c241b7e9848e89cf9e599d0eed2d8e5350fbf5', ); assert.strictEqual(params.get('client_id'), BUNDLED_CLIENT_IDS[GITLAB_COM_URL]); assert.strictEqual(params.get('redirect_uri'), 'vscode://gitlab.gitlab-workflow/authentication'); assert.strictEqual(params.get('response_type'), 'code'); assert.strictEqual(params.get('scope'), 'api'); Loading @@ -35,6 +37,7 @@ const fakeOAuthService = (urlString: string): string => { describe('OAuthFlow', () => { let uriHandler: GitLabUriHandler; const mockGetAuthenticationConfiguration = jest.mocked(getAuthenticationConfiguration); beforeEach(async () => { uriHandler = new GitLabUriHandler(); Loading @@ -45,10 +48,15 @@ describe('OAuthFlow', () => { created_at: 0, }; // Default configuration for GitLab.com mockGetAuthenticationConfiguration.mockReturnValue({ oauthClientIds: { [GITLAB_COM_URL]: BUNDLED_CLIENT_IDS[GITLAB_COM_URL], }, }); jest.mocked(GitLabService.exchangeToken).mockResolvedValue(exchangeToken); jest.mocked(GitLabService).mockImplementation(({ instanceUrl, token }: Credentials) => { assert.strictEqual(instanceUrl, 'https://gitlab.com'); assert.strictEqual(token, 'test_token'); jest.mocked(GitLabService).mockImplementation(() => { return { fetchFromApi: createFakeFetchFromApi({ request: currentUserRequest, Loading @@ -75,7 +83,7 @@ describe('OAuthFlow', () => { expect(account?.token).toEqual('test_token'); expect(vscode.window.withProgress).toHaveBeenCalledWith( { title: 'Waiting for OAuth redirect from GitLab.com.', title: 'Waiting for OAuth redirect from https://gitlab.com.', location: vscode.ProgressLocation.Notification, }, expect.any(Function), Loading Loading @@ -105,4 +113,278 @@ describe('OAuthFlow', () => { }); }); }); describe('supportsGitLabInstance', () => { it('returns true when OAuth client ID is configured for the instance', () => { mockGetAuthenticationConfiguration.mockReturnValue({ oauthClientIds: { 'https://gitlab.com': 'gitlab-com-client-id', 'https://self-managed.example.com': 'self-managed-client-id', }, }); const flow = new OAuthFlow(uriHandler); expect(flow.supportsGitLabInstance('https://gitlab.com')).toBe(true); expect(flow.supportsGitLabInstance('https://self-managed.example.com')).toBe(true); }); it('returns false when OAuth client ID is not configured for the instance', () => { mockGetAuthenticationConfiguration.mockReturnValue({ oauthClientIds: { 'https://gitlab.com': 'gitlab-com-client-id', }, }); const flow = new OAuthFlow(uriHandler); expect(flow.supportsGitLabInstance('https://unconfigured.example.com')).toBe(false); }); it('returns false when OAuth client ID is empty string', () => { mockGetAuthenticationConfiguration.mockReturnValue({ oauthClientIds: { 'https://gitlab.com': '', }, }); const flow = new OAuthFlow(uriHandler); expect(flow.supportsGitLabInstance('https://gitlab.com')).toBe(false); }); it('returns false when OAuth client ID is undefined', () => { mockGetAuthenticationConfiguration.mockReturnValue({ oauthClientIds: { 'https://gitlab.com': undefined, }, }); const flow = new OAuthFlow(uriHandler); expect(flow.supportsGitLabInstance('https://gitlab.com')).toBe(false); }); }); describe('authenticate with multiple instances', () => { const selfManagedUrl = 'https://self-managed.example.com'; const dedicatedUrl = 'https://dedicated.gitlab.com'; beforeEach(() => { mockGetAuthenticationConfiguration.mockReturnValue({ oauthClientIds: { [GITLAB_COM_URL]: BUNDLED_CLIENT_IDS[GITLAB_COM_URL], [selfManagedUrl]: 'self-managed-client-id', [dedicatedUrl]: 'dedicated-client-id', }, }); }); it('authenticates with self-managed GitLab instance', async () => { const exchangeToken: ExchangeTokenResponse = { access_token: 'self_managed_token', expires_in: 3600, refresh_token: 'self_managed_refresh', created_at: 1234567890, }; jest.mocked(GitLabService.exchangeToken).mockResolvedValue(exchangeToken); jest.mocked(GitLabService).mockImplementation(({ instanceUrl, token }: Credentials) => { assert.strictEqual(instanceUrl, selfManagedUrl); assert.strictEqual(token, 'self_managed_token'); return { fetchFromApi: createFakeFetchFromApi({ request: currentUserRequest, response: { id: 456, username: 'self_managed_user' }, }), } as GitLabService; }); jest.mocked(openUrl).mockImplementationOnce(async urlString => { // Simulate OAuth service response for self-managed instance const url = new URL(urlString); const params = url.searchParams; expect(url.origin).toBe(selfManagedUrl); expect(params.get('client_id')).toBe('self-managed-client-id'); const responseParams = new URLSearchParams({ state: params.get('state') || '', code: 'self_managed_code', }); uriHandler.fire(vscode.Uri.parse(`${params.get('redirect_uri')}?${responseParams}`)); }); const flow = new OAuthFlow(uriHandler); const account = await flow.authenticate(selfManagedUrl); expect(account?.id).toEqual(`${selfManagedUrl}|456`); expect(account?.token).toEqual('self_managed_token'); expect(account?.instanceUrl).toEqual(selfManagedUrl); expect(vscode.window.withProgress).toHaveBeenCalledWith( { title: `Waiting for OAuth redirect from ${selfManagedUrl}.`, location: vscode.ProgressLocation.Notification, }, expect.any(Function), ); }); it('authenticates with GitLab Dedicated instance', async () => { const exchangeToken: ExchangeTokenResponse = { access_token: 'dedicated_token', expires_in: 7200, refresh_token: 'dedicated_refresh', created_at: 1234567890, }; jest.mocked(GitLabService.exchangeToken).mockResolvedValue(exchangeToken); jest.mocked(GitLabService).mockImplementation(({ instanceUrl, token }: Credentials) => { assert.strictEqual(instanceUrl, dedicatedUrl); assert.strictEqual(token, 'dedicated_token'); return { fetchFromApi: createFakeFetchFromApi({ request: currentUserRequest, response: { id: 789, username: 'dedicated_user' }, }), } as GitLabService; }); jest.mocked(openUrl).mockImplementationOnce(async urlString => { const url = new URL(urlString); const params = url.searchParams; expect(url.origin).toBe(dedicatedUrl); expect(params.get('client_id')).toBe('dedicated-client-id'); const responseParams = new URLSearchParams({ state: params.get('state') || '', code: 'dedicated_code', }); uriHandler.fire(vscode.Uri.parse(`${params.get('redirect_uri')}?${responseParams}`)); }); const flow = new OAuthFlow(uriHandler); const account = await flow.authenticate(dedicatedUrl); expect(account?.id).toEqual(`${dedicatedUrl}|789`); expect(account?.token).toEqual('dedicated_token'); expect(account?.instanceUrl).toEqual(dedicatedUrl); }); it('returns undefined for unsupported instances', async () => { const flow = new OAuthFlow(uriHandler); const account = await flow.authenticate('https://unsupported.example.com'); expect(account).toBeUndefined(); }); it('handles multiple concurrent authentication requests', async () => { const flow = new OAuthFlow(uriHandler); // Mock different responses for different instances let callCount = 0; jest.mocked(GitLabService.exchangeToken).mockImplementation(async () => { callCount++; return { access_token: `token_${callCount}`, expires_in: 3600, refresh_token: `refresh_${callCount}`, created_at: 1234567890, }; }); jest.mocked(GitLabService).mockImplementation(() => { return { fetchFromApi: createFakeFetchFromApi({ request: currentUserRequest, response: { id: callCount, username: `user_${callCount}` }, }), } as GitLabService; }); jest.mocked(openUrl).mockImplementation(async urlString => { const url = new URL(urlString); const params = url.searchParams; const responseParams = new URLSearchParams({ state: params.get('state') || '', code: `code_for_${url.origin}`, }); uriHandler.fire(vscode.Uri.parse(`${params.get('redirect_uri')}?${responseParams}`)); }); // Start multiple authentication requests const [gitlabComAccount, selfManagedAccount] = await Promise.all([ flow.authenticate(GITLAB_COM_URL), flow.authenticate(selfManagedUrl), ]); expect(gitlabComAccount?.instanceUrl).toBe(GITLAB_COM_URL); expect(selfManagedAccount?.instanceUrl).toBe(selfManagedUrl); expect(gitlabComAccount?.id).not.toBe(selfManagedAccount?.id); }); }); describe('error handling', () => { it('handles OAuth client ID configuration errors gracefully', async () => { mockGetAuthenticationConfiguration.mockReturnValue({ oauthClientIds: { 'https://gitlab.com': '', // Empty client ID }, }); const flow = new OAuthFlow(uriHandler); const account = await flow.authenticate('https://gitlab.com'); expect(account).toBeUndefined(); }); it('handles token exchange failures', async () => { jest .mocked(GitLabService.exchangeToken) .mockRejectedValue(new Error('Token exchange failed')); jest.mocked(openUrl).mockImplementationOnce(async urlString => { const url = new URL(urlString); const params = url.searchParams; const responseParams = new URLSearchParams({ state: params.get('state') || '', code: 'test_code', }); uriHandler.fire(vscode.Uri.parse(`${params.get('redirect_uri')}?${responseParams}`)); }); const flow = new OAuthFlow(uriHandler); await expect(flow.authenticate(GITLAB_COM_URL)).rejects.toThrow('Token exchange failed'); }); it('handles user API request failures', async () => { const exchangeToken: ExchangeTokenResponse = { access_token: 'test_token', expires_in: 7200, refresh_token: 'test_refresh', created_at: 0, }; jest.mocked(GitLabService.exchangeToken).mockResolvedValue(exchangeToken); jest.mocked(GitLabService).mockImplementation(() => { return { fetchFromApi: jest.fn().mockRejectedValue(new Error('User API request failed')), } as unknown as GitLabService; }); jest.mocked(openUrl).mockImplementationOnce(async urlString => { const url = new URL(urlString); const params = url.searchParams; const responseParams = new URLSearchParams({ state: params.get('state') || '', code: 'test_code', }); uriHandler.fire(vscode.Uri.parse(`${params.get('redirect_uri')}?${responseParams}`)); }); const flow = new OAuthFlow(uriHandler); await expect(flow.authenticate(GITLAB_COM_URL)).rejects.toThrow('User API request failed'); }); }); }); src/desktop/accounts/auth_flows/oauth_flow.ts +40 −24 Original line number Diff line number Diff line import crypto from 'crypto'; import assert from 'assert'; import vscode from 'vscode'; import { GITLAB_COM_URL } from '../../../common/constants'; import { openUrl } from '../../commands/openers'; import { PromiseAdapter, promiseFromEvent } from '../../utils/promise_from_event'; import { GitLabUriHandler, gitlabUriHandler } from '../../gitlab_uri_handler'; import { OAUTH_CLIENT_ID, OAUTH_REDIRECT_URI } from '../../constants'; import { OAUTH_REDIRECT_URI } from '../../constants'; import { generateSecret } from '../../../common/utils/generate_secret'; import { log } from '../../../common/log'; import { makeAccountId, OAuthAccount } from '../../../common/platform/gitlab_account'; Loading @@ -15,28 +14,33 @@ import { GitLabService, } from '../../gitlab/gitlab_service'; import { currentUserRequest } from '../../../common/gitlab/api/get_current_user'; import { getAuthenticationConfiguration } from '../../utils/extension_configuration'; import { Flow } from './flow'; const createOAuthAccountFromCode: ( params: AuthorizationCodeTokenExchangeParams & { scopes: readonly string[] }, ) => Promise<OAuthAccount> = async params => { clientId: string, ) => Promise<OAuthAccount> = async (params, clientId) => { const { code, codeVerifier } = params; const tokenResponse = await GitLabService.exchangeToken({ instanceUrl: GITLAB_COM_URL, const tokenResponse = await GitLabService.exchangeToken( { instanceUrl: params.instanceUrl, grantType: 'authorization_code', code, codeVerifier, }); }, clientId, ); const user = await new GitLabService({ instanceUrl: GITLAB_COM_URL, instanceUrl: params.instanceUrl, token: tokenResponse.access_token, }).fetchFromApi(currentUserRequest); const account: OAuthAccount = { instanceUrl: GITLAB_COM_URL, instanceUrl: params.instanceUrl, token: tokenResponse.access_token, refreshToken: tokenResponse.refresh_token, expiresAtTimestampInSeconds: createExpiresTimestamp(tokenResponse), id: makeAccountId(GITLAB_COM_URL, user.id), id: makeAccountId(params.instanceUrl, user.id), type: 'oauth', username: user.username, scopes: [...params.scopes], Loading @@ -53,6 +57,7 @@ const generateCodeChallengeFromVerifier = (v: string) => { }; interface OAuthUrlParams { instanceUrl: string; clientId: string; redirectUri: string; responseType?: string; Loading @@ -63,6 +68,7 @@ interface OAuthUrlParams { } const createAuthUrl = ({ instanceUrl, clientId, redirectUri, responseType = 'code', Loading @@ -71,7 +77,7 @@ const createAuthUrl = ({ codeChallenge, codeChallengeMethod = 'S256', }: OAuthUrlParams) => `${GITLAB_COM_URL}/oauth/authorize?${new URLSearchParams({ `${instanceUrl}/oauth/authorize?${new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: responseType, Loading @@ -82,18 +88,20 @@ const createAuthUrl = ({ })}`; const createLoginUrl = ( instanceUrl: string, scopesParam?: readonly string[], ): { url: string; state: string; codeVerifier: string } => { ): { url: string; state: string; codeVerifier: string; clientId: string } => { const state = generateSecret(); const redirectUri = OAUTH_REDIRECT_URI; const codeVerifier = generateSecret(); const codeChallenge = generateCodeChallengeFromVerifier(codeVerifier); const scopes = (scopesParam ?? ['api']).join(' '); const clientId = OAUTH_CLIENT_ID; const clientId = getAuthenticationConfiguration().oauthClientIds[instanceUrl] || ''; return { url: createAuthUrl({ clientId, redirectUri, state, scopes, codeChallenge }), url: createAuthUrl({ instanceUrl, clientId, redirectUri, state, scopes, codeChallenge }), state, codeVerifier, clientId, }; }; Loading @@ -111,26 +119,29 @@ export class OAuthFlow implements Flow { } supportsGitLabInstance(url: string): boolean { return url === GITLAB_COM_URL; const clientId = getAuthenticationConfiguration().oauthClientIds[url]; return clientId !== undefined && clientId !== null && clientId !== ''; } async authenticate(url: string) { if (url !== GITLAB_COM_URL) return undefined; if (this.supportsGitLabInstance(url)) { return this.#createAccount(url, ['api']); } return this.#createAccount(['api']); return undefined; } async #createAccount(scopes: readonly string[]): Promise<OAuthAccount> { const { url, state, codeVerifier } = createLoginUrl(scopes); async #createAccount(instanceUrl: string, scopes: readonly string[]): Promise<OAuthAccount> { const { url, state, codeVerifier, clientId } = createLoginUrl(instanceUrl, scopes); this.#requestsInProgress[state] = codeVerifier; const { promise: receivedRedirectUrl, cancel: cancelWaitingForRedirectUrl } = promiseFromEvent( this.#uriHandler.event, this.#exchangeCodeForToken(state, scopes), this.#exchangeCodeForToken(instanceUrl, state, scopes, clientId), ); await openUrl(url); const account = await vscode.window.withProgress( { title: 'Waiting for OAuth redirect from GitLab.com.', title: `Waiting for OAuth redirect from ${instanceUrl}.`, location: vscode.ProgressLocation.Notification, }, () => Loading @@ -153,12 +164,14 @@ export class OAuthFlow implements Flow { } #exchangeCodeForToken: ( instanceUrl: string, state: string, scopes: readonly string[], clientId: string, ) => PromiseAdapter<vscode.Uri, OAuthAccount> = /* This callback is triggered on every vscode://gitlab-workflow URL. We will ignore invocations that are not related to the OAuth login with given `state`. */ (state, scopes) => async (uri, resolve, reject) => { (instanceUrl, state, scopes, clientId) => async (uri, resolve, reject) => { if (uri.path !== '/authentication') return; const searchParams = new URLSearchParams(uri.query); const urlState = searchParams.get('state'); Loading @@ -175,13 +188,16 @@ export class OAuthFlow implements Flow { return; } try { const account = await createOAuthAccountFromCode({ instanceUrl: GITLAB_COM_URL, const account = await createOAuthAccountFromCode( { instanceUrl, grantType: 'authorization_code', code, codeVerifier, scopes, }); }, clientId, ); resolve(account); } catch (e) { log.error('OAuth flow: Creating account from code failed: ', e); Loading src/desktop/constants.ts +0 −1 Original line number Diff line number Diff line Loading @@ -20,7 +20,6 @@ export const HAS_COMMENTS_QUERY_KEY = 'hasComments'; export const PATCH_TITLE_PREFIX = 'patch: '; export const PATCH_FILE_SUFFIX = '.patch'; export const OAUTH_CLIENT_ID = '36f2a70cddeb5a0889d4fd8295c241b7e9848e89cf9e599d0eed2d8e5350fbf5'; export const OAUTH_REDIRECT_URI = `${vscode.env.uriScheme}://gitlab.gitlab-workflow/authentication`; /** Synced comment is stored in the GitLab instance */ Loading Loading
desktop.package.json +12 −0 Original line number Diff line number Diff line Loading @@ -1018,6 +1018,18 @@ } } }, { "id": "authentication", "order": 4, "title": "Authentication", "properties": { "gitlab.authentication.oauthClientIds": { "markdownDescription": "Set up [OAuth authentication](https://docs.gitlab.com/integration/oauth_provider/) for a GitLab instance. Enter the GitLab instance's URL in the item column and the OAuth application's client ID in the value field.", "type": "object", "additionalProperties": { "type": "string" } } } }, { "id": "other", "properties": { Loading
src/common/utils/type_predicates.ts +7 −0 Original line number Diff line number Diff line Loading @@ -13,4 +13,11 @@ export function isRecordOfStringBoolean(value: unknown): value is Record<string, return Object.values(value).every(val => isBoolean(val)); } export function isRecordOfStringString(value: unknown): value is Record<string, string> { if (!value) return false; if (!isPlainObject(value)) return false; return Object.values(value).every(val => isString(val)); } export { isBoolean, isString };
src/desktop/accounts/auth_flows/oauth_flow.test.ts +290 −8 Original line number Diff line number Diff line Loading @@ -8,20 +8,22 @@ import { currentUserRequest } from '../../../common/gitlab/api/get_current_user' import { doNotAwait } from '../../../common/utils/do_not_await'; import { GITLAB_COM_URL } from '../../../common/constants'; import { Credentials } from '../../../common/platform/gitlab_account'; import { BUNDLED_CLIENT_IDS, getAuthenticationConfiguration, } from '../../utils/extension_configuration'; import { OAuthFlow } from './oauth_flow'; jest.mock('../../commands/openers'); jest.mock('../../gitlab/gitlab_service'); jest.mock('../../utils/extension_configuration'); jest.useFakeTimers(); /* This method simulates the first response from GitLab OAuth, it accepts the authentication URL and returns redirect URL */ const fakeOAuthService = (urlString: string): string => { const url = new URL(urlString); const params = url.searchParams; assert.strictEqual( params.get('client_id'), '36f2a70cddeb5a0889d4fd8295c241b7e9848e89cf9e599d0eed2d8e5350fbf5', ); assert.strictEqual(params.get('client_id'), BUNDLED_CLIENT_IDS[GITLAB_COM_URL]); assert.strictEqual(params.get('redirect_uri'), 'vscode://gitlab.gitlab-workflow/authentication'); assert.strictEqual(params.get('response_type'), 'code'); assert.strictEqual(params.get('scope'), 'api'); Loading @@ -35,6 +37,7 @@ const fakeOAuthService = (urlString: string): string => { describe('OAuthFlow', () => { let uriHandler: GitLabUriHandler; const mockGetAuthenticationConfiguration = jest.mocked(getAuthenticationConfiguration); beforeEach(async () => { uriHandler = new GitLabUriHandler(); Loading @@ -45,10 +48,15 @@ describe('OAuthFlow', () => { created_at: 0, }; // Default configuration for GitLab.com mockGetAuthenticationConfiguration.mockReturnValue({ oauthClientIds: { [GITLAB_COM_URL]: BUNDLED_CLIENT_IDS[GITLAB_COM_URL], }, }); jest.mocked(GitLabService.exchangeToken).mockResolvedValue(exchangeToken); jest.mocked(GitLabService).mockImplementation(({ instanceUrl, token }: Credentials) => { assert.strictEqual(instanceUrl, 'https://gitlab.com'); assert.strictEqual(token, 'test_token'); jest.mocked(GitLabService).mockImplementation(() => { return { fetchFromApi: createFakeFetchFromApi({ request: currentUserRequest, Loading @@ -75,7 +83,7 @@ describe('OAuthFlow', () => { expect(account?.token).toEqual('test_token'); expect(vscode.window.withProgress).toHaveBeenCalledWith( { title: 'Waiting for OAuth redirect from GitLab.com.', title: 'Waiting for OAuth redirect from https://gitlab.com.', location: vscode.ProgressLocation.Notification, }, expect.any(Function), Loading Loading @@ -105,4 +113,278 @@ describe('OAuthFlow', () => { }); }); }); describe('supportsGitLabInstance', () => { it('returns true when OAuth client ID is configured for the instance', () => { mockGetAuthenticationConfiguration.mockReturnValue({ oauthClientIds: { 'https://gitlab.com': 'gitlab-com-client-id', 'https://self-managed.example.com': 'self-managed-client-id', }, }); const flow = new OAuthFlow(uriHandler); expect(flow.supportsGitLabInstance('https://gitlab.com')).toBe(true); expect(flow.supportsGitLabInstance('https://self-managed.example.com')).toBe(true); }); it('returns false when OAuth client ID is not configured for the instance', () => { mockGetAuthenticationConfiguration.mockReturnValue({ oauthClientIds: { 'https://gitlab.com': 'gitlab-com-client-id', }, }); const flow = new OAuthFlow(uriHandler); expect(flow.supportsGitLabInstance('https://unconfigured.example.com')).toBe(false); }); it('returns false when OAuth client ID is empty string', () => { mockGetAuthenticationConfiguration.mockReturnValue({ oauthClientIds: { 'https://gitlab.com': '', }, }); const flow = new OAuthFlow(uriHandler); expect(flow.supportsGitLabInstance('https://gitlab.com')).toBe(false); }); it('returns false when OAuth client ID is undefined', () => { mockGetAuthenticationConfiguration.mockReturnValue({ oauthClientIds: { 'https://gitlab.com': undefined, }, }); const flow = new OAuthFlow(uriHandler); expect(flow.supportsGitLabInstance('https://gitlab.com')).toBe(false); }); }); describe('authenticate with multiple instances', () => { const selfManagedUrl = 'https://self-managed.example.com'; const dedicatedUrl = 'https://dedicated.gitlab.com'; beforeEach(() => { mockGetAuthenticationConfiguration.mockReturnValue({ oauthClientIds: { [GITLAB_COM_URL]: BUNDLED_CLIENT_IDS[GITLAB_COM_URL], [selfManagedUrl]: 'self-managed-client-id', [dedicatedUrl]: 'dedicated-client-id', }, }); }); it('authenticates with self-managed GitLab instance', async () => { const exchangeToken: ExchangeTokenResponse = { access_token: 'self_managed_token', expires_in: 3600, refresh_token: 'self_managed_refresh', created_at: 1234567890, }; jest.mocked(GitLabService.exchangeToken).mockResolvedValue(exchangeToken); jest.mocked(GitLabService).mockImplementation(({ instanceUrl, token }: Credentials) => { assert.strictEqual(instanceUrl, selfManagedUrl); assert.strictEqual(token, 'self_managed_token'); return { fetchFromApi: createFakeFetchFromApi({ request: currentUserRequest, response: { id: 456, username: 'self_managed_user' }, }), } as GitLabService; }); jest.mocked(openUrl).mockImplementationOnce(async urlString => { // Simulate OAuth service response for self-managed instance const url = new URL(urlString); const params = url.searchParams; expect(url.origin).toBe(selfManagedUrl); expect(params.get('client_id')).toBe('self-managed-client-id'); const responseParams = new URLSearchParams({ state: params.get('state') || '', code: 'self_managed_code', }); uriHandler.fire(vscode.Uri.parse(`${params.get('redirect_uri')}?${responseParams}`)); }); const flow = new OAuthFlow(uriHandler); const account = await flow.authenticate(selfManagedUrl); expect(account?.id).toEqual(`${selfManagedUrl}|456`); expect(account?.token).toEqual('self_managed_token'); expect(account?.instanceUrl).toEqual(selfManagedUrl); expect(vscode.window.withProgress).toHaveBeenCalledWith( { title: `Waiting for OAuth redirect from ${selfManagedUrl}.`, location: vscode.ProgressLocation.Notification, }, expect.any(Function), ); }); it('authenticates with GitLab Dedicated instance', async () => { const exchangeToken: ExchangeTokenResponse = { access_token: 'dedicated_token', expires_in: 7200, refresh_token: 'dedicated_refresh', created_at: 1234567890, }; jest.mocked(GitLabService.exchangeToken).mockResolvedValue(exchangeToken); jest.mocked(GitLabService).mockImplementation(({ instanceUrl, token }: Credentials) => { assert.strictEqual(instanceUrl, dedicatedUrl); assert.strictEqual(token, 'dedicated_token'); return { fetchFromApi: createFakeFetchFromApi({ request: currentUserRequest, response: { id: 789, username: 'dedicated_user' }, }), } as GitLabService; }); jest.mocked(openUrl).mockImplementationOnce(async urlString => { const url = new URL(urlString); const params = url.searchParams; expect(url.origin).toBe(dedicatedUrl); expect(params.get('client_id')).toBe('dedicated-client-id'); const responseParams = new URLSearchParams({ state: params.get('state') || '', code: 'dedicated_code', }); uriHandler.fire(vscode.Uri.parse(`${params.get('redirect_uri')}?${responseParams}`)); }); const flow = new OAuthFlow(uriHandler); const account = await flow.authenticate(dedicatedUrl); expect(account?.id).toEqual(`${dedicatedUrl}|789`); expect(account?.token).toEqual('dedicated_token'); expect(account?.instanceUrl).toEqual(dedicatedUrl); }); it('returns undefined for unsupported instances', async () => { const flow = new OAuthFlow(uriHandler); const account = await flow.authenticate('https://unsupported.example.com'); expect(account).toBeUndefined(); }); it('handles multiple concurrent authentication requests', async () => { const flow = new OAuthFlow(uriHandler); // Mock different responses for different instances let callCount = 0; jest.mocked(GitLabService.exchangeToken).mockImplementation(async () => { callCount++; return { access_token: `token_${callCount}`, expires_in: 3600, refresh_token: `refresh_${callCount}`, created_at: 1234567890, }; }); jest.mocked(GitLabService).mockImplementation(() => { return { fetchFromApi: createFakeFetchFromApi({ request: currentUserRequest, response: { id: callCount, username: `user_${callCount}` }, }), } as GitLabService; }); jest.mocked(openUrl).mockImplementation(async urlString => { const url = new URL(urlString); const params = url.searchParams; const responseParams = new URLSearchParams({ state: params.get('state') || '', code: `code_for_${url.origin}`, }); uriHandler.fire(vscode.Uri.parse(`${params.get('redirect_uri')}?${responseParams}`)); }); // Start multiple authentication requests const [gitlabComAccount, selfManagedAccount] = await Promise.all([ flow.authenticate(GITLAB_COM_URL), flow.authenticate(selfManagedUrl), ]); expect(gitlabComAccount?.instanceUrl).toBe(GITLAB_COM_URL); expect(selfManagedAccount?.instanceUrl).toBe(selfManagedUrl); expect(gitlabComAccount?.id).not.toBe(selfManagedAccount?.id); }); }); describe('error handling', () => { it('handles OAuth client ID configuration errors gracefully', async () => { mockGetAuthenticationConfiguration.mockReturnValue({ oauthClientIds: { 'https://gitlab.com': '', // Empty client ID }, }); const flow = new OAuthFlow(uriHandler); const account = await flow.authenticate('https://gitlab.com'); expect(account).toBeUndefined(); }); it('handles token exchange failures', async () => { jest .mocked(GitLabService.exchangeToken) .mockRejectedValue(new Error('Token exchange failed')); jest.mocked(openUrl).mockImplementationOnce(async urlString => { const url = new URL(urlString); const params = url.searchParams; const responseParams = new URLSearchParams({ state: params.get('state') || '', code: 'test_code', }); uriHandler.fire(vscode.Uri.parse(`${params.get('redirect_uri')}?${responseParams}`)); }); const flow = new OAuthFlow(uriHandler); await expect(flow.authenticate(GITLAB_COM_URL)).rejects.toThrow('Token exchange failed'); }); it('handles user API request failures', async () => { const exchangeToken: ExchangeTokenResponse = { access_token: 'test_token', expires_in: 7200, refresh_token: 'test_refresh', created_at: 0, }; jest.mocked(GitLabService.exchangeToken).mockResolvedValue(exchangeToken); jest.mocked(GitLabService).mockImplementation(() => { return { fetchFromApi: jest.fn().mockRejectedValue(new Error('User API request failed')), } as unknown as GitLabService; }); jest.mocked(openUrl).mockImplementationOnce(async urlString => { const url = new URL(urlString); const params = url.searchParams; const responseParams = new URLSearchParams({ state: params.get('state') || '', code: 'test_code', }); uriHandler.fire(vscode.Uri.parse(`${params.get('redirect_uri')}?${responseParams}`)); }); const flow = new OAuthFlow(uriHandler); await expect(flow.authenticate(GITLAB_COM_URL)).rejects.toThrow('User API request failed'); }); }); });
src/desktop/accounts/auth_flows/oauth_flow.ts +40 −24 Original line number Diff line number Diff line import crypto from 'crypto'; import assert from 'assert'; import vscode from 'vscode'; import { GITLAB_COM_URL } from '../../../common/constants'; import { openUrl } from '../../commands/openers'; import { PromiseAdapter, promiseFromEvent } from '../../utils/promise_from_event'; import { GitLabUriHandler, gitlabUriHandler } from '../../gitlab_uri_handler'; import { OAUTH_CLIENT_ID, OAUTH_REDIRECT_URI } from '../../constants'; import { OAUTH_REDIRECT_URI } from '../../constants'; import { generateSecret } from '../../../common/utils/generate_secret'; import { log } from '../../../common/log'; import { makeAccountId, OAuthAccount } from '../../../common/platform/gitlab_account'; Loading @@ -15,28 +14,33 @@ import { GitLabService, } from '../../gitlab/gitlab_service'; import { currentUserRequest } from '../../../common/gitlab/api/get_current_user'; import { getAuthenticationConfiguration } from '../../utils/extension_configuration'; import { Flow } from './flow'; const createOAuthAccountFromCode: ( params: AuthorizationCodeTokenExchangeParams & { scopes: readonly string[] }, ) => Promise<OAuthAccount> = async params => { clientId: string, ) => Promise<OAuthAccount> = async (params, clientId) => { const { code, codeVerifier } = params; const tokenResponse = await GitLabService.exchangeToken({ instanceUrl: GITLAB_COM_URL, const tokenResponse = await GitLabService.exchangeToken( { instanceUrl: params.instanceUrl, grantType: 'authorization_code', code, codeVerifier, }); }, clientId, ); const user = await new GitLabService({ instanceUrl: GITLAB_COM_URL, instanceUrl: params.instanceUrl, token: tokenResponse.access_token, }).fetchFromApi(currentUserRequest); const account: OAuthAccount = { instanceUrl: GITLAB_COM_URL, instanceUrl: params.instanceUrl, token: tokenResponse.access_token, refreshToken: tokenResponse.refresh_token, expiresAtTimestampInSeconds: createExpiresTimestamp(tokenResponse), id: makeAccountId(GITLAB_COM_URL, user.id), id: makeAccountId(params.instanceUrl, user.id), type: 'oauth', username: user.username, scopes: [...params.scopes], Loading @@ -53,6 +57,7 @@ const generateCodeChallengeFromVerifier = (v: string) => { }; interface OAuthUrlParams { instanceUrl: string; clientId: string; redirectUri: string; responseType?: string; Loading @@ -63,6 +68,7 @@ interface OAuthUrlParams { } const createAuthUrl = ({ instanceUrl, clientId, redirectUri, responseType = 'code', Loading @@ -71,7 +77,7 @@ const createAuthUrl = ({ codeChallenge, codeChallengeMethod = 'S256', }: OAuthUrlParams) => `${GITLAB_COM_URL}/oauth/authorize?${new URLSearchParams({ `${instanceUrl}/oauth/authorize?${new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: responseType, Loading @@ -82,18 +88,20 @@ const createAuthUrl = ({ })}`; const createLoginUrl = ( instanceUrl: string, scopesParam?: readonly string[], ): { url: string; state: string; codeVerifier: string } => { ): { url: string; state: string; codeVerifier: string; clientId: string } => { const state = generateSecret(); const redirectUri = OAUTH_REDIRECT_URI; const codeVerifier = generateSecret(); const codeChallenge = generateCodeChallengeFromVerifier(codeVerifier); const scopes = (scopesParam ?? ['api']).join(' '); const clientId = OAUTH_CLIENT_ID; const clientId = getAuthenticationConfiguration().oauthClientIds[instanceUrl] || ''; return { url: createAuthUrl({ clientId, redirectUri, state, scopes, codeChallenge }), url: createAuthUrl({ instanceUrl, clientId, redirectUri, state, scopes, codeChallenge }), state, codeVerifier, clientId, }; }; Loading @@ -111,26 +119,29 @@ export class OAuthFlow implements Flow { } supportsGitLabInstance(url: string): boolean { return url === GITLAB_COM_URL; const clientId = getAuthenticationConfiguration().oauthClientIds[url]; return clientId !== undefined && clientId !== null && clientId !== ''; } async authenticate(url: string) { if (url !== GITLAB_COM_URL) return undefined; if (this.supportsGitLabInstance(url)) { return this.#createAccount(url, ['api']); } return this.#createAccount(['api']); return undefined; } async #createAccount(scopes: readonly string[]): Promise<OAuthAccount> { const { url, state, codeVerifier } = createLoginUrl(scopes); async #createAccount(instanceUrl: string, scopes: readonly string[]): Promise<OAuthAccount> { const { url, state, codeVerifier, clientId } = createLoginUrl(instanceUrl, scopes); this.#requestsInProgress[state] = codeVerifier; const { promise: receivedRedirectUrl, cancel: cancelWaitingForRedirectUrl } = promiseFromEvent( this.#uriHandler.event, this.#exchangeCodeForToken(state, scopes), this.#exchangeCodeForToken(instanceUrl, state, scopes, clientId), ); await openUrl(url); const account = await vscode.window.withProgress( { title: 'Waiting for OAuth redirect from GitLab.com.', title: `Waiting for OAuth redirect from ${instanceUrl}.`, location: vscode.ProgressLocation.Notification, }, () => Loading @@ -153,12 +164,14 @@ export class OAuthFlow implements Flow { } #exchangeCodeForToken: ( instanceUrl: string, state: string, scopes: readonly string[], clientId: string, ) => PromiseAdapter<vscode.Uri, OAuthAccount> = /* This callback is triggered on every vscode://gitlab-workflow URL. We will ignore invocations that are not related to the OAuth login with given `state`. */ (state, scopes) => async (uri, resolve, reject) => { (instanceUrl, state, scopes, clientId) => async (uri, resolve, reject) => { if (uri.path !== '/authentication') return; const searchParams = new URLSearchParams(uri.query); const urlState = searchParams.get('state'); Loading @@ -175,13 +188,16 @@ export class OAuthFlow implements Flow { return; } try { const account = await createOAuthAccountFromCode({ instanceUrl: GITLAB_COM_URL, const account = await createOAuthAccountFromCode( { instanceUrl, grantType: 'authorization_code', code, codeVerifier, scopes, }); }, clientId, ); resolve(account); } catch (e) { log.error('OAuth flow: Creating account from code failed: ', e); Loading
src/desktop/constants.ts +0 −1 Original line number Diff line number Diff line Loading @@ -20,7 +20,6 @@ export const HAS_COMMENTS_QUERY_KEY = 'hasComments'; export const PATCH_TITLE_PREFIX = 'patch: '; export const PATCH_FILE_SUFFIX = '.patch'; export const OAUTH_CLIENT_ID = '36f2a70cddeb5a0889d4fd8295c241b7e9848e89cf9e599d0eed2d8e5350fbf5'; export const OAUTH_REDIRECT_URI = `${vscode.env.uriScheme}://gitlab.gitlab-workflow/authentication`; /** Synced comment is stored in the GitLab instance */ Loading