Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions documentation/releasing.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# How to release

## Authentication API dependency

Enterprise API destinations use the selected session's `authorizationServer`,
with `/login/oauth` removed, rather than `github-enterprise.uri` (setup only).
This requires the **proposed** `authIssuers` API from
[microsoft/vscode#337846](https://github.com/microsoft/vscode/pull/337846),
merged at `2f84f5b38264788d80a9399413cc47cb2a17db51` on the 1.140 development line.
The engine floor is 1.140.0; Insiders must contain that commit. Production
allowlisting is a separate [vscode-distro](https://github.com/microsoft/vscode-distro)
change. Missing enterprise metadata is explicitly unavailable, without a
configuration fallback. Public GitHub/PAT authentication does not require it.

## Release steps

1. Edit version in [package.json](https://github.com/Microsoft/vscode-pull-request-github/blob/main/package.json)
- Update version of the extension - this is usually the minor version.
**Until the marketplace supports semantic versioning, the minor version should always be an event number. Odd numbers are reserved for the pre-release version of the extension.**
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"activeComment",
"agentSessionsWorkspace",
"agentsWindowActivation",
"authIssuers",
"chatContextProvider",
"chatParticipantAdditions",
"chatParticipantPrivate",
Expand Down Expand Up @@ -48,7 +49,7 @@
"publisher": "GitHub",
"engines": {
"node": ">=20",
"vscode": "^1.137.0"
"vscode": "^1.140.0"
},
"categories": [
"Other",
Expand Down
64 changes: 64 additions & 0 deletions src/@types/vscode.proposed.authIssuers.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

declare module 'vscode' {
export interface AuthenticationSession {
/**
* The authorization server that issued this session, when provided by the authentication provider.
* This identifies the OAuth server, not a REST API endpoint or resource audience.
*/
readonly authorizationServer?: Uri;
}

export interface AuthenticationProviderOptions {
/**
* When specified, this provider will be associated with these authorization servers. They can still contain globs
* just like their extension contribution counterparts.
*/
readonly supportedAuthorizationServers?: Uri[];
}

export interface AuthenticationProviderSessionOptions {
/**
* When specified, the authentication provider will use the provided authorization server URL to
* authenticate the user. This is only used when a provider has `supportedAuthorizationServers` set
*/
authorizationServer?: Uri;

/**
* When specified, the authentication provider will use the provided client ID for the OAuth flow
* instead of its default client ID.
*/
clientId?: string;

/**
* When specified, the authentication provider will request a token bound to this resource URI
* (RFC 8707 resource indicator). The provider should forward this to the authorization server
* so the issued access token is audience-restricted to the given resource.
*/
resource?: string;
}

export interface AuthenticationGetSessionOptions {
/**
* When specified, the authentication provider will use the provided authorization server URL to
* authenticate the user. This is only used when a provider has `supportedAuthorizationServers` set
*/
authorizationServer?: Uri;

/**
* When specified, the authentication provider will use the provided client ID for the OAuth flow
* instead of its default client ID.
*/
clientId?: string;

/**
* When specified, the authentication provider will request a token bound to this resource URI
* (RFC 8707 resource indicator). The provider should forward this to the authorization server
* so the issued access token is audience-restricted to the given resource.
*/
resource?: string;
}
}
14 changes: 9 additions & 5 deletions src/authentication/githubServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
import fetch from 'cross-fetch';
import * as vscode from 'vscode';
import { HostHelper } from './configuration';
import { GitHubServerType } from '../common/authentication';
import { AuthProvider, GitHubServerType } from '../common/authentication';
import Logger from '../common/logger';
import { ITelemetry } from '../common/telemetry';
import { agent } from '../env/node/net';
import { getEnterpriseUri } from '../github/utils';
import type { CredentialStore } from '../github/credentials';

export class GitHubManager {
private static readonly _githubDotComServers = new Set<string>().add('github.com').add('ssh.github.com');
Expand All @@ -19,7 +19,10 @@ export class GitHubManager {
private static readonly _reportedEnterpriseVersions = new Set<string>();
private _knownServers: Map<string, GitHubServerType> = new Map([...Array.from(GitHubManager._githubDotComServers.keys()).map(key => [key, GitHubServerType.GitHubDotCom]), ...Array.from(GitHubManager._gheServers.keys()).map(key => [key, GitHubServerType.Enterprise])] as [string, GitHubServerType][]);

constructor(private readonly _telemetry?: ITelemetry) { }
constructor(
private readonly credentialStore: CredentialStore,
private readonly _telemetry?: ITelemetry,
) { }

public static isGithubDotCom(host: string): boolean {
return this._githubDotComServers.has(host);
Expand Down Expand Up @@ -60,8 +63,9 @@ export class GitHubManager {

const matchingKnownServer = Array.from(this._knownServers.keys()).find(server => authority.endsWith(server));

const knownEnterprise = getEnterpriseUri();
if ((host.authority.toLowerCase() === knownEnterprise?.authority.toLowerCase()) && (!matchingKnownServer || (this._knownServers.get(matchingKnownServer) === GitHubServerType.None))) {
const enterprise = this.credentialStore.getHub(AuthProvider.githubEnterprise);
if (enterprise && authority === new URL(enterprise.serverUri.toString()).hostname.toLowerCase()
&& (!matchingKnownServer || (this._knownServers.get(matchingKnownServer) === GitHubServerType.None))) {
return GitHubServerType.Enterprise;
}

Expand Down
29 changes: 27 additions & 2 deletions src/common/authentication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,36 @@ export enum AuthProvider {
}

export class AuthenticationError extends Error {
constructor() {
super(vscode.l10n.t('Not authenticated'));
constructor(message: string = vscode.l10n.t('Not authenticated')) {
super(message);
}
}

export function getSessionGitHubUri(authProviderId: AuthProvider, session: Pick<vscode.AuthenticationSession, 'authorizationServer'> | undefined): vscode.Uri {
if (authProviderId === AuthProvider.github) {
return vscode.Uri.parse('https://github.com');
}

const issuer = session?.authorizationServer;
const oauthSuffix = '/login/oauth';
const path = issuer?.path.replace(/\/$/, '');
const unavailable = () => new AuthenticationError(vscode.l10n.t('GitHub Enterprise is unavailable because the authentication session does not include a supported authorization server. Use a VS Code build with authIssuers session support and sign in again.'));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be possible, right? That's part of what the VS Code version bump prevents?

if (!issuer || (issuer.scheme !== 'https' && issuer.scheme !== 'http') || !issuer.authority
|| /[@\s]/.test(issuer.authority) || issuer.query || issuer.fragment || !path?.endsWith(oauthSuffix)) {
throw unavailable();
}
try {
new URL(issuer.toString());
} catch (error) {
if (error instanceof TypeError) {
throw unavailable();
}
throw error;
}

return issuer.with({ path: path.slice(0, -oauthSuffix.length) });
}

export function isSamlError(e: { message?: string }): boolean {
return !!e.message?.includes('Resource protected by organization SAML enforcement.');
}
26 changes: 23 additions & 3 deletions src/common/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import * as vscode from 'vscode';
import { AuthProvider, GitHubServerType } from './authentication';
import Logger from './logger';
import { Protocol } from './protocol';
import { Protocol, ProtocolType } from './protocol';
import { Repository } from '../api/api';
import { getEnterpriseUri, isEnterprise } from '../github/utils';
import { isEnterprise } from '../github/utils';

export class Remote {
public get host(): string {
Expand All @@ -21,12 +22,18 @@ export class Remote {
}

public get normalizedHost(): string {
if (this.gitProtocol.type === ProtocolType.HTTP) {
const uri = this.gitProtocol.url;
const path = uri.path.replace(/\/$/, '');
const deploymentPath = path.slice(0, path.lastIndexOf('/', path.lastIndexOf('/') - 1));
return uri.with({ authority: uri.authority.replace(/^.*@/, ''), path: deploymentPath, query: '', fragment: '' }).toString().replace(/\/$/, '');
}
const normalizedUri = this.gitProtocol.normalizeUri();
return `${normalizedUri!.scheme}://${normalizedUri!.authority}`;
}

public get authProviderId(): AuthProvider {
return this.host === getEnterpriseUri()?.authority ? AuthProvider.githubEnterprise : AuthProvider.github;
return ['github.com', 'ssh.github.com'].includes(this.host.toLowerCase()) ? AuthProvider.github : AuthProvider.githubEnterprise;
}

public get isEnterprise(): boolean {
Expand All @@ -39,6 +46,19 @@ export class Remote {
public readonly gitProtocol: Protocol,
) { }

public matchesServerUri(serverUri: vscode.Uri): boolean {
const server = new URL(serverUri.toString());
if (server.hostname.toLowerCase() === 'github.com') {
return ['github.com', 'ssh.github.com'].includes(this.host.toLowerCase());
}
if (this.gitProtocol.type !== ProtocolType.HTTP) {
return this.host.toLowerCase() === server.hostname.toLowerCase();
}
const remote = new URL(this.normalizedHost);
return remote.host.toLowerCase() === server.host.toLowerCase()
&& remote.pathname.replace(/\/$/, '') === server.pathname.replace(/\/$/, '');
}

equals(remote: Remote): boolean {
if (this.remoteName !== remote.remoteName) {
return false;
Expand Down
5 changes: 3 additions & 2 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,8 @@ async function init(
if (e.provider.id !== AuthProvider.github && e.provider.id !== AuthProvider.githubEnterprise) {
return;
}
if (e.accountChanged) {
const clearAuthState = e.accountChanged || e.serverChanged;
if (clearAuthState) {
IssueOverviewPanel.clearAll();
PullRequestOverviewPanel.clearAll();
activePrViewCoordinator.clearForAuthChange();
Expand All @@ -291,7 +292,7 @@ async function init(
}
await reposManager.refreshRepositories();
await Promise.all(reviewsManager.reviewManagers.map(reviewManager => reviewManager.updateState(true)));
reviewsManager.refreshPullRequestsTree(!e.accountChanged);
reviewsManager.refreshPullRequestsTree(!clearAuthState);
await issueStateManager.refreshAfterAuthChange();
notificationsManager.refresh();
}));
Expand Down
6 changes: 3 additions & 3 deletions src/gitExtensionIntegration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export class GithubRemoteSourceProvider implements RemoteSourceProvider {
readonly icon = 'github';
readonly supportsQuery = true;

private userReposCache: RemoteSource[] = [];
private userReposCache = new WeakMap<GitHub, RemoteSource[]>();

constructor(private readonly credentialStore: CredentialStore, private readonly authProviderId: AuthProvider = AuthProvider.github) {
if (isEnterprise(authProviderId)) {
Expand Down Expand Up @@ -65,10 +65,10 @@ export class GithubRemoteSourceProvider implements RemoteSourceProvider {
private async getUserRemoteSources(hub: GitHub, query?: string): Promise<RemoteSource[]> {
if (!query) {
const res = await hub.octokit.call(hub.octokit.api.repos.listForAuthenticatedUser, { sort: 'pushed', per_page: 100 });
this.userReposCache = res.data.map(asRemoteSource);
this.userReposCache.set(hub, res.data.map(asRemoteSource));
}

return this.userReposCache;
return this.userReposCache.get(hub) ?? [];
}

private async getQueryRemoteSources(hub: GitHub, query?: string): Promise<RemoteSource[]> {
Expand Down
3 changes: 1 addition & 2 deletions src/github/copilotApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import fetch from 'cross-fetch';
import * as vscode from 'vscode';
import { CredentialStore } from './credentials';
import { LoggingOctokit } from './loggingOctokit';
import { hasEnterpriseUri } from './utils';
import { AuthProvider } from '../common/authentication';
import Logger from '../common/logger';
import { ITelemetry } from '../common/telemetry';
Expand Down Expand Up @@ -117,7 +116,7 @@ export interface SessionInfo {

export async function getCopilotApi(credentialStore: CredentialStore, telemetry: ITelemetry, authProvider?: AuthProvider): Promise<CopilotApi | undefined> {
if (!authProvider) {
if (credentialStore.isAuthenticated(AuthProvider.githubEnterprise) && hasEnterpriseUri()) {
if (credentialStore.isAuthenticated(AuthProvider.githubEnterprise)) {
authProvider = AuthProvider.githubEnterprise;
} else if (credentialStore.isAuthenticated(AuthProvider.github)) {
authProvider = AuthProvider.github;
Expand Down
Loading
Loading