diff --git a/dtwo/.claude-plugin/plugin.json b/dtwo/.claude-plugin/plugin.json index 7d32bab..ded6de7 100644 --- a/dtwo/.claude-plugin/plugin.json +++ b/dtwo/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dtwo", - "version": "1.3.4", + "version": "1.3.5", "description": "Manage Dtwo gateways, policies, and Rego with the Dtwo MCP server.", "author": { "name": "Dtwo", diff --git a/dtwo/.codex-plugin/plugin.json b/dtwo/.codex-plugin/plugin.json index 97ea777..120c9d9 100644 --- a/dtwo/.codex-plugin/plugin.json +++ b/dtwo/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dtwo", - "version": "1.3.4", + "version": "1.3.5", "description": "Manage Dtwo gateways, policies, and Rego with the Dtwo MCP server.", "author": { "name": "Dtwo", diff --git a/dtwo/.cursor-plugin/plugin.json b/dtwo/.cursor-plugin/plugin.json index 34ef67a..8f9b37d 100644 --- a/dtwo/.cursor-plugin/plugin.json +++ b/dtwo/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dtwo", - "version": "1.3.4", + "version": "1.3.5", "description": "Manage Dtwo gateways, policies, and Rego with the Dtwo MCP server.", "author": { "name": "Dtwo", diff --git a/dtwo/skills/dtwo-gateway-config/SKILL.md b/dtwo/skills/dtwo-gateway-config/SKILL.md index cbf59bb..aa2710d 100644 --- a/dtwo/skills/dtwo-gateway-config/SKILL.md +++ b/dtwo/skills/dtwo-gateway-config/SKILL.md @@ -180,7 +180,7 @@ All 4 fields are required when this object is present. These govern the **inboun |---|---|---|---|---| | `jwt_algorithm` | yes | enum: `HS256`/`HS384`/`HS512`/`RS256`/`RS384`/`RS512`/`ES256`/`ES384`/`ES512` | `JWT_ALGORITHM` | Set to the asymmetric algorithm your identity provider signs with — RS256 for most (Auth0, Entra), ES256 for elliptic-curve tenants. The HS256/HS384/HS512 values are symmetric and are rejected here: `jwks_info` always implies a JWKS endpoint, which publishes public keys only, and the gateway refuses to start on that combination. | | `jwt_jwks_uri` | yes | URL | `JWT_JWKS_URI` | Set to the JWKS URL your identity provider publishes; the gateway fetches public keys from here to validate incoming tokens. Must begin with a lowercase `https://` — the gateway rejects every other scheme, `file://` included, as an SSRF and key-substitution vector, and refuses to start. | -| `jwt_issuer` | yes | string | `JWT_ISSUER` | Set to the `iss` claim your provider emits; the gateway rejects tokens whose `iss` does not match. Use the `https://` form — the gateway dereferences this URL for `/userinfo` token introspection and refuses to start on `http://`. Auth0 issuers carry a trailing slash and the comparison is exact, so `https://tenant.us.auth0.com/` and `https://tenant.us.auth0.com` are different values. | +| `jwt_issuer` | yes | string | `JWT_ISSUER` | Set to the `iss` claim your provider emits; the gateway rejects tokens whose `iss` does not match. Use the `https://` form — the gateway dereferences this URL for `/userinfo` token introspection and refuses to start on `http://`. Auth0 issuers carry a trailing slash and the comparison is exact, so `https://tenant.us.auth0.com/` and `https://tenant.us.auth0.com` are different values. With authentication enabled, a value the gateway env file would read differently is rejected: surrounding spaces or quote marks, or a ` #` comment. | | `jwt_audience` | yes | string | `JWT_AUDIENCE` | Set to the `aud` claim the provider targets at this gateway; prevents tokens meant for other services from being accepted. | **Cross-field constraints** (verbatim from artifact): @@ -189,6 +189,8 @@ All 4 fields are required when this object is present. These govern the **inboun > When authentication is enabled (the default), `jwt_algorithm` cannot be HS256, HS384 or HS512 — a JWKS endpoint publishes public keys, which only verify asymmetric signatures, so the gateway refuses to start. Use RS256 or ES256. > > When authentication is enabled (the default), `jwt_issuer` cannot begin with "http://" — the gateway dereferences it for /userinfo token introspection and refuses to start rather than send a bearer token in cleartext. +> +> When authentication is enabled (the default), `jwt_issuer` must be read by the gateway exactly as written: no spaces or quote marks around it, no " #" comment, and no control characters. The gateway env file would otherwise load a different value from the one Dtwo checks. #### `gateway.authentication.oauth_dcr` — DCR and discovery overrides @@ -283,7 +285,7 @@ Human-gated session clearing (session-control) registration: the IdP app the bro | Field | Required | Type | Default | Target | Guidance (from artifact) | |---|---|---|---|---|---| -| `client_id` | no | string | `not declared` | `SESSION_CONTROL_CLIENT_ID` | Leave unset on a gateway using Dtwo authentication. Set it only when the gateway trusts your own IdP: the ceremony app must live in the IdP that issues the gateway's inbound tokens, so register a dedicated public client there (authorization-code + PKCE, no refresh grant) and paste its client id here. Do not reuse the gateway API client. | +| `client_id` | no | string | `not declared` | `SESSION_CONTROL_CLIENT_ID` | Leave unset on a gateway using Dtwo authentication. Set it only when the gateway trusts your own IdP: the ceremony app must live in the IdP that issues the gateway's inbound tokens, so register a dedicated public client there (authorization-code + PKCE, no refresh grant) and paste its client id here. Do not reuse the gateway API client. On Okta that is a Native app with an Okta-generated client ID, included in the custom authorization server's access policy. The gateway picks the identity claim it binds on from the issuer host (Entra, Okta or other); most gateways need nothing more. For an Okta org on a custom domain or a sovereign-cloud Entra issuer — and only then — add `SESSION_SUBJECT_PROVIDER=okta` (or `entra`) to `gateway.advanced`, or every clear fails `subject_mismatch`. | | `redirect_uri` | no | string | `not declared` | `SESSION_CONTROL_REDIRECT_URI` | Leave unset in almost all cases — the gateway derives `/session-control/clear/callback` and the platform registers that. Set it when a reverse proxy or split-horizon DNS puts the browser on a different origin than the audience: give the URL the browser can actually reach, at the root path (`/mcp/*` routes to the streamable-HTTP handler, which rejects browser GETs). It is required when the first `jwt_audience` is not an absolute URL — e.g. an Entra ID v2.0 gateway, whose audience is the gateway app's client ID (a GUID) — since there is no origin to derive from, and the deploy rejects an armed gateway that leaves it unset. | **Cross-field constraints** (verbatim from artifact): @@ -398,7 +400,7 @@ Every field marked `secret: true` in the artifact. Put a Vault reference there ( - **`targetKind: platform`** — value is applied as a platform-side control at the named `platform.*` path rather than written to the gateway env file. - **`targetKind: sotwPath`** — value is written into the SOTW YAML at the named dotted path (e.g. `sotw.url`, `sotw.oauth_config.client_secret`). Read the `Target` column per field; do not infer a field's target from its section. - + @@ -409,6 +411,17 @@ Controls authentication, SSRF protection, `log_level`, `advanced` flags, DCR / d - **Authentication** defaults to enabled when omitted. Every field in `gateway.authentication` — including the token-validation flags (`jwt_issuer_verification`, `jwt_audience_verification`, `require_jti`, `require_token_expiration`, `mcp_oauth_resource_metadata_enabled`) — is listed in the Schema Digest above with its target, its default, and the schema's own guidance on when to set it. Read the table there rather than guessing from this summary; most of those flags carry a gateway-applied default (their Default column reads `` `true` (gateway) ``), and where the artifact declares none (`mcp_oauth_resource_metadata_enabled` reads `not declared`) the Guidance column states the effective value. - **Gateway-side `jwks_info` is independent of any `mcp_servers[].authentication` block.** When the prompt supplies an IdP tenant and audience (e.g. Auth0), populate `gateway.authentication.jwks_info` (`jwt_algorithm`, `jwt_jwks_uri`, `jwt_issuer`, `jwt_audience`) — even when the upstream MCP server uses OAuth/DCR, and even when the prompt says the upstream server "only supports OAuth" or "does not accept bearer tokens." Those statements describe the outbound leg to the MCP server, not the inbound leg from clients to the gateway. - **SSRF** defaults to strict (block localhost, block private networks, fail-closed DNS) when omitted. Set `allow_private_networks: true` to permit access to `host.docker.internal` and other private addresses. +- **Human-gated clearing on your own IdP** (a gateway whose `jwks_info` points at Okta, Entra ID or another IdP rather than Dtwo authentication) needs a `session_control.client_id` for a dedicated public client in that same IdP. The digest's `client_id` guidance covers the general rule. Things users get wrong: + - **Okta:** create a **Native** OIDC app (authorization code with PKCE, no client secret, no refresh token) and choose **Okta-generated client ID**, not a Client ID Metadata Document. Add it, and every app the MCP clients sign in with, to the custom authorization server's **access policy**, and make sure the policy's **rule** allows the Authorization Code grant and the `openid` and `email` scopes (the ceremony requests `openid email`). Otherwise Okta refuses the sign-in before the gateway sees it. + - **Sign-in redirect URI:** register `/session-control/clear/callback` on the clearing app, or whatever `session_control.redirect_uri` names. It is required when the first `jwt_audience` is not a URL (Entra ID v2.0), and when the browser reaches the gateway at a different origin than its audience. + - **Intent clears need intent capture:** set `gateway.intent.enabled: true`, or `set_intent` succeeds but nothing is stored and a clear finds nothing to clear. + - **Identity claim — most gateways set nothing.** The gateway detects Entra ID (`login.microsoftonline.com`, `sts.windows.net`) and Okta (`*.okta.com`, `*.oktapreview.com`, `*.okta-emea.com`, `*.okta-gov.com`) from the `jwt_issuer` host, treats every other issuer (Auth0 included) as standard OIDC, and binds clears on the claim that identifies the same person in both tokens. Do not add `SESSION_SUBJECT_PROVIDER` unless the issuer is an **Okta org on a custom domain** or a **sovereign-cloud Entra ID** host; for those two only, add the provider to `gateway.advanced` as a list entry, or every clear fails `subject_mismatch`. The accepted values are `auto` (the default; an empty value means the same), `oidc`, `entra` and `okta`. Once the gateway runs a version that knows the setting, anything else fails the deploy; on an older version, or on the deploy that upgrades it, a bad value is ignored without an error, so check the gateway's `Session subject mapping` startup log line after upgrading. + + ```yaml + gateway: + advanced: + - SESSION_SUBJECT_PROVIDER=okta + ``` ### MCP Servers Section diff --git a/dtwo/skills/dtwo-gateway-config/schema-reference.json b/dtwo/skills/dtwo-gateway-config/schema-reference.json index 1940fb8..a44c486 100644 --- a/dtwo/skills/dtwo-gateway-config/schema-reference.json +++ b/dtwo/skills/dtwo-gateway-config/schema-reference.json @@ -395,7 +395,7 @@ "deployDefault": null, "target": "JWT_ISSUER", "description": "Expected iss claim value.", - "rationale": "Set to the `iss` claim your provider emits; the gateway rejects tokens whose `iss` does not match. Use the `https://` form — the gateway dereferences this URL for `/userinfo` token introspection and refuses to start on `http://`. Auth0 issuers carry a trailing slash and the comparison is exact, so `https://tenant.us.auth0.com/` and `https://tenant.us.auth0.com` are different values.", + "rationale": "Set to the `iss` claim your provider emits; the gateway rejects tokens whose `iss` does not match. Use the `https://` form — the gateway dereferences this URL for `/userinfo` token introspection and refuses to start on `http://`. Auth0 issuers carry a trailing slash and the comparison is exact, so `https://tenant.us.auth0.com/` and `https://tenant.us.auth0.com` are different values. With authentication enabled, a value the gateway env file would read differently is rejected: surrounding spaces or quote marks, or a ` #` comment.", "targetKind": "envVar" }, { @@ -422,6 +422,9 @@ }, { "message": "When authentication is enabled (the default), \"jwt_issuer\" cannot begin with \"http://\" — the gateway dereferences it for /userinfo token introspection and refuses to start rather than send a bearer token in cleartext." + }, + { + "message": "When authentication is enabled (the default), \"jwt_issuer\" must be read by the gateway exactly as written: no spaces or quote marks around it, no \" #\" comment, and no control characters. The gateway env file would otherwise load a different value from the one Dtwo checks." } ] }, @@ -717,7 +720,7 @@ "deployDefault": null, "target": "SESSION_CONTROL_CLIENT_ID", "description": "Public client id of the dedicated session-control OAuth app (native, PKCE, no client secret). Optional on a gateway authenticating against Dtwo's Auth0: the platform provisions one per organization and renders it at deploy. An explicit value always wins.", - "rationale": "Leave unset on a gateway using Dtwo authentication. Set it only when the gateway trusts your own IdP: the ceremony app must live in the IdP that issues the gateway's inbound tokens, so register a dedicated public client there (authorization-code + PKCE, no refresh grant) and paste its client id here. Do not reuse the gateway API client.", + "rationale": "Leave unset on a gateway using Dtwo authentication. Set it only when the gateway trusts your own IdP: the ceremony app must live in the IdP that issues the gateway's inbound tokens, so register a dedicated public client there (authorization-code + PKCE, no refresh grant) and paste its client id here. Do not reuse the gateway API client. On Okta that is a Native app with an Okta-generated client ID, included in the custom authorization server's access policy. The gateway picks the identity claim it binds on from the issuer host (Entra, Okta or other); most gateways need nothing more. For an Okta org on a custom domain or a sovereign-cloud Entra issuer — and only then — add `SESSION_SUBJECT_PROVIDER=okta` (or `entra`) to `gateway.advanced`, or every clear fails `subject_mismatch`.", "targetKind": "envVar" }, { diff --git a/skill-harness/src/schemaArtifact.ts b/skill-harness/src/schemaArtifact.ts index d4643ed..23fa28d 100644 --- a/skill-harness/src/schemaArtifact.ts +++ b/skill-harness/src/schemaArtifact.ts @@ -45,7 +45,7 @@ export const SCHEMA_ARTIFACT_VERSION = '1.1.0'; * in reviewed source. It cannot stop a PR that bumps both the artifact and * this constant — judging that change is still review's job. */ -export const EXPECTED_SCHEMA_ARTIFACT_SHA256 = '4dc79f71108e6c02642bd5fc8463e310924d0c3c453e634cd5afc60d9b46af16'; +export const EXPECTED_SCHEMA_ARTIFACT_SHA256 = 'd7e5770c131413175a8efee14ef5b84fc59bccffab696ea55917fe86d206d119'; /** * `platform` is emitted by the currently vendored artifact (every diff --git a/skill-harness/src/validatorBundle.ts b/skill-harness/src/validatorBundle.ts index ab137e3..544cbb7 100644 --- a/skill-harness/src/validatorBundle.ts +++ b/skill-harness/src/validatorBundle.ts @@ -39,7 +39,7 @@ export const EXPECTED_VALIDATOR_BUNDLE_VERSION = '4.0.0'; * `__tests__/schemaDigest.test.ts`, not at module load. Bump this together * with `EXPECTED_VALIDATOR_BUNDLE_VERSION` when the bundle is re-vendored. */ -export const EXPECTED_VALIDATOR_BUNDLE_SHA256 = '9dba8b63ab3b7ef486aa3e448048ebe8eea04fac3e26598cc75f57e112d1474f'; +export const EXPECTED_VALIDATOR_BUNDLE_SHA256 = '54224d86bb46d1e711996311b231b7f73ab60e6a238062557720017159b51bdc'; const HERE = dirname(fileURLToPath(import.meta.url)); diff --git a/skill-harness/vendor/config-validator.bundle.mjs b/skill-harness/vendor/config-validator.bundle.mjs index c8d2e57..3a9e4d0 100644 --- a/skill-harness/vendor/config-validator.bundle.mjs +++ b/skill-harness/vendor/config-validator.bundle.mjs @@ -22790,6 +22790,35 @@ function date4(params) { return _coercedDate(ZodDate, params); } +// src/dotenvValue.ts +function dotenvLoadedValue(raw) { + const value = raw.replace(/^[^\S\r\n]+/, ""); + if (value.startsWith("'")) { + const match = /^'((?:\\'|[^'])*)'/.exec(value); + if (!match || !AFTER_QUOTED_VALUE.test(value.slice(match[0].length))) return void 0; + return match[1].replace(/\\([\\'])/g, "$1"); + } + if (value.startsWith('"')) { + const match = /^"((?:\\"|[^"])*)"/.exec(value); + if (!match || !AFTER_QUOTED_VALUE.test(value.slice(match[0].length))) return void 0; + return match[1].replace(/\\([\\'"abfnrtv])/g, (_, escaped) => DOUBLE_QUOTE_ESCAPES[escaped] ?? escaped); + } + return value.replace(/[\r\n].*$/s, "").replace(/\s+#.*$/, "").replace(/\s+$/, ""); +} +var AFTER_QUOTED_VALUE = /^[^\S\r\n]*(?:#[^\r\n]*)?$/; +var DOUBLE_QUOTE_ESCAPES = { + "\\": "\\", + "'": "'", + '"': '"', + a: "\x07", + b: "\b", + f: "\f", + n: "\n", + r: "\r", + t: " ", + v: "\v" +}; + // src/gatewayTemplate.ts var ContextForgeConfigTemplate = `# ============================================================================= # ContextForge Gateway \u2014 Environment Template @@ -23061,6 +23090,10 @@ function validateAdvancedEntry(line) { } var SESSION_CONTROL_CLIENT_ID_RE = /^[\x21-\x7E]+$/; var CONTROL_OR_WHITESPACE_RE = /[\s\p{Cc}]/u; +var CONTROL_CHARACTER_RE = new RegExp("\\p{Cc}", "u"); +function isReadVerbatimByGateway(issuer) { + return !CONTROL_CHARACTER_RE.test(issuer) && dotenvLoadedValue(issuer) === issuer; +} function isNormalizedHttpsIssuer(value) { if (CONTROL_OR_WHITESPACE_RE.test(value)) return false; let url2; @@ -23133,7 +23166,7 @@ var JwtAuthSchema = external_exports.preprocess( description: "Expected iss claim value.", target: "JWT_ISSUER", audience: "user", - rationale: "Set to the `iss` claim your provider emits; the gateway rejects tokens whose `iss` does not match. Use the `https://` form \u2014 the gateway dereferences this URL for `/userinfo` token introspection and refuses to start on `http://`. Auth0 issuers carry a trailing slash and the comparison is exact, so `https://tenant.us.auth0.com/` and `https://tenant.us.auth0.com` are different values." + rationale: "Set to the `iss` claim your provider emits; the gateway rejects tokens whose `iss` does not match. Use the `https://` form \u2014 the gateway dereferences this URL for `/userinfo` token introspection and refuses to start on `http://`. Auth0 issuers carry a trailing slash and the comparison is exact, so `https://tenant.us.auth0.com/` and `https://tenant.us.auth0.com` are different values. With authentication enabled, a value the gateway env file would read differently is rejected: surrounding spaces or quote marks, or a ` #` comment." }) ), jwt_audience: external_exports.string().min(1).meta( @@ -23150,7 +23183,8 @@ var JwtAuthSchema = external_exports.preprocess( crossFieldConstraints: [ 'When authentication is enabled (the default), "jwt_jwks_uri" must begin with a lowercase "https://" \u2014 the gateway rejects every other scheme, "file://" included, as an SSRF and key-substitution vector, and refuses to start.', 'When authentication is enabled (the default), "jwt_algorithm" cannot be HS256, HS384 or HS512 \u2014 a JWKS endpoint publishes public keys, which only verify asymmetric signatures, so the gateway refuses to start. Use RS256 or ES256.', - 'When authentication is enabled (the default), "jwt_issuer" cannot begin with "http://" \u2014 the gateway dereferences it for /userinfo token introspection and refuses to start rather than send a bearer token in cleartext.' + 'When authentication is enabled (the default), "jwt_issuer" cannot begin with "http://" \u2014 the gateway dereferences it for /userinfo token introspection and refuses to start rather than send a bearer token in cleartext.', + 'When authentication is enabled (the default), "jwt_issuer" must be read by the gateway exactly as written: no spaces or quote marks around it, no " #" comment, and no control characters. The gateway env file would otherwise load a different value from the one Dtwo checks.' ] }) ) @@ -23291,6 +23325,12 @@ var GatewayAuthSchema = external_exports.object({ message: '"jwt_issuer" cannot begin with "http://" while "jwt_jwks_uri" is set \u2014 the gateway refuses to start. It dereferences the issuer URL over the network for /userinfo token introspection and will not send a bearer token in cleartext. Use the https:// form of the same issuer. Only the "http://" prefix is rejected: non-URL issuers (for example "urn:" forms) are unaffected.', path: ["jwks_info", "jwt_issuer"] } +).refine( + (data) => !(data.enabled === true && data.jwks_info !== void 0 && !isReadVerbatimByGateway(data.jwks_info.jwt_issuer)), + { + message: `"jwt_issuer" must be exactly the token's iss claim, with no spaces or quote marks around it, no " #" comment, and no control characters. For example: https://tenant.us.auth0.com/.`, + path: ["jwks_info", "jwt_issuer"] + } ).meta( meta3({ description: "Gateway authentication settings.", @@ -23447,7 +23487,7 @@ var SessionControlSchema = external_exports.object({ description: "Public client id of the dedicated session-control OAuth app (native, PKCE, no client secret). Optional on a gateway authenticating against Dtwo's Auth0: the platform provisions one per organization and renders it at deploy. An explicit value always wins.", target: "SESSION_CONTROL_CLIENT_ID", audience: "user", - rationale: "Leave unset on a gateway using Dtwo authentication. Set it only when the gateway trusts your own IdP: the ceremony app must live in the IdP that issues the gateway's inbound tokens, so register a dedicated public client there (authorization-code + PKCE, no refresh grant) and paste its client id here. Do not reuse the gateway API client." + rationale: "Leave unset on a gateway using Dtwo authentication. Set it only when the gateway trusts your own IdP: the ceremony app must live in the IdP that issues the gateway's inbound tokens, so register a dedicated public client there (authorization-code + PKCE, no refresh grant) and paste its client id here. Do not reuse the gateway API client. On Okta that is a Native app with an Okta-generated client ID, included in the custom authorization server's access policy. The gateway picks the identity claim it binds on from the issuer host (Entra, Okta or other); most gateways need nothing more. For an Okta org on a custom domain or a sovereign-cloud Entra issuer \u2014 and only then \u2014 add `SESSION_SUBJECT_PROVIDER=okta` (or `entra`) to `gateway.advanced`, or every clear fails `subject_mismatch`." }) ), redirect_uri: external_exports.string().min(1).refine(isRenderableRedirectUri, { @@ -23462,8 +23502,8 @@ var SessionControlSchema = external_exports.object({ ), // There is deliberately NO `issuer` field. The ceremony's issuer is always // `gateway.authentication.jwks_info.jwt_issuer` — the callback binds the - // inbound and browser-login identities as raw `iss|` (`sub`, or `oid` - // on an Entra issuer), so any other value + // inbound and browser-login identities as raw `iss|` (`sub`; `oid` + // on an Entra issuer; `uid` on Okta, compared with the ID token's `sub`), so any other value // makes every ceremony deterministically fail subject_mismatch. A field // whose only legal value is a copy of another field is pure misconfiguration // surface, so the deploy derives SESSION_CONTROL_ISSUER from jwt_issuer