* 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>
171 lines
5.3 KiB
TypeScript
171 lines
5.3 KiB
TypeScript
/**
|
|
* Browser-safe client.json parse/validate (no Node file I/O).
|
|
*/
|
|
|
|
import { z } from "zod";
|
|
import type { ClientConfig } from "./types.js";
|
|
|
|
/**
|
|
* True when `value` (trimmed) is an absolute `http:`/`https:` URL with a real
|
|
* host. An OAuth IdP issuer is always http(s), so other parseable schemes
|
|
* (`mailto:`, `foo:bar`, `javascript:`) are rejected rather than deferred to a
|
|
* later connect failure. Beyond "parses + http(s)", the host must actually look
|
|
* like a host — a dotted domain or IP, or `localhost` — so bare or degenerate
|
|
* values the URL parser still accepts (`https://foo`, `https://.`, `https://..`,
|
|
* `https://example`) are rejected too.
|
|
*/
|
|
export function isAbsoluteHttpUrl(value: string): boolean {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(value.trim());
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
return isRealHost(url.hostname);
|
|
}
|
|
|
|
/**
|
|
* A hostname the URL parser produced that we accept as an actual host:
|
|
* - `localhost` (common in dev),
|
|
* - an IPv6 literal (arrives bracketed, e.g. `[::1]`),
|
|
* - otherwise a dotted name / IPv4 with at least two non-empty labels — which
|
|
* rejects single-label (`foo`), bare-dot (`.`, `..`) and empty-label
|
|
* (`a..b`, `.a`, `a.`) hosts. An empty hostname splits to `[""]` and is
|
|
* rejected here too (http(s) URLs can't reach this with an empty host).
|
|
*/
|
|
function isRealHost(hostname: string): boolean {
|
|
if (hostname === "localhost") return true;
|
|
if (hostname.startsWith("[")) return hostname.endsWith("]");
|
|
const labels = hostname.split(".");
|
|
return labels.length >= 2 && labels.every((label) => label !== "");
|
|
}
|
|
|
|
const HttpUrlStringSchema = z
|
|
.string()
|
|
.min(1)
|
|
.superRefine((val, ctx) => {
|
|
const trimmed = val.trim();
|
|
if (!isAbsoluteHttpUrl(trimmed)) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: `Invalid URL: "${trimmed}" — must be an http(s) URL (e.g. https://idp.example.com)`,
|
|
});
|
|
}
|
|
});
|
|
|
|
/** Field-level error when a CIMD metadata URL is not a parseable http(s) URL. */
|
|
export const CIMD_METADATA_URL_INVALID_ERROR =
|
|
"Must be a valid URL, like https://example.com/oauth/client.json";
|
|
|
|
/** Field-level error when a CIMD metadata URL is not HTTPS. */
|
|
export const CIMD_METADATA_URL_HTTPS_ERROR =
|
|
"CIMD client metadata URL must use HTTPS";
|
|
|
|
/** Field-level error when a CIMD metadata URL has no path segment. */
|
|
export const CIMD_METADATA_URL_PATH_ERROR =
|
|
"Must include a path (not the site root), like https://example.com/oauth/client.json";
|
|
|
|
/**
|
|
* Inline / form validation for a non-empty CIMD client metadata URL.
|
|
* Returns undefined when the value is valid; empty strings are not flagged here
|
|
* (required-field gating lives in {@link canPersistClientSettingsDraft}).
|
|
*/
|
|
export function getCimdClientMetadataUrlError(value: string): string | undefined {
|
|
const trimmed = value.trim();
|
|
if (trimmed === "") return undefined;
|
|
if (!isAbsoluteHttpUrl(trimmed)) {
|
|
return CIMD_METADATA_URL_INVALID_ERROR;
|
|
}
|
|
try {
|
|
const url = new URL(trimmed);
|
|
if (url.protocol !== "https:") {
|
|
return CIMD_METADATA_URL_HTTPS_ERROR;
|
|
}
|
|
if (url.pathname === "/" || url.pathname === "") {
|
|
return CIMD_METADATA_URL_PATH_ERROR;
|
|
}
|
|
} catch {
|
|
return CIMD_METADATA_URL_INVALID_ERROR;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function refineCimdMetadataUrl(
|
|
val: string,
|
|
ctx: z.RefinementCtx,
|
|
required: boolean,
|
|
): void {
|
|
const trimmed = val.trim();
|
|
if (trimmed === "") {
|
|
if (required) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: "CIMD client metadata URL is required when CIMD is enabled",
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
const error = getCimdClientMetadataUrlError(trimmed);
|
|
if (error) {
|
|
ctx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
message: error,
|
|
});
|
|
}
|
|
}
|
|
|
|
const CimdConfigSchema = z
|
|
.object({
|
|
enabled: z.boolean(),
|
|
clientMetadataUrl: z.string(),
|
|
})
|
|
.superRefine((data, ctx) => {
|
|
refineCimdMetadataUrl(data.clientMetadataUrl, ctx, data.enabled === true);
|
|
});
|
|
|
|
const EnterpriseManagedAuthIdpConfigSchema = z.object({
|
|
issuer: HttpUrlStringSchema,
|
|
clientId: z.string().min(1),
|
|
clientSecret: z.string().optional(),
|
|
});
|
|
|
|
const ClientConfigSchema = z.object({
|
|
enterpriseManagedAuth: z
|
|
.object({
|
|
enabled: z.boolean().optional(),
|
|
idp: EnterpriseManagedAuthIdpConfigSchema,
|
|
})
|
|
.optional(),
|
|
cimd: CimdConfigSchema.optional(),
|
|
});
|
|
|
|
/**
|
|
* Parse and validate unknown JSON into {@link ClientConfig}.
|
|
* @throws {z.ZodError} when shape is invalid
|
|
*/
|
|
export function parseClientConfig(raw: unknown): ClientConfig {
|
|
return ClientConfigSchema.parse(raw);
|
|
}
|
|
|
|
/** Canonical JSON serialization for client.json (matches backend store-io format). */
|
|
export function serializeClientConfig(config: ClientConfig): string {
|
|
return JSON.stringify(config, null, 2);
|
|
}
|
|
|
|
/** Human-readable message for a failed client.json load (parse or transport). */
|
|
export function formatClientConfigLoadError(error: unknown): string {
|
|
if (error instanceof z.ZodError) {
|
|
const issue = error.issues[0];
|
|
if (issue) {
|
|
const field =
|
|
issue.path.length > 0 ? issue.path.map(String).join(".") : "config";
|
|
return `${field}: ${issue.message}`;
|
|
}
|
|
return "Invalid client.json shape";
|
|
}
|
|
if (error instanceof Error) {
|
|
return error.message;
|
|
}
|
|
return String(error);
|
|
}
|