Commit 3d71c40f authored by Erran Carey's avatar Erran Carey Committed by Tristan Read
Browse files

feat: Support OAuth logins for GitLab Self-Managed and GitLab Dedicated

parent 91d42125
Loading
Loading
Loading
Loading
+12 −0
Original line number Diff line number Diff line
@@ -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": {
+7 −0
Original line number Diff line number Diff line
@@ -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 };
+290 −8
Original line number Diff line number Diff line
@@ -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');
@@ -35,6 +37,7 @@ const fakeOAuthService = (urlString: string): string => {

describe('OAuthFlow', () => {
  let uriHandler: GitLabUriHandler;
  const mockGetAuthenticationConfiguration = jest.mocked(getAuthenticationConfiguration);

  beforeEach(async () => {
    uriHandler = new GitLabUriHandler();
@@ -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,
@@ -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),
@@ -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');
    });
  });
});
+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';
@@ -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],
@@ -53,6 +57,7 @@ const generateCodeChallengeFromVerifier = (v: string) => {
};

interface OAuthUrlParams {
  instanceUrl: string;
  clientId: string;
  redirectUri: string;
  responseType?: string;
@@ -63,6 +68,7 @@ interface OAuthUrlParams {
}

const createAuthUrl = ({
  instanceUrl,
  clientId,
  redirectUri,
  responseType = 'code',
@@ -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,
@@ -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,
  };
};

@@ -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,
      },
      () =>
@@ -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');
@@ -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);
+0 −1
Original line number Diff line number Diff line
@@ -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