Guided-auth removal, OAuth smoke testing, and TUI/CLI support (#1531)

* refactor(web): update OAuth E2E tests to use new authentication method

- Replaced `runGuidedAuth` with `authenticate` in the InspectorClient OAuth end-to-end tests to support quick authentication with CIMD pre-registration.
- Updated comments to reflect the change in authentication method and its support for HTTP test metadata URLs.
- Ensured that the tests continue to validate the expected authorization URL after the change.

Additionally, imported `ensureCimdClientRegistration` in the OAuthManager to streamline client registration during the authentication process.

* Guided-auth removal, OAuth smoke testing, and TUI/CLI support

* Added CLI OAuth runner flags tests

* Added clarifying comment on port 0 binding

* README notes on TUI oauth callback port, prettier fixes for CLI tests

* CIMD validation uses inline like EMA IdP

* Fixed TUI test failures from merge

* test: bring 5 OAuth files over the 90 per-file coverage gate

The guided-auth-removal work added/touched several files that fell below
the repo's >=90 per-file coverage gate (npm run coverage). CI doesn't run
the gate (it's local-only), so validate + test:integration passed while
these gaps slipped through. Bring them all back over the line:

- core/auth/node/runner-oauth-callback.ts: test bad-URL/non-http-scheme
  throws, no-port->80 default, and IPv6 hostname bracketing. Annotate the
  provably-dead guards (empty host, out-of-range port, String(err)
  fallback, pathname "/" fallback) -- all rejected by new URL() upstream --
  with justified v8 ignore comments instead of lowering the gate.
- core/auth/cimd.ts: test the AS-doesn't-support-CIMD early return.
- core/react/useClientSettingsDraft.ts: test the functional-updater path,
  the stale-onChange-after-close prev===null guard, and the
  timer-fires-after-close value===null guard.
- ClientSettingsForm.tsx: test the CIMD checkbox toggle and CIMD URL clear.
- clients/tui/src/utils/oauthDisplay.ts: test static/dcr registration
  kinds, the expired IdP session, and the configured-scope/empty-scope
  branches of formatScopes.

npm run coverage now passes (exit 0) across web, cli, tui, launcher.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: cliffhall <cliff@futurescale.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bob Dickinson 2026-06-29 12:35:51 -07:00 • committed by GitHub
commit 13dafe457b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 5702 additions and 4444 deletions

View file

@ -27,3 +27,16 @@ export function getServerType(config: MCPServerConfig): ServerType {
export function isOAuthCapableServerType(type: ServerType): boolean {
return type === "sse" || type === "streamable-http";
}
/**
* MCP server URL used as the OAuth storage key (includes path, for discovery).
* Undefined for stdio transports.
*/
export function getOAuthServerUrl(
config: MCPServerConfig,
): string | undefined {
if (config.type === "sse" || config.type === "streamable-http") {
return config.url;
}
return undefined;
}

View file

@ -11,7 +11,11 @@ export type {
// Re-export type-safe event target types for consumers
export type { InspectorClientEventMap } from "./inspectorClientEventTarget.js";
export { getServerType, isOAuthCapableServerType } from "./config.js";
export {
getOAuthServerUrl,
getServerType,
isOAuthCapableServerType,
} from "./config.js";
// Re-export types used by consumers
export type {

View file

@ -179,6 +179,8 @@ export class InspectorClient extends InspectorClientEventTarget {
private outputValidator: AjvJsonSchemaValidator | null = null;
private transport: Transport | MessageTrackingTransport | null = null;
private baseTransport: Transport | null = null;
/** True when the cached transport was built with an OAuth authProvider attached. */
private transportHasAuthProvider = false;
private pipeStderr: boolean;
private initialLoggingLevel?: LoggingLevel;
private sample: boolean;
@ -308,8 +310,6 @@ export class InspectorClient extends InspectorClientEventTarget {
initialConfig: oauthConfig,
enterpriseManagedAuth: options.enterpriseManagedAuth,
installEnterpriseManagedAuth: options.installEnterpriseManagedAuth,
dispatchOAuthStepChange: (detail) =>
this.dispatchTypedEvent("oauthStepChange", detail),
dispatchOAuthComplete: (detail) =>
this.dispatchTypedEvent("oauthComplete", detail),
dispatchOAuthAuthorizationRequired: (detail) =>
@ -680,6 +680,26 @@ export class InspectorClient extends InspectorClientEventTarget {
return updatedTask;
}
/**
* Drop the cached MCP transport without a full disconnect() teardown.
* Used when a pre-auth connect failed or tokens arrived after an unauthenticated
* transport was created, so the next connect() can attach authProvider.
*/
private async dropCachedTransport(): Promise<void> {
if (!this.baseTransport && !this.transport) {
this.transportHasAuthProvider = false;
return;
}
try {
await this.client?.close();
} catch {
// Ignore errors on close
}
this.baseTransport = null;
this.transport = null;
this.transportHasAuthProvider = false;
}
/**
* Connect to the MCP server
*/
@ -691,6 +711,18 @@ export class InspectorClient extends InspectorClientEventTarget {
return;
}
const oauthManager = this.oauthManager;
if (
this.baseTransport &&
this.isHttpOAuthConfig() &&
oauthManager &&
!this.transportHasAuthProvider &&
!oauthManager.isEnterpriseManaged() &&
(await oauthManager.isOAuthAuthorized())
) {
await this.dropCachedTransport();
}
// Create transport (single place for create / wrap / attach).
if (!this.baseTransport) {
const transportOptions: CreateTransportOptions = {
@ -707,13 +739,10 @@ export class InspectorClient extends InspectorClientEventTarget {
},
...(this.serverSettings && { settings: this.serverSettings }),
};
const oauthManager = this.oauthManager;
if (this.isHttpOAuthConfig() && oauthManager) {
if (oauthManager.isEnterpriseManaged()) {
await oauthManager.trySilentEnterpriseManagedAuth();
}
const provider = await oauthManager.createOAuthProviderForTransport();
if (oauthManager.isEnterpriseManaged()) {
const provider = await oauthManager.createOAuthProviderForTransport();
const tokens = await provider.tokens();
if (!tokens?.access_token) {
const err = new Error(
@ -723,9 +752,16 @@ export class InspectorClient extends InspectorClientEventTarget {
err.code = 401;
throw err;
}
transportOptions.authProvider = provider;
} else if (await oauthManager.isOAuthAuthorized()) {
// Without stored tokens, omit authProvider so connect() surfaces a plain
// 401 instead of the SDK opening a browser before the app callback
// server is listening (TUI/CLI run authenticate() explicitly).
transportOptions.authProvider =
await oauthManager.createOAuthProviderForTransport();
}
transportOptions.authProvider = provider;
}
this.transportHasAuthProvider = !!transportOptions.authProvider;
const { transport: baseTransport } = this.transportClientFactory(
this.transportConfig,
transportOptions,
@ -1082,6 +1118,9 @@ export class InspectorClient extends InspectorClientEventTarget {
} catch (error) {
this.status = "error";
this.dispatchTypedEvent("statusChange", this.status);
if (this.baseTransport && !this.transportHasAuthProvider) {
await this.dropCachedTransport();
}
// Deliberately do NOT dispatch the `error` event here: this is the
// awaited `connect()` path, so re-throwing hands the reason straight to
// the caller. The `error` event is reserved for non-awaited transitions
@ -1143,6 +1182,7 @@ export class InspectorClient extends InspectorClientEventTarget {
// Null out transport so next connect() creates a fresh one.
this.baseTransport = null;
this.transport = null;
this.transportHasAuthProvider = false;
// Update status - any onclose fired during close() above deferred to us
// (see `disconnecting`), so this is the single place the explicit-disconnect
// path settles the status and emits `disconnect`.
@ -2673,54 +2713,15 @@ export class InspectorClient extends InspectorClientEventTarget {
}
/**
* Initiates OAuth flow using SDK's auth() function (normal mode)
* Can be called directly by user or automatically triggered by 401 errors
* Initiates OAuth flow. Can be called directly by user or automatically
* triggered by 401 errors.
*/
async authenticate(): Promise<URL | undefined> {
return this.ensureOAuthManager().authenticate();
}
/**
* Starts guided OAuth flow (step-by-step). Runs only the first step.
* Use proceedOAuthStep() to advance. When oauthStep is "authorization_code",
* set authorizationCode and call proceedOAuthStep() to complete.
*/
async beginGuidedAuth(): Promise<void> {
return this.ensureOAuthManager().beginGuidedAuth();
}
/**
* Runs guided OAuth flow to completion. If already started (via beginGuidedAuth),
* continues from current step. Otherwise initializes and runs from the start.
* Returns the authorization URL when user must authorize, or undefined if already complete.
*/
async runGuidedAuth(): Promise<URL | undefined> {
return this.ensureOAuthManager().runGuidedAuth();
}
/**
* Set authorization code for guided OAuth flow.
* Validates that the client is in guided OAuth mode (has active state machine).
* @param authorizationCode The authorization code from the OAuth callback
* @param completeFlow If true, automatically proceed through all remaining steps to completion.
* If false, only set the code and wait for manual progression via proceedOAuthStep().
* Defaults to false for manual step-by-step control.
* @throws Error if not in guided OAuth flow or not at authorization_code step
*/
async setGuidedAuthorizationCode(
authorizationCode: string,
completeFlow: boolean = false,
): Promise<void> {
return this.ensureOAuthManager().setGuidedAuthorizationCode(
authorizationCode,
completeFlow,
);
}
/**
* Completes OAuth flow with authorization code.
* For guided mode, this calls setGuidedAuthorizationCode(code, true) internally.
* For normal mode, uses SDK auth() directly.
* Completes OAuth flow with authorization code from the redirect callback.
*/
async completeOAuthFlow(authorizationCode: string): Promise<void> {
return this.ensureOAuthManager().completeOAuthFlow(authorizationCode);
@ -2754,17 +2755,14 @@ export class InspectorClient extends InspectorClientEventTarget {
}
/**
* In-memory OAuth flow snapshot (quick or guided). Undefined when no flow
* has run on this client instance; use {@link getOAuthState} for persisted
* authorization state.
* In-memory OAuth flow snapshot. Undefined when no flow has run on this
* client instance; use {@link getOAuthState} for persisted authorization state.
*/
getOAuthFlowState(): OAuthFlowState | undefined {
return this.oauthManager?.getOAuthFlowState();
}
/**
* Current step when an OAuth flow is active (quick or guided).
*/
/** Current step when an OAuth flow is active. */
getOAuthFlowStep(): OAuthStep | undefined {
return this.oauthManager?.getOAuthFlowStep();
}
@ -2779,11 +2777,4 @@ export class InspectorClient extends InspectorClientEventTarget {
}
return this.oauthManager.getOAuthState();
}
/**
* Manually progress to next step in guided OAuth flow
*/
async proceedOAuthStep(): Promise<void> {
return this.ensureOAuthManager().proceedOAuthStep();
}
}

View file

@ -39,7 +39,6 @@ import type { SamplingCreateMessage } from "./samplingCreateMessage.js";
import type { ElicitationCreateMessage } from "./elicitationCreateMessage.js";
import type { JsonValue } from "../json/jsonUtils.js";
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
import type { OAuthStep, OAuthFlowState } from "../auth/types.js";
/** Task with createdAt optional so we can emit synthetic tasks (e.g. on result/error) that omit it. */
export type TaskWithOptionalCreatedAt = Omit<Task, "createdAt"> & {
@ -146,11 +145,6 @@ export interface InspectorClientEventMap {
// Session persistence (dispatched by client; FetchRequestLogState listens and saves)
saveSession: { sessionId: string };
// OAuth events (#1302 — fired by the ported oauthManager / InspectorClient)
oauthStepChange: {
step: OAuthStep;
previousStep: OAuthStep;
state: Partial<OAuthFlowState>;
};
oauthComplete: { tokens: OAuthTokens };
oauthAuthorizationRequired: { url: URL };
oauthError: { error: Error };

View file

@ -19,4 +19,5 @@ export {
type ResolvedServer,
type ServerLoadOptions,
} from "./servers.js";
export { rehydrateMcpConfigFromKeychain } from "./server-secrets.js";
export { createTransportNode } from "./transport.js";

View file

@ -0,0 +1,34 @@
import type { SecretStore } from "../../auth/node/secret-store.js";
import {
expectedSecretFields,
mergeSecretsIntoStored,
} from "../serverList.js";
import type { MCPConfig } from "../types.js";
/**
* Merge per-server secrets from the OS keychain into an on-disk MCP catalog
* shape. Mirrors the web `/api/servers` GET rehydration path so TUI/CLI see
* the same effective OAuth client secrets and stdio env values as the browser.
*/
export async function rehydrateMcpConfigFromKeychain(
config: MCPConfig,
secretStore: SecretStore,
): Promise<MCPConfig> {
const out: MCPConfig = { mcpServers: {} };
await Promise.all(
Object.entries(config.mcpServers).map(async ([id, stored]) => {
const fields = expectedSecretFields(stored);
const secrets: Record<string, string> = {};
await Promise.all(
fields.map(async (field) => {
const value = await secretStore.get(id, field);
if (value !== null) {
secrets[field] = value;
}
}),
);
out.mcpServers[id] = mergeSecretsIntoStored(stored, secrets);
}),
);
return out;
}

View file

@ -1,3 +1,7 @@
import {
KeyringSecretStore,
type SecretStore,
} from "../../auth/node/secret-store.js";
import type { InspectorServerSettings, MCPServerConfig } from "../types.js";
import { DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_TASK_TTL_MS } from "../types.js";
import { mcpConfigToServerEntries } from "../serverList.js";
@ -11,6 +15,7 @@ import {
withDefaultCatalogPath,
type ServerConfigOptions,
} from "./config.js";
import { rehydrateMcpConfigFromKeychain } from "./server-secrets.js";
/**
* A server resolved from a catalog/config file or an ad-hoc target, paired with
@ -27,6 +32,8 @@ export type ResolvedServer = {
* broadcast into every resolved server's settings. */
export type ServerLoadOptions = ServerConfigOptions & {
headers?: Record<string, string>;
/** Test injection; defaults to {@link KeyringSecretStore} for catalog/config loads. */
secretStore?: SecretStore;
};
/**
@ -78,9 +85,9 @@ function mergeSettings(
* enforces the `--catalog`/`--config`/ad-hoc conflict matrix, and seeds an empty
* writable catalog on first run (a missing read-only `--config` still errors).
*/
export function loadServerEntries(
export async function loadServerEntries(
serverOptions: ServerLoadOptions,
): Record<string, ResolvedServer> {
): Promise<Record<string, ResolvedServer>> {
serverOptions = withDefaultCatalogPath(serverOptions);
const conflict = serverSourceConflict({
@ -95,7 +102,9 @@ export function loadServerEntries(
const source = resolveServerSource(serverOptions);
if (source) {
const config = readServerListFile(source.path, source.writable);
let config = readServerListFile(source.path, source.writable);
const secretStore = serverOptions.secretStore ?? new KeyringSecretStore();
config = await rehydrateMcpConfigFromKeychain(config, secretStore);
const entries = mcpConfigToServerEntries(config);
const result: Record<string, ResolvedServer> = {};
for (const entry of entries) {

View file

@ -1,13 +1,12 @@
/**
* Internal OAuth sub-manager for InspectorClient.
* Holds OAuth config, state machine, and guided state; orchestrates quick and guided flows.
* Holds OAuth config and in-memory flow state; orchestrates authenticate() / completeOAuthFlow().
* Not part of the public API; InspectorClient delegates to this module.
*/
import { BaseOAuthClientProvider } from "../auth/providers.js";
import type { AuthExecution, OAuthFlowState, OAuthStep } from "../auth/types.js";
import type { OAuthFlowState, OAuthStep } from "../auth/types.js";
import { EMPTY_OAUTH_FLOW_STATE } from "../auth/types.js";
import { OAuthStateMachine } from "../auth/state-machine.js";
import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
import type { OAuthClientInformation } from "@modelcontextprotocol/sdk/shared/auth.js";
@ -24,9 +23,11 @@ import {
} from "../auth/ema/emaFlow.js";
import {
buildOAuthConnectionState,
hasPersistedOAuthServerState,
isServerOAuthConfigured,
protocolFromOAuthConfig,
} from "../auth/connection-state.js";
import { ensureCimdClientRegistration } from "../auth/cimd.js";
import type { OAuthConnectionState } from "../auth/types.js";
import { EmaTransportOAuthProvider } from "../auth/ema/transportProvider.js";
import type {
@ -46,11 +47,6 @@ export interface OAuthManagerParams {
enterpriseManagedAuth?: { idp: EnterpriseManagedAuthIdpConfig };
/** Install-level EMA block (including when disabled) for user-facing errors. */
installEnterpriseManagedAuth?: ClientConfig["enterpriseManagedAuth"];
dispatchOAuthStepChange: (detail: {
step: OAuthStep;
previousStep: OAuthStep;
state: Partial<OAuthFlowState>;
}) => void;
dispatchOAuthComplete: (detail: { tokens: OAuthTokens }) => void;
dispatchOAuthAuthorizationRequired: (detail: { url: URL }) => void;
dispatchOAuthError: (detail: { error: Error }) => void;
@ -63,7 +59,6 @@ export interface OAuthManagerParams {
export class OAuthManager {
private params: OAuthManagerParams;
private oauthConfig: OAuthManagerConfig;
private oauthStateMachine: OAuthStateMachine | null = null;
private oauthFlowState: OAuthFlowState | null = null;
constructor(params: OAuthManagerParams) {
@ -88,9 +83,7 @@ export class OAuthManager {
return this.params.getServerUrl();
}
private async createOAuthProvider(
execution: AuthExecution,
): Promise<BaseOAuthClientProvider> {
private async createOAuthProvider(): Promise<BaseOAuthClientProvider> {
if (
!this.oauthConfig.storage ||
!this.oauthConfig.redirectUrlProvider ||
@ -102,16 +95,12 @@ export class OAuthManager {
}
const serverUrl = this.getServerUrl();
const provider = new BaseOAuthClientProvider(
serverUrl,
{
storage: this.oauthConfig.storage,
redirectUrlProvider: this.oauthConfig.redirectUrlProvider,
navigation: this.oauthConfig.navigation,
clientMetadataUrl: this.oauthConfig.clientMetadataUrl,
},
execution,
);
const provider = new BaseOAuthClientProvider(serverUrl, {
storage: this.oauthConfig.storage,
redirectUrlProvider: this.oauthConfig.redirectUrlProvider,
navigation: this.oauthConfig.navigation,
clientMetadataUrl: this.oauthConfig.clientMetadataUrl,
});
provider.setEventTarget(this.params.getEventTarget());
@ -158,7 +147,7 @@ export class OAuthManager {
resourceClientId: this.oauthConfig.clientId,
resourceClientSecret: this.oauthConfig.clientSecret,
scope: this.oauthConfig.scope,
redirectUrl: this.oauthConfig.redirectUrlProvider.getRedirectUrl("quick"),
redirectUrl: this.oauthConfig.redirectUrlProvider.getRedirectUrl(),
storage: this.oauthConfig.storage,
fetchFn: this.params.effectiveAuthFetch,
};
@ -195,7 +184,6 @@ export class OAuthManager {
this.oauthConfig.navigation!.navigateToAuthorization(authorizationUrl);
this.oauthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
execution: "quick",
oauthStep: "authorization_code",
authorizationUrl,
};
@ -207,11 +195,17 @@ export class OAuthManager {
return this.authenticateEnterpriseManaged();
}
const provider = await this.createOAuthProvider("quick");
const provider = await this.createOAuthProvider();
const serverUrl = this.getServerUrl();
provider.clearCapturedAuthUrl();
await ensureCimdClientRegistration({
serverUrl,
provider,
fetchFn: this.params.effectiveAuthFetch,
});
const result = await auth(provider, {
serverUrl,
scope: provider.scope,
@ -240,7 +234,6 @@ export class OAuthManager {
const clientInfo = await provider.clientInformation();
this.oauthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
execution: "quick",
oauthStep: "authorization_code",
authorizationUrl: capturedUrl,
oauthClientInfo: clientInfo ?? null,
@ -248,132 +241,6 @@ export class OAuthManager {
return capturedUrl;
}
async beginGuidedAuth(): Promise<void> {
const provider = await this.createOAuthProvider("guided");
const serverUrl = this.getServerUrl();
this.oauthFlowState = { ...EMPTY_OAUTH_FLOW_STATE };
if (this.oauthConfig.clientId) {
this.oauthFlowState.oauthClientInfo = {
client_id: this.oauthConfig.clientId,
...(this.oauthConfig.clientSecret && {
client_secret: this.oauthConfig.clientSecret,
}),
};
}
this.oauthStateMachine = new OAuthStateMachine(
serverUrl,
provider,
(updates) => {
const state = this.oauthFlowState;
if (!state) throw new Error("OAuth state not initialized");
const previousStep = state.oauthStep;
this.oauthFlowState = { ...state, ...updates };
if (updates.oauthStep === "complete") {
this.oauthFlowState!.completedAt = Date.now();
}
const step = updates.oauthStep ?? previousStep;
this.params.dispatchOAuthStepChange({
step,
previousStep,
state: updates,
});
},
this.params.effectiveAuthFetch,
);
await this.oauthStateMachine.executeStep(this.oauthFlowState);
}
async runGuidedAuth(): Promise<URL | undefined> {
if (!this.oauthStateMachine || !this.oauthFlowState) {
await this.beginGuidedAuth();
}
const machine = this.oauthStateMachine;
if (!machine) {
throw new Error("Guided auth failed to initialize state");
}
while (true) {
const state = this.oauthFlowState;
if (!state) {
throw new Error("Guided auth failed to initialize state");
}
if (
state.oauthStep === "authorization_code" ||
state.oauthStep === "complete"
) {
break;
}
await machine.executeStep(state);
}
const state = this.oauthFlowState;
if (state?.oauthStep === "complete") {
return undefined;
}
if (!state?.authorizationUrl) {
throw new Error("Failed to generate authorization URL");
}
const stateParam = state.authorizationUrl.searchParams.get("state");
if (stateParam && this.params.onBeforeOAuthRedirect) {
const parsedState = parseOAuthState(stateParam);
if (parsedState?.authId) {
await this.params.onBeforeOAuthRedirect(parsedState.authId);
}
}
this.params.dispatchOAuthAuthorizationRequired({
url: state.authorizationUrl,
});
return state.authorizationUrl;
}
async setGuidedAuthorizationCode(
authorizationCode: string,
completeFlow: boolean = false,
): Promise<void> {
if (!this.oauthStateMachine || !this.oauthFlowState) {
throw new Error(
"Not in guided OAuth flow. Call beginGuidedAuth() first.",
);
}
const currentStep = this.oauthFlowState.oauthStep;
if (currentStep !== "authorization_code") {
throw new Error(
`Cannot set authorization code at step ${currentStep}. Expected step: authorization_code`,
);
}
this.oauthFlowState.authorizationCode = authorizationCode;
if (completeFlow) {
await this.oauthStateMachine.executeStep(this.oauthFlowState);
let step: OAuthStep = this.oauthFlowState.oauthStep;
while (step !== "complete") {
await this.oauthStateMachine.executeStep(this.oauthFlowState);
step = this.oauthFlowState.oauthStep;
}
if (!this.oauthFlowState.oauthTokens) {
throw new Error("Failed to exchange authorization code for tokens");
}
this.params.dispatchOAuthComplete({
tokens: this.oauthFlowState.oauthTokens,
});
} else {
this.params.dispatchOAuthStepChange({
step: this.oauthFlowState.oauthStep,
previousStep: this.oauthFlowState.oauthStep,
state: { authorizationCode },
});
}
}
async completeOAuthFlow(authorizationCode: string): Promise<void> {
try {
if (this.isEnterpriseManaged()) {
@ -385,7 +252,6 @@ export class OAuthManager {
const completedAt = Date.now();
this.oauthFlowState = {
...EMPTY_OAUTH_FLOW_STATE,
execution: "quick",
oauthStep: "complete",
oauthTokens: tokens,
completedAt,
@ -394,50 +260,45 @@ export class OAuthManager {
return;
}
if (this.oauthStateMachine && this.oauthFlowState) {
await this.setGuidedAuthorizationCode(authorizationCode, true);
} else {
const provider = await this.createOAuthProvider("quick");
const serverUrl = this.getServerUrl();
const provider = await this.createOAuthProvider();
const serverUrl = this.getServerUrl();
const result = await auth(provider, {
serverUrl,
authorizationCode,
fetchFn: this.params.effectiveAuthFetch,
});
const result = await auth(provider, {
serverUrl,
authorizationCode,
fetchFn: this.params.effectiveAuthFetch,
});
if (result !== "AUTHORIZED") {
throw new Error(
`Expected AUTHORIZED after providing authorization code, got: ${result}`,
);
}
const tokens = await provider.tokens();
if (!tokens) {
throw new Error("Failed to retrieve tokens after authorization");
}
const clientInfo = await provider.clientInformation();
const completedAt = Date.now();
this.oauthFlowState = this.oauthFlowState
? {
...this.oauthFlowState,
oauthStep: "complete",
oauthTokens: tokens,
oauthClientInfo: clientInfo ?? null,
completedAt,
}
: {
...EMPTY_OAUTH_FLOW_STATE,
execution: "quick",
oauthStep: "complete",
oauthTokens: tokens,
oauthClientInfo: clientInfo ?? null,
completedAt,
};
this.params.dispatchOAuthComplete({ tokens });
if (result !== "AUTHORIZED") {
throw new Error(
`Expected AUTHORIZED after providing authorization code, got: ${result}`,
);
}
const tokens = await provider.tokens();
if (!tokens) {
throw new Error("Failed to retrieve tokens after authorization");
}
const clientInfo = await provider.clientInformation();
const completedAt = Date.now();
this.oauthFlowState = this.oauthFlowState
? {
...this.oauthFlowState,
oauthStep: "complete",
oauthTokens: tokens,
oauthClientInfo: clientInfo ?? null,
completedAt,
}
: {
...EMPTY_OAUTH_FLOW_STATE,
oauthStep: "complete",
oauthTokens: tokens,
oauthClientInfo: clientInfo ?? null,
completedAt,
};
this.params.dispatchOAuthComplete({ tokens });
} catch (error) {
this.params.dispatchOAuthError({
error: error instanceof Error ? error : new Error(String(error)),
@ -451,7 +312,7 @@ export class OAuthManager {
return this.oauthFlowState.oauthTokens;
}
const provider = await this.createOAuthProvider("quick");
const provider = await this.createOAuthProvider();
try {
return await provider.tokens();
} catch {
@ -468,7 +329,6 @@ export class OAuthManager {
this.oauthConfig.storage.clear(serverUrl);
this.oauthFlowState = null;
this.oauthStateMachine = null;
}
async isOAuthAuthorized(): Promise<boolean> {
@ -489,7 +349,17 @@ export class OAuthManager {
* Returns undefined when OAuth is not configured for the server.
*/
async getOAuthState(): Promise<OAuthConnectionState | undefined> {
if (!isServerOAuthConfigured(this.oauthConfig)) {
const storage = this.oauthConfig.storage;
if (!storage) {
return undefined;
}
const serverUrl = this.getServerUrl();
const hasConfiguredOptions = isServerOAuthConfigured(this.oauthConfig);
if (
!hasConfiguredOptions &&
!(await hasPersistedOAuthServerState(storage, serverUrl))
) {
return undefined;
}
@ -503,16 +373,6 @@ export class OAuthManager {
});
}
async proceedOAuthStep(): Promise<void> {
if (!this.oauthStateMachine || !this.oauthFlowState) {
throw new Error(
"Not in guided OAuth flow. Call authenticateGuided() first.",
);
}
await this.oauthStateMachine.executeStep(this.oauthFlowState);
}
/**
* Re-run EMA legs 2–3 when resource tokens expire but IdP session remains valid.
*/
@ -529,7 +389,7 @@ export class OAuthManager {
async createOAuthProviderForTransport(): Promise<
BaseOAuthClientProvider | EmaTransportOAuthProvider
> {
const provider = await this.createOAuthProvider("quick");
const provider = await this.createOAuthProvider();
if (this.isEnterpriseManaged()) {
return new EmaTransportOAuthProvider(provider, this.getEmaFlowConfig());
}