All of the mid-session and step-up auth work
This commit is contained in:
parent
1f31e0f1f8
commit
562bdd00f1
1 changed files with 11437 additions and 1212 deletions
230
clients/cli/__tests__/cliOAuth.test.ts
Normal file
230
clients/cli/__tests__/cliOAuth.test.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||
import { MutableRedirectUrlProvider } from "@inspector/core/auth/index.js";
|
||||
import * as runnerInteractive from "@inspector/core/auth/node/runner-interactive-oauth.js";
|
||||
import {
|
||||
handleCliAuthRecoveryRequired,
|
||||
isStandardOAuthStepUp,
|
||||
runCliInteractiveOAuth,
|
||||
withCliAuthRecoveryRetry,
|
||||
} from "../src/cliOAuth.js";
|
||||
|
||||
describe("cliOAuth", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("isStandardOAuthStepUp", () => {
|
||||
it("returns true for insufficient_scope on non-EMA servers", () => {
|
||||
expect(
|
||||
isStandardOAuthStepUp(
|
||||
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
|
||||
{},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for EMA servers", () => {
|
||||
expect(
|
||||
isStandardOAuthStepUp(
|
||||
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
|
||||
{ enterpriseManaged: true },
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("runCliInteractiveOAuth writes success to stderr", async () => {
|
||||
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
|
||||
kind: "success",
|
||||
});
|
||||
const client = {
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
};
|
||||
const redirectUrlProvider = new MutableRedirectUrlProvider();
|
||||
const stderrSpy = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation(() => true);
|
||||
|
||||
await runCliInteractiveOAuth(client, redirectUrlProvider, {
|
||||
hostname: "127.0.0.1",
|
||||
port: 6276,
|
||||
pathname: "/oauth/callback",
|
||||
});
|
||||
|
||||
expect(stderrSpy).toHaveBeenCalledWith("Authorization complete.\n");
|
||||
});
|
||||
|
||||
it("runCliInteractiveOAuth throws when scopes remain insufficient", async () => {
|
||||
const challenge = {
|
||||
reason: "insufficient_scope" as const,
|
||||
requiredScopes: ["weather:read"],
|
||||
};
|
||||
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
|
||||
kind: "insufficient_scope",
|
||||
challenge,
|
||||
});
|
||||
const client = {
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
runCliInteractiveOAuth(
|
||||
client,
|
||||
new MutableRedirectUrlProvider(),
|
||||
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||
{ authChallenge: challenge },
|
||||
),
|
||||
).rejects.toThrow(/required scopes were not granted/);
|
||||
});
|
||||
|
||||
it("handleCliAuthRecoveryRequired declines standard step-up when user says no", async () => {
|
||||
const runSpy = vi
|
||||
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||
.mockResolvedValue({ kind: "success" });
|
||||
const client = {
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
};
|
||||
const error = new AuthRecoveryRequiredError(
|
||||
new URL("https://as.example/authorize"),
|
||||
{ reason: "insufficient_scope", requiredScopes: ["weather:read"] },
|
||||
);
|
||||
|
||||
await expect(
|
||||
handleCliAuthRecoveryRequired(
|
||||
client,
|
||||
error,
|
||||
new MutableRedirectUrlProvider(),
|
||||
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||
{},
|
||||
async () => false,
|
||||
),
|
||||
).rejects.toThrow("Step-up authorization declined.");
|
||||
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handleCliAuthRecoveryRequired runs OAuth after step-up confirm", async () => {
|
||||
const runSpy = vi
|
||||
.spyOn(runnerInteractive, "runRunnerInteractiveOAuth")
|
||||
.mockResolvedValue({ kind: "success" });
|
||||
const client = {
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
};
|
||||
const authorizationUrl = new URL("https://as.example/authorize");
|
||||
const challenge = {
|
||||
reason: "insufficient_scope" as const,
|
||||
requiredScopes: ["weather:read"],
|
||||
};
|
||||
const error = new AuthRecoveryRequiredError(authorizationUrl, challenge);
|
||||
|
||||
await handleCliAuthRecoveryRequired(
|
||||
client,
|
||||
error,
|
||||
new MutableRedirectUrlProvider(),
|
||||
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||
{},
|
||||
async () => true,
|
||||
);
|
||||
|
||||
expect(runSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authorizationUrl,
|
||||
authChallenge: challenge,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("handleCliAuthRecoveryRequired skips step-up when storage already satisfies", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const client = {
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const error = new AuthRecoveryRequiredError(
|
||||
new URL("https://as.example/authorize"),
|
||||
{
|
||||
reason: "insufficient_scope",
|
||||
requiredScopes: ["weather:read"],
|
||||
},
|
||||
);
|
||||
|
||||
await handleCliAuthRecoveryRequired(
|
||||
client,
|
||||
error,
|
||||
new MutableRedirectUrlProvider(),
|
||||
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||
);
|
||||
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handleCliAuthRecoveryRequired skips OAuth when storage already satisfies reauth", async () => {
|
||||
const runSpy = vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth");
|
||||
const client = {
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const error = new AuthRecoveryRequiredError(
|
||||
new URL("https://as.example/authorize"),
|
||||
{ reason: "token_expired" },
|
||||
);
|
||||
|
||||
await handleCliAuthRecoveryRequired(
|
||||
client,
|
||||
error,
|
||||
new MutableRedirectUrlProvider(),
|
||||
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||
);
|
||||
|
||||
expect(runSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("withCliAuthRecoveryRetry reruns the operation after interactive recovery", async () => {
|
||||
vi.spyOn(runnerInteractive, "runRunnerInteractiveOAuth").mockResolvedValue({
|
||||
kind: "success",
|
||||
});
|
||||
const client = {
|
||||
authenticate: vi.fn(),
|
||||
beginInteractiveAuthorization: vi.fn(),
|
||||
completeOAuthFlow: vi.fn(),
|
||||
checkAuthChallengeSatisfied: vi.fn(),
|
||||
};
|
||||
const fn = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
new AuthRecoveryRequiredError(new URL("https://as.example/authorize"), {
|
||||
reason: "unauthorized",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce("ok");
|
||||
|
||||
const result = await withCliAuthRecoveryRetry(
|
||||
client,
|
||||
new MutableRedirectUrlProvider(),
|
||||
{ hostname: "127.0.0.1", port: 6276, pathname: "/oauth/callback" },
|
||||
{ enterpriseManaged: true },
|
||||
fn,
|
||||
async () => true,
|
||||
);
|
||||
|
||||
expect(result).toBe("ok");
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue