Skip to content
Open
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
3 changes: 2 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
module.exports = {
preset: "ts-jest/presets/default-esm",
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1"
"^(\\.{1,2}/.*)\\.js$": "$1",
"^vscode$": "<rootDir>/src/test/vscode.ts"
},
transform: {
"^.+\\.tsx?$": [
Expand Down
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -310,10 +310,15 @@
"contents": "Sign in to GitHub to display runs, workflows, and configure Actions settings.\n[Sign in to GitHub](command:github-actions.sign-in)",
"when": "!github-actions.signed-in"
},
{
"view": "github-actions.empty-view",
"contents": "GitHub rejected the saved sign-in. It may have expired or been revoked.\n[Sign in again](command:github-actions.sign-in)",
"when": "github-actions.signed-in && github-actions.auth-invalid"
},
{
"view": "github-actions.empty-view",
"contents": "Unable to connect to the GitHub API, please check your internet connection.\n[Refresh](command:github-actions.explorer.refresh)",
"when": "github-actions.signed-in && !github-actions.internet-access"
"when": "github-actions.signed-in && !github-actions.internet-access && !github-actions.auth-invalid"
},
{
"view": "github-actions.empty-view",
Expand Down
13 changes: 12 additions & 1 deletion src/api/api.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,24 @@
import {Octokit} from "@octokit/rest";
import {version} from "../../package.json";
import {getGitHubApiUri} from "../configuration/configuration";
import {isUnauthorizedError, notifyUnauthorized} from "./authErrors";

export const userAgent = `VS Code GitHub Actions (${version})`;

export function getClient(token: string): Octokit {
return new Octokit({
const client = new Octokit({
auth: token,
userAgent: userAgent,
baseUrl: getGitHubApiUri()
});

// Surface rejected tokens wherever they happen, so the user is offered a way to sign in again
client.hook.error("request", error => {
if (isUnauthorizedError(error)) {
notifyUnauthorized();
}
throw error;
});

return client;
}
33 changes: 33 additions & 0 deletions src/api/authErrors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {describe, expect, it, jest} from "@jest/globals";
import {isUnauthorizedError, notifyUnauthorized, onUnauthorized} from "./authErrors";

describe("isUnauthorizedError", () => {
it("detects a 401 response", () => {
expect(isUnauthorizedError(Object.assign(new Error("Bad credentials"), {status: 401}))).toBe(true);
});

it("ignores other response statuses", () => {
expect(isUnauthorizedError(Object.assign(new Error("Forbidden"), {status: 403}))).toBe(false);
expect(isUnauthorizedError(Object.assign(new Error("Server error"), {status: 500}))).toBe(false);
});

it("ignores errors without a status", () => {
expect(isUnauthorizedError(new Error("getaddrinfo ENOTFOUND api.github.com"))).toBe(false);
expect(isUnauthorizedError(undefined)).toBe(false);
expect(isUnauthorizedError("401")).toBe(false);
});
});

describe("onUnauthorized", () => {
it("notifies listeners until they are disposed", () => {
const listener = jest.fn();
const subscription = onUnauthorized(listener);

notifyUnauthorized();
expect(listener).toHaveBeenCalledTimes(1);

subscription.dispose();
notifyUnauthorized();
expect(listener).toHaveBeenCalledTimes(1);
});
});
26 changes: 26 additions & 0 deletions src/api/authErrors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
type UnauthorizedListener = () => void;

const listeners: UnauthorizedListener[] = [];

/**
* Whether the error is a GitHub API response rejecting the session token (e.g., it was revoked or has expired)
*/
export function isUnauthorizedError(error: unknown): boolean {
return typeof error === "object" && error !== null && (error as {status?: unknown}).status === 401;
}

export function onUnauthorized(listener: UnauthorizedListener): {dispose: () => void} {
listeners.push(listener);
return {
dispose: () => {
const index = listeners.indexOf(listener);
if (index !== -1) {
listeners.splice(index, 1);
}
}
};
}

export function notifyUnauthorized() {
listeners.forEach(l => l());
}
54 changes: 54 additions & 0 deletions src/api/canReachGitHubAPI.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {beforeEach, describe, expect, it, jest} from "@jest/globals";

const getSession = jest.fn<() => Promise<unknown>>();
const request = jest.fn<() => Promise<unknown>>();

jest.unstable_mockModule("../auth/auth", () => ({getSession}));
jest.unstable_mockModule("../log", () => ({logError: jest.fn()}));
jest.unstable_mockModule("./api", () => ({getClient: () => ({request})}));

const {getGitHubAPIStatus, resetGitHubAPIStatus} = await import("./canReachGitHubAPI");

describe("getGitHubAPIStatus", () => {
beforeEach(() => {
resetGitHubAPIStatus();
getSession.mockReset().mockResolvedValue({accessToken: "token"});
request.mockReset();
});

it("returns signed-out without a session", async () => {
getSession.mockResolvedValue(undefined);

expect(await getGitHubAPIStatus()).toBe("signed-out");
expect(request).not.toHaveBeenCalled();
});

it("returns ok when the API accepts the token", async () => {
request.mockResolvedValue({status: 200});

expect(await getGitHubAPIStatus()).toBe("ok");
});

it("returns unauthorized when the API rejects the token", async () => {
request.mockRejectedValue(Object.assign(new Error("Bad credentials"), {status: 401}));

expect(await getGitHubAPIStatus()).toBe("unauthorized");
});

it("returns unreachable for other failures", async () => {
request.mockRejectedValue(new Error("getaddrinfo ENOTFOUND api.github.com"));

expect(await getGitHubAPIStatus()).toBe("unreachable");
});

it("caches the status until it is reset", async () => {
request.mockRejectedValue(Object.assign(new Error("Bad credentials"), {status: 401}));
expect(await getGitHubAPIStatus()).toBe("unauthorized");

request.mockResolvedValue({status: 200});
expect(await getGitHubAPIStatus()).toBe("unauthorized");

resetGitHubAPIStatus();
expect(await getGitHubAPIStatus()).toBe("ok");
});
});
27 changes: 22 additions & 5 deletions src/api/canReachGitHubAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,40 @@ import {TTLCache} from "@actions/languageserver/utils/cache";
import {getSession} from "../auth/auth";
import {logError} from "../log";
import {getClient} from "./api";
import {isUnauthorizedError} from "./authErrors";

const API_ACCESS_TTL_MS = 10 * 1000;
const cache = new TTLCache(API_ACCESS_TTL_MS);

export async function canReachGitHubAPI() {
/**
* - `ok`: the API accepted the session token
* - `signed-out`: there is no session
* - `unauthorized`: the API rejected the session token (e.g., it was revoked or has expired)
* - `unreachable`: any other failure, e.g., no network connection
*/
export type GitHubAPIStatus = "ok" | "signed-out" | "unauthorized" | "unreachable";

export async function getGitHubAPIStatus(): Promise<GitHubAPIStatus> {
const session = await getSession();
if (!session) {
return false;
return "signed-out";
}
return await cache.get("canReachGitHubAPI", undefined, async () => {
return await cache.get("gitHubAPIStatus", undefined, async (): Promise<GitHubAPIStatus> => {
try {
const octokit = getClient(session.accessToken);
await octokit.request("GET /");
} catch (e) {
logError(e as Error, "Error getting GitHub context");
return false;
return isUnauthorizedError(e) ? "unauthorized" : "unreachable";
}
return true;
return "ok";
});
}

export async function canReachGitHubAPI() {
return (await getGitHubAPIStatus()) === "ok";
}

export function resetGitHubAPIStatus() {
cache.clear();
}
7 changes: 5 additions & 2 deletions src/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,11 @@ async function getSessionInternal(
typeof createOrForceMessage === "string"
? {forceNewSession: {detail: createOrForceMessage}}
: {createIfNone: createOrForceMessage};
const authProviderId = useEnterprise() ? AUTH_PROVIDER_ID_ENTERPRISE : AUTH_PROVIDER_ID;
return await vscode.authentication.getSession(authProviderId, getScopes(), options);
return await vscode.authentication.getSession(getAuthProviderId(), getScopes(), options);
}

export function getAuthProviderId(): string {
return useEnterprise() ? AUTH_PROVIDER_ID_ENTERPRISE : AUTH_PROVIDER_ID;
}

function getScopes(): string[] {
Expand Down
85 changes: 85 additions & 0 deletions src/auth/authState.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import * as vscode from "vscode";

import {onUnauthorized} from "../api/authErrors";
import {GitHubAPIStatus, getGitHubAPIStatus, resetGitHubAPIStatus} from "../api/canReachGitHubAPI";
import {getGitHubContext, resetGitHubContext} from "../git/repository";
import {logError} from "../log";
import {restartLanguageServer} from "../workflow/languageServer";
import {getAuthProviderId} from "./auth";

let unauthorizedWarningShown = false;
let sessionChange: Promise<void> = Promise.resolve();

/**
* Checks the session against the GitHub API and updates the context keys that control which views are shown
*/
export async function updateContextKeys(): Promise<{status: GitHubAPIStatus; hasGitHubRepos: boolean}> {
const status = await getGitHubAPIStatus();
const ghContext = status === "ok" ? await getGitHubContext() : undefined;
const hasGitHubRepos = !!ghContext && ghContext.repos.length > 0;

await Promise.all([
vscode.commands.executeCommand("setContext", "github-actions.signed-in", status !== "signed-out"),
vscode.commands.executeCommand("setContext", "github-actions.internet-access", status === "ok"),
vscode.commands.executeCommand("setContext", "github-actions.auth-invalid", status === "unauthorized"),
vscode.commands.executeCommand("setContext", "github-actions.has-repos", hasGitHubRepos)
]);

if (status !== "unauthorized") {
unauthorizedWarningShown = false;
}

return {status, hasGitHubRepos};
}

export function initAuthStateTracking(context: vscode.ExtensionContext) {
context.subscriptions.push(
onUnauthorized(() => {
handleUnauthorized().catch(e => logError(e as Error, "Error handling rejected GitHub session"));
})
);

context.subscriptions.push(
vscode.authentication.onDidChangeSessions(e => {
if (e.provider.id !== getAuthProviderId()) {
return;
}

// Serialize handling so overlapping events don't restart the language server concurrently
sessionChange = sessionChange
.then(() => handleSessionChange(context))
.catch(e => logError(e as Error, "Error handling GitHub session change"));
})
);
}

async function handleUnauthorized() {
await Promise.all([
vscode.commands.executeCommand("setContext", "github-actions.auth-invalid", true),
vscode.commands.executeCommand("setContext", "github-actions.internet-access", false)
]);

if (unauthorizedWarningShown) {
return;
}
unauthorizedWarningShown = true;

const signInAction = "Sign in again";
const result = await vscode.window.showWarningMessage(
"GitHub rejected the saved sign-in for GitHub Actions. It may have expired or been revoked.",
signInAction
);
if (result === signInAction) {
await vscode.commands.executeCommand("github-actions.sign-in");
}
}

async function handleSessionChange(context: vscode.ExtensionContext) {
resetGitHubAPIStatus();
resetGitHubContext();

// The language server only receives the token on initialization
await restartLanguageServer(context);

await vscode.commands.executeCommand("github-actions.explorer.refresh");
}
Loading