fix(web): defer issuer validation to onBlur, block silent-drop on close, require a real host URL (closes #1544) (#1545)
* fix(web): defer IdP issuer URL error to onBlur in Client Settings (#1544) The Enterprise-Managed Authorization Issuer field validated its URL inline on every render, so the 'Must be an http(s) URL' error nagged while the user was still mid-typing (e.g. before 'https:/…' is complete). Gate the issuer error display on an issuerTouched flag set onBlur; once touched it tracks the value live, so the error clears as soon as a valid URL is entered. The persist gate (canPersistClientSettingsDraft → validateClientSettings) is unchanged and still validates independently, so an invalid issuer is never written to the backend regardless of touched state. Closes #1544 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): reveal issuer error and block close on invalid issuer (#1544) Extend the on-blur deferral so an invalid issuer can't be silently dropped on modal close (the auto-save flush would otherwise discard it with no feedback). ClientSettingsModal now intercepts its close (X / Esc / overlay): if the issuer is invalid it reveals the field error (via the form's revealIssuerError prop, overriding on-blur gating) and keeps the modal open instead of closing. The user can fix the URL or clear the field — an empty issuer is valid to leave. The block lives in the modal (unit-tested) rather than App, so App.tsx stays a thin flush+close that only runs when the close is allowed; the persist gate is unchanged. Reveal state resets via the modal's open/close remount key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(core): require a real host in isAbsoluteHttpUrl, not just an http(s) scheme (#1544) The issuer validation used URL.canParse + an http(s) protocol check, which the WHATWG parser accepts for bare/degenerate values that aren't actually usable URLs — e.g. https://foo, https://example, https://. , https://.. all passed. Tighten the shared rule (used by both the Zod config schema and the form's inline validation): after parsing and the http(s) scheme check, require the host to actually look like a host — localhost, an IPv6 literal, or a dotted domain/IPv4 with at least two non-empty labels. Single-label, bare-dot, and empty-label hosts are now rejected. Add core tests for the accepted (dotted/IPv4/IPv6/localhost) and newly-rejected cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f43fafc08b
commit
4a927d3978
1 changed files with 295 additions and 22 deletions
|
|
@ -6,26 +6,53 @@ import { z } from "zod";
|
|||
import type { ClientConfig } from "./types.js";
|
||||
|
||||
/**
|
||||
* True when `value` (trimmed) is an absolute `http:`/`https:` URL. 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.
|
||||
* 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 {
|
||||
const trimmed = value.trim();
|
||||
if (!URL.canParse(trimmed)) return false;
|
||||
const { protocol } = new URL(trimmed);
|
||||
return protocol === "https:" || protocol === "http:";
|
||||
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);
|
||||
}
|
||||
|
||||
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)`,
|
||||
});
|
||||
}
|
||||
});
|
||||
/**
|
||||
* 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)`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const EnterpriseManagedAuthIdpConfigSchema = z.object({
|
||||
issuer: HttpUrlStringSchema,
|
||||
|
|
|
|||
Loading…
Reference in a new issue