diff --git a/CHANGELOG.md b/CHANGELOG.md index acac656635..a0ca491b29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,9 +20,13 @@ ### Fixed -- OAuth sign-in now works on deployments that enforce token scopes. Users signed - in with OAuth are asked to sign in again once. -- Revoke OAuth tokens on logout even when their scopes are outdated. +- OAuth sign-in now works on deployments that enforce token scopes. Sessions + missing required scopes stop refreshing and require sign-in when they expire. +- Revoke OAuth tokens on logout and after successful session replacement, even + when their scopes are outdated. Failed or cancelled sign-ins do not revoke + the stored session. +- Preserve OAuth refresh credentials when reusing a stored token, and request + the permissions needed to refresh workspace external-auth links. ## [v1.16.4](https://github.com/coder/vscode-coder/releases/tag/v1.16.4) 2026-09-23 diff --git a/src/api/authInterceptor.ts b/src/api/authInterceptor.ts index 7ea7bc5cab..3925c7df70 100644 --- a/src/api/authInterceptor.ts +++ b/src/api/authInterceptor.ts @@ -102,7 +102,7 @@ export class AuthInterceptor implements vscode.Disposable { // 1) OAuth refresh path. const isOAuth = - await this.oauthSessionManager.isLoggedInWithOAuth(hostname); + await this.oauthSessionManager.canRefreshOAuthSession(hostname); recorder.setRefreshAttempted(isOAuth); if (isOAuth) { const newToken = await this.tryOAuthRefresh(); diff --git a/src/login/loginCoordinator.ts b/src/login/loginCoordinator.ts index 98263562cf..60356e18f6 100644 --- a/src/login/loginCoordinator.ts +++ b/src/login/loginCoordinator.ts @@ -8,6 +8,7 @@ import { HttpStatusCode } from "../api/httpStatusCode"; import { isSameOrigin, openInBrowser } from "../common/url"; import { CertificateError } from "../error/certificateError"; import { OAuthAuthorizer } from "../oauth/authorizer"; +import { revokeOAuthTokens } from "../oauth/revocation"; import { buildOAuthTokenData } from "../oauth/tokens"; import { withCancellableProgress } from "../ui/progress"; import { maybeAskAuthMethod, maybeAskUrl } from "../ui/prompts"; @@ -15,7 +16,10 @@ import { vscodeProposed } from "../vscode/proposed"; import { showStoreCredentialsError } from "./credentials"; -import type { User } from "coder/site/src/api/typesGenerated"; +import type { + User, + OAuth2ClientRegistrationResponse, +} from "coder/site/src/api/typesGenerated"; import type { CliCredentialManager } from "../cli/cliCredentialManager"; import type { Deployment } from "../deployment/types"; @@ -109,18 +113,14 @@ export class LoginCoordinator implements vscode.Disposable { options: LoginOptions & { url: string }, ): Promise { const { safeHostname, url } = options; - return this.executeWithGuard(async () => { - const result = await this.attemptLogin( + return this.executeWithGuard(() => + this.attemptLogin( { safeHostname, url }, options.autoLogin ?? false, options.token, options.tokenSignInConfirmed ?? false, - ); - - await this.persistSessionAuth(result, safeHostname, url); - - return result; - }); + ), + ); } /** @@ -170,15 +170,11 @@ export class LoginCoordinator implements vscode.Disposable { return { success: false, reason: "no_url_provided" }; } - const result = await this.attemptLogin( + return this.attemptLogin( { url: newUrl, safeHostname }, false, options.token, ); - - await this.persistSessionAuth(result, safeHostname, newUrl); - - return result; } // User cancelled return { success: false, reason: "user_dismissed" }; @@ -198,28 +194,35 @@ export class LoginCoordinator implements vscode.Disposable { } private async persistSessionAuth( - result: LoginResult, - safeHostname: string, - url: string, + result: Extract, + deployment: Deployment, + registration: OAuth2ClientRegistrationResponse | undefined, ): Promise { + const { safeHostname, url } = deployment; + const previous = await this.secretsManager.getSessionAuth(safeHostname); + const sameToken = previous?.token === result.token; + const reusesToken = sameToken && previous?.url === url; + // Empty token is valid for mTLS - if (result.success) { - await this.secretsManager.setSessionAuth(safeHostname, { - url, - token: result.token, - username: result.user.username, - oauth: result.oauth, // undefined for non-OAuth logins - }); - await this.mementoManager.addToUrlHistory(url); - - if (result.token) { - const configs = vscode.workspace.getConfiguration(); - this.cliCredentialManager - .storeToken(url, result.token, configs) - .catch((error) => - showStoreCredentialsError(error, configs, this.logger), - ); - } + await this.secretsManager.setSessionAuth(safeHostname, { + url, + token: result.token, + username: result.user.username, + oauth: result.oauth ?? (reusesToken ? previous?.oauth : undefined), + }); + await this.mementoManager.addToUrlHistory(url); + + if (result.method !== "stored_token" && previous?.oauth && !sameToken) { + void revokeOAuthTokens(previous, registration, this.logger); + } + + if (result.token) { + const configs = vscode.workspace.getConfiguration(); + this.cliCredentialManager + .storeToken(url, result.token, configs) + .catch((error) => + showStoreCredentialsError(error, configs, this.logger), + ); } } @@ -286,15 +289,22 @@ export class LoginCoordinator implements vscode.Disposable { providedToken?: string, tokenSignInConfirmed = false, ): Promise { + const registration = await this.secretsManager.getOAuthClientRegistration( + deployment.safeHostname, + ); const client = CoderApi.create(deployment.url, "", this.logger); try { - return await this.runLoginAttempts( + const result = await this.runLoginAttempts( client, deployment, isAutoLogin, providedToken, tokenSignInConfirmed, ); + if (result.success) { + await this.persistSessionAuth(result, deployment, registration); + } + return result; } finally { client.dispose(); } diff --git a/src/oauth/constants.ts b/src/oauth/constants.ts index 32c314fa1a..447258b8c8 100644 --- a/src/oauth/constants.ts +++ b/src/oauth/constants.ts @@ -12,6 +12,7 @@ export const DEFAULT_OAUTH_SCOPES = [ "workspace:create", "user:read", "user:read_personal", + "user:update_personal", ].join(" "); /** diff --git a/src/oauth/metadataClient.ts b/src/oauth/metadataClient.ts index b6b5b4e24f..bb2d5fb31a 100644 --- a/src/oauth/metadataClient.ts +++ b/src/oauth/metadataClient.ts @@ -1,3 +1,4 @@ +import { CoderApi } from "../api/coderApi"; import { parseApiResponse } from "../api/responseValidation"; import { @@ -142,6 +143,33 @@ export class OAuthMetadataClient { } } +/** + * Run `fn` with a short-lived client for `url` and the server's OAuth + * metadata. The client is disposed on exit so its config-change + * subscriptions never outlive the operation. + */ +export async function withOAuthMetadata( + url: string, + token: string | undefined, + logger: Logger, + fn: (ctx: { + axiosInstance: AxiosInstance; + metadata: OAuth2AuthorizationServerMetadata; + }) => Promise, +): Promise { + const client = CoderApi.create(url, token, logger); + try { + const axiosInstance = client.getAxiosInstance(); + const metadata = await new OAuthMetadataClient( + axiosInstance, + logger, + ).getMetadata(); + return await fn({ axiosInstance, metadata }); + } finally { + client.dispose(); + } +} + /** * Check if an array includes all required types. */ diff --git a/src/oauth/revocation.ts b/src/oauth/revocation.ts new file mode 100644 index 0000000000..4ed034a3eb --- /dev/null +++ b/src/oauth/revocation.ts @@ -0,0 +1,60 @@ +import { withOAuthMetadata } from "./metadataClient"; +import { toUrlSearchParams } from "./tokens"; + +import type { + OAuth2ClientRegistrationResponse, + OAuth2TokenRevocationRequest, +} from "coder/site/src/api/typesGenerated"; + +import type { Logger } from "../logging/logger"; +import type { SessionAuth } from "../storage/secretsManager"; + +/** Best-effort revocation of a captured OAuth session; never reads replacement credentials. */ +export async function revokeOAuthTokens( + auth: SessionAuth, + registration: OAuth2ClientRegistrationResponse | undefined, + logger: Logger, +): Promise { + if (!auth.oauth || !registration) { + return; + } + // Refresh token first, while the access token still authenticates the call. + const targets: Array<[string, "access_token" | "refresh_token"]> = []; + if (auth.oauth.refresh_token) { + targets.push([auth.oauth.refresh_token, "refresh_token"]); + } + targets.push([auth.token, "access_token"]); + + try { + await withOAuthMetadata( + auth.url, + auth.token, + logger, + async ({ axiosInstance, metadata }) => { + const endpoint = metadata.revocation_endpoint; + if (!endpoint) { + logger.debug("No revocation endpoint; skipping revocation"); + return; + } + for (const [token, token_type_hint] of targets) { + const params: OAuth2TokenRevocationRequest = { + token, + client_id: registration.client_id, + client_secret: registration.client_secret, + token_type_hint, + }; + try { + await axiosInstance.post(endpoint, toUrlSearchParams(params), { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + }); + logger.debug(`Revoked ${token_type_hint}`); + } catch (error) { + logger.warn(`Failed to revoke ${token_type_hint}:`, error); + } + } + }, + ); + } catch (error) { + logger.warn("Token revocation failed:", error); + } +} diff --git a/src/oauth/sessionManager.ts b/src/oauth/sessionManager.ts index 843d4b3658..6d8eb98221 100644 --- a/src/oauth/sessionManager.ts +++ b/src/oauth/sessionManager.ts @@ -6,7 +6,8 @@ import { import { DEFAULT_OAUTH_SCOPES, REFRESH_GRANT_TYPE } from "./constants"; import { OAuthError, parseOAuthError } from "./errors"; -import { OAuthMetadataClient } from "./metadataClient"; +import { withOAuthMetadata } from "./metadataClient"; +import { revokeOAuthTokens } from "./revocation"; import { buildOAuthTokenData, toUrlSearchParams } from "./tokens"; import { OAuth2TokenResponseSchema, parseOAuthResponse } from "./validation"; @@ -16,7 +17,6 @@ import type { OAuth2ClientRegistrationResponse, OAuth2TokenRequest, OAuth2TokenResponse, - OAuth2TokenRevocationRequest, } from "coder/site/src/api/typesGenerated"; import type * as vscode from "vscode"; @@ -295,11 +295,10 @@ export class OAuthSessionManager implements vscode.Disposable { } /** - * Run `fn` with a per-call CoderApi configured for the current - * deployment. The client is disposed on exit so its config-change - * subscriptions never outlive the operation. + * Run `fn` with a per-call client, the OAuth metadata and the client + * registration of the current deployment. */ - private async withOAuthOperation( + private withOAuthOperation( token: string | undefined, fn: (ctx: { axiosInstance: AxiosInstance; @@ -308,26 +307,21 @@ export class OAuthSessionManager implements vscode.Disposable { }) => Promise, ): Promise { const deployment = this.requireDeployment(); - const client = CoderApi.create(deployment.url, token, this.logger); - try { - const axiosInstance = client.getAxiosInstance(); - const metadataClient = new OAuthMetadataClient( - axiosInstance, - this.logger, - ); - const metadata = await metadataClient.getMetadata(); - - const registration = await this.secretsManager.getOAuthClientRegistration( - deployment.safeHostname, - ); - if (!registration) { - throw new Error("No client registration found"); - } - - return await fn({ axiosInstance, metadata, registration }); - } finally { - client.dispose(); - } + return withOAuthMetadata( + deployment.url, + token, + this.logger, + async (ctx) => { + const registration = + await this.secretsManager.getOAuthClientRegistration( + deployment.safeHostname, + ); + if (!registration) { + throw new Error("No client registration found"); + } + return fn({ ...ctx, registration }); + }, + ); } public async setDeployment(deployment: Deployment): Promise { @@ -484,64 +478,32 @@ export class OAuthSessionManager implements vscode.Disposable { /** Best-effort server-side revocation of the stored refresh and access tokens; never throws. */ public async revokeTokens(): Promise { - // Includes tokens lacking a required scope, which still work on the server. - const storedTokens = await this.getStoredTokens().catch((error) => { - this.logger.warn("Failed to read stored tokens for revocation:", error); - return undefined; - }); - if (!storedTokens) { + const deployment = this.deployment; + if (!deployment) { return; } - - // Refresh token first, while the access token still authenticates the call. - const targets: Array<[string, "access_token" | "refresh_token"]> = []; - if (storedTokens.refresh_token) { - targets.push([storedTokens.refresh_token, "refresh_token"]); - } - targets.push([storedTokens.access_token, "access_token"]); - - try { - await this.withOAuthOperation( - storedTokens.access_token, - async ({ axiosInstance, metadata, registration }) => { - const endpoint = metadata.revocation_endpoint; - if (!endpoint) { - this.logger.debug("No revocation endpoint; skipping revocation"); - return; - } - for (const [token, token_type_hint] of targets) { - const params: OAuth2TokenRevocationRequest = { - token, - client_id: registration.client_id, - client_secret: registration.client_secret, - token_type_hint, - }; - try { - await axiosInstance.post(endpoint, toUrlSearchParams(params), { - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - }); - this.logger.debug(`Revoked ${token_type_hint}`); - } catch (error) { - this.logger.warn(`Failed to revoke ${token_type_hint}:`, error); - } - } - }, - ); - } catch (error) { - this.logger.warn("Token revocation failed:", error); + // Includes tokens lacking a required scope, which still work on the server. + const auth = await this.secretsManager.getSessionAuth( + deployment.safeHostname, + ); + if (auth?.url !== deployment.url) { + return; } + const registration = await this.secretsManager.getOAuthClientRegistration( + deployment.safeHostname, + ); + await revokeOAuthTokens(auth, registration, this.logger); } /** - * Returns true if OAuth tokens exist for the current deployment. - * Always reads fresh from secrets to ensure cross-window synchronization. + * Returns true if the current deployment has OAuth tokens that may be + * refreshed, i.e. they carry every required scope. Always reads fresh from + * secrets to ensure cross-window synchronization. * * @param hostname Optional hostname to validate against current deployment. * If provided and doesn't match, returns false (race-safety). */ - public async isLoggedInWithOAuth(hostname?: string): Promise { + public async canRefreshOAuthSession(hostname?: string): Promise { if (hostname && hostname !== this.deployment?.safeHostname) { return false; } diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 3e6cef9be5..66dff94346 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -1066,7 +1066,7 @@ export class MockOAuthSessionManager { .fn() .mockResolvedValue({ access_token: "test-token" }); readonly revokeTokens = vi.fn().mockResolvedValue(undefined); - readonly isLoggedInWithOAuth = vi.fn().mockResolvedValue(false); + readonly canRefreshOAuthSession = vi.fn().mockResolvedValue(false); readonly clearOAuthState = vi.fn().mockResolvedValue(undefined); readonly dispose = vi.fn(); } diff --git a/test/unit/api/authInterceptor.test.ts b/test/unit/api/authInterceptor.test.ts index 94888450f9..4259e6561a 100644 --- a/test/unit/api/authInterceptor.test.ts +++ b/test/unit/api/authInterceptor.test.ts @@ -116,7 +116,7 @@ function createTestContext() { const mockOAuthManager = new MockOAuthSessionManager(); // Default: not logged in with OAuth - mockOAuthManager.isLoggedInWithOAuth.mockResolvedValue(false); + mockOAuthManager.canRefreshOAuthSession.mockResolvedValue(false); /** Sets up OAuth tokens in storage and configures mock */ const setupOAuthTokens = async () => { @@ -129,7 +129,7 @@ function createTestContext() { scope: "workspace:read", }, }); - mockOAuthManager.isLoggedInWithOAuth.mockImplementation( + mockOAuthManager.canRefreshOAuthSession.mockImplementation( async (hostname?: string) => { if (hostname && hostname !== TEST_HOSTNAME) { return false; @@ -556,8 +556,8 @@ describe("AuthInterceptor", () => { await setupOAuthTokens(); - // Make isLoggedInWithOAuth return false for different hostname - mockOAuthManager.isLoggedInWithOAuth.mockImplementation( + // Make canRefreshOAuthSession return false for different hostname + mockOAuthManager.canRefreshOAuthSession.mockImplementation( (hostname?: string) => { // Simulate hostname mismatch (deployment changed) if (hostname === TEST_HOSTNAME) { diff --git a/test/unit/login/loginCoordinator.test.ts b/test/unit/login/loginCoordinator.test.ts index ab413003ce..19335e4124 100644 --- a/test/unit/login/loginCoordinator.test.ts +++ b/test/unit/login/loginCoordinator.test.ts @@ -6,9 +6,11 @@ import { HttpStatusCode } from "@/api/httpStatusCode"; import { getHeaders } from "@/headers"; import { AuthTelemetry } from "@/instrumentation/auth"; import { LoginCoordinator, type LoginMethod } from "@/login/loginCoordinator"; +import { OAuthAuthorizer } from "@/oauth/authorizer"; import { OAuthCallback } from "@/oauth/oauthCallback"; +import { revokeOAuthTokens } from "@/oauth/revocation"; import { MementoManager } from "@/storage/mementoManager"; -import { SecretsManager } from "@/storage/secretsManager"; +import { SecretsManager, type SessionAuth } from "@/storage/secretsManager"; import { maybeAskAuthMethod, maybeAskUrl } from "@/ui/prompts"; import { createTestTelemetryService, TestSink } from "../../mocks/telemetry"; @@ -23,6 +25,10 @@ import { MockProgressReporter, MockUserInteraction, } from "../../mocks/testHelpers"; +import { + createMockClientRegistration, + createMockTokenResponse, +} from "../oauth/testUtils"; import type { User } from "coder/site/src/api/typesGenerated"; @@ -75,6 +81,8 @@ vi.mock("@/ui/prompts", () => ({ maybeAskUrl: vi.fn(), })); +vi.mock("@/oauth/revocation", () => ({ revokeOAuthTokens: vi.fn() })); + // Mock CoderApi to control getAuthenticatedUser behavior const mockGetAuthenticatedUser = vi.hoisted(() => vi.fn()); vi.mock("@/api/coderApi", async (importOriginal) => { @@ -853,6 +861,112 @@ describe("LoginCoordinator", () => { }); }); + describe("replacing an OAuth session", () => { + const registration = createMockClientRegistration(); + const oldSession: SessionAuth = { + url: TEST_URL, + token: "old-token", + username: createMockUser().username, + oauth: { + refresh_token: "old-refresh-token", + scope: "coder:all", + expiry_timestamp: Date.now() + 60 * 60 * 1000, + }, + }; + + async function setup() { + const ctx = createTestContext(); + const user = ctx.mockSuccessfulAuth(); + await ctx.secretsManager.setSessionAuth(TEST_HOSTNAME, oldSession); + await ctx.secretsManager.setOAuthClientRegistration( + TEST_HOSTNAME, + registration, + ); + return { + ...ctx, + user, + login: (token?: string) => + ctx.coordinator.ensureLoggedIn({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token, + }), + stored: () => ctx.secretsManager.getSessionAuth(TEST_HOSTNAME), + }; + } + + it.each([undefined, "old-token"])( + "keeps the OAuth data of a reused session (provided token: %s)", + async (token) => { + const t = await setup(); + + expect(await t.login(token)).toMatchObject({ success: true }); + expect(await t.stored()).toEqual(oldSession); + expect(revokeOAuthTokens).not.toHaveBeenCalled(); + }, + ); + + it.each<{ + name: string; + token: string | undefined; + method: "legacy" | "oauth"; + }>([ + { name: "a manual token", token: undefined, method: "legacy" }, + { name: "a provided token", token: "new-token", method: "legacy" }, + { name: "an OAuth login", token: undefined, method: "oauth" }, + ])( + "revokes the session replaced by $name, after saving it", + async ({ token, method }) => { + const t = await setup(); + const rotated = { ...oldSession, token: "rotated-token" }; + // While the first token is checked, a refresh rotates the session + // and a different client registers; the stored token has expired. + t.mockGetAuthenticatedUser.mockImplementationOnce(async () => { + await t.secretsManager.setSessionAuth(TEST_HOSTNAME, rotated); + await t.secretsManager.setOAuthClientRegistration( + TEST_HOSTNAME, + createMockClientRegistration({ client_id: "other-client" }), + ); + if (!token) { + throw createAxiosError(HttpStatusCode.UNAUTHORIZED, "Expired"); + } + return t.user; + }); + vi.mocked(maybeAskAuthMethod).mockResolvedValue(method); + t.userInteraction.setInputBoxValue("new-token"); + vi.spyOn(OAuthAuthorizer.prototype, "login").mockResolvedValue({ + tokenResponse: createMockTokenResponse({ access_token: "new-token" }), + user: t.user, + }); + let storedAtRevoke: Promise | undefined; + vi.mocked(revokeOAuthTokens).mockImplementation(() => { + storedAtRevoke = t.stored(); + return Promise.resolve(); + }); + + expect(await t.login(token)).toMatchObject({ token: "new-token" }); + expect((await storedAtRevoke)?.token).toBe("new-token"); + expect(revokeOAuthTokens).toHaveBeenCalledExactlyOnceWith( + rotated, + registration, + t.logger, + ); + }, + ); + + it.each([ + { name: "the provided token is invalid", token: "bad-token" }, + { name: "the manual token prompt is cancelled", token: undefined }, + ])("keeps the old session when $name", async ({ token }) => { + const t = await setup(); + t.mockAuthFailure(); + + expect(await t.login(token)).toMatchObject({ success: false }); + expect(await t.stored()).toEqual(oldSession); + expect(revokeOAuthTokens).not.toHaveBeenCalled(); + }); + }); + describe("telemetry", () => { const dialogOptions = (trigger: "auth_required" | "missing_session") => ({ url: TEST_URL, diff --git a/test/unit/oauth/sessionManager.test.ts b/test/unit/oauth/sessionManager.test.ts index 71d38f1d50..58e1d8a3c7 100644 --- a/test/unit/oauth/sessionManager.test.ts +++ b/test/unit/oauth/sessionManager.test.ts @@ -136,14 +136,14 @@ function createTestContext(deployment: Deployment = createTestDeployment()) { } describe("OAuthSessionManager", () => { - describe("isLoggedInWithOAuth", () => { - interface IsLoggedInTestCase { + describe("canRefreshOAuthSession", () => { + interface CanRefreshTestCase { name: string; auth: SessionAuth | null; expected: boolean; } - it.each([ + it.each([ { name: "returns true when OAuth tokens exist", auth: { @@ -177,7 +177,7 @@ describe("OAuthSessionManager", () => { await secretsManager.setSessionAuth(TEST_HOSTNAME, auth); } - const result = await manager.isLoggedInWithOAuth(); + const result = await manager.canRefreshOAuthSession(); expect(result).toBe(expected); }); }); @@ -264,7 +264,7 @@ describe("OAuthSessionManager", () => { }, }); - expect(await manager.isLoggedInWithOAuth()).toBe(false); + expect(await manager.canRefreshOAuthSession()).toBe(false); }); }); @@ -279,7 +279,7 @@ describe("OAuthSessionManager", () => { await manager.setDeployment(newDeployment); - const result = await manager.isLoggedInWithOAuth(); + const result = await manager.canRefreshOAuthSession(); expect(result).toBe(false); }); }); @@ -290,7 +290,7 @@ describe("OAuthSessionManager", () => { manager.clearDeployment(); - const result = await manager.isLoggedInWithOAuth(); + const result = await manager.canRefreshOAuthSession(); expect(result).toBe(false); }); }); @@ -518,7 +518,7 @@ describe("OAuthSessionManager", () => { }); describe("scope validation", () => { - it("rejects tokens with insufficient scopes", async () => { + it("cannot refresh tokens with insufficient scopes", async () => { const { secretsManager, manager } = createTestContext(); await secretsManager.setSessionAuth(TEST_HOSTNAME, { @@ -531,7 +531,7 @@ describe("OAuthSessionManager", () => { }, }); - const result = await manager.isLoggedInWithOAuth(); + const result = await manager.canRefreshOAuthSession(); expect(result).toBe(false); }); @@ -550,7 +550,7 @@ describe("OAuthSessionManager", () => { }, }); - const result = await manager.isLoggedInWithOAuth(); + const result = await manager.canRefreshOAuthSession(); expect(result).toBe(true); }); @@ -558,7 +558,7 @@ describe("OAuthSessionManager", () => { const { manager, setupOAuthSession } = createTestContext(); await setupOAuthSession({ scope: "coder:all" }); - expect(await manager.isLoggedInWithOAuth()).toBe(true); + expect(await manager.canRefreshOAuthSession()).toBe(true); }); });