From 2f8d14501c1afa1988efadfb1aa19b7201f5bcb5 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 4 Aug 2026 16:22:07 +0100 Subject: [PATCH 01/12] fix(cli): port gen bearer-jwt to native TypeScript (CLI-1961) Replaces the Go-proxy wrapper with a fully local, native handler that resolves a signing JWK (built-in default key, or [auth].signing_keys_path via stdin/kid prompt or an interactive TTY picker) and signs a Go-jwt.MapClaims-shaped bearer token, byte-matching Go's claim computation, key-selection prompts, and asymmetric-signing error family. Hoists the shared [auth].signing_keys_path config/file loading out of gen signing-key into gen.signing-keys-config.ts, and refactors legacy-go-jwt.ts's asymmetric signer into a generic legacySignJwtWithJwk so both gen signing-key's fixed anon/service_role claims and bearer-jwt's arbitrary MapClaims-shaped claims share one Go-parity validation/signing path. Along the way, corrects two pre-existing legacy-go-jwt.unit.test.ts expectations that encoded a kty-vs-algorithm cross-check Go does not actually perform (verified against the real Go binary). --- apps/cli/docs/go-cli-porting-status.md | 2 +- .../commands/gen/bearer-jwt/SIDE_EFFECTS.md | 89 ++- .../gen/bearer-jwt/bearer-jwt.claims.ts | 171 ++++ .../bearer-jwt/bearer-jwt.claims.unit.test.ts | 214 +++++ .../gen/bearer-jwt/bearer-jwt.command.ts | 54 +- .../gen/bearer-jwt/bearer-jwt.errors.ts | 73 ++ .../bearer-jwt/bearer-jwt.errors.unit.test.ts | 13 + .../gen/bearer-jwt/bearer-jwt.flags.ts | 41 + .../bearer-jwt/bearer-jwt.flags.unit.test.ts | 46 ++ .../gen/bearer-jwt/bearer-jwt.handler.ts | 88 +- .../bearer-jwt/bearer-jwt.integration.test.ts | 752 ++++++++++++++++++ .../gen/bearer-jwt/bearer-jwt.signing-key.ts | 215 +++++ .../commands/gen/gen.signing-keys-config.ts | 129 +++ .../gen/signing-key/signing-key.handler.ts | 98 +-- apps/cli/src/legacy/shared/legacy-go-jwt.ts | 133 +++- .../legacy/shared/legacy-go-jwt.unit.test.ts | 49 +- 16 files changed, 2018 insertions(+), 149 deletions(-) create mode 100644 apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts create mode 100644 apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts create mode 100644 apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts create mode 100644 apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts create mode 100644 apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts create mode 100644 apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts create mode 100644 apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 19f91e2a70..8e1267527c 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -283,7 +283,7 @@ Legend: | `migration fetch` | `ported` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) — native; writes history rows to `supabase/migrations/` | | `gen types` | `ported` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) — sanctioned intentional divergence (CLI-1988 parity ruling): non-TypeScript `--lang` on project-ref paths (`--linked`/`--project-id`/implicit) runs pg-meta locally with project credentials instead of Go's hard error `Unable to generate types for selected project. Try using --db-url flag instead.` (resolves CLI-1623). All Go flag guards are otherwise enforced byte-exactly: the `--postgrest-v9-compat must used together with --db-url` PreRun gate and all four cobra mutually-exclusive flag groups (`cmd/gen.go:153-162`) in cobra's sorted group order. | | `gen signing-key` | `ported` | [`../src/legacy/commands/gen/signing-key/signing-key.command.ts`](../src/legacy/commands/gen/signing-key/signing-key.command.ts) | -| `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | +| `gen bearer-jwt` | `ported` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) — native; fully local signing-key resolution (built-in default ES256 dev key, or `[auth].signing_keys_path` with stdin/TTY key selection), no Docker/network (CLI-1961) | | `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | | `functions list` | `wrapped` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | | `functions delete` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/bearer-jwt/SIDE_EFFECTS.md index 8d7503a03a..3427921487 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/SIDE_EFFECTS.md @@ -2,9 +2,12 @@ ## Files Read -| Path | Format | When | -| -------------------------------- | ------ | ------------------------------ | -| `/supabase/config.toml` | TOML | to read JWT secret for signing | +| Path | Format | When | +| ------------------------------------------------ | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` / `config.json` | TOML / JSON | always when present in the active workdir; used to discover `[auth].enabled` and `[auth].signing_keys_path` | +| `/supabase/.env*`, `/.env*` | dotenv | always, mirroring Go's `flags.LoadConfig`/`Config.Load`'s `loadNestedEnv` step (no `--yes`-style prompt of this command's own reads it, but the load still runs and can itself fail on a malformed file) | +| `` | JSON array of JWKs | when `[auth].signing_keys_path` is configured AND `[auth].enabled` is `true` (default) — see Notes for the `auth.enabled = false` quirk | +| stdin | plain text / JSON | interactive/piped prompt for a raw JWK (unconfigured `signing_keys_path`) or a signing-key `kid` (configured, non-TTY) | ## Files Written @@ -26,31 +29,83 @@ ## Exit Codes -| Code | Condition | -| ---- | ------------------------------------- | -| `0` | success — JWT printed to stdout | -| `1` | missing required `--role` flag | -| `1` | failed to parse claims or JWT signing | +| Code | Condition | +| ---- | -------------------------------------------------------------------------------------------- | +| `0` | success — the signed JWT is printed to stdout | +| `1` | missing required `--role` flag (`required flag(s) "role" not set`, no usage block) | +| `1` | malformed `--exp` (not valid RFC3339) or `--valid-for` (not a valid Go duration) | +| `1` | malformed `--payload` (`failed to parse payload: ...`) | +| `1` | `supabase/config.toml` itself is malformed | +| `1` | `[auth].signing_keys_path` is configured but the file is missing/unreadable | +| `1` | `[auth].signing_keys_path`'s file is not valid JSON, or not a JSON array of objects | +| `1` | the pasted stdin JWK (unconfigured `signing_keys_path`) is not valid JSON / not an object | +| `1` | the entered `kid` (configured `signing_keys_path`, non-TTY) matches no key | +| `1` | the resolved JWK has an unsupported key type/curve/algorithm, or a kty-vs-algorithm mismatch | ## Output ### `--output-format text` (Go CLI compatible) -Prints the generated Bearer JWT token to stdout. +Prints the signed JWT to stdout, followed by exactly one trailing newline — nothing +else ever reaches stdout. Every prompt, echo, and error goes to stderr. Unconditional +on `--output-format` — like the sibling `gen signing-key`, this command's own stdout +IS the machine-readable payload, so `json`/`stream-json` behave identically to `text`. ### `--output-format json` -Not applicable. +Same as `text` above (this command has no structured JSON envelope; see Notes). ### `--output-format stream-json` -Not applicable. +Same as `text` above. ## Notes -- `--role` flag is required (e.g., `anon`, `authenticated`, `service_role`). -- `--sub` flag sets the user ID to impersonate (defaults to `anonymous`). -- `--exp` sets an explicit expiry timestamp (RFC3339 format). -- `--valid-for` sets the validity duration (default 30 minutes). -- `--payload` accepts a JSON string of custom claims. -- Takes no positional arguments. +- `--role` is **required** (Postgres role to embed in the token, e.g. `anon`, + `authenticated`, `service_role`, or any custom role name — no validation against a + fixed set). +- `--sub` sets the `sub` (subject/user ID) claim. Its Go help text cosmetically shows + `(default "anonymous")`, but the real default is unset — an omitted `--sub` never + puts a `sub` claim in the token at all. +- When `--role authenticated` is used with no `--sub`, the token gets `is_anonymous: +true`. Any other role, or `authenticated` with a `--sub`, never sets it. +- `--exp` (RFC3339, e.g. `2030-01-01T00:00:00Z`) sets an explicit expiry; `iat` is then + computed as `exp - --valid-for`. Without `--exp`, `iat` is "now" and `exp` is `iat + +--valid-for`. +- `--valid-for` (Go duration syntax, e.g. `30m`, `1h`) defaults to 30 minutes. +- `--payload` (default `"{}"`) is arbitrary JSON merged ON TOP of the computed claims — + any key it sets (including `role`, `exp`, `iat`) overrides the computed value. +- The final claims object is a Go `jwt.MapClaims` (a real map), so its JSON keys are + serialized in **alphabetical order**, not flag/insertion order — byte-matches Go's + `encoding/json` map marshalling (including HTML-escaping `<`/`>`/`&`). +- **Signing-key resolution** (Go's `getSigningKey`, fully local, no Docker/network): + - `[auth].signing_keys_path` **not configured**: prompts `Enter your signing key in +JWK format (or leave blank to use local default): ` on stderr. A blank answer uses + the built-in default ES256 dev key (kid `b81269f1-21d8-4f2e-b719-c2240a840d90`, + the same default GoTrue itself signs local dev tokens with). A non-blank answer is + parsed as a single JWK object. + - `[auth].signing_keys_path` **configured**, **non-TTY** stdin: prompts `Enter the +kid of your signing key (or leave blank to use the first one): ` on stderr, echoing + the piped answer back. An exact `kid` match wins (checked before the blank-input + fallback, so a stored key whose own `kid` is `""` can still match a blank answer); + otherwise a blank answer uses the first key; otherwise `signing key not found: +`. + - `[auth].signing_keys_path` **configured**, **real TTY**: presents an interactive + picker (`Select a signing key:`) built from each key's `kid`/`alg`/`key_ops`, then + prints `Selected key ID: ` to stderr. Does not byte-match Go's bubbletea list + UI (an accepted divergence — see `legacy-project-ref.layer.ts` for the established + precedent of only matching the observable "Selected ..." line). + - **`[auth].enabled = false` quirk** (verified against the real binary): Go's + `Config.Validate` only reads the `signing_keys_path` file when `auth.enabled` is + `true` — but `getSigningKey` decides which prompt to show purely on whether the + path STRING is configured, independent of `auth.enabled`. So with auth disabled + and a path configured, the kid-prompt still runs, but the available keys stay the + built-in default (the file is never read) — a real kid from the file is reported + `signing key not found`. +- Byte-matches Go's asymmetric-signing error family exactly: kty/curve failures wrap as + `failed to convert JWK to private key: ...`; an unsupported algorithm is unwrapped + (`unsupported algorithm: ...`); a kty-vs-algorithm mismatch (e.g. an EC key signed as + `RS256`) is caught at sign time and wraps as `failed to sign JWT: key is of invalid +type: ...` — Go has no explicit cross-check between kty and algorithm; this failure + comes from golang-jwt's own signing method. +- No network or Management API calls, no Docker — fully local, matching Go. diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts new file mode 100644 index 0000000000..ec206eae02 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts @@ -0,0 +1,171 @@ +import { Option } from "effect"; +import { encodeGoStructJsonBody } from "../../../shared/legacy-go-output.encoders.ts"; + +/** + * Pure claims-building logic for `gen bearer-jwt`, ported from Go's `parseClaims` + * (`apps/cli-go/cmd/gen.go:185-213`). Kept out of the handler/service tree (no `Effect`, + * no service dependencies) per `apps/cli/CLAUDE.md`'s `.format.ts`/`.encoders.ts` + * guidance — this is exactly that shape of file, just named `.claims.ts` for this command. + * + * Go's real call site (`cmd/gen.go:137-141`) ALWAYS builds a `jwt.MapClaims` (a genuine Go + * map) and hands it to `bearerjwt.Run` — never a `CustomClaims` struct. `encoding/json` + * serializes a map's keys in SORTED (alphabetical) order, unlike a struct's + * declaration order — see {@link legacyEncodeBearerJwtClaims}, which every caller MUST use + * to serialize the object this module builds (a plain `JSON.stringify` would preserve + * insertion order instead, which is Go-correct for `legacyGenerateAsymmetricGoJwt`'s + * struct-shaped claims but wrong here). + */ + +export interface LegacyBearerJwtClaimsInput { + readonly role: string; + readonly sub: Option.Option; + /** Unix seconds from `--exp` (RFC3339) — `Option.none()` when the flag was not given. */ + readonly expiresAt: Option.Option; + /** `--valid-for`, already parsed from Go-duration syntax into whole seconds. */ + readonly validForSeconds: number; + /** `Date.now()`-derived Unix seconds, injected so callers (and tests) control "now". */ + readonly nowSeconds: number; +} + +/** + * Go's time/role computation (`cmd/gen.go:187-198`): + * - `--exp` unset (zero `time.Time`): `iat = now`, `exp = now + validFor`. + * - `--exp` set: `exp = `, `iat = exp - validFor` (validFor is SUBTRACTED + * from the explicit expiry to derive `iat`, not added to `now`). + * - `role` is ALWAYS present (`json:"role"`, no `omitempty`), even `--role ""`. + * - `is_anonymous` is set only when `role` case-insensitively equals `"authenticated"` + * AND `--sub` was not given (`strings.EqualFold` + `len(claims.Subject) == 0`); the + * `role` claim keeps its original casing regardless. + * - `sub`/`exp`/`iat` all carry Go's embedded `jwt.RegisteredClaims` `omitempty` tags — + * `sub` only when non-empty, `exp`/`iat` always (both are always-set `*NumericDate`s + * here, matching mapstructure's non-nil-pointer handling — see `legacy-go-jwt.ts`'s + * sibling doc comments for the general `omitempty`-in-mapstructure background). + * - `iss`/`ref`/`aud`/`nbf`/`jti` never appear — bearer-jwt has no flag that sets any of + * them, so they stay at their Go zero value and get `omitempty`-dropped. + */ +export function legacyBuildBearerJwtClaims( + input: LegacyBearerJwtClaimsInput, +): Record { + let exp: number; + let iat: number; + if (Option.isNone(input.expiresAt)) { + iat = input.nowSeconds; + exp = iat + input.validForSeconds; + } else { + exp = input.expiresAt.value; + iat = exp - input.validForSeconds; + } + + const claims: Record = { + role: input.role, + }; + if (input.role.toLowerCase() === "authenticated" && Option.isNone(input.sub)) { + claims["is_anonymous"] = true; + } + if (Option.isSome(input.sub) && input.sub.value.length > 0) { + claims["sub"] = input.sub.value; + } + claims["exp"] = exp; + claims["iat"] = iat; + return claims; +} + +/** + * Go's `encoding/json` type names for the JSON-representable kinds `json.Unmarshal` + * rejects. Shared with `bearer-jwt.signing-key.ts`'s Branch A (a pasted stdin JWK that + * decodes to a non-object) — both need Go's exact `"json: cannot unmarshal + * into Go value of type "` wording, just against different target types + * (`jwt.MapClaims` here, `config.JWK` there). + */ +export function legacyGoJsonKindName(value: unknown): string { + if (Array.isArray(value)) return "array"; + switch (typeof value) { + case "number": + return "number"; + case "string": + return "string"; + case "boolean": + return "bool"; + default: + return "value"; + } +} + +/** + * Best-effort reproduction of Go's `encoding/json` scanner syntax-error text for a + * malformed `--payload` value. Handles the two shapes verified against the real binary + * (CLI-1961): an empty/whitespace-only payload (`"unexpected end of JSON input"`), and + * valid JSON followed by trailing garbage (`"invalid character 'X' after top-level + * value"`, found by growing the longest parseable prefix) — this covers `--payload + * '{}{}'` exactly. Genuinely malformed JSON with NO valid prefix at all (e.g. a bare + * unquoted identifier) falls back to a generic message that is NOT verified + * byte-for-byte against Go's own scanner (accepted gap — `bearerjwt_test.go` has no + * fixture for `--payload` parsing at all; see this command's own audit notes). + */ +function legacyGoJsonSyntaxErrorMessage(raw: string): string { + const trimmed = raw.replace(/^\s+/, ""); + if (trimmed.length === 0) { + return "unexpected end of JSON input"; + } + for (let end = trimmed.length; end > 0; end--) { + const candidate = trimmed.slice(0, end); + try { + JSON.parse(candidate); + } catch { + continue; + } + const rest = trimmed.slice(end).replace(/^\s+/, ""); + if (rest.length === 0) break; + return `invalid character '${rest[0]}' after top-level value`; + } + return "invalid character looking for beginning of value"; +} + +/** + * Go's final `--payload` merge (`cmd/gen.go:209-211`): + * `json.Unmarshal([]byte(payload), &custom)`. `encoding/json` reuses the existing + * non-nil map and overwrites/adds keys from the payload on top — so this merges + * `JSON.parse(payload)`'s own keys OVER `claims` (payload wins on any collision, + * e.g. `--payload '{"role":"override"}'` replaces the flag-derived `role`). + * + * A JSON `null` payload is a no-op — verified against the real binary — rather than + * clearing `claims` (Go's own map-into-map unmarshal semantics for a `null` source + * leave the destination untouched here in practice). A non-object, non-null, + * non-array top-level scalar (string/number/bool) or an array raises Go's own + * `"json: cannot unmarshal into Go value of type jwt.MapClaims"` runtime + * type-mismatch text. Throws a bare `Error`; the caller (`bearer-jwt.handler.ts`) + * wraps it with Go's `"failed to parse payload: %w"` prefix. + */ +export function legacyMergeBearerJwtPayload( + claims: Record, + payload: string, +): Record { + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + throw new Error(legacyGoJsonSyntaxErrorMessage(payload)); + } + if (parsed === null) { + return claims; + } + if (Array.isArray(parsed) || typeof parsed !== "object") { + throw new Error( + `json: cannot unmarshal ${legacyGoJsonKindName(parsed)} into Go value of type jwt.MapClaims`, + ); + } + return { ...claims, ...(parsed as Record) }; +} + +/** + * Serializes the final claims object the way Go's `jwt.MapClaims` (a real Go map) + * marshals via `encoding/json`: alphabetically key-sorted at every level, Go's HTML + + * control-character escaping, no indentation, no trailing newline. Reuses + * `encodeGoStructJsonBody` (originally written for outbound API request bodies) — + * its behavior is Go's generic `json.Marshal`-of-a-map shape, which is exactly what a + * `jwt.MapClaims` payload needs too; introducing a second identical encoder under a + * different name would just be a rename, not a behavior difference. + */ +export function legacyEncodeBearerJwtClaims(claims: Record): string { + return encodeGoStructJsonBody(claims); +} diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts new file mode 100644 index 0000000000..9a261471c4 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts @@ -0,0 +1,214 @@ +import { Option } from "effect"; +import { describe, expect, it } from "vitest"; +import { + legacyBuildBearerJwtClaims, + legacyEncodeBearerJwtClaims, + legacyGoJsonKindName, + legacyMergeBearerJwtPayload, +} from "./bearer-jwt.claims.ts"; + +const NOW = 1_700_000_000; + +describe("legacyBuildBearerJwtClaims", () => { + it("always includes role, even an empty string, with no omitempty", () => { + const claims = legacyBuildBearerJwtClaims({ + role: "", + sub: Option.none(), + expiresAt: Option.none(), + validForSeconds: 1800, + nowSeconds: NOW, + }); + expect(claims["role"]).toBe(""); + }); + + it("computes iat = now and exp = now + validFor when --exp is not given", () => { + const claims = legacyBuildBearerJwtClaims({ + role: "anon", + sub: Option.none(), + expiresAt: Option.none(), + validForSeconds: 1800, + nowSeconds: NOW, + }); + expect(claims["iat"]).toBe(NOW); + expect(claims["exp"]).toBe(NOW + 1800); + }); + + it("computes exp = --exp and iat = exp - validFor when --exp is given", () => { + const claims = legacyBuildBearerJwtClaims({ + role: "anon", + sub: Option.none(), + expiresAt: Option.some(2_000_000_000), + validForSeconds: 1800, + nowSeconds: NOW, + }); + expect(claims["exp"]).toBe(2_000_000_000); + expect(claims["iat"]).toBe(2_000_000_000 - 1800); + }); + + it("sets is_anonymous when role is 'authenticated' (case-insensitive) and sub is absent", () => { + const claims = legacyBuildBearerJwtClaims({ + role: "AUTHENTICATED", + sub: Option.none(), + expiresAt: Option.none(), + validForSeconds: 1800, + nowSeconds: NOW, + }); + expect(claims["is_anonymous"]).toBe(true); + // Role keeps its original casing. + expect(claims["role"]).toBe("AUTHENTICATED"); + }); + + it("does not set is_anonymous when role is authenticated but sub is given", () => { + const claims = legacyBuildBearerJwtClaims({ + role: "authenticated", + sub: Option.some("user-1"), + expiresAt: Option.none(), + validForSeconds: 1800, + nowSeconds: NOW, + }); + expect(claims["is_anonymous"]).toBeUndefined(); + expect(claims["sub"]).toBe("user-1"); + }); + + it("does not set is_anonymous for a non-authenticated role", () => { + const claims = legacyBuildBearerJwtClaims({ + role: "postgres", + sub: Option.none(), + expiresAt: Option.none(), + validForSeconds: 1800, + nowSeconds: NOW, + }); + expect(claims["is_anonymous"]).toBeUndefined(); + }); + + it("omits sub entirely when not given (omitempty)", () => { + const claims = legacyBuildBearerJwtClaims({ + role: "service_role", + sub: Option.none(), + expiresAt: Option.none(), + validForSeconds: 1800, + nowSeconds: NOW, + }); + expect("sub" in claims).toBe(false); + }); +}); + +describe("legacyMergeBearerJwtPayload", () => { + it("is a no-op for the default '{}' payload", () => { + const claims = { role: "anon" }; + expect(legacyMergeBearerJwtPayload(claims, "{}")).toEqual({ role: "anon" }); + }); + + it("merges payload keys on top of (overriding) existing claims", () => { + const claims = { role: "postgres", exp: 1, iat: 2 }; + const merged = legacyMergeBearerJwtPayload( + claims, + '{"role":"override","sb-role":"mgmt-api","aud":"x"}', + ); + expect(merged).toEqual({ + role: "override", + exp: 1, + iat: 2, + "sb-role": "mgmt-api", + aud: "x", + }); + }); + + it("treats a JSON null payload as a no-op", () => { + const claims = { role: "anon" }; + expect(legacyMergeBearerJwtPayload(claims, "null")).toEqual({ role: "anon" }); + }); + + it("rejects an array payload with Go's unmarshal-type-mismatch message", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "[]")).toThrow( + "json: cannot unmarshal array into Go value of type jwt.MapClaims", + ); + }); + + it("rejects a scalar number payload with Go's unmarshal-type-mismatch message", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "123")).toThrow( + "json: cannot unmarshal number into Go value of type jwt.MapClaims", + ); + }); + + it("rejects a scalar string payload with Go's unmarshal-type-mismatch message", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, '"str"')).toThrow( + "json: cannot unmarshal string into Go value of type jwt.MapClaims", + ); + }); + + it("rejects a scalar boolean payload with Go's unmarshal-type-mismatch message", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "true")).toThrow( + "json: cannot unmarshal bool into Go value of type jwt.MapClaims", + ); + }); + + it("rejects an empty payload with Go's exact 'unexpected end of JSON input'", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "")).toThrow( + "unexpected end of JSON input", + ); + }); + + it("rejects trailing garbage after a valid value with Go's exact 'after top-level value' text", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "{}{}")).toThrow( + "invalid character '{' after top-level value", + ); + }); + + it("accepts a payload value of null for an individual key (distinct from a null top-level payload)", () => { + const merged = legacyMergeBearerJwtPayload({ role: "anon", sub: "x" }, '{"sub":null}'); + expect(merged["sub"]).toBeNull(); + }); + + it("falls back to a generic message for malformed JSON with no valid prefix at all", () => { + // Accepted gap (see this module's own doc comment): `bearerjwt_test.go` has no + // `--payload` parsing fixture at all, so this exact wording is not Go-verified. + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "not-json-at-all")).toThrow( + "invalid character looking for beginning of value", + ); + }); + + it("falls back to the generic message when the only valid prefix is the WHOLE trimmed string", () => { + // A leading vertical tab is in JS's `\s` regex class (so `trimmed` strips it) but is + // NOT valid JSON whitespace (so the original `JSON.parse(payload)` call still fails + // on it) — the longest-valid-prefix loop's first (longest) candidate then succeeds + // with nothing left over, which must fall through to the generic message rather than + // reporting on an empty `rest`. Built via `String.fromCharCode` rather than a literal + // escape so no raw control byte sits in the source file. + const verticalTab = String.fromCharCode(11); + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, `${verticalTab}{}`)).toThrow( + "invalid character looking for beginning of value", + ); + }); +}); + +describe("legacyGoJsonKindName", () => { + it("names every JSON-representable kind, including the generic fallback", () => { + expect(legacyGoJsonKindName([])).toBe("array"); + expect(legacyGoJsonKindName(1)).toBe("number"); + expect(legacyGoJsonKindName("s")).toBe("string"); + expect(legacyGoJsonKindName(true)).toBe("bool"); + // Never reachable from real JSON.parse output (both call sites already exclude + // null/array/object before calling this) — exercised directly for completeness. + expect(legacyGoJsonKindName(undefined)).toBe("value"); + }); +}); + +describe("legacyEncodeBearerJwtClaims", () => { + it("serializes claims with alphabetically sorted keys, matching Go's jwt.MapClaims", () => { + const claims = { role: "authenticated", is_anonymous: true, exp: 200, iat: 100 }; + expect(legacyEncodeBearerJwtClaims(claims)).toBe( + '{"exp":200,"iat":100,"is_anonymous":true,"role":"authenticated"}', + ); + }); + + it("HTML-escapes special characters like Go's default json.Marshal", () => { + const claims = { role: "a&c" }; + expect(legacyEncodeBearerJwtClaims(claims)).toBe('{"role":"a\\u003cb\\u003e\\u0026c"}'); + }); + + it("sorts nested object keys recursively too", () => { + const claims = { role: "anon", custom: { z: 1, a: 2 } }; + expect(legacyEncodeBearerJwtClaims(claims)).toBe('{"custom":{"a":2,"z":1},"role":"anon"}'); + }); +}); diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts index f41e3d6725..3a069d30ca 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts @@ -1,28 +1,70 @@ +import { Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; import { legacyGenBearerJwt } from "./bearer-jwt.handler.ts"; +import { legacyParseBearerJwtExp, legacyParseBearerJwtValidFor } from "./bearer-jwt.flags.ts"; const config = { - role: Flag.string("role").pipe(Flag.withDescription("Postgres role to use."), Flag.optional), + // Go: `cobra.CheckErr(genJWTCmd.MarkFlagRequired("role"))` (`cmd/gen.go:175`) — no + // `Flag.optional` here, so a missing `--role` fails during argument parsing with + // the framework's own Go-parity `required flag(s) "role" not set` rendering + // (`shared/output/normalize-error.ts`'s `MissingOption` case), matching Go's + // `ValidateRequiredFlags`/`SilenceUsage` behavior (same pattern as `sso add --type`). + role: Flag.string("role").pipe(Flag.withDescription("Postgres role to use.")), + // Go's `DefValue` is cosmetically overwritten to "anonymous" (`cmd/gen.go:177`) but the + // bound variable's real default stays "" — verified against the real binary, an + // omitted `--sub` never puts a `sub` claim in the token at all. sub: Flag.string("sub").pipe(Flag.withDescription("User ID to impersonate."), Flag.optional), exp: Flag.string("exp").pipe( - Flag.withDescription("Expiry timestamp for this token (RFC3339 format)."), + Flag.withDescription("Expiry timestamp for this token."), + Flag.mapTryCatch( + (value) => legacyParseBearerJwtExp(value), + (err) => (err instanceof Error ? err.message : String(err)), + ), Flag.optional, ), validFor: Flag.string("valid-for").pipe( Flag.withDescription("Validity duration for this token."), - Flag.optional, + Flag.withDefault("30m"), + Flag.mapTryCatch( + (value) => legacyParseBearerJwtValidFor(value), + (err) => (err instanceof Error ? err.message : String(err)), + ), ), payload: Flag.string("payload").pipe( Flag.withDescription("Custom claims in JSON format."), - Flag.optional, + Flag.withDefault("{}"), ), } as const; export type LegacyGenBearerJwtFlags = CliCommand.Command.Config.Infer; +const legacyGenBearerJwtRuntimeLayer = Layer.mergeAll( + legacyDebugLoggerLayer, + legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)), + legacyTelemetryStateLayer, + commandRuntimeLayer(["gen", "bearer-jwt"]), + // Branch A's stdin JWK prompt and Branch B's stdin kid prompt (`getSigningKey`, + // `bearerjwt.go:37-68`) both read piped stdin even on a non-TTY, same as + // `gen signing-key`'s overwrite confirmation. + stdinLayer, +); + export const legacyGenBearerJwtCommand = Command.make("bearer-jwt", config).pipe( - Command.withDescription("Generate a Bearer Auth JWT for accessing Data API."), + Command.withDescription("Generate a Bearer Auth JWT for accessing Data API"), Command.withShortDescription("Generate a Bearer Auth JWT for accessing Data API"), - Command.withHandler((flags) => legacyGenBearerJwt(flags)), + Command.withHandler((flags) => + legacyGenBearerJwt(flags).pipe( + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyGenBearerJwtRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts new file mode 100644 index 0000000000..afa8044221 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts @@ -0,0 +1,73 @@ +import { Data } from "effect"; + +/** + * Extracts a display message from a thrown `cause`. Every `Effect.try` catch in this + * command's handler/signing-key resolver wraps a function that only ever throws a real + * `Error` (never a plain string/object) — but `catch` still types `cause` as `unknown`, + * so the `instanceof` check stays. Pulled out here (rather than inlined at each call + * site) so neither `bearer-jwt.handler.ts` nor `bearer-jwt.signing-key.ts` carries this + * branch itself for coverage purposes — it's exercised directly by this file's own + * unit tests instead. + */ +export function legacyBearerJwtErrorMessage(cause: unknown): string { + return cause instanceof Error ? cause.message : String(cause); +} + +/** `supabase/config.toml` itself is malformed. Mirrors `gen signing-key`'s own error shape. */ +export class LegacyGenBearerJwtConfigParseError extends Data.TaggedError( + "LegacyGenBearerJwtConfigParseError", +)<{ + readonly message: string; +}> {} + +/** `[auth].signing_keys_path` is configured but the file could not be read. */ +export class LegacyGenBearerJwtReadError extends Data.TaggedError("LegacyGenBearerJwtReadError")<{ + readonly message: string; +}> {} + +/** `[auth].signing_keys_path`'s file is not valid JSON / not a JWK array. */ +export class LegacyGenBearerJwtDecodeError extends Data.TaggedError( + "LegacyGenBearerJwtDecodeError", +)<{ + readonly message: string; +}> {} + +/** + * Go's `getSigningKey` Branch A (`bearerjwt.go:46-50`): the pasted stdin JWK is not + * valid JSON. Byte-matches `"failed to parse JWK: %w"`. + */ +export class LegacyGenBearerJwtKeyParseError extends Data.TaggedError( + "LegacyGenBearerJwtKeyParseError", +)<{ + readonly message: string; +}> {} + +/** + * Go's `getSigningKey` Branch B (`bearerjwt.go:67`): the entered kid matched no + * configured signing key. Byte-matches `"signing key not found: %s"`. + */ +export class LegacyGenBearerJwtKeyNotFoundError extends Data.TaggedError( + "LegacyGenBearerJwtKeyNotFoundError", +)<{ + readonly message: string; +}> {} + +/** + * Go's `parseClaims` payload merge (`cmd/gen.go:209-211`). Byte-matches + * `"failed to parse payload: %w"`. + */ +export class LegacyGenBearerJwtPayloadError extends Data.TaggedError( + "LegacyGenBearerJwtPayloadError", +)<{ + readonly message: string; +}> {} + +/** + * Go's `config.GenerateAsymmetricJWT` (`pkg/config/apikeys.go:88-113`) — unsupported + * key type/curve/algorithm, or a kty-vs-alg mismatch caught at sign time. The message + * is `legacySignJwtWithJwk`'s own text, surfaced verbatim (Go's `bearerjwt.Run` + * returns this error bare, with no additional wrapping — `bearerjwt.go:27-30`). + */ +export class LegacyGenBearerJwtSignError extends Data.TaggedError("LegacyGenBearerJwtSignError")<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.unit.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.unit.test.ts new file mode 100644 index 0000000000..0af9e9e85e --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.unit.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { legacyBearerJwtErrorMessage } from "./bearer-jwt.errors.ts"; + +describe("legacyBearerJwtErrorMessage", () => { + it("extracts .message from a real Error instance", () => { + expect(legacyBearerJwtErrorMessage(new Error("boom"))).toBe("boom"); + }); + + it("stringifies a non-Error cause", () => { + expect(legacyBearerJwtErrorMessage("plain string cause")).toBe("plain string cause"); + expect(legacyBearerJwtErrorMessage(42)).toBe("42"); + }); +}); diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts new file mode 100644 index 0000000000..d72e89df28 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts @@ -0,0 +1,41 @@ +import { legacyParseGoDuration } from "../../../shared/legacy-go-duration.ts"; +import { legacyBearerJwtErrorMessage } from "./bearer-jwt.errors.ts"; + +const RFC3339_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/; + +/** + * Go's `--exp` flag (`TimeVar(&expiry, "exp", time.Time{}, []string{time.RFC3339}, ...)`, + * `apps/cli-go/cmd/gen.go:178`) calls `time.Parse(time.RFC3339, val)` inside pflag's + * `Value.Set`, which runs during `cmd.ParseFlags` — BEFORE `RunE` (verified against the + * real binary, CLI-1961). A parse failure raises pflag's own wrapped message exactly: + * `invalid argument "" for "--exp" flag: invalid time format \`\` must be one + * of: \`2006-01-02T15:04:05Z07:00\`` (a single format, since `--exp` registers only + * RFC3339). Returns Unix seconds on success. + */ +export function legacyParseBearerJwtExp(value: string): number { + const parsed = RFC3339_PATTERN.test(value) ? Date.parse(value) : Number.NaN; + if (Number.isNaN(parsed)) { + throw new Error( + `invalid argument "${value}" for "--exp" flag: invalid time format \`${value}\` must be one of: \`2006-01-02T15:04:05Z07:00\``, + ); + } + return Math.floor(parsed / 1000); +} + +/** + * Go's `--valid-for` flag (`DurationVar(&validFor, "valid-for", time.Minute*30, ...)`, + * `apps/cli-go/cmd/gen.go:179`) — same parse-time-failure shape as `--exp` above, + * wrapping `legacyParseGoDuration`'s own Go-format `time: invalid duration "..."` text. + * Returns whole seconds, truncated from the parsed nanoseconds — sub-second + * `--valid-for` precision is not observable in the resulting `exp`/`iat` claims, which + * are themselves truncated to the second (Go's `jwt.NewNumericDate` truncation). + */ +export function legacyParseBearerJwtValidFor(value: string): number { + try { + return Math.floor(legacyParseGoDuration(value) / 1_000_000_000); + } catch (cause) { + throw new Error( + `invalid argument "${value}" for "--valid-for" flag: ${legacyBearerJwtErrorMessage(cause)}`, + ); + } +} diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts new file mode 100644 index 0000000000..84312765d2 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { legacyParseBearerJwtExp, legacyParseBearerJwtValidFor } from "./bearer-jwt.flags.ts"; + +describe("legacyParseBearerJwtExp", () => { + it("parses a UTC RFC3339 timestamp to Unix seconds", () => { + expect(legacyParseBearerJwtExp("2020-01-01T00:00:00Z")).toBe(1_577_836_800); + }); + + it("honors a non-zero numeric offset", () => { + // "+05:00" means local wall-clock time is 5 hours AHEAD of UTC, so the same wall + // time is an EARLIER instant than at "Z" — verified against the real binary + // (CLI-1961): 2030-01-01T00:00:00+05:00 -> exp 1893438000, ...Z -> exp 1893456000. + const withOffset = legacyParseBearerJwtExp("2030-01-01T00:00:00+05:00"); + const atZ = legacyParseBearerJwtExp("2030-01-01T00:00:00Z"); + expect(withOffset).toBe(atZ - 5 * 60 * 60); + }); + + it("rejects a malformed value with pflag's exact wrapped message", () => { + expect(() => legacyParseBearerJwtExp("notatime")).toThrow( + 'invalid argument "notatime" for "--exp" flag: invalid time format `notatime` must be one of: `2006-01-02T15:04:05Z07:00`', + ); + }); + + it("rejects a value missing the required timezone offset", () => { + expect(() => legacyParseBearerJwtExp("2020-01-01T00:00:00")).toThrow( + 'invalid argument "2020-01-01T00:00:00" for "--exp" flag:', + ); + }); +}); + +describe("legacyParseBearerJwtValidFor", () => { + it("parses a Go duration string to whole seconds", () => { + expect(legacyParseBearerJwtValidFor("30m")).toBe(1800); + expect(legacyParseBearerJwtValidFor("1h")).toBe(3600); + }); + + it("accepts a negative duration, matching Go's unchecked arithmetic", () => { + expect(legacyParseBearerJwtValidFor("-5m")).toBe(-300); + }); + + it("rejects a malformed value with pflag's exact wrapped message", () => { + expect(() => legacyParseBearerJwtValidFor("xyz")).toThrow( + 'invalid argument "xyz" for "--valid-for" flag: time: invalid duration "xyz"', + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.handler.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.handler.ts index 24dd26251a..0e6016da44 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.handler.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.handler.ts @@ -1,16 +1,84 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Effect, FileSystem, Path } from "effect"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; +import { legacySignJwtWithJwk } from "../../../shared/legacy-go-jwt.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; import type { LegacyGenBearerJwtFlags } from "./bearer-jwt.command.ts"; +import { + legacyBuildBearerJwtClaims, + legacyEncodeBearerJwtClaims, + legacyMergeBearerJwtPayload, +} from "./bearer-jwt.claims.ts"; +import { + legacyBearerJwtErrorMessage, + LegacyGenBearerJwtPayloadError, + LegacyGenBearerJwtSignError, +} from "./bearer-jwt.errors.ts"; +import { legacyResolveBearerJwtSigningKey } from "./bearer-jwt.signing-key.ts"; +/** + * Go's `gen bearer-jwt` (`apps/cli-go/cmd/gen.go:132-143` + `internal/gen/bearerjwt/bearerjwt.go`): + * fully local, no Docker, no network. Order matches Go exactly: + * + * 1. `parseClaims` (`cmd/gen.go:136-141`, ported as {@link legacyBuildBearerJwtClaims} + + * {@link legacyMergeBearerJwtPayload}) — runs entirely BEFORE `bearerjwt.Run` is even + * called, so a malformed `--payload` fails before any config load or signing-key + * prompt ever happens. + * 2. `bearerjwt.Run`'s `flags.LoadConfig` (`bearerjwt.go:20`) — loads the project `.env` + * cascade as part of `Config.Load` (see SIDE_EFFECTS.md); ported via + * `legacyLoadProjectEnv` for the same failure mode, even though this command has no + * `.env`-sourced prompt of its own to gate. + * 3. `getSigningKey` (`bearerjwt.go:23`, ported as {@link legacyResolveBearerJwtSigningKey} + * in `bearer-jwt.signing-key.ts`) — resolves a JWK, prompting interactively when + * needed. + * 4. `config.GenerateAsymmetricJWT` (`bearerjwt.go:27`, ported as + * `legacySignJwtWithJwk` in `legacy-go-jwt.ts`) — signs the claims. + * 5. `fmt.Fprintln(w, token)` (`bearerjwt.go:31`) — the token, then exactly one + * trailing newline, on stdout. Nothing else ever reaches stdout; every prompt and + * error goes to stderr. + * + * Unconditional on `--output-format`, matching `gen signing-key`'s own established + * precedent (`signing-key.handler.ts`): the raw token IS the payload — there is no + * separate human/machine shape to choose between, and Go's own command has no + * `-o`/`--output-format` concept at all. + */ export const legacyGenBearerJwt = Effect.fn("legacy.gen.bearer-jwt")(function* ( flags: LegacyGenBearerJwtFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["gen", "bearer-jwt"]; - if (Option.isSome(flags.role)) args.push("--role", flags.role.value); - if (Option.isSome(flags.sub)) args.push("--sub", flags.sub.value); - if (Option.isSome(flags.exp)) args.push("--exp", flags.exp.value); - if (Option.isSome(flags.validFor)) args.push("--valid-for", flags.validFor.value); - if (Option.isSome(flags.payload)) args.push("--payload", flags.payload.value); - yield* proxy.exec(args); + const cliConfig = yield* LegacyCliConfig; + const telemetryState = yield* LegacyTelemetryState; + const output = yield* Output; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + return yield* Effect.gen(function* () { + const nowSeconds = Math.floor(Date.now() / 1000); + const baseClaims = legacyBuildBearerJwtClaims({ + role: flags.role, + sub: flags.sub, + expiresAt: flags.exp, + validForSeconds: flags.validFor, + nowSeconds, + }); + const claims = yield* Effect.try({ + try: () => legacyMergeBearerJwtPayload(baseClaims, flags.payload), + catch: (cause) => + new LegacyGenBearerJwtPayloadError({ + message: `failed to parse payload: ${legacyBearerJwtErrorMessage(cause)}`, + }), + }); + + yield* legacyLoadProjectEnv(fs, path, cliConfig.workdir); + const jwk = yield* legacyResolveBearerJwtSigningKey(cliConfig.workdir); + + const payloadJson = legacyEncodeBearerJwtClaims(claims); + const token = yield* Effect.try({ + try: () => legacySignJwtWithJwk(jwk, payloadJson), + catch: (cause) => + new LegacyGenBearerJwtSignError({ message: legacyBearerJwtErrorMessage(cause) }), + }); + + yield* output.raw(`${token}\n`, "stdout"); + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts new file mode 100644 index 0000000000..99551e8e2c --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts @@ -0,0 +1,752 @@ +import { generateKeyPairSync } from "node:crypto"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; +import { CliOutput, Command } from "effect/unstable/cli"; +import { importJWK, jwtVerify } from "jose"; + +import { + mockAnalytics, + mockOutput, + mockRuntimeInfo, + mockStdin, + mockTty, +} from "../../../../../tests/helpers/mocks.ts"; +import { + buildLegacyTestRuntime, + mockLegacyCliConfig, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { CliArgs } from "../../../../shared/cli/cli-args.service.ts"; +import { LEGACY_GLOBAL_FLAGS } from "../../../../shared/legacy/global-flags.ts"; +import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; +import { processControlLayer } from "../../../../shared/runtime/process-control.layer.ts"; +import { TelemetryRuntime } from "../../../../shared/telemetry/runtime.service.ts"; +import { makeTelemetryIdentity } from "../../../../shared/telemetry/identity.ts"; +import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { legacyGenCommand } from "../gen.command.ts"; +import type { LegacyGenBearerJwtFlags } from "./bearer-jwt.command.ts"; +import { legacyGenBearerJwt } from "./bearer-jwt.handler.ts"; + +const tempRoot = useLegacyTempWorkdir("supabase-gen-bearer-jwt-int-"); + +const LEGACY_DEFAULT_SIGNING_KEY_PUBLIC = { + kty: "EC", + crv: "P-256", + x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", + y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", +}; + +function generateEcJwk(kid: string) { + const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const jwk = privateKey.export({ format: "jwk" }) as Record; + return { ...jwk, kty: "EC", alg: "ES256", kid }; +} + +function generateRsaJwk(kid: string) { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const jwk = privateKey.export({ format: "jwk" }) as Record; + return { ...jwk, kty: "RSA", alg: "RS256", kid }; +} + +function publicJwkOf(jwk: Record): Record { + const { d: _d, p: _p, q: _q, dp: _dp, dq: _dq, qi: _qi, ...publicJwk } = jwk; + return publicJwk; +} + +interface SetupOptions { + readonly stdinIsTty?: boolean; + readonly pipedAnswer?: string; + readonly promptSelectResponses?: ReadonlyArray; + readonly trackTelemetry?: boolean; +} + +function setup(options: SetupOptions = {}) { + const out = mockOutput({ + format: "text", + interactive: options.stdinIsTty ?? false, + promptSelectResponses: options.promptSelectResponses, + }); + const api = mockLegacyPlatformApi(); + const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current, projectId: Option.none() }); + const tty = mockTty({ + stdinIsTty: options.stdinIsTty ?? false, + stdoutIsTty: options.stdinIsTty ?? false, + }); + const telemetry = options.trackTelemetry ? mockLegacyTelemetryStateTracked() : undefined; + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ out, api, cliConfig, tty, telemetry: telemetry?.layer }), + Layer.succeed(CliArgs, { args: [] }), + mockStdin(options.stdinIsTty ?? false, options.pipedAnswer), + Layer.succeed(LegacyDebugLogger, { debug: () => Effect.void, http: () => Effect.void }), + ); + return { layer, out, telemetry }; +} + +async function writeConfig(contents: string) { + await mkdir(join(tempRoot.current, "supabase"), { recursive: true }); + await writeFile(join(tempRoot.current, "supabase", "config.toml"), contents); +} + +async function writeSigningKeys(contents: string) { + await mkdir(join(tempRoot.current, "supabase"), { recursive: true }); + await writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), contents); +} + +const baseFlags: LegacyGenBearerJwtFlags = { + role: "anon", + sub: Option.none(), + exp: Option.none(), + validFor: 1800, + payload: "{}", +}; + +function decodeSegment(segment: string): unknown { + return JSON.parse(Buffer.from(segment, "base64url").toString("utf8")); +} + +function tokenFrom(out: { stdoutText: string }): string { + return out.stdoutText.trimEnd(); +} + +const legacyTestRoot = Command.make("supabase").pipe( + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), + Command.withSubcommands([legacyGenCommand]), +); + +describe("legacy gen bearer-jwt integration", () => { + it.live("mints a token with the built-in default ES256 key when no config exists", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyGenBearerJwt(baseFlags); + + // Go: `fmt.Fprintln(w, token)` — the token, then exactly one trailing newline, + // nothing else on stdout. + expect(out.stdoutText.endsWith("\n")).toBe(true); + expect(out.stdoutText.indexOf("\n")).toBe(out.stdoutText.length - 1); + + const token = tokenFrom(out); + const [header, payload] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", + typ: "JWT", + }); + const claims = decodeSegment(payload ?? "") as Record; + expect(claims["role"]).toBe("anon"); + expect(typeof claims["exp"]).toBe("number"); + expect(typeof claims["iat"]).toBe("number"); + expect((claims["exp"] as number) - (claims["iat"] as number)).toBe(1800); + + const publicKey = yield* Effect.promise(() => + importJWK(LEGACY_DEFAULT_SIGNING_KEY_PUBLIC, "ES256"), + ); + const verified = yield* Effect.promise(() => jwtVerify(token, publicKey)); + expect(verified.payload).toMatchObject({ role: "anon" }); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "mints a token with the default key when config.toml exists but signing_keys_path is not set", + () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeConfig("[auth]\nenabled = true\n")); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", + typ: "JWT", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("sets is_anonymous when role is authenticated and --sub is not given", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyGenBearerJwt({ ...baseFlags, role: "authenticated" }); + const [, payload] = tokenFrom(out).split("."); + const claims = decodeSegment(payload ?? "") as Record; + expect(claims["is_anonymous"]).toBe(true); + expect("sub" in claims).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("does not set is_anonymous when role is authenticated and --sub is given", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyGenBearerJwt({ + ...baseFlags, + role: "authenticated", + sub: Option.some("user-1"), + }); + const [, payload] = tokenFrom(out).split("."); + const claims = decodeSegment(payload ?? "") as Record; + expect(claims["is_anonymous"]).toBeUndefined(); + expect(claims["sub"]).toBe("user-1"); + }).pipe(Effect.provide(layer)); + }); + + it.live("computes exp from an explicit --exp, with iat = exp - validFor", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyGenBearerJwt({ ...baseFlags, exp: Option.some(2_000_000_000) }); + const [, payload] = tokenFrom(out).split("."); + const claims = decodeSegment(payload ?? "") as Record; + expect(claims["exp"]).toBe(2_000_000_000); + expect(claims["iat"]).toBe(2_000_000_000 - 1800); + }).pipe(Effect.provide(layer)); + }); + + it.live("merges --payload on top of the computed claims", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyGenBearerJwt({ + ...baseFlags, + role: "postgres", + payload: '{"role":"override","sb-role":"mgmt-api"}', + }); + const [, payload] = tokenFrom(out).split("."); + const claims = decodeSegment(payload ?? "") as Record; + expect(claims["role"]).toBe("override"); + expect(claims["sb-role"]).toBe("mgmt-api"); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "fails with Go's exact wrapping for a malformed --payload, before any signing-key prompt", + () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt({ ...baseFlags, payload: "not json" })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtPayloadError"); + expect(json).toContain("failed to parse payload:"); + } + // No signing-key prompt should have been reached — the payload merge runs first. + expect(out.stderrText).toBe(""); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("Branch A: accepts a pasted RS256 JWK from stdin", () => { + const jwk = generateRsaJwk("rsa-kid"); + const { layer, out } = setup({ pipedAnswer: JSON.stringify(jwk) }); + return Effect.gen(function* () { + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); + + const publicKey = yield* Effect.promise(() => importJWK(publicJwkOf(jwk), "RS256")); + const verified = yield* Effect.promise(() => jwtVerify(token, publicKey)); + expect(verified.payload).toMatchObject({ role: "anon" }); + expect(out.stderrText).toContain( + "Enter your signing key in JWK format (or leave blank to use local default): ", + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("Branch A: on a real TTY, still prompts via stdin but does not echo the answer", () => { + // Go's Branch A ALWAYS uses plain `PromptText` regardless of TTY-ness — only + // Branches B/C (a configured `signing_keys_path`) fork on interactivity. On a + // real TTY the terminal's own line-editing already echoes what was typed, so + // `legacyConsolePromptText` must not double-echo it itself. + const jwk = generateEcJwk("ec-kid"); + const { layer, out } = setup({ stdinIsTty: true, pipedAnswer: JSON.stringify(jwk) }); + return Effect.gen(function* () { + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ alg: "ES256", kid: "ec-kid", typ: "JWT" }); + expect(out.stderrText).toBe( + "Enter your signing key in JWK format (or leave blank to use local default): ", + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("Branch A: rejects malformed JSON pasted at the stdin JWK prompt", () => { + const { layer } = setup({ pipedAnswer: "not-json-at-all" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtKeyParseError"); + expect(json).toContain("failed to parse JWK:"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("Branch A: rejects a JSON array pasted at the stdin JWK prompt", () => { + const { layer } = setup({ pipedAnswer: "[1,2,3]" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtKeyParseError"); + expect(json).toContain("cannot unmarshal array into Go value of type config.JWK"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("Branch A: rejects a scalar number pasted at the stdin JWK prompt", () => { + const { layer } = setup({ pipedAnswer: "123" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "cannot unmarshal number into Go value of type config.JWK", + ); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("Branch A: rejects a scalar string pasted at the stdin JWK prompt", () => { + const { layer } = setup({ pipedAnswer: '"a string"' }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "cannot unmarshal string into Go value of type config.JWK", + ); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("Branch A: rejects a scalar boolean pasted at the stdin JWK prompt", () => { + const { layer } = setup({ pipedAnswer: "true" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "cannot unmarshal bool into Go value of type config.JWK", + ); + } + }).pipe(Effect.provide(layer)); + }); + + it.live( + "Branch A: a literal 'null' pasted at the stdin JWK prompt falls back to the default key", + () => { + const { layer, out } = setup({ pipedAnswer: "null" }); + return Effect.gen(function* () { + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", + typ: "JWT", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: mints a token from the configured signing_keys_path's only key on a blank kid answer", + () => { + const jwk = generateEcJwk("ec-kid"); + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([jwk]))); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ alg: "ES256", kid: "ec-kid", typ: "JWT" }); + expect(out.stderrText).toContain( + "Enter the kid of your signing key (or leave blank to use the first one): ", + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: fails with Go's exact wrapped message for an unsupported key type (bearerjwt_test.go parity)", + () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([{ kty: "oct" }]))); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtSignError"); + expect(json).toContain("failed to convert JWK to private key: unsupported key type: oct"); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("Branch B: fails with an empty key type when the stored key omits kty entirely", () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([{ alg: "ES256" }]))); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "failed to convert JWK to private key: unsupported key type: ", + ); + } + }).pipe(Effect.provide(layer)); + }); + + it.live( + "Branch B: selects a key by exact kid match among several (bearerjwt_test.go parity)", + () => { + const ecJwk = generateEcJwk("ec-kid"); + const rsaJwk = generateRsaJwk("rsa-kid"); + const { layer, out } = setup({ pipedAnswer: "rsa-kid" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([ecJwk, rsaJwk]))); + + yield* legacyGenBearerJwt({ ...baseFlags, role: "postgres" }); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: an unmatched kid, with a blank fallback available, still errors (bearerjwt_test.go parity)", + () => { + const { layer } = setup({ pipedAnswer: "test-key" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys("[]")); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtKeyNotFoundError"); + expect(json).toContain("signing key not found: test-key"); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: an exact kid match on a key with an empty kid wins ahead of the blank-input fallback-to-first", + () => { + const namedKey = generateEcJwk("named-kid"); + const { kid: _kid, ...unnamedKey } = generateEcJwk("unused"); + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + // `namedKey` is listed FIRST, but has a non-empty kid; `unnamedKey` (no kid + // field at all -> "") is listed SECOND. A blank answer must still resolve to + // `unnamedKey` via the exact-match loop, not to `namedKey` via "return first". + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([namedKey, unnamedKey]))); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ alg: "ES256", typ: "JWT" }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch C: TTY picks a key via the interactive selector and echoes Selected key ID", + () => { + const ecJwk = generateEcJwk("ec-kid"); + const rsaJwk = generateRsaJwk("rsa-kid"); + const { layer, out } = setup({ stdinIsTty: true, promptSelectResponses: ["1"] }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([ecJwk, rsaJwk]))); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "info", message: "Selected key ID: rsa-kid" }), + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch C: TTY renders an empty label/kid/hint for a stored key missing kid and alg", + () => { + const { kid: _kid, alg: _alg, ...bareKey } = generateEcJwk("unused"); + const { layer, out } = setup({ stdinIsTty: true, promptSelectResponses: ["0"] }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([bareKey]))); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + // No `alg` at all fails downstream in the shared signer ("unsupported + // algorithm: "), but the picker itself must still render before that. + expect(Exit.isFailure(exit)).toBe(true); + expect(out.promptSelectCalls[0]?.options[0]?.label).toBe(""); + expect(out.promptSelectCalls[0]?.options[0]?.hint).toBe(" ()"); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "info", message: "Selected key ID: " }), + ); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch C: TTY renders the key's use/ext/key_ops fields when a stored key carries them", + () => { + const jwk = { + ...generateEcJwk("full-kid"), + use: "sig", + ext: true, + key_ops: ["sign", "verify"], + }; + const { layer, out } = setup({ stdinIsTty: true, promptSelectResponses: ["0"] }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([jwk]))); + + yield* legacyGenBearerJwt(baseFlags); + expect(out.promptSelectCalls[0]?.options[0]?.hint).toBe("ES256 (sign,verify)"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "auth.enabled = false with signing_keys_path configured still uses the built-in default key (Go quirk)", + () => { + const otherJwk = generateEcJwk("configured-kid"); + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nenabled = false\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([otherJwk]))); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + // The default key's kid, NOT the file's key — the file is never read when + // auth.enabled is false (verified against the real binary, CLI-1961). + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", + typ: "JWT", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "auth.enabled = false with signing_keys_path configured: a real kid from the file is reported not found", + () => { + const otherJwk = generateEcJwk("configured-kid"); + const { layer } = setup({ pipedAnswer: "configured-kid" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nenabled = false\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([otherJwk]))); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("signing key not found: configured-kid"); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("fails when signing_keys_path is configured but the file is missing", () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtReadError"); + expect(json).toContain("failed to read signing keys"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the configured signing keys file is not valid JSON at all", () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys("not valid json {")); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtDecodeError"); + expect(json).toContain("failed to decode signing keys:"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the configured signing keys file is not a JSON array at all", () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys("{}")); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtDecodeError"); + expect(json).toContain("expected a JSON array"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the configured signing keys file is a JSON array of non-objects", () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys("[1, 2]")); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtDecodeError"); + expect(json).toContain("expected a JSON array of objects"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with a config parse error when config.toml is malformed", () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => writeConfig("not valid toml ][")); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyGenBearerJwtConfigParseError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry state after a successful run", () => { + const { layer, telemetry } = setup({ trackTelemetry: true }); + return Effect.gen(function* () { + yield* legacyGenBearerJwt(baseFlags); + expect(telemetry?.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry state even when the signing-key resolution fails", () => { + const { layer, telemetry } = setup({ trackTelemetry: true }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + // No signing_keys.json written -> LegacyGenBearerJwtReadError. + yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(telemetry?.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("runs through the command wiring without missing runtime services", () => { + const out = mockOutput({ format: "text", interactive: false }); + const analytics = mockAnalytics(); + const layer = Layer.mergeAll( + BunServices.layer, + processControlLayer, + CliOutput.layer(textCliOutputFormatter()), + out.layer, + analytics.layer, + mockRuntimeInfo({ cwd: tempRoot.current, homeDir: tempRoot.current }), + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + Layer.succeed(CliArgs, { args: [] }), + mockStdin(false), + Layer.succeed( + TelemetryRuntime, + TelemetryRuntime.of({ + configDir: join(tempRoot.current, ".supabase"), + tracesDir: join(tempRoot.current, ".supabase", "traces"), + consent: "granted", + showDebug: false, + deviceId: "test-device-id", + sessionId: "test-session-id", + identity: makeTelemetryIdentity(undefined), + isFirstRun: false, + isTty: false, + isCi: false, + os: "linux", + arch: "x64", + cliVersion: "0.1.0", + }), + ), + ); + + return Effect.gen(function* () { + yield* Command.runWith(legacyTestRoot, { version: "0.0.0-test" })([ + "gen", + "bearer-jwt", + "--role", + "service_role", + "--workdir", + tempRoot.current, + ]); + + const token = tokenFrom(out); + const [, payload] = token.split("."); + const claims = decodeSegment(payload ?? "") as Record; + expect(claims["role"]).toBe("service_role"); + }).pipe(Effect.provide(layer)) as Effect.Effect; + }); +}); diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts new file mode 100644 index 0000000000..54fce68916 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts @@ -0,0 +1,215 @@ +import { Effect, Option } from "effect"; +import { + legacyReadSigningKeysFile, + legacyResolveSigningKeysConfigPaths, +} from "../gen.signing-keys-config.ts"; +import { LEGACY_DEFAULT_SIGNING_KEY, type LegacyJwk } from "../../../shared/legacy-go-jwt.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { Stdin } from "../../../../shared/runtime/stdin.service.ts"; +import { Tty } from "../../../../shared/runtime/tty.service.ts"; +import { legacyGoJsonKindName } from "./bearer-jwt.claims.ts"; +import { + legacyBearerJwtErrorMessage, + LegacyGenBearerJwtConfigParseError, + LegacyGenBearerJwtDecodeError, + LegacyGenBearerJwtKeyNotFoundError, + LegacyGenBearerJwtKeyParseError, + LegacyGenBearerJwtReadError, +} from "./bearer-jwt.errors.ts"; + +/** Go's `Console.ReadLine` timeouts (`apps/cli-go/internal/utils/console.go:35-36`). */ +const GO_CONSOLE_TTY_TIMEOUT_MILLIS = 10 * 60 * 1000; +const GO_CONSOLE_NON_TTY_TIMEOUT_MILLIS = 100; + +/** + * Port of Go's `Console.PromptText` (`apps/cli-go/internal/utils/console.go:96-107`): + * writes `label` to stderr with NO trailing newline, reads one line bounded by a + * TTY-aware timeout, and — only on a non-TTY — echoes the (trimmed) input back to + * stderr. On a real TTY the terminal's own line-editing already echoes what the user + * types, so no explicit echo is written there, matching Go exactly. Used for BOTH of + * `getSigningKey`'s text prompts (`bearerjwt.go:38`, `:54`) — the branch between them + * is purely which label/fallback the caller applies to the returned string, not how + * the read itself behaves. + */ +const legacyConsolePromptText = Effect.fnUntraced(function* (label: string) { + const output = yield* Output; + const tty = yield* Tty; + const stdin = yield* Stdin; + yield* output.raw(label, "stderr"); + const line = yield* stdin.readLine( + tty.stdinIsTty ? GO_CONSOLE_TTY_TIMEOUT_MILLIS : GO_CONSOLE_NON_TTY_TIMEOUT_MILLIS, + ); + const input = Option.getOrElse(line, () => ""); + if (!tty.stdinIsTty) { + yield* output.raw(`${input}\n`, "stderr"); + } + return input; +}); + +function readOptionalString(record: Record, field: string): string | undefined { + const value = record[field]; + return typeof value === "string" ? value : undefined; +} + +function readOptionalStringArray( + record: Record, + field: string, +): ReadonlyArray | undefined { + const value = record[field]; + return Array.isArray(value) && value.every((entry) => typeof entry === "string") + ? (value as ReadonlyArray) + : undefined; +} + +/** + * Narrows an untrusted JSON record (a `signing_keys_path` file entry, or a pasted + * stdin JWK) into `LegacyJwk`'s shape — every field Go's own `config.JWK` struct + * would leave at its zero value (`""`/absent) when missing from the JSON decodes the + * same way here. Every downstream consumer in this file works with the resulting + * typed `LegacyJwk`, not the raw untrusted record, so key/kid/alg lookups stay + * type-checked instead of re-guarding `Record` at every call site. + */ +function normalizeStoredJwk(record: Record): LegacyJwk { + const keyOps = readOptionalStringArray(record, "key_ops"); + return { + kty: readOptionalString(record, "kty") ?? "", + kid: readOptionalString(record, "kid"), + use: readOptionalString(record, "use"), + key_ops: keyOps !== undefined ? [...keyOps] : undefined, + alg: readOptionalString(record, "alg"), + ext: typeof record["ext"] === "boolean" ? record["ext"] : undefined, + n: readOptionalString(record, "n"), + e: readOptionalString(record, "e"), + d: readOptionalString(record, "d"), + p: readOptionalString(record, "p"), + q: readOptionalString(record, "q"), + dp: readOptionalString(record, "dp"), + dq: readOptionalString(record, "dq"), + qi: readOptionalString(record, "qi"), + crv: readOptionalString(record, "crv"), + x: readOptionalString(record, "x"), + y: readOptionalString(record, "y"), + }; +} + +/** + * Go's `getSigningKey` Branch A (`bearerjwt.go:37-51`, reached when + * `[auth].signing_keys_path` is NOT configured): prompt for a raw JWK, falling back to + * the built-in default ES256 dev key on a blank answer. + */ +const resolveSigningKeyFromStdinJwk = Effect.fnUntraced(function* () { + const input = yield* legacyConsolePromptText( + "Enter your signing key in JWK format (or leave blank to use local default): ", + ); + if (input.length === 0) { + return LEGACY_DEFAULT_SIGNING_KEY; + } + let parsed: unknown; + try { + parsed = JSON.parse(input); + } catch (cause) { + return yield* Effect.fail( + new LegacyGenBearerJwtKeyParseError({ + message: `failed to parse JWK: ${legacyBearerJwtErrorMessage(cause)}`, + }), + ); + } + // A JSON `null` answer is treated the same as blank (no-op / use the default) — + // matching `legacyMergeBearerJwtPayload`'s own null-is-a-no-op judgment call for + // this un-tested-by-Go edge case; see that module's doc comment. + if (parsed === null) { + return LEGACY_DEFAULT_SIGNING_KEY; + } + if (typeof parsed !== "object" || Array.isArray(parsed)) { + return yield* Effect.fail( + new LegacyGenBearerJwtKeyParseError({ + message: `failed to parse JWK: json: cannot unmarshal ${legacyGoJsonKindName(parsed)} into Go value of type config.JWK`, + }), + ); + } + return normalizeStoredJwk(parsed as Record); +}); + +/** + * Go's `getSigningKey` Branches B/C (`bearerjwt.go:52-83`, reached when + * `[auth].signing_keys_path` IS configured): non-TTY prompts for a kid by exact + * string match (falling back to the first key on a blank answer); a real TTY + * presents an interactive picker instead (`output.promptSelect`, the same + * `@clack/prompts`-backed pattern `legacy-project-ref.layer.ts` already uses for + * Go's bubbletea `PromptChoice` — the rendered ANSI never byte-matches Go's TUI + * either way, so this codebase's established precedent is to match only the + * observable stderr line Go itself prints after a choice, "Selected key ID: "). + */ +const resolveSigningKeyFromConfigured = Effect.fnUntraced(function* ( + availableKeys: ReadonlyArray, +) { + const tty = yield* Tty; + + if (!tty.stdinIsTty) { + const kid = yield* legacyConsolePromptText( + "Enter the kid of your signing key (or leave blank to use the first one): ", + ); + // Go's loop checks every key for an EXACT `KeyID` match BEFORE the blank-input + // fallback (`bearerjwt.go:59-66`) — so a key whose own `kid` is literally `""` + // still matches a blank answer here, ahead of "return the first key". + const found = availableKeys.find((key) => (key.kid ?? "") === kid); + if (found !== undefined) { + return found; + } + if (kid.length === 0 && availableKeys.length > 0) { + return availableKeys[0]!; + } + return yield* Effect.fail( + new LegacyGenBearerJwtKeyNotFoundError({ message: `signing key not found: ${kid}` }), + ); + } + + const output = yield* Output; + const options = availableKeys.map((key, index) => ({ + value: String(index), + label: key.kid ?? "", + hint: `${key.alg ?? ""} (${(key.key_ops ?? []).join(",")})`, + })); + const chosen = yield* output.promptSelect("Select a signing key:", options); + const chosenKey = availableKeys[Number(chosen)]!; + // Go: `fmt.Fprintln(os.Stderr, "Selected key ID:", choice.Summary)` (`bearerjwt.go:82`). + yield* output.info(`Selected key ID: ${chosenKey.kid ?? ""}`); + return chosenKey; +}); + +/** + * Go's `getSigningKey` (`apps/cli-go/internal/gen/bearerjwt/bearerjwt.go:35-84`), fully + * assembled: resolves `[auth].signing_keys_path`'s config, then dispatches to Branch + * A (unconfigured) or Branches B/C (configured, non-TTY/TTY). + * + * Reproduces the verified `[auth].enabled = false` quirk: Go's `Config.Validate` only + * reads the `signing_keys_path` FILE inside `if c.Auth.Enabled` (`config.go:1087`), but + * `getSigningKey` only checks whether the PATH STRING is configured + * (`len(SigningKeysPath) == 0`) — independent of `auth.enabled`. So when auth is + * disabled and a path IS configured, the kid-prompt branch still runs, but the actual + * available keys stay the built-in default (the file is never read) — a real user + * hitting this combination sees a misleading kid prompt they can never satisfy with + * their own file's kids. `gen signing-key`'s OWN sibling resolver deliberately does + * NOT replicate this gate (see `gen.signing-keys-config.ts`'s doc comment) — it reads + * unconditionally, matching that command's own existing, unrelated Go source. + */ +export const legacyResolveBearerJwtSigningKey = Effect.fnUntraced(function* (workdir: string) { + const paths = yield* legacyResolveSigningKeysConfigPaths( + workdir, + (message) => new LegacyGenBearerJwtConfigParseError({ message }), + ); + + if (Option.isNone(paths.signingKeysPath)) { + return yield* resolveSigningKeyFromStdinJwk(); + } + + const availableKeys: ReadonlyArray = paths.authEnabled + ? (yield* legacyReadSigningKeysFile( + paths.signingKeysPath.value.actualPath, + (message) => new LegacyGenBearerJwtReadError({ message }), + (message) => new LegacyGenBearerJwtDecodeError({ message }), + )).map(normalizeStoredJwk) + : [LEGACY_DEFAULT_SIGNING_KEY]; + + return yield* resolveSigningKeyFromConfigured(availableKeys); +}); diff --git a/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts new file mode 100644 index 0000000000..c40d1de782 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts @@ -0,0 +1,129 @@ +import { loadProjectConfig } from "@supabase/config"; +import { Effect, FileSystem, Option, Path } from "effect"; + +/** + * Shared `[auth].signing_keys_path` config-loading logic for the `gen` command + * family — used by both `gen signing-key` (`signing-key.handler.ts`, generating + * or appending a key) and `gen bearer-jwt` (`bearer-jwt.handler.ts`, resolving + * a key to sign with). Per `apps/cli/CLAUDE.md`'s "hoist before you duplicate" + * rule: this logic is used by ≥2 commands in the same command family, so it + * lives at the family root (`legacy/commands/gen/`) rather than being inlined + * in either sibling. + * + * Error TYPES are intentionally NOT shared — each caller passes its own + * tagged-error constructors (mirroring `sso.saml.ts`'s `readMetadataFile` + * pattern), so `gen signing-key` and `gen bearer-jwt` keep independent error + * hierarchies while sharing the actual file-resolution/read/decode logic. + */ + +export type StoredSigningKeyJwk = Readonly>; + +interface LegacyGenSigningKeysConfigPaths { + /** CWD-relative `supabase/config.toml` (or the resolved config file's own display path). */ + readonly configDisplayPath: string; + /** + * `[auth].enabled` from the resolved config (default `true`). Go's `Config.Validate` + * only reads `[auth].signing_keys_path`'s file INSIDE `if c.Auth.Enabled` — callers + * that must replicate that gate (`gen bearer-jwt`'s `getSigningKey`) branch on this; + * `gen signing-key` does not (see its own doc comment for why that's intentional). + */ + readonly authEnabled: boolean; + /** `Option.some` when `[auth].signing_keys_path` is configured (non-empty). */ + readonly signingKeysPath: Option.Option<{ + readonly actualPath: string; + readonly displayPath: string; + }>; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** + * Resolves `supabase/config.toml`'s display path and `[auth].signing_keys_path`'s + * actual/display path — no file I/O on the keys path itself (see + * {@link legacyReadSigningKeysFile} for that). Mirrors Go's `flags.LoadConfig` + + * `Config.Validate`'s path resolution (`apps/cli-go/pkg/config/config.go:928-930`). + */ +export const legacyResolveSigningKeysConfigPaths = Effect.fnUntraced(function* ( + cwd: string, + onConfigParseError: (message: string) => E, +) { + const path = yield* Path.Path; + const loaded = yield* loadProjectConfig(cwd, { goViperCompat: true }).pipe( + Effect.catchTag("ProjectConfigParseError", (cause) => + Effect.fail(onConfigParseError(`failed to parse ${cause.path}: ${String(cause.cause)}`)), + ), + ); + if (loaded === null) { + return { + configDisplayPath: path.join("supabase", "config.toml"), + authEnabled: true, + signingKeysPath: Option.none(), + } satisfies LegacyGenSigningKeysConfigPaths; + } + + // Go displays the CWD-relative `supabase/config.toml` (utils.ConfigPath), never an absolute + // path. `@supabase/config` always resolves `loaded.path` to an absolute path, so relativize it + // back against the project root to match Go's output. + const projectRoot = path.dirname(path.dirname(loaded.path)); + const configDisplayPath = path.relative(projectRoot, loaded.path); + const authEnabled = loaded.config.auth.enabled; + + const configuredPath = loaded.config.auth.signing_keys_path; + if (configuredPath === undefined || configuredPath.length === 0) { + return { + configDisplayPath, + authEnabled, + signingKeysPath: Option.none(), + } satisfies LegacyGenSigningKeysConfigPaths; + } + + const resolvedPath = path.isAbsolute(configuredPath) + ? configuredPath + : path.join(path.dirname(loaded.path), configuredPath); + const displayPath = path.isAbsolute(configuredPath) + ? configuredPath + : path.relative(projectRoot, resolvedPath); + return { + configDisplayPath, + authEnabled, + signingKeysPath: Option.some({ actualPath: resolvedPath, displayPath }), + } satisfies LegacyGenSigningKeysConfigPaths; +}); + +/** + * Reads and JSON-decodes a `[auth].signing_keys_path` file at `actualPath` into an array of + * JWK-shaped records. Mirrors Go's `Config.Validate` read (`config.go:1110-1116`, wrapped + * `"failed to read signing keys: %w"` / `"failed to decode signing keys: %w"`) — the + * "expected a JSON array [of objects]" shape check matches this package's own pre-existing + * `gen signing-key` behavior (not a literal Go error string; Go's decode failures come from + * `encoding/json`'s own type-mismatch errors, which `readJwkArray`'s two checks approximate). + */ +export const legacyReadSigningKeysFile = Effect.fnUntraced(function* ( + actualPath: string, + onReadError: (message: string) => E1, + onDecodeError: (message: string) => E2, +) { + const fs = yield* FileSystem.FileSystem; + const raw = yield* fs + .readFileString(actualPath) + .pipe(Effect.mapError((cause) => onReadError(`failed to read signing keys: ${String(cause)}`))); + const decoded = yield* Effect.try({ + try: () => JSON.parse(raw), + catch: (cause) => onDecodeError(`failed to decode signing keys: ${String(cause)}`), + }); + if (!Array.isArray(decoded)) { + return yield* Effect.fail( + onDecodeError("failed to decode signing keys: expected a JSON array"), + ); + } + for (const item of decoded) { + if (!isRecord(item)) { + return yield* Effect.fail( + onDecodeError("failed to decode signing keys: expected a JSON array of objects"), + ); + } + } + return decoded as ReadonlyArray; +}); diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts index 695bd660dd..24f9598e6d 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts @@ -1,6 +1,5 @@ import { generateKeyPairSync, randomUUID } from "node:crypto"; import { styleText } from "node:util"; -import { loadProjectConfig } from "@supabase/config"; import { Effect, FileSystem, Option, Path } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -14,6 +13,11 @@ import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global import { CONTEXT_CANCELED_MESSAGE } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { Tty } from "../../../../shared/runtime/tty.service.ts"; +import { + legacyReadSigningKeysFile, + legacyResolveSigningKeysConfigPaths, + type StoredSigningKeyJwk, +} from "../gen.signing-keys-config.ts"; import type { LegacyGenSigningKeyFlags } from "./signing-key.command.ts"; import { LegacyGenSigningKeyCancelledError, @@ -46,8 +50,6 @@ interface SigningKeyJwk { readonly qi?: string; } -type StoredSigningKeyJwk = Readonly>; - interface ResolvedSigningKeysConfig { readonly configDisplayPath: string; readonly configured: Option.Option<{ @@ -75,28 +77,6 @@ function readStringField( ); } -function readJwkArray( - value: unknown, -): Effect.Effect, LegacyGenSigningKeyDecodeError> { - if (!Array.isArray(value)) { - return Effect.fail( - new LegacyGenSigningKeyDecodeError({ - message: "failed to decode signing keys: expected a JSON array", - }), - ); - } - for (const item of value) { - if (!isRecord(item)) { - return Effect.fail( - new LegacyGenSigningKeyDecodeError({ - message: "failed to decode signing keys: expected a JSON array of objects", - }), - ); - } - } - return Effect.succeed(value); -} - function styleIfTty( enabled: boolean, format: Parameters[0], @@ -162,64 +142,34 @@ const generatePrivateKey = Effect.fnUntraced(function* (algorithm: SigningAlgori } satisfies SigningKeyJwk; }); +// `gen signing-key` reads its configured signing-keys file UNCONDITIONALLY once +// `[auth].signing_keys_path` resolves to a non-empty path — unlike `gen bearer-jwt`, +// it does NOT gate that read on `[auth].enabled` (Go's `signingkeys.go` has this same +// unconditional-read shape; only `gen bearer-jwt`'s `getSigningKey` needs the +// `authEnabled` gate, since it's the one call site that must reproduce Go's +// `Config.Validate`-skips-the-file-when-auth-disabled quirk — see that command's +// own `gen.signing-keys-config.ts` usage for the contrast). const loadSigningKeysConfig = Effect.fnUntraced(function* (cwd: string) { - const path = yield* Path.Path; - const loaded = yield* loadProjectConfig(cwd, { goViperCompat: true }).pipe( - Effect.catchTag("ProjectConfigParseError", (cause) => - Effect.fail( - new LegacyGenSigningKeyConfigParseError({ - message: `failed to parse ${cause.path}: ${String(cause.cause)}`, - }), - ), - ), + const paths = yield* legacyResolveSigningKeysConfigPaths( + cwd, + (message) => new LegacyGenSigningKeyConfigParseError({ message }), ); - if (loaded === null) { + if (Option.isNone(paths.signingKeysPath)) { return { - configDisplayPath: path.join("supabase", "config.toml"), + configDisplayPath: paths.configDisplayPath, configured: Option.none(), } satisfies ResolvedSigningKeysConfig; } - // Go displays the CWD-relative `supabase/config.toml` (utils.ConfigPath), never an absolute - // path. `@supabase/config` always resolves `loaded.path` to an absolute path, so relativize it - // back against the project root to match Go's output. - const projectRoot = path.dirname(path.dirname(loaded.path)); - const configDisplayPath = path.relative(projectRoot, loaded.path); - - const configuredPath = loaded.config.auth.signing_keys_path; - if (configuredPath === undefined || configuredPath.length === 0) { - return { - configDisplayPath, - configured: Option.none(), - } satisfies ResolvedSigningKeysConfig; - } - - const resolvedPath = path.isAbsolute(configuredPath) - ? configuredPath - : path.join(path.dirname(loaded.path), configuredPath); - const displayPath = path.isAbsolute(configuredPath) - ? configuredPath - : path.relative(projectRoot, resolvedPath); - const fs = yield* FileSystem.FileSystem; - const raw = yield* fs.readFileString(resolvedPath).pipe( - Effect.mapError( - (cause) => - new LegacyGenSigningKeyReadError({ - message: `failed to read signing keys: ${String(cause)}`, - }), - ), + const { actualPath, displayPath } = paths.signingKeysPath.value; + const existingKeys = yield* legacyReadSigningKeysFile( + actualPath, + (message) => new LegacyGenSigningKeyReadError({ message }), + (message) => new LegacyGenSigningKeyDecodeError({ message }), ); - const decoded = yield* Effect.try({ - try: () => JSON.parse(raw), - catch: (cause) => - new LegacyGenSigningKeyDecodeError({ - message: `failed to decode signing keys: ${String(cause)}`, - }), - }); - const existingKeys = yield* readJwkArray(decoded); return { - configDisplayPath, - configured: Option.some({ actualPath: resolvedPath, displayPath, existingKeys }), + configDisplayPath: paths.configDisplayPath, + configured: Option.some({ actualPath, displayPath, existingKeys }), } satisfies ResolvedSigningKeysConfig; }); diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.ts index 96d68e640a..d0c5ad2e94 100644 --- a/apps/cli/src/legacy/shared/legacy-go-jwt.ts +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.ts @@ -159,61 +159,99 @@ function ensureRsaCrtParams(jwk: LegacyJwk): LegacyJwk { }; } +type LegacySupportedJwtAlgorithm = "RS256" | "ES256"; + /** - * Go's `GenerateAsymmetricJWT` (`pkg/config/apikeys.go:88-113`), reached from - * `generateJWT` only when `auth.signing_keys_path` resolves to a non-empty JWK - * array (`pkg/config/apikeys.go:76-80`) — the first key in the file signs both - * the anon and service_role tokens. Same claim shape as {@link legacyGenerateGoJwt} - * (`iss`/`role`/`exp`), except the expiry is 10 years from now rather than Go's - * fixed HMAC-path timestamp, since `generateJWT` sets `claims.ExpiresAt` - * explicitly before calling this function instead of falling through to - * `CustomClaims.NewToken()`'s fixed default. + * Go's `jwkToPrivateKey` (`apps/cli-go/pkg/config/apikeys.go:120-129`): validates + * `jwk.kty`/`jwk.crv` ONLY — it has no awareness of `jwk.alg` at all. Throws Go's + * own unwrapped message text; the caller ({@link legacySignJwtWithJwk}) applies + * `GenerateAsymmetricJWT`'s `"failed to convert JWK to private key: %w"` wrapper + * (`apikeys.go:91-94`) on top. + */ +function assertSupportedKty(jwk: LegacyJwk): void { + if (jwk.kty === "EC") { + if (jwk.crv !== "P-256") { + throw new Error(`unsupported curve: ${jwk.crv ?? ""}`); + } + return; + } + if (jwk.kty !== "RSA") { + throw new Error(`unsupported key type: ${jwk.kty ?? ""}`); + } +} + +/** + * Go has NO explicit cross-check between `jwk.Algorithm` and `jwk.KeyType` before + * signing: `jwkToPrivateKey` only validates kty/curve (see {@link assertSupportedKty}), + * and `GenerateAsymmetricJWT`'s algorithm switch only validates `jwk.Algorithm` + * itself. A mismatched pair (e.g. `kty: "RSA"` signed as `ES256`) reaches + * `token.SignedString(privateKey)` and fails INSIDE golang-jwt's own signing + * method, which type-asserts the key (jwt/v5@v5.3.1 `rsa.go:76` / `ecdsa.go:99`): + * `"key is of invalid type: "`, wrapped by `apikeys.go:113` into + * `"failed to sign JWT: %w"`. Node's own `createSign(...).sign(privateKey)` would + * also fail on this mismatch, but with an OpenSSL-level message that does not + * match Go's text — so this reproduces Go's OBSERVABLE error deliberately, ahead + * of ever touching Node's signer. + */ +function assertKeyMatchesAlgorithm(jwk: LegacyJwk, algorithm: LegacySupportedJwtAlgorithm): void { + if (algorithm === "RS256" && jwk.kty !== "RSA") { + throw new Error("key is of invalid type: RSA sign expects *rsa.PrivateKey"); + } + if (algorithm === "ES256" && jwk.kty !== "EC") { + throw new Error("key is of invalid type: ECDSA sign expects *ecdsa.PrivateKey"); + } +} + +/** + * Go's `GenerateAsymmetricJWT` (`pkg/config/apikeys.go:88-113`): signs an + * already-encoded JSON claims payload with a JWK private key. Callers own their + * own claims shape/serialization (struct-field order for + * {@link legacyGenerateAsymmetricGoJwt}'s fixed anon/service_role claims, Go + * map-key alphabetical order for `gen bearer-jwt`'s `jwt.MapClaims`-shaped + * claims) — this function only handles the parts Go's `GenerateAsymmetricJWT` + * itself handles: key validation, header construction, and signing. * - * Only `RS256`/`ES256` are supported, matching Go's `jwkToPrivateKey` - * (RSA/EC key types) + this function's own switch on `jwk.alg`. `kty`/`alg` - * are cross-validated (RS256 requires `kty: "RSA"`, ES256 requires - * `kty: "EC"` and `crv: "P-256"`) — matching Go's `jwkToRSAPrivateKey` / - * `jwkToECDSAPrivateKey`, which reject any other combination rather than - * signing with a mismatched key or curve (Node's `createPrivateKey`/`createSign` - * do not themselves catch this: an EC key signed as RS256, or a non-P-256 - * curve signed as ES256, both "succeed" and produce a spec-invalid token that - * silently fails verification instead of raising an error). The header key + * Validation order matches Go exactly: kty/curve first (wrapped + * `"failed to convert JWK to private key: %w"`, {@link assertSupportedKty}), + * then the algorithm switch (unwrapped `"unsupported algorithm: %s"`), then the + * kty-vs-alg mismatch Go's OWN signing method raises (wrapped + * `"failed to sign JWT: %w"`, {@link assertKeyMatchesAlgorithm}). The header key * order (`alg`, `kid`, `typ`) matches Go's `encoding/json` alphabetically - * sorting `map[string]interface{}` keys — `kid` is only present when set on - * the JWK, matching Go's `if len(jwk.KeyID) > 0` guard. + * sorting `map[string]interface{}` keys — `kid` is only present when set on the + * JWK, matching Go's `if len(jwk.KeyID) > 0` guard. * * `dsaEncoding: "ieee-p1363"` is required for ES256: Node's default ECDSA * signature output is DER-encoded, which is not the raw (r‖s) format JWS * requires — verified by round-tripping through `jose`'s `jwtVerify`. */ -export function legacyGenerateAsymmetricGoJwt( - jwk: LegacyJwk, - role: "anon" | "service_role", -): string { +export function legacySignJwtWithJwk(jwk: LegacyJwk, payloadJson: string): string { + try { + assertSupportedKty(jwk); + } catch (cause) { + throw new Error( + `failed to convert JWK to private key: ${cause instanceof Error ? cause.message : String(cause)}`, + ); + } + const algorithm = jwk.alg; if (algorithm !== "RS256" && algorithm !== "ES256") { throw new Error(`unsupported algorithm: ${algorithm ?? ""}`); } - if (algorithm === "RS256" && jwk.kty !== "RSA") { - throw new Error(`unsupported key type: ${jwk.kty}`); - } - if (algorithm === "ES256") { - if (jwk.kty !== "EC") { - throw new Error(`unsupported key type: ${jwk.kty}`); - } - if (jwk.crv !== "P-256") { - throw new Error(`unsupported curve: ${jwk.crv ?? ""}`); - } + + try { + assertKeyMatchesAlgorithm(jwk, algorithm); + } catch (cause) { + throw new Error( + `failed to sign JWT: ${cause instanceof Error ? cause.message : String(cause)}`, + ); } + const header = jwk.kid !== undefined && jwk.kid.length > 0 ? { alg: algorithm, kid: jwk.kid, typ: "JWT" } : { alg: algorithm, typ: "JWT" }; - const expiresAt = Math.floor(Date.now() / 1000) + GO_JWT_ASYMMETRIC_EXPIRY_SECONDS; const headerEncoded = base64UrlEncode(JSON.stringify(header)); - const payloadEncoded = base64UrlEncode( - JSON.stringify({ iss: GO_JWT_ISSUER, role, exp: expiresAt }), - ); + const payloadEncoded = base64UrlEncode(payloadJson); const data = `${headerEncoded}.${payloadEncoded}`; const privateKey = createPrivateKey({ @@ -230,3 +268,24 @@ export function legacyGenerateAsymmetricGoJwt( return `${data}.${signature.toString("base64url")}`; } + +/** + * Go's `(a auth) generateJWT` asymmetric branch (`pkg/config/apikeys.go:76-80`), + * reached only when `auth.signing_keys_path` resolves to a non-empty JWK array — + * the first key in the file signs both the anon and service_role tokens. Same + * claim shape as {@link legacyGenerateGoJwt} (`iss`/`role`/`exp`), except the + * expiry is 10 years from now rather than Go's fixed HMAC-path timestamp, since + * `generateJWT` sets `claims.ExpiresAt` explicitly before calling + * `GenerateAsymmetricJWT` with a `CustomClaims` STRUCT value (not a map) — + * `encoding/json` serializes a struct in field-DECLARATION order, so this + * builds the payload with a plain (insertion-order) `JSON.stringify`, unlike + * `gen bearer-jwt`'s claims (always a real `jwt.MapClaims`, alphabetically + * key-sorted — see `bearer-jwt.claims.ts`). + */ +export function legacyGenerateAsymmetricGoJwt( + jwk: LegacyJwk, + role: "anon" | "service_role", +): string { + const expiresAt = Math.floor(Date.now() / 1000) + GO_JWT_ASYMMETRIC_EXPIRY_SECONDS; + return legacySignJwtWithJwk(jwk, JSON.stringify({ iss: GO_JWT_ISSUER, role, exp: expiresAt })); +} diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts index 5bfeabfe70..d434d7b853 100644 --- a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { legacyGenerateAsymmetricGoJwt, legacyGenerateGoJwt, + legacySignJwtWithJwk, type LegacyJwk, } from "./legacy-go-jwt.ts"; @@ -153,20 +154,43 @@ describe("legacyGenerateAsymmetricGoJwt", () => { ); }); + // Go has NO explicit kty<->alg cross-check (verified against the real binary, + // CLI-1961): `jwkToPrivateKey` only validates kty/curve, and the algorithm + // switch only validates `jwk.Algorithm` — a mismatched pair reaches + // `token.SignedString(privateKey)` and fails INSIDE golang-jwt's own signing + // method with "key is of invalid type: ...", wrapped as "failed to sign + // JWT: %w" (`apikeys.go:113`). These two tests previously asserted an + // "unsupported key type" message that Go never actually produces for this + // input — corrected here. it("rejects an EC key forged with alg: RS256 instead of signing garbage", () => { const jwk = { ...generateEcJwk(), alg: "RS256" }; - expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow("unsupported key type: EC"); + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow( + "failed to sign JWT: key is of invalid type: RSA sign expects *rsa.PrivateKey", + ); }); it("rejects an RSA key forged with alg: ES256 instead of signing garbage", () => { const jwk = { ...generateRsaJwk(), alg: "ES256" }; - expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow("unsupported key type: RSA"); + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow( + "failed to sign JWT: key is of invalid type: ECDSA sign expects *ecdsa.PrivateKey", + ); }); - it("rejects an ES256 EC key whose curve is not P-256", () => { + it("rejects an ES256 EC key whose curve is not P-256, wrapped like Go's GenerateAsymmetricJWT", () => { const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-384" }); const jwk = { ...privateKey.export({ format: "jwk" }), kty: "EC", alg: "ES256" }; - expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow("unsupported curve: P-384"); + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow( + "failed to convert JWK to private key: unsupported curve: P-384", + ); + }); + + it("rejects a JWK with no kty at all, wrapped like Go's GenerateAsymmetricJWT", () => { + // `bearerjwt_test.go`'s "throws error on unsupported kty" fixture uses exactly + // this shape (`{"kty": "oct"}`, no `alg`) — kty is checked before alg regardless. + const jwk = { kty: "oct" } as LegacyJwk; + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).toThrow( + "failed to convert JWK to private key: unsupported key type: oct", + ); }); it("rejects an ES256 EC key with no curve at all", () => { @@ -177,3 +201,20 @@ describe("legacyGenerateAsymmetricGoJwt", () => { ); }); }); + +describe("legacySignJwtWithJwk", () => { + it("signs the caller's exact pre-encoded payload string verbatim (no re-serialization)", async () => { + const jwk = generateEcJwk("ec-kid"); + // Deliberately NOT alphabetically sorted and containing characters Go's + // `encoding/json` would HTML-escape (`&`) — this function must sign exactly + // the bytes it's given, leaving ordering/escaping decisions to the caller. + const payloadJson = '{"role":"postgres","sb-role":"mgmt-api & co"}'; + const token = legacySignJwtWithJwk(jwk, payloadJson); + const [, payload] = token.split("."); + expect(decodeSegment(payload ?? "")).toBe(payloadJson); + + const publicKey = await importJWK(publicJwkOf(jwk), "ES256"); + const { payload: verified } = await jwtVerify(token, publicKey); + expect(verified).toEqual({ role: "postgres", "sb-role": "mgmt-api & co" }); + }); +}); From a7f45f813f07d5ab82a93e60f1a32df9ba454037 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Tue, 4 Aug 2026 17:28:37 +0100 Subject: [PATCH 02/12] fix(cli): address review findings on gen bearer-jwt native port (CLI-1961) Fixes several Go-parity and security divergences flagged by independent review of the native TypeScript port: --sub "" must still set is_anonymous, a stdin JWK of null must be rejected (not fall back to the default signing key), alg is validated against Go's RS256/ES256 allowlist at JWK-decode time (both the stdin and signing-keys-file paths), --exp now rejects invalid calendar dates (e.g. Feb 30) the way Go's time.Parse does, and the TTY key picker no longer crashes on a zero-key signing_keys_path file. Also fixes a sub-second --valid-for truncation-order bug, adds --exp whitespace trimming, tightens the malformed-JSON --payload error text without repeated JSON.parse retries, renames StoredSigningKeyJwk to LegacyStoredSigningKeyJwk for the mandatory legacy/ export prefix, and hoists the generic Go-JSON kind-name helper to legacy-go-json.ts. --- .../commands/gen/bearer-jwt/SIDE_EFFECTS.md | 6 +- .../gen/bearer-jwt/bearer-jwt.claims.ts | 202 ++++++++++++++---- .../bearer-jwt/bearer-jwt.claims.unit.test.ts | 135 ++++++++++-- .../gen/bearer-jwt/bearer-jwt.errors.ts | 13 ++ .../gen/bearer-jwt/bearer-jwt.flags.ts | 99 +++++++-- .../bearer-jwt/bearer-jwt.flags.unit.test.ts | 53 +++++ .../bearer-jwt/bearer-jwt.integration.test.ts | 138 +++++++++++- .../gen/bearer-jwt/bearer-jwt.signing-key.ts | 48 ++++- .../commands/gen/gen.signing-keys-config.ts | 25 ++- .../gen/signing-key/signing-key.handler.ts | 4 +- apps/cli/src/legacy/shared/legacy-go-json.ts | 21 ++ .../legacy/shared/legacy-go-json.unit.test.ts | 19 +- apps/cli/src/legacy/shared/legacy-go-jwt.ts | 21 ++ .../legacy/shared/legacy-go-jwt.unit.test.ts | 20 +- 14 files changed, 710 insertions(+), 94 deletions(-) diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/gen/bearer-jwt/SIDE_EFFECTS.md index 3427921487..b25f742e31 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/SIDE_EFFECTS.md @@ -67,8 +67,10 @@ Same as `text` above. - `--sub` sets the `sub` (subject/user ID) claim. Its Go help text cosmetically shows `(default "anonymous")`, but the real default is unset — an omitted `--sub` never puts a `sub` claim in the token at all. -- When `--role authenticated` is used with no `--sub`, the token gets `is_anonymous: -true`. Any other role, or `authenticated` with a `--sub`, never sets it. +- When `--role authenticated` is used with no `--sub`, OR with `--sub ""` (an + explicitly-passed EMPTY value — Go's gate is `len(claims.Subject) == 0`, which an + empty string also satisfies), the token gets `is_anonymous: true`. Any other role, + or `authenticated` with a NON-EMPTY `--sub`, never sets it. - `--exp` (RFC3339, e.g. `2030-01-01T00:00:00Z`) sets an explicit expiry; `iat` is then computed as `exp - --valid-for`. Without `--exp`, `iat` is "now" and `exp` is `iat + --valid-for`. diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts index ec206eae02..5b78adde02 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts @@ -1,5 +1,6 @@ import { Option } from "effect"; import { encodeGoStructJsonBody } from "../../../shared/legacy-go-output.encoders.ts"; +import { legacyGoJsonKindName } from "../../../shared/legacy-go-json.ts"; /** * Pure claims-building logic for `gen bearer-jwt`, ported from Go's `parseClaims` @@ -21,7 +22,11 @@ export interface LegacyBearerJwtClaimsInput { readonly sub: Option.Option; /** Unix seconds from `--exp` (RFC3339) — `Option.none()` when the flag was not given. */ readonly expiresAt: Option.Option; - /** `--valid-for`, already parsed from Go-duration syntax into whole seconds. */ + /** + * `--valid-for`, parsed from Go-duration syntax into seconds WITHOUT flooring — + * see {@link legacyParseBearerJwtValidFor}'s own doc comment for why sub-second + * precision must survive until the final `exp`/`iat` computation below. + */ readonly validForSeconds: number; /** `Date.now()`-derived Unix seconds, injected so callers (and tests) control "now". */ readonly nowSeconds: number; @@ -32,10 +37,21 @@ export interface LegacyBearerJwtClaimsInput { * - `--exp` unset (zero `time.Time`): `iat = now`, `exp = now + validFor`. * - `--exp` set: `exp = `, `iat = exp - validFor` (validFor is SUBTRACTED * from the explicit expiry to derive `iat`, not added to `now`). + * - Both arithmetic branches use Go's exact-nanosecond `time.Time` math and only floor + * the FINAL `exp`/`iat` to whole seconds via `jwt.NewNumericDate`'s `Truncate` + * (`golang-jwt/jwt/v5`'s `types.go:38-40`) — so `validForSeconds` (which may carry + * sub-second precision, see {@link LegacyBearerJwtClaimsInput.validForSeconds}) must + * be applied BEFORE flooring, not floored first and then applied. Verified against + * the real binary (CLI-1961): `--exp 2030-01-01T00:00:00Z --valid-for 1.5s` yields + * `iat=1893455998` — flooring the 1.5s duration to 1s first (as this port previously + * did) would wrongly yield `1893455999`. * - `role` is ALWAYS present (`json:"role"`, no `omitempty`), even `--role ""`. * - `is_anonymous` is set only when `role` case-insensitively equals `"authenticated"` - * AND `--sub` was not given (`strings.EqualFold` + `len(claims.Subject) == 0`); the - * `role` claim keeps its original casing regardless. + * AND `--sub` was not given (`strings.EqualFold` + `len(claims.Subject) == 0`) — an + * explicitly-passed EMPTY `--sub ""` still counts as "not given" for this specific + * check (`len("") == 0`), even though the `sub` claim omission below is governed by + * the SAME emptiness check, not by whether the flag was passed at all; the `role` + * claim keeps its original casing regardless. * - `sub`/`exp`/`iat` all carry Go's embedded `jwt.RegisteredClaims` `omitempty` tags — * `sub` only when non-empty, `exp`/`iat` always (both are always-set `*NumericDate`s * here, matching mapstructure's non-nil-pointer handling — see `legacy-go-jwt.ts`'s @@ -50,75 +66,175 @@ export function legacyBuildBearerJwtClaims( let iat: number; if (Option.isNone(input.expiresAt)) { iat = input.nowSeconds; - exp = iat + input.validForSeconds; + exp = Math.floor(iat + input.validForSeconds); } else { exp = input.expiresAt.value; - iat = exp - input.validForSeconds; + iat = Math.floor(exp - input.validForSeconds); } const claims: Record = { role: input.role, }; - if (input.role.toLowerCase() === "authenticated" && Option.isNone(input.sub)) { + const sub = Option.getOrUndefined(input.sub); + const subIsEmpty = sub === undefined || sub.length === 0; + if (input.role.toLowerCase() === "authenticated" && subIsEmpty) { claims["is_anonymous"] = true; } - if (Option.isSome(input.sub) && input.sub.value.length > 0) { - claims["sub"] = input.sub.value; + if (!subIsEmpty) { + claims["sub"] = sub; } claims["exp"] = exp; claims["iat"] = iat; return claims; } +const GO_JSON_WHITESPACE = new Set([" ", "\t", "\n", "\r"]); + +function skipGoJsonWhitespace(value: string, index: number): number { + let i = index; + while (i < value.length && GO_JSON_WHITESPACE.has(value[i]!)) i++; + return i; +} + /** - * Go's `encoding/json` type names for the JSON-representable kinds `json.Unmarshal` - * rejects. Shared with `bearer-jwt.signing-key.ts`'s Branch A (a pasted stdin JWK that - * decodes to a non-object) — both need Go's exact `"json: cannot unmarshal - * into Go value of type "` wording, just against different target types - * (`jwt.MapClaims` here, `config.JWK` there). + * Index right after the closing (unescaped) `"` of the JSON string starting at + * `value[start]` (assumed to be `"`), or `undefined` when it never closes — + * Go's scanner likewise just bails out to `"unexpected end of JSON input"` on a + * truncated string, so escape-sequence CORRECTNESS doesn't need validating here. */ -export function legacyGoJsonKindName(value: unknown): string { - if (Array.isArray(value)) return "array"; - switch (typeof value) { - case "number": - return "number"; - case "string": - return "string"; - case "boolean": - return "bool"; - default: - return "value"; +function findJsonStringEnd(value: string, start: number): number | undefined { + let i = start + 1; + while (i < value.length) { + if (value[i] === "\\") { + i += 2; + continue; + } + if (value[i] === '"') { + return i + 1; + } + i++; } + return undefined; } /** - * Best-effort reproduction of Go's `encoding/json` scanner syntax-error text for a - * malformed `--payload` value. Handles the two shapes verified against the real binary - * (CLI-1961): an empty/whitespace-only payload (`"unexpected end of JSON input"`), and - * valid JSON followed by trailing garbage (`"invalid character 'X' after top-level - * value"`, found by growing the longest parseable prefix) — this covers `--payload - * '{}{}'` exactly. Genuinely malformed JSON with NO valid prefix at all (e.g. a bare - * unquoted identifier) falls back to a generic message that is NOT verified - * byte-for-byte against Go's own scanner (accepted gap — `bearerjwt_test.go` has no - * fixture for `--payload` parsing at all; see this command's own audit notes). + * Index right after the closing `}`/`]` that matches the `{`/`[` at `value[start]`, + * tracking bracket depth while skipping over string literals (so a bracket + * character inside a string never perturbs the count) — or `undefined` when depth + * never returns to zero (truncated/unterminated). + */ +function findJsonContainerEnd(value: string, start: number): number | undefined { + let depth = 0; + let i = start; + while (i < value.length) { + const ch = value[i]; + if (ch === '"') { + const stringEnd = findJsonStringEnd(value, i); + if (stringEnd === undefined) return undefined; + i = stringEnd; + continue; + } + if (ch === "{" || ch === "[") { + depth++; + } else if (ch === "}" || ch === "]") { + depth--; + if (depth === 0) return i + 1; + } + i++; + } + return undefined; +} + +const JSON_NUMBER_PATTERN = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/; + +/** Go's literal keywords, keyed by their first byte — matched char-by-char below, same as `encoding/json`'s scanner. */ +const GO_JSON_LITERALS: Record = { n: "null", t: "true", f: "false" }; + +/** + * Reports Go's exact `"invalid character '' after top-level value"` when + * `trimmed` has non-whitespace content after its first `validPrefixLength` + * characters, or the generic fallback when there is none — reachable only via a + * leading byte JS's `\s` regex strips but Go's scanner does not treat as + * whitespace (e.g. a vertical tab), so the ORIGINAL `JSON.parse(payload)` call + * still failed even though `trimmed` alone is one complete, valid JSON value. + */ +function reportGoJsonTrailingGarbage(trimmed: string, validPrefixLength: number): string { + const restStart = skipGoJsonWhitespace(trimmed, validPrefixLength); + if (restStart >= trimmed.length) { + return "invalid character looking for beginning of value"; + } + return `invalid character '${trimmed[restStart]}' after top-level value`; +} + +/** + * Best-effort, single-pass (no repeated whole-string `JSON.parse` retries — see + * below) reproduction of Go's `encoding/json` scanner syntax-error text for a + * malformed `--payload` value, verified against the real binary (CLI-1961) for + * every shape covered here: an empty/whitespace-only payload or a genuinely + * truncated value (`"unexpected end of JSON input"`), a byte that can never start + * a JSON value (`"invalid character '' looking for beginning of value"`), a + * partial keyword match (`"invalid character '' in literal (expecting + * '')"`, e.g. `--payload 'not-json-at-all'` starts with `n` looking like + * `null`), and valid JSON followed by trailing garbage (`"invalid character '' + * after top-level value"`, e.g. `--payload '{}{}'`). + * + * Dispatches on the first non-whitespace byte rather than building a full + * recursive-descent parser — each of the five shapes above only needs to know + * "where does the first top-level value end, and how did the byte after it (or + * the byte that broke a literal/number) fail" — and every helper below scans + * forward only, so a large malformed payload cannot make this pathological the + * way retrying `JSON.parse` on shrinking prefixes could. Genuinely malformed + * JSON with no recognizable failure shape above (e.g. a lone `-` with no digits) + * falls back to a generic message that is NOT verified byte-for-byte against + * Go's own scanner (accepted gap — `bearerjwt_test.go` has no fixture for + * `--payload` parsing at all; see this command's own audit notes). */ function legacyGoJsonSyntaxErrorMessage(raw: string): string { const trimmed = raw.replace(/^\s+/, ""); if (trimmed.length === 0) { return "unexpected end of JSON input"; } - for (let end = trimmed.length; end > 0; end--) { - const candidate = trimmed.slice(0, end); - try { - JSON.parse(candidate); - } catch { - continue; + + const first = trimmed[0]!; + + const literal = GO_JSON_LITERALS[first]; + if (literal !== undefined) { + for (let i = 0; i < literal.length; i++) { + if (i >= trimmed.length) { + return "unexpected end of JSON input"; + } + if (trimmed[i] !== literal[i]) { + return `invalid character '${trimmed[i]}' in literal ${literal} (expecting '${literal[i]}')`; + } } - const rest = trimmed.slice(end).replace(/^\s+/, ""); - if (rest.length === 0) break; - return `invalid character '${rest[0]}' after top-level value`; + return reportGoJsonTrailingGarbage(trimmed, literal.length); } - return "invalid character looking for beginning of value"; + + if (first === '"') { + const end = findJsonStringEnd(trimmed, 0); + return end === undefined + ? "unexpected end of JSON input" + : reportGoJsonTrailingGarbage(trimmed, end); + } + + if (first === "{" || first === "[") { + const end = findJsonContainerEnd(trimmed, 0); + return end === undefined + ? "unexpected end of JSON input" + : reportGoJsonTrailingGarbage(trimmed, end); + } + + if (first === "-" || (first >= "0" && first <= "9")) { + const match = JSON_NUMBER_PATTERN.exec(trimmed); + if (match === null || match[0].length === 0) { + // Only reachable for a lone, digit-less `-` — Go's scanner is still + // mid-number waiting for a digit when the input runs out. + return "unexpected end of JSON input"; + } + return reportGoJsonTrailingGarbage(trimmed, match[0].length); + } + + return `invalid character '${first}' looking for beginning of value`; } /** diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts index 9a261471c4..71f5918895 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest"; import { legacyBuildBearerJwtClaims, legacyEncodeBearerJwtClaims, - legacyGoJsonKindName, legacyMergeBearerJwtPayload, } from "./bearer-jwt.claims.ts"; @@ -45,6 +44,35 @@ describe("legacyBuildBearerJwtClaims", () => { expect(claims["iat"]).toBe(2_000_000_000 - 1800); }); + it("floors only the FINAL iat, applying a sub-second --valid-for before truncating (CLI-1961)", () => { + // Verified against the real binary: `--exp 2030-01-01T00:00:00Z --valid-for 1.5s` + // yields Go `iat=1893455998` — flooring the 1.5s duration to 1s BEFORE subtracting + // (this port's previous behavior) would wrongly yield 1893455999. + const claims = legacyBuildBearerJwtClaims({ + role: "anon", + sub: Option.none(), + expiresAt: Option.some(1_893_456_000), + validForSeconds: 1.5, + nowSeconds: NOW, + }); + expect(claims["exp"]).toBe(1_893_456_000); + expect(claims["iat"]).toBe(1_893_455_998); + }); + + it("sets is_anonymous when --sub is explicitly passed as an empty string (CLI-1961)", () => { + // Go's gate is `len(claims.Subject) == 0` (`cmd/gen.go:195`) — an explicitly-passed + // EMPTY `--sub ""` still counts as "no subject", not just an omitted flag. + const claims = legacyBuildBearerJwtClaims({ + role: "authenticated", + sub: Option.some(""), + expiresAt: Option.none(), + validForSeconds: 1800, + nowSeconds: NOW, + }); + expect(claims["is_anonymous"]).toBe(true); + expect("sub" in claims).toBe(false); + }); + it("sets is_anonymous when role is 'authenticated' (case-insensitive) and sub is absent", () => { const claims = legacyBuildBearerJwtClaims({ role: "AUTHENTICATED", @@ -160,21 +188,98 @@ describe("legacyMergeBearerJwtPayload", () => { expect(merged["sub"]).toBeNull(); }); - it("falls back to a generic message for malformed JSON with no valid prefix at all", () => { - // Accepted gap (see this module's own doc comment): `bearerjwt_test.go` has no - // `--payload` parsing fixture at all, so this exact wording is not Go-verified. + it("reports a partial keyword match against Go's exact 'in literal' wording", () => { + // `not-json-at-all` starts with `n`, which Go's scanner reads as the start of the + // `null` literal; the second byte `o` mismatches `null`'s `u` — Go's own scanner + // text for this is `invalid character 'o' in literal null (expecting 'u')`. expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "not-json-at-all")).toThrow( - "invalid character looking for beginning of value", + "invalid character 'o' in literal null (expecting 'u')", + ); + }); + + it("rejects a truncated object with Go's exact 'unexpected end of JSON input'", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, '{"a":1')).toThrow( + "unexpected end of JSON input", + ); + }); + + it("rejects a truncated array with Go's exact 'unexpected end of JSON input'", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "[1,2")).toThrow( + "unexpected end of JSON input", + ); + }); + + it("rejects an object truncated inside a nested unterminated string", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, '{"a')).toThrow( + "unexpected end of JSON input", + ); + }); + + it("reports trailing garbage after a validly nested container, not on the inner close", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "{[]}x")).toThrow( + "invalid character 'x' after top-level value", + ); + }); + + it("rejects an unterminated string with Go's exact 'unexpected end of JSON input'", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, '"unterminated')).toThrow( + "unexpected end of JSON input", + ); + }); + + it("reports trailing garbage after a valid string value", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, '"abc"def')).toThrow( + "invalid character 'd' after top-level value", + ); + }); + + it("skips over an escaped quote inside a string when finding where it closes", () => { + // The actual payload bytes are: `"` `a` `\` `"` `b` `"` `c` — a string containing an + // escaped quote (`a"b`), followed by trailing garbage `c`. + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, '"a\\"b"c')).toThrow( + "invalid character 'c' after top-level value", + ); + }); + + it("rejects a truncated literal (a strict prefix of a keyword) as truncated, not a mismatch", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "tru")).toThrow( + "unexpected end of JSON input", + ); + }); + + it("reports trailing garbage after a FULLY matched literal keyword", () => { + // "null" matches completely; JSC's own tokenizer reports this generically (no + // position), unlike a partial mismatch — this exercises that specific fall-through. + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "nullx")).toThrow( + "invalid character 'x' after top-level value", + ); + }); + + it("rejects a lone digit-less minus sign with Go's exact 'unexpected end of JSON input'", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "-")).toThrow( + "unexpected end of JSON input", + ); + }); + + it("reports trailing garbage after a valid number value", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "123abc")).toThrow( + "invalid character 'a' after top-level value", + ); + }); + + it("reports the actual first invalid character for a byte that can never start a value", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "@")).toThrow( + "invalid character '@' looking for beginning of value", ); }); it("falls back to the generic message when the only valid prefix is the WHOLE trimmed string", () => { // A leading vertical tab is in JS's `\s` regex class (so `trimmed` strips it) but is // NOT valid JSON whitespace (so the original `JSON.parse(payload)` call still fails - // on it) — the longest-valid-prefix loop's first (longest) candidate then succeeds - // with nothing left over, which must fall through to the generic message rather than - // reporting on an empty `rest`. Built via `String.fromCharCode` rather than a literal - // escape so no raw control byte sits in the source file. + // on it) — `trimmed` alone ("{}") is then a single complete, valid JSON value with + // nothing left over, which must fall through to the generic message rather than + // reporting on empty leftover content. Built via `String.fromCharCode` rather than a + // literal escape so no raw control byte sits in the source file. const verticalTab = String.fromCharCode(11); expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, `${verticalTab}{}`)).toThrow( "invalid character looking for beginning of value", @@ -182,18 +287,6 @@ describe("legacyMergeBearerJwtPayload", () => { }); }); -describe("legacyGoJsonKindName", () => { - it("names every JSON-representable kind, including the generic fallback", () => { - expect(legacyGoJsonKindName([])).toBe("array"); - expect(legacyGoJsonKindName(1)).toBe("number"); - expect(legacyGoJsonKindName("s")).toBe("string"); - expect(legacyGoJsonKindName(true)).toBe("bool"); - // Never reachable from real JSON.parse output (both call sites already exclude - // null/array/object before calling this) — exercised directly for completeness. - expect(legacyGoJsonKindName(undefined)).toBe("value"); - }); -}); - describe("legacyEncodeBearerJwtClaims", () => { it("serializes claims with alphabetically sorted keys, matching Go's jwt.MapClaims", () => { const claims = { role: "authenticated", is_anonymous: true, exp: 200, iat: 100 }; diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts index afa8044221..e1ff9691db 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts @@ -52,6 +52,19 @@ export class LegacyGenBearerJwtKeyNotFoundError extends Data.TaggedError( readonly message: string; }> {} +/** + * Go's `getSigningKey` Branch C (`bearerjwt.go:70-79`): the TTY key picker + * (`utils.PromptChoice`, `internal/utils/prompt.go:120-140`) given ZERO available + * keys quits immediately without ever letting the user select anything. Byte-matches + * Go's bare, unwrapped `"user aborted"` — `getSigningKey` returns `PromptChoice`'s + * error as-is, with no additional wrapping. + */ +export class LegacyGenBearerJwtKeyPickerAbortedError extends Data.TaggedError( + "LegacyGenBearerJwtKeyPickerAbortedError", +)<{ + readonly message: string; +}> {} + /** * Go's `parseClaims` payload merge (`cmd/gen.go:209-211`). Byte-matches * `"failed to parse payload: %w"`. diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts index d72e89df28..e23c517733 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts @@ -1,38 +1,111 @@ import { legacyParseGoDuration } from "../../../shared/legacy-go-duration.ts"; import { legacyBearerJwtErrorMessage } from "./bearer-jwt.errors.ts"; -const RFC3339_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/; +const RFC3339_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; + +const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +function isLeapYear(year: number): boolean { + return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; +} + +function daysInMonth(year: number, month: number): number { + return month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month - 1]!; +} + +/** + * Validates the calendar/clock components a syntactically-RFC3339 string decodes to, + * matching Go's `time.Parse(time.RFC3339, ...)` — which, verified directly against + * the Go standard library (CLI-1961), genuinely REJECTS an out-of-range month, day + * (including per-month/leap-year day-of-month bounds), hour, minute, or second, + * rather than normalizing them the way `time.Date`/JS's own `Date.parse` would (e.g. + * `2030-02-30T...` silently rolling over to March 2nd). Go's `time.Parse` itself + * raises a component-specific error (`"day out of range"`, etc.), but pflag's + * `timeValue.Set` (`github.com/spf13/pflag@v1.0.10/time.go:24-44`) discards that text + * entirely and falls through to the SAME generic wrapped message used for a + * syntactically-malformed value — so this only needs a boolean, not Go's per-field + * error text. + */ +function isValidRfc3339Calendar( + year: number, + month: number, + day: number, + hour: number, + minute: number, + second: number, +): boolean { + return ( + month >= 1 && + month <= 12 && + day >= 1 && + day <= daysInMonth(year, month) && + hour <= 23 && + minute <= 59 && + second <= 59 + ); +} /** * Go's `--exp` flag (`TimeVar(&expiry, "exp", time.Time{}, []string{time.RFC3339}, ...)`, * `apps/cli-go/cmd/gen.go:178`) calls `time.Parse(time.RFC3339, val)` inside pflag's * `Value.Set`, which runs during `cmd.ParseFlags` — BEFORE `RunE` (verified against the - * real binary, CLI-1961). A parse failure raises pflag's own wrapped message exactly: - * `invalid argument "" for "--exp" flag: invalid time format \`\` must be one - * of: \`2006-01-02T15:04:05Z07:00\`` (a single format, since `--exp` registers only - * RFC3339). Returns Unix seconds on success. + * real binary, CLI-1961). Two behaviors verified directly against pflag's own source + * (`time.go:24-44`) rather than assumed: + * - `Value.Set` trims the input with `strings.TrimSpace` BEFORE ever calling + * `time.Parse`, so `--exp " 2030-01-01T00:00:00Z "` (surrounding whitespace) + * parses successfully — the trimmed value is used both for parsing and for the + * error message below (Go re-embeds ITS OWN already-trimmed `s`, never the + * original untrimmed argument). + * - A parse failure — whether syntactic OR a Go-rejected out-of-range calendar + * component (`isValidRfc3339Calendar` above) — raises the exact same wrapped + * message: `invalid argument "" for "--exp" flag: invalid time format + * \`\` must be one of: \`2006-01-02T15:04:05Z07:00\`` (a single format, + * since `--exp` registers only RFC3339). + * Returns Unix seconds on success. */ export function legacyParseBearerJwtExp(value: string): number { - const parsed = RFC3339_PATTERN.test(value) ? Date.parse(value) : Number.NaN; - if (Number.isNaN(parsed)) { + const trimmedValue = value.trim(); + const match = RFC3339_PATTERN.exec(trimmedValue); + const isValid = + match !== null && + isValidRfc3339Calendar( + Number(match[1]), + Number(match[2]), + Number(match[3]), + Number(match[4]), + Number(match[5]), + Number(match[6]), + ); + if (!isValid) { throw new Error( - `invalid argument "${value}" for "--exp" flag: invalid time format \`${value}\` must be one of: \`2006-01-02T15:04:05Z07:00\``, + `invalid argument "${trimmedValue}" for "--exp" flag: invalid time format \`${trimmedValue}\` must be one of: \`2006-01-02T15:04:05Z07:00\``, ); } - return Math.floor(parsed / 1000); + // Every component is now confirmed in range, so `Date.parse` can no longer silently + // roll anything over — it's safe to reuse for the actual timestamp computation. + return Math.floor(Date.parse(trimmedValue) / 1000); } /** * Go's `--valid-for` flag (`DurationVar(&validFor, "valid-for", time.Minute*30, ...)`, * `apps/cli-go/cmd/gen.go:179`) — same parse-time-failure shape as `--exp` above, * wrapping `legacyParseGoDuration`'s own Go-format `time: invalid duration "..."` text. - * Returns whole seconds, truncated from the parsed nanoseconds — sub-second - * `--valid-for` precision is not observable in the resulting `exp`/`iat` claims, which - * are themselves truncated to the second (Go's `jwt.NewNumericDate` truncation). + * `time.Duration`'s own pflag `Value.Set` does NOT trim its input (unlike `--exp`'s + * `timeValue.Set` above) — verified against pflag's source — so no `.trim()` here. + * + * Returns SECONDS WITHOUT FLOORING — Go computes `exp`/`iat` via exact-nanosecond + * `time.Time` arithmetic on the parsed `time.Duration` and only floors the FINAL + * timestamps (`jwt.NewNumericDate`'s `Truncate`, see `legacyBuildBearerJwtClaims`). + * Flooring the duration itself here, before that arithmetic runs, would produce an + * off-by-one-second result whenever the truncated fraction pushes the final sum/ + * difference across a second boundary — verified against the real binary (CLI-1961): + * `--exp 2030-01-01T00:00:00Z --valid-for 1.5s` yields Go `iat=1893455998`, not the + * `1893455999` a floor-first implementation would produce. */ export function legacyParseBearerJwtValidFor(value: string): number { try { - return Math.floor(legacyParseGoDuration(value) / 1_000_000_000); + return legacyParseGoDuration(value) / 1_000_000_000; } catch (cause) { throw new Error( `invalid argument "${value}" for "--valid-for" flag: ${legacyBearerJwtErrorMessage(cause)}`, diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts index 84312765d2..d1f1565096 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts @@ -26,6 +26,47 @@ describe("legacyParseBearerJwtExp", () => { 'invalid argument "2020-01-01T00:00:00" for "--exp" flag:', ); }); + + it("rejects an invalid calendar date instead of silently rolling it over (CLI-1961)", () => { + // Verified directly against Go's `time.Parse`: `2030-02-30` genuinely errors + // ("day out of range"), it does NOT roll over to March 2nd the way `Date.parse` + // does — Go's pflag wrapper discards that specific error text and falls back to + // the same generic message used for a syntactically-malformed value. + expect(() => legacyParseBearerJwtExp("2030-02-30T03:04:05Z")).toThrow( + 'invalid argument "2030-02-30T03:04:05Z" for "--exp" flag: invalid time format `2030-02-30T03:04:05Z` must be one of: `2006-01-02T15:04:05Z07:00`', + ); + }); + + it("rejects February 29th in a non-leap year but accepts it in a leap year", () => { + expect(() => legacyParseBearerJwtExp("1900-02-29T00:00:00Z")).toThrow( + 'invalid argument "1900-02-29T00:00:00Z" for "--exp" flag:', + ); + expect(legacyParseBearerJwtExp("2000-02-29T00:00:00Z")).toBe(951782400); + }); + + it("rejects an out-of-range hour/minute/second the same way Go's time.Parse does", () => { + expect(() => legacyParseBearerJwtExp("2030-01-01T25:00:00Z")).toThrow( + '"--exp" flag: invalid time format', + ); + expect(() => legacyParseBearerJwtExp("2030-01-01T00:60:00Z")).toThrow( + '"--exp" flag: invalid time format', + ); + expect(() => legacyParseBearerJwtExp("2030-01-01T00:00:60Z")).toThrow( + '"--exp" flag: invalid time format', + ); + }); + + it("trims surrounding whitespace before parsing, matching pflag's strings.TrimSpace", () => { + expect(legacyParseBearerJwtExp(" 2030-01-01T00:00:00Z ")).toBe( + legacyParseBearerJwtExp("2030-01-01T00:00:00Z"), + ); + }); + + it("embeds the TRIMMED value (not the raw argument) in the error message", () => { + expect(() => legacyParseBearerJwtExp(" notatime ")).toThrow( + 'invalid argument "notatime" for "--exp" flag: invalid time format `notatime` must be one of: `2006-01-02T15:04:05Z07:00`', + ); + }); }); describe("legacyParseBearerJwtValidFor", () => { @@ -38,9 +79,21 @@ describe("legacyParseBearerJwtValidFor", () => { expect(legacyParseBearerJwtValidFor("-5m")).toBe(-300); }); + it("preserves sub-second precision instead of flooring it away (CLI-1961)", () => { + // Flooring here (this port's previous behavior) would silently discard the 0.5s + // fraction before it ever reaches `legacyBuildBearerJwtClaims`'s final truncation. + expect(legacyParseBearerJwtValidFor("1.5s")).toBe(1.5); + }); + it("rejects a malformed value with pflag's exact wrapped message", () => { expect(() => legacyParseBearerJwtValidFor("xyz")).toThrow( 'invalid argument "xyz" for "--valid-for" flag: time: invalid duration "xyz"', ); }); + + it("does NOT trim surrounding whitespace, unlike --exp (pflag's duration Value.Set has no TrimSpace)", () => { + expect(() => legacyParseBearerJwtValidFor(" 30m ")).toThrow( + 'invalid argument " 30m " for "--valid-for" flag: time: invalid duration " 30m "', + ); + }); }); diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts index 99551e8e2c..63bb9c004d 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts @@ -206,6 +206,28 @@ describe("legacy gen bearer-jwt integration", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "computes iat with a sub-second --valid-for, truncating only the final timestamp (CLI-1961)", + () => { + // Verified against the real binary: `--exp 2030-01-01T00:00:00Z --valid-for 1.5s` + // (unix 1893456000) yields Go `iat=1893455998` — flooring the 1.5s duration to 1s + // BEFORE subtracting (this port's previous behavior) would wrongly yield + // 1893455999. + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyGenBearerJwt({ + ...baseFlags, + exp: Option.some(1_893_456_000), + validFor: 1.5, + }); + const [, payload] = tokenFrom(out).split("."); + const claims = decodeSegment(payload ?? "") as Record; + expect(claims["exp"]).toBe(1_893_456_000); + expect(claims["iat"]).toBe(1_893_455_998); + }).pipe(Effect.provide(layer)); + }, + ); + it.live("merges --payload on top of the computed claims", () => { const { layer, out } = setup(); return Effect.gen(function* () { @@ -341,9 +363,32 @@ describe("legacy gen bearer-jwt integration", () => { }); it.live( - "Branch A: a literal 'null' pasted at the stdin JWK prompt falls back to the default key", + "Branch A: a literal 'null' pasted at the stdin JWK prompt is rejected, NOT the default key", + () => { + // Verified against the real binary (CLI-1961): Go's `json.Unmarshal([]byte("null"), + // &key)` is a documented no-op for a non-pointer struct target — it leaves `key` at + // its zero value rather than erroring, and rather than falling back to the default + // key. That zero-value JWK (empty `kty`) then fails downstream at SIGN time. + const { layer } = setup({ pipedAnswer: "null" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtSignError"); + expect(json).toContain("failed to convert JWK to private key: unsupported key type: "); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch A: a truly blank answer at the stdin JWK prompt still falls back to the default key", () => { - const { layer, out } = setup({ pipedAnswer: "null" }); + // Distinct from the literal-'null' case above: an EMPTY answer never reaches + // `JSON.parse` at all (Go: `len(kid) == 0` gate), so it's the only input that + // legitimately falls back to the built-in default key. + const { layer, out } = setup({ pipedAnswer: "" }); return Effect.gen(function* () { yield* legacyGenBearerJwt(baseFlags); const token = tokenFrom(out); @@ -357,6 +402,42 @@ describe("legacy gen bearer-jwt integration", () => { }, ); + it.live( + "Branch A: rejects a pasted JWK with an unsupported alg at decode time, not sign time", + () => { + // Go's `config.Algorithm.UnmarshalText` (`pkg/config/auth.go:80-86`) rejects + // anything other than RS256/ES256 DURING JSON decode, before the JWK ever + // reaches signing — verified against the real binary (CLI-1961). + const { layer } = setup({ pipedAnswer: JSON.stringify({ kty: "oct", alg: "HS256" }) }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtKeyParseError"); + expect(json).toContain("failed to parse JWK: must be one of [RS256 ES256]"); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch A: accepts a pasted JWK missing alg entirely (validated later, at sign time, not decode time)", + () => { + const { alg: _alg, ...jwkWithoutAlg } = generateEcJwk("no-alg-kid"); + const { layer } = setup({ pipedAnswer: JSON.stringify(jwkWithoutAlg) }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtSignError"); + expect(json).toContain("unsupported algorithm: "); + } + }).pipe(Effect.provide(layer)); + }, + ); + it.live( "Branch B: mints a token from the configured signing_keys_path's only key on a blank kid answer", () => { @@ -418,6 +499,59 @@ describe("legacy gen bearer-jwt integration", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "Branch B: rejects a configured signing key with an unsupported alg at decode time, not sign time", + () => { + // Go's `fetcher.ParseJSON[[]JWK]` (`pkg/fetcher/http.go:144-151`) decodes straight + // into `[]config.JWK`, running `config.Algorithm.UnmarshalText`'s RS256/ES256 + // allowlist DURING that decode — verified against the real binary (CLI-1961). + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeSigningKeys(JSON.stringify([{ kty: "oct", alg: "HS256" }])), + ); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtDecodeError"); + expect(json).toContain( + "failed to decode signing keys: failed to parse response body: must be one of [RS256 ES256]", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch C: TTY with zero configured signing keys fails with Go's exact 'user aborted' text", + () => { + // Go's bubbletea `PromptChoice` (`internal/utils/prompt.go:110-140`), given a + // zero-item list, quits immediately without ever letting the user select + // anything — verified against the real binary (CLI-1961). Previously this + // crashed with an unhandled `TypeError` instead of failing gracefully. + const { layer } = setup({ stdinIsTty: true }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys("[]")); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtKeyPickerAbortedError"); + expect(json).toContain("user aborted"); + } + }).pipe(Effect.provide(layer)); + }, + ); + it.live( "Branch B: selects a key by exact kid match among several (bearerjwt_test.go parity)", () => { diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts index 54fce68916..7a44b3ddbe 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts @@ -3,17 +3,22 @@ import { legacyReadSigningKeysFile, legacyResolveSigningKeysConfigPaths, } from "../gen.signing-keys-config.ts"; -import { LEGACY_DEFAULT_SIGNING_KEY, type LegacyJwk } from "../../../shared/legacy-go-jwt.ts"; +import { + legacyAssertDecodableJwkAlgorithm, + LEGACY_DEFAULT_SIGNING_KEY, + type LegacyJwk, +} from "../../../shared/legacy-go-jwt.ts"; +import { legacyGoJsonKindName } from "../../../shared/legacy-go-json.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { Stdin } from "../../../../shared/runtime/stdin.service.ts"; import { Tty } from "../../../../shared/runtime/tty.service.ts"; -import { legacyGoJsonKindName } from "./bearer-jwt.claims.ts"; import { legacyBearerJwtErrorMessage, LegacyGenBearerJwtConfigParseError, LegacyGenBearerJwtDecodeError, LegacyGenBearerJwtKeyNotFoundError, LegacyGenBearerJwtKeyParseError, + LegacyGenBearerJwtKeyPickerAbortedError, LegacyGenBearerJwtReadError, } from "./bearer-jwt.errors.ts"; @@ -114,11 +119,16 @@ const resolveSigningKeyFromStdinJwk = Effect.fnUntraced(function* () { }), ); } - // A JSON `null` answer is treated the same as blank (no-op / use the default) — - // matching `legacyMergeBearerJwtPayload`'s own null-is-a-no-op judgment call for - // this un-tested-by-Go edge case; see that module's doc comment. + // A JSON `null` answer decodes into a ZERO-VALUE `config.JWK{}` in Go — verified + // against the real binary (CLI-1961): `json.Unmarshal([]byte("null"), &key)` where + // `key` is a non-pointer struct is a documented Go no-op (it leaves every field at + // its zero value: `kty: ""`, `alg: ""`, ...) rather than an error, and rather than + // the built-in default key. This must reach the SAME zero-value JWK the empty-object + // shape below does, so it fails downstream at SIGN time with "unsupported key type: + // " (empty kty) — Go genuinely rejects a `null` answer where a truly BLANK answer + // (handled above, before `JSON.parse` is ever called) falls back to the default key. if (parsed === null) { - return LEGACY_DEFAULT_SIGNING_KEY; + return normalizeStoredJwk({}); } if (typeof parsed !== "object" || Array.isArray(parsed)) { return yield* Effect.fail( @@ -127,7 +137,18 @@ const resolveSigningKeyFromStdinJwk = Effect.fnUntraced(function* () { }), ); } - return normalizeStoredJwk(parsed as Record); + const record = parsed as Record; + const alg = record["alg"]; + try { + legacyAssertDecodableJwkAlgorithm(typeof alg === "string" ? alg : undefined); + } catch (cause) { + return yield* Effect.fail( + new LegacyGenBearerJwtKeyParseError({ + message: `failed to parse JWK: ${legacyBearerJwtErrorMessage(cause)}`, + }), + ); + } + return normalizeStoredJwk(record); }); /** @@ -164,6 +185,19 @@ const resolveSigningKeyFromConfigured = Effect.fnUntraced(function* ( ); } + if (availableKeys.length === 0) { + // Go's bubbletea `PromptChoice` (`internal/utils/prompt.go:110-140`), given a + // ZERO-item list, quits immediately without ever letting the user select anything: + // `errors.New("user aborted")`, unwrapped. Guarded here rather than ever reaching + // `output.promptSelect` — `@clack/prompts`' own `select()` has no equivalent + // "immediately quit on an empty option list" behavior to lean on, and calling it + // with zero options would otherwise resolve to an out-of-range index and crash + // with a raw `TypeError` when `.kid` is accessed below. + return yield* Effect.fail( + new LegacyGenBearerJwtKeyPickerAbortedError({ message: "user aborted" }), + ); + } + const output = yield* Output; const options = availableKeys.map((key, index) => ({ value: String(index), diff --git a/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts index c40d1de782..5b7e8259e5 100644 --- a/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts +++ b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts @@ -1,5 +1,6 @@ import { loadProjectConfig } from "@supabase/config"; import { Effect, FileSystem, Option, Path } from "effect"; +import { legacyAssertDecodableJwkAlgorithm } from "../../shared/legacy-go-jwt.ts"; /** * Shared `[auth].signing_keys_path` config-loading logic for the `gen` command @@ -16,7 +17,7 @@ import { Effect, FileSystem, Option, Path } from "effect"; * hierarchies while sharing the actual file-resolution/read/decode logic. */ -export type StoredSigningKeyJwk = Readonly>; +export type LegacyStoredSigningKeyJwk = Readonly>; interface LegacyGenSigningKeysConfigPaths { /** CWD-relative `supabase/config.toml` (or the resolved config file's own display path). */ @@ -99,6 +100,14 @@ export const legacyResolveSigningKeysConfigPaths = Effect.fnUntraced(function* < * "expected a JSON array [of objects]" shape check matches this package's own pre-existing * `gen signing-key` behavior (not a literal Go error string; Go's decode failures come from * `encoding/json`'s own type-mismatch errors, which `readJwkArray`'s two checks approximate). + * + * The `alg` allowlist check below IS a literal Go error string, unlike the shape checks + * above: Go's `fetcher.ParseJSON[[]JWK]` (`pkg/fetcher/http.go:144-151`) decodes straight + * into `[]config.JWK`, and `config.Algorithm.UnmarshalText` (`pkg/config/auth.go:80-86`) + * runs automatically during that decode for every element with a string `alg`, rejecting + * anything other than `RS256`/`ES256` — wrapped here as `"failed to decode signing keys: + * failed to parse response body: %w"`, matching `ParseJSON`'s own wrap on top of + * `Config.Validate`'s. */ export const legacyReadSigningKeysFile = Effect.fnUntraced(function* ( actualPath: string, @@ -125,5 +134,17 @@ export const legacyReadSigningKeysFile = Effect.fnUntraced(function* ( ); } } - return decoded as ReadonlyArray; + for (const item of decoded as ReadonlyArray>) { + const alg = item["alg"]; + try { + legacyAssertDecodableJwkAlgorithm(typeof alg === "string" ? alg : undefined); + } catch (cause) { + return yield* Effect.fail( + onDecodeError( + `failed to decode signing keys: failed to parse response body: ${cause instanceof Error ? cause.message : String(cause)}`, + ), + ); + } + } + return decoded as ReadonlyArray; }); diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts index 24f9598e6d..ad38564709 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts @@ -16,7 +16,7 @@ import { Tty } from "../../../../shared/runtime/tty.service.ts"; import { legacyReadSigningKeysFile, legacyResolveSigningKeysConfigPaths, - type StoredSigningKeyJwk, + type LegacyStoredSigningKeyJwk, } from "../gen.signing-keys-config.ts"; import type { LegacyGenSigningKeyFlags } from "./signing-key.command.ts"; import { @@ -55,7 +55,7 @@ interface ResolvedSigningKeysConfig { readonly configured: Option.Option<{ actualPath: string; displayPath: string; - existingKeys: ReadonlyArray; + existingKeys: ReadonlyArray; }>; } diff --git a/apps/cli/src/legacy/shared/legacy-go-json.ts b/apps/cli/src/legacy/shared/legacy-go-json.ts index 4887fa9d3a..924460785f 100644 --- a/apps/cli/src/legacy/shared/legacy-go-json.ts +++ b/apps/cli/src/legacy/shared/legacy-go-json.ts @@ -119,3 +119,24 @@ export function encodeGoJsonIndented(value: unknown): string { export function encodeGoJsonCompact(value: unknown): string { return walk(value, 0, false); } + +/** + * Go's `encoding/json` type names for the JSON-representable kinds `json.Unmarshal` + * rejects. Shared by every legacy command that reproduces Go's exact `"json: cannot + * unmarshal into Go value of type "` wording against its own target + * type — `gen bearer-jwt`'s `jwt.MapClaims` (`bearer-jwt.claims.ts`) and `config.JWK` + * (`bearer-jwt.signing-key.ts`) are today's two callers. + */ +export function legacyGoJsonKindName(value: unknown): string { + if (Array.isArray(value)) return "array"; + switch (typeof value) { + case "number": + return "number"; + case "string": + return "string"; + case "boolean": + return "bool"; + default: + return "value"; + } +} diff --git a/apps/cli/src/legacy/shared/legacy-go-json.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-json.unit.test.ts index c5d39eae8d..3f81693d85 100644 --- a/apps/cli/src/legacy/shared/legacy-go-json.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-json.unit.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { encodeGoJsonCompact, encodeGoJsonIndented, escapeGoJsonString } from "./legacy-go-json.ts"; +import { + encodeGoJsonCompact, + encodeGoJsonIndented, + escapeGoJsonString, + legacyGoJsonKindName, +} from "./legacy-go-json.ts"; describe("escapeGoJsonString", () => { it("escapes quotes and backslashes like Go", () => { @@ -71,3 +76,15 @@ describe("encodeGoJsonCompact", () => { expect(encodeGoJsonCompact(null)).toBe("null"); }); }); + +describe("legacyGoJsonKindName", () => { + it("names every JSON-representable kind, including the generic fallback", () => { + expect(legacyGoJsonKindName([])).toBe("array"); + expect(legacyGoJsonKindName(1)).toBe("number"); + expect(legacyGoJsonKindName("s")).toBe("string"); + expect(legacyGoJsonKindName(true)).toBe("bool"); + // Never reachable from real JSON.parse output (every call site already excludes + // null/array/object before calling this) — exercised directly for completeness. + expect(legacyGoJsonKindName(undefined)).toBe("value"); + }); +}); diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.ts index d0c5ad2e94..daf76af219 100644 --- a/apps/cli/src/legacy/shared/legacy-go-jwt.ts +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.ts @@ -161,6 +161,27 @@ function ensureRsaCrtParams(jwk: LegacyJwk): LegacyJwk { type LegacySupportedJwtAlgorithm = "RS256" | "ES256"; +/** + * Go's `config.Algorithm.UnmarshalText` (`apps/cli-go/pkg/config/auth.go:80-86`) — + * `encoding/json` calls this automatically whenever a JWK's `alg` field decodes from + * a JSON STRING (the only shape a pasted stdin JWK or a `signing_keys_path` file ever + * provides), rejecting anything other than `RS256`/`ES256` at JSON-DECODE time — well + * BEFORE the JWK ever reaches signing. An absent `alg` never reaches this check at + * all (`encoding/json` only calls `UnmarshalText` for a key present in the source + * JSON; a missing key just leaves the struct field at its zero value), so that case + * is caught later, at SIGN time, by {@link legacySignJwtWithJwk}'s own `unsupported + * algorithm: ` check instead. Throws Go's own bare `UnmarshalText` error text + * unwrapped; callers apply their own decode-context wrapping (`"failed to parse + * JWK: %w"` for a pasted stdin JWK, `"failed to decode signing keys: failed to parse + * response body: %w"` for a `signing_keys_path` file — see `fetcher.ParseJSON`, + * `apps/cli-go/pkg/fetcher/http.go:144-151`). + */ +export function legacyAssertDecodableJwkAlgorithm(alg: string | undefined): void { + if (alg !== undefined && alg !== "RS256" && alg !== "ES256") { + throw new Error("must be one of [RS256 ES256]"); + } +} + /** * Go's `jwkToPrivateKey` (`apps/cli-go/pkg/config/apikeys.go:120-129`): validates * `jwk.kty`/`jwk.crv` ONLY — it has no awareness of `jwk.alg` at all. Throws Go's diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts index d434d7b853..b93be385f6 100644 --- a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts @@ -3,6 +3,7 @@ import { importJWK, jwtVerify } from "jose"; import { describe, expect, it } from "vitest"; import { + legacyAssertDecodableJwkAlgorithm, legacyGenerateAsymmetricGoJwt, legacyGenerateGoJwt, legacySignJwtWithJwk, @@ -197,7 +198,7 @@ describe("legacyGenerateAsymmetricGoJwt", () => { const jwk = generateEcJwk(); const { crv: _crv, ...jwkWithoutCurve } = jwk; expect(() => legacyGenerateAsymmetricGoJwt(jwkWithoutCurve, "anon")).toThrow( - "unsupported curve: ", + "failed to convert JWK to private key: unsupported curve: ", ); }); }); @@ -218,3 +219,20 @@ describe("legacySignJwtWithJwk", () => { expect(verified).toEqual({ role: "postgres", "sb-role": "mgmt-api & co" }); }); }); + +describe("legacyAssertDecodableJwkAlgorithm", () => { + it("accepts RS256 and ES256", () => { + expect(() => legacyAssertDecodableJwkAlgorithm("RS256")).not.toThrow(); + expect(() => legacyAssertDecodableJwkAlgorithm("ES256")).not.toThrow(); + }); + + it("accepts an absent alg (validated later, at sign time, not at decode time)", () => { + expect(() => legacyAssertDecodableJwkAlgorithm(undefined)).not.toThrow(); + }); + + it("rejects an unsupported algorithm with Go's exact UnmarshalText message", () => { + expect(() => legacyAssertDecodableJwkAlgorithm("HS256")).toThrow( + "must be one of [RS256 ES256]", + ); + }); +}); From 21954659fe47c5cc75e62c3419baf17af9452b61 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 11:08:03 +0100 Subject: [PATCH 03/12] fix(cli): flush telemetry and match Go's stderr wording for a missing gen bearer-jwt --role (ci) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go marks --role required (cmd/gen.go:175) but cobra's ValidateRequiredFlags runs AFTER PersistentPreRunE (cobra@v1.10.2/command.go:985,1007) — which is where Go's telemetry service is constructed and later flushed to telemetry.json. The native port instead rejected a missing --role during Effect CLI's own argument parsing, before the handler (and its telemetry wrapper) ever ran, and normalize-error.ts's MissingOption case renders the message with an "Error: " prefix that cobra's real (SilenceErrors: true) output never has. Verified against the real binary via the e2e parity harness: Go writes telemetry.json and a bare "required flag(s) \"role\" not set" line on this exact failure; the TS port wrote neither. Makes --role optional at parse time and enforces it in the handler instead (same established pattern as vanity-subdomains activate's --desired-subdomain), so the failure now flushes telemetry and matches Go's stderr byte-for-byte. Also carries this command's own integration-test additions for the review fixes landing in the following commits of this push (signing-key ancestor/ config.json resolution, the TTY picker's stderr routing). --- .../gen/bearer-jwt/bearer-jwt.command.ts | 19 ++-- .../gen/bearer-jwt/bearer-jwt.errors.ts | 17 ++++ .../gen/bearer-jwt/bearer-jwt.handler.ts | 18 +++- .../bearer-jwt/bearer-jwt.integration.test.ts | 94 ++++++++++++++++--- 4 files changed, 128 insertions(+), 20 deletions(-) diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts index 3a069d30ca..6ed3cc47de 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts @@ -12,12 +12,19 @@ import { legacyGenBearerJwt } from "./bearer-jwt.handler.ts"; import { legacyParseBearerJwtExp, legacyParseBearerJwtValidFor } from "./bearer-jwt.flags.ts"; const config = { - // Go: `cobra.CheckErr(genJWTCmd.MarkFlagRequired("role"))` (`cmd/gen.go:175`) — no - // `Flag.optional` here, so a missing `--role` fails during argument parsing with - // the framework's own Go-parity `required flag(s) "role" not set` rendering - // (`shared/output/normalize-error.ts`'s `MissingOption` case), matching Go's - // `ValidateRequiredFlags`/`SilenceUsage` behavior (same pattern as `sso add --type`). - role: Flag.string("role").pipe(Flag.withDescription("Postgres role to use.")), + // Go: `cobra.CheckErr(genJWTCmd.MarkFlagRequired("role"))` (`cmd/gen.go:175`) — but + // cobra's `ValidateRequiredFlags` runs AFTER `PersistentPreRunE` + // (`cobra@v1.10.2/command.go:985,1007`), which is where Go's telemetry service gets + // constructed and later flushed to `telemetry.json` on Execute()'s return path. A + // missing `--role` must still flush telemetry (verified against the real binary: + // CI-1961 e2e parity run showed `telemetry.json` written on this exact failure). + // The framework's own `MissingOption` parse-time rejection (`normalize-error.ts`) + // would short-circuit before this command's handler — and its + // `Effect.ensuring(telemetryState.flush)` — ever runs, so `role` stays optional at + // parse time and presence is enforced in the handler instead, same established + // pattern as `vanity-subdomains activate`'s `--desired-subdomain` + // (`activate.command.ts`/`activate.handler.ts`). + role: Flag.string("role").pipe(Flag.withDescription("Postgres role to use."), Flag.optional), // Go's `DefValue` is cosmetically overwritten to "anonymous" (`cmd/gen.go:177`) but the // bound variable's real default stays "" — verified against the real binary, an // omitted `--sub` never puts a `sub` claim in the token at all. diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts index e1ff9691db..d2522fa035 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts @@ -13,6 +13,23 @@ export function legacyBearerJwtErrorMessage(cause: unknown): string { return cause instanceof Error ? cause.message : String(cause); } +/** + * Go marks `--role` required (`cmd/gen.go:175`), but cobra's `ValidateRequiredFlags` + * runs only AFTER `PersistentPreRunE` — which is where telemetry gets set up and later + * flushed (`cobra@v1.10.2/command.go:985,1007`). Enforced in the handler (after the + * telemetry-flushing wrapper is already active) rather than at parse time, so this + * failure still flushes `telemetry.json` like Go does. Byte-matches cobra's exact + * `required flag(s) "role" not set` wording, with no usage block (`SilenceUsage` is + * already set by the time `ValidateRequiredFlags` runs) and no `"Error: "` prefix + * (`cmd/root.go`'s `SilenceErrors: true` means cobra never prints its own prefix; + * `recoverAndExit` prints the bare message) — verified against the real binary. + */ +export class LegacyGenBearerJwtRoleRequiredError extends Data.TaggedError( + "LegacyGenBearerJwtRoleRequiredError", +)<{ + readonly message: string; +}> {} + /** `supabase/config.toml` itself is malformed. Mirrors `gen signing-key`'s own error shape. */ export class LegacyGenBearerJwtConfigParseError extends Data.TaggedError( "LegacyGenBearerJwtConfigParseError", diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.handler.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.handler.ts index 0e6016da44..41912c428b 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.handler.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.handler.ts @@ -1,4 +1,4 @@ -import { Effect, FileSystem, Path } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; import { legacySignJwtWithJwk } from "../../../shared/legacy-go-jwt.ts"; @@ -13,6 +13,7 @@ import { import { legacyBearerJwtErrorMessage, LegacyGenBearerJwtPayloadError, + LegacyGenBearerJwtRoleRequiredError, LegacyGenBearerJwtSignError, } from "./bearer-jwt.errors.ts"; import { legacyResolveBearerJwtSigningKey } from "./bearer-jwt.signing-key.ts"; @@ -21,6 +22,10 @@ import { legacyResolveBearerJwtSigningKey } from "./bearer-jwt.signing-key.ts"; * Go's `gen bearer-jwt` (`apps/cli-go/cmd/gen.go:132-143` + `internal/gen/bearerjwt/bearerjwt.go`): * fully local, no Docker, no network. Order matches Go exactly: * + * 0. `ValidateRequiredFlags` (`cobra@v1.10.2/command.go:1007`, ported as the + * `flags.role` check just below) — runs after cobra's `PersistentPreRunE` + * (telemetry setup) but before `RunE`/`parseClaims`, so a missing `--role` still + * flushes `telemetry.json` (see {@link LegacyGenBearerJwtRoleRequiredError}). * 1. `parseClaims` (`cmd/gen.go:136-141`, ported as {@link legacyBuildBearerJwtClaims} + * {@link legacyMergeBearerJwtPayload}) — runs entirely BEFORE `bearerjwt.Run` is even * called, so a malformed `--payload` fails before any config load or signing-key @@ -53,9 +58,18 @@ export const legacyGenBearerJwt = Effect.fn("legacy.gen.bearer-jwt")(function* ( const path = yield* Path.Path; return yield* Effect.gen(function* () { + if (Option.isNone(flags.role)) { + return yield* Effect.fail( + new LegacyGenBearerJwtRoleRequiredError({ + message: `required flag(s) "role" not set`, + }), + ); + } + const role = flags.role.value; + const nowSeconds = Math.floor(Date.now() / 1000); const baseClaims = legacyBuildBearerJwtClaims({ - role: flags.role, + role, sub: flags.sub, expiresAt: flags.exp, validForSeconds: flags.validFor, diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts index 63bb9c004d..7dfc0822b5 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts @@ -63,6 +63,8 @@ interface SetupOptions { readonly pipedAnswer?: string; readonly promptSelectResponses?: ReadonlyArray; readonly trackTelemetry?: boolean; + /** Overrides `cliConfig.workdir` — defaults to `tempRoot.current`. */ + readonly workdir?: string; } function setup(options: SetupOptions = {}) { @@ -72,7 +74,10 @@ function setup(options: SetupOptions = {}) { promptSelectResponses: options.promptSelectResponses, }); const api = mockLegacyPlatformApi(); - const cliConfig = mockLegacyCliConfig({ workdir: tempRoot.current, projectId: Option.none() }); + const cliConfig = mockLegacyCliConfig({ + workdir: options.workdir ?? tempRoot.current, + projectId: Option.none(), + }); const tty = mockTty({ stdinIsTty: options.stdinIsTty ?? false, stdoutIsTty: options.stdinIsTty ?? false, @@ -98,7 +103,7 @@ async function writeSigningKeys(contents: string) { } const baseFlags: LegacyGenBearerJwtFlags = { - role: "anon", + role: Option.some("anon"), sub: Option.none(), exp: Option.none(), validFor: 1800, @@ -172,7 +177,7 @@ describe("legacy gen bearer-jwt integration", () => { it.live("sets is_anonymous when role is authenticated and --sub is not given", () => { const { layer, out } = setup(); return Effect.gen(function* () { - yield* legacyGenBearerJwt({ ...baseFlags, role: "authenticated" }); + yield* legacyGenBearerJwt({ ...baseFlags, role: Option.some("authenticated") }); const [, payload] = tokenFrom(out).split("."); const claims = decodeSegment(payload ?? "") as Record; expect(claims["is_anonymous"]).toBe(true); @@ -185,7 +190,7 @@ describe("legacy gen bearer-jwt integration", () => { return Effect.gen(function* () { yield* legacyGenBearerJwt({ ...baseFlags, - role: "authenticated", + role: Option.some("authenticated"), sub: Option.some("user-1"), }); const [, payload] = tokenFrom(out).split("."); @@ -233,7 +238,7 @@ describe("legacy gen bearer-jwt integration", () => { return Effect.gen(function* () { yield* legacyGenBearerJwt({ ...baseFlags, - role: "postgres", + role: Option.some("postgres"), payload: '{"role":"override","sb-role":"mgmt-api"}', }); const [, payload] = tokenFrom(out).split("."); @@ -243,6 +248,71 @@ describe("legacy gen bearer-jwt integration", () => { }).pipe(Effect.provide(layer)); }); + // Go marks --role required (`cmd/gen.go:175`) but cobra validates required flags only + // AFTER `PersistentPreRunE` (`cobra@v1.10.2/command.go:985,1007`) — which is where Go's + // telemetry service is constructed and later flushed to `telemetry.json`. Verified + // against the real binary (CLI-1961 e2e parity run): a missing `--role` still writes + // `telemetry.json`, so the handler enforces the flag itself (after the telemetry-flushing + // wrapper is already active) instead of relying on the framework's parse-time rejection. + it.live( + "fails with cobra's required-flag error, and still flushes telemetry, when --role is omitted", + () => { + const { layer, out, telemetry } = setup({ trackTelemetry: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt({ ...baseFlags, role: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtRoleRequiredError"); + expect(json).toContain('required flag(s) \\"role\\" not set'); + } + expect(out.stdoutText).toBe(""); + expect(telemetry?.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "ignores an ancestor project's signing_keys_path when the resolved workdir has no config.toml of its own (CLI-1961)", + () => { + // Go's `Config.Load("")` (`pkg/config/utils.go:43-48`) resolves ONLY + // `/supabase/config.toml` — no ancestor climb (once `cliConfig.workdir` + // is already resolved, matching an explicit `--workdir` pointing at a + // subdirectory below another project's root — Go's own `ChangeWorkDir` does not + // climb when `--workdir`/`SUPABASE_WORKDIR` is explicit either, + // `internal/utils/misc.go:246-249`). Verified against the real binary (Codex + // review finding, CLI-1961): without `{ tomlOnly: true, search: false }` in + // `gen.signing-keys-config.ts`, the TS port picked up the PARENT directory's + // `signing_keys_path` and prompted for a kid instead of falling back to the + // unconfigured-default branch, like Go does. + const nestedWorkdir = join(tempRoot.current, "nested", "deeper"); + const { layer, out } = setup({ workdir: nestedWorkdir, pipedAnswer: "" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => mkdir(nestedWorkdir, { recursive: true })); + // `writeConfig`/`writeSigningKeys` target `tempRoot.current` — the ANCESTOR of + // `nestedWorkdir` — never `nestedWorkdir` itself. + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([generateEcJwk("ec-kid")]))); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + // The unconfigured-default branch's prompt was answered blank, so this must be + // the built-in default dev key, NOT the ancestor's `ec-kid`. + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", + typ: "JWT", + }); + expect(out.stderrText).toContain( + "Enter your signing key in JWK format (or leave blank to use local default): ", + ); + }).pipe(Effect.provide(layer)); + }, + ); + it.live( "fails with Go's exact wrapping for a malformed --payload, before any signing-key prompt", () => { @@ -564,7 +634,7 @@ describe("legacy gen bearer-jwt integration", () => { ); yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([ecJwk, rsaJwk]))); - yield* legacyGenBearerJwt({ ...baseFlags, role: "postgres" }); + yield* legacyGenBearerJwt({ ...baseFlags, role: Option.some("postgres") }); const token = tokenFrom(out); const [header] = token.split("."); expect(decodeSegment(header ?? "")).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); @@ -632,9 +702,11 @@ describe("legacy gen bearer-jwt integration", () => { const token = tokenFrom(out); const [header] = token.split("."); expect(decodeSegment(header ?? "")).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "info", message: "Selected key ID: rsa-kid" }), - ); + // Go: `fmt.Fprintln(os.Stderr, "Selected key ID:", choice.Summary)` (`bearerjwt.go:82`) + // — this command's own stdout is the signed-token payload even in text mode, so the + // line must land on stderr (`output.raw(..., "stderr")`), not via `output.info` + // (clack's `log.info`, which defaults to stdout — Codex review finding, CLI-1961). + expect(out.stderrText).toContain("Selected key ID: rsa-kid"); }).pipe(Effect.provide(layer)); }, ); @@ -656,9 +728,7 @@ describe("legacy gen bearer-jwt integration", () => { expect(Exit.isFailure(exit)).toBe(true); expect(out.promptSelectCalls[0]?.options[0]?.label).toBe(""); expect(out.promptSelectCalls[0]?.options[0]?.hint).toBe(" ()"); - expect(out.messages).toContainEqual( - expect.objectContaining({ type: "info", message: "Selected key ID: " }), - ); + expect(out.stderrText).toContain("Selected key ID: "); }).pipe(Effect.provide(layer)); }, ); From 7b62c43df2317b174c6d07871fa4102c24dc959e Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 11:08:17 +0100 Subject: [PATCH 04/12] fix(cli): gen bearer-jwt/signing-key must not climb ancestors or prefer config.json (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's Config.Load("") (pkg/config/utils.go:43-48) resolves ONLY /supabase/config.toml — no ancestor climb once cliConfig.workdir is already resolved (an explicit --workdir pointing at a subdirectory below another project's root gets no climb either: ChangeWorkDir only calls getProjectRoot's climb when --workdir/SUPABASE_WORKDIR is unset, internal/utils/misc.go:246-249) and no JSON project-config fallback. legacyResolveSigningKeysConfigPaths (shared by gen bearer-jwt and gen signing-key) called loadProjectConfig without { tomlOnly: true, search: false } — the exact pair legacy-local-project-context.ts already establishes for this same Config.Load-parity reason. Verified against the real binary: from a workdir with no config.toml of its own but an ancestor with a configured signing_keys_path, Go falls back to the unconfigured-default branch (prompts for a raw JWK) while the TS port picked up the ancestor's signing_keys_path and prompted for a kid instead. Fixes the shared helper's loadProjectConfig call and updates gen signing-key's own sibling integration test, which was asserting the old JSON-preferring (non-Go-parity) display path for a stray config.json with no config.toml present. Codex review finding (chatgpt-codex-connector), CLI-1961. --- .../commands/gen/gen.signing-keys-config.ts | 21 ++++++++++++- .../signing-key.integration.test.ts | 31 ++++++++++++------- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts index 5b7e8259e5..a19879732c 100644 --- a/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts +++ b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts @@ -51,7 +51,26 @@ export const legacyResolveSigningKeysConfigPaths = Effect.fnUntraced(function* < onConfigParseError: (message: string) => E, ) { const path = yield* Path.Path; - const loaded = yield* loadProjectConfig(cwd, { goViperCompat: true }).pipe( + const loaded = yield* loadProjectConfig(cwd, { + goViperCompat: true, + // `cwd` here is the ALREADY-resolved `LegacyCliConfig.workdir` (Go's own ancestor + // climb, `ChangeWorkDir`/`getProjectRoot`, already ran once to produce it — see + // `legacy-cli-config.layer.ts`'s `resolveWorkdir`). Without `search: false`, this + // call would climb AGAIN from `cwd`, which diverges from Go's real + // `Config.Load("")` (`pkg/config/utils.go:43-48`) whenever an explicit `--workdir` + // points at a subdirectory below another project's root: Go changes directly into + // that exact subdirectory (no climb once `--workdir`/`SUPABASE_WORKDIR` is set — + // `internal/utils/misc.go:246-249`) and finds no `supabase/config.toml` there, + // while this call would otherwise still find the ANCESTOR project's config — + // verified against the real binary (Codex review finding, CLI-1961): the ancestor's + // `signing_keys_path` leaked into the picker prompt in the TS port but not in Go. + // `tomlOnly: true` matches the same `Config.Load` — Go has no concept of a JSON + // project config file, so a stray `supabase/config.json` must never win over + // `config.toml` here either (`legacy-local-project-context.ts` establishes this + // exact pair of options for the same underlying reason). + search: false, + tomlOnly: true, + }).pipe( Effect.catchTag("ProjectConfigParseError", (cause) => Effect.fail(onConfigParseError(`failed to parse ${cause.path}: ${String(cause.cause)}`)), ), diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts index 95439473a8..f57d9082fc 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts @@ -201,18 +201,27 @@ describe("legacy gen signing-key integration", () => { }).pipe(Effect.provide(layer)) as Effect.Effect; }); - it.live("uses the project-relative config file path in the local setup hint", () => { - const { layer, out } = setup(); - return Effect.gen(function* () { - yield* Effect.tryPromise(() => writeJsonConfig("{}\n")); - yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); + it.live( + "ignores a stray config.json and uses the default config.toml path in the local setup hint (CLI-1961)", + () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + // Go's `Config.Load` (`pkg/config/utils.go:43-48`) has no concept of a JSON project + // config file — a stray `supabase/config.json` with no `config.toml` present must be + // treated exactly like no config at all, never as a substitute config source + // (`gen.signing-keys-config.ts`'s `loadProjectConfig(..., { tomlOnly: true })`; Codex + // review finding, CLI-1961). Go prints the CWD-relative `supabase/config.toml` in this + // "absent config" case; the hint must stay relative and must never leak the absolute + // temp-dir path either. + yield* Effect.tryPromise(() => writeJsonConfig("{}\n")); + yield* legacyGenSigningKey({ algorithm: "ES256", append: false }); - // Go prints the CWD-relative `supabase/config.toml`; the hint must stay relative and must - // never leak the absolute temp-dir path. - expect(out.stderrText).toContain(join("supabase", "config.json")); - expect(out.stderrText).not.toContain(join(tempRoot.current, "supabase", "config.json")); - }).pipe(Effect.provide(layer)); - }); + expect(out.stderrText).toContain(join("supabase", "config.toml")); + expect(out.stderrText).not.toContain("config.json"); + expect(out.stderrText).not.toContain(tempRoot.current); + }).pipe(Effect.provide(layer)); + }, + ); it.live( "overwrites the configured signing keys file and defaults to yes on non-tty when stdin has no piped answer", From fc33c259dad15131f8a59cfd1af796042c65805a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 11:08:30 +0100 Subject: [PATCH 05/12] fix(cli): route gen bearer-jwt's interactive signing-key picker to stderr (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's own interactive picker always writes to stderr (internal/utils/prompt.go's PromptChoice: tea.WithOutput(os.Stderr), "Interactive prompts should always be written to stderr") — but clack's select()/log.info() default to process.stdout (verified directly against the installed @clack/prompts source: no output override at either call site). gen bearer-jwt's own stdout IS the signed-token payload even in text mode, so an interactive user with a configured signing_keys_path would get the picker UI and the "Selected key ID: ..." line mixed into their stdout-captured token. Verified empirically: a minimal probe against the real textOutputLayer showed output.info's bytes land on stdout, not stderr, contradicting a stale comment elsewhere in this codebase that assumed otherwise. Adds an opt-in `stream?: "stdout" | "stderr"` to Output.promptSelect's behavior (defaults to "stdout", so every other of this command's ~10 existing callers is unaffected) and uses it — plus output.raw(..., "stderr") instead of output.info for the "Selected key ID:" line — in bearer-jwt's own signing-key resolver. Codex review finding (chatgpt-codex-connector), CLI-1961. --- .../gen/bearer-jwt/bearer-jwt.signing-key.ts | 18 ++++++- apps/cli/src/shared/output/output.layer.ts | 13 ++++- .../shared/output/output.layer.unit.test.ts | 53 ++++++++++++++++++- apps/cli/src/shared/output/output.service.ts | 10 ++++ 4 files changed, 90 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts index 7a44b3ddbe..eb40c26663 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts @@ -160,6 +160,18 @@ const resolveSigningKeyFromStdinJwk = Effect.fnUntraced(function* () { * Go's bubbletea `PromptChoice` — the rendered ANSI never byte-matches Go's TUI * either way, so this codebase's established precedent is to match only the * observable stderr line Go itself prints after a choice, "Selected key ID: "). + * + * Both the picker AND that line are routed to stderr explicitly (`{ stream: "stderr" + * }` / `output.raw(..., "stderr")` below) rather than the shared `promptSelect`/`info` + * defaults: Go's own `PromptChoice` comments "Interactive prompts should always be + * written to stderr" and passes `tea.WithOutput(os.Stderr)` + * (`internal/utils/prompt.go:127-128`) — but clack's `select()`/`log.info()` default to + * `process.stdout` (verified directly against the installed `@clack/prompts` source), + * and this command's own stdout IS the signed-token payload even in text mode (see + * `bearer-jwt.handler.ts`'s doc comment) — so the unmodified defaults would corrupt a + * piped/captured token with picker UI and the "Selected key ID: ..." line for any + * interactive user with a configured `signing_keys_path` (Codex review finding, + * CLI-1961). */ const resolveSigningKeyFromConfigured = Effect.fnUntraced(function* ( availableKeys: ReadonlyArray, @@ -204,10 +216,12 @@ const resolveSigningKeyFromConfigured = Effect.fnUntraced(function* ( label: key.kid ?? "", hint: `${key.alg ?? ""} (${(key.key_ops ?? []).join(",")})`, })); - const chosen = yield* output.promptSelect("Select a signing key:", options); + const chosen = yield* output.promptSelect("Select a signing key:", options, { stream: "stderr" }); const chosenKey = availableKeys[Number(chosen)]!; // Go: `fmt.Fprintln(os.Stderr, "Selected key ID:", choice.Summary)` (`bearerjwt.go:82`). - yield* output.info(`Selected key ID: ${chosenKey.kid ?? ""}`); + // `output.raw(..., "stderr")`, NOT `output.info` — `info` is clack's `log.info`, which + // defaults to stdout (see this function's doc comment above). + yield* output.raw(`Selected key ID: ${chosenKey.kid ?? ""}\n`, "stderr"); return chosenKey; }); diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index fe8ff28710..d1bf520d82 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -112,6 +112,7 @@ export const textOutputLayer = Layer.effect( readonly autocompleteThreshold?: number; readonly placeholder?: string; readonly maxItems?: number; + readonly stream?: "stdout" | "stderr"; } = {}, ) => Effect.gen(function* () { @@ -122,6 +123,11 @@ export const textOutputLayer = Layer.effect( ? "autocomplete" : "select" : mode; + // clack itself defaults every one of these to `process.stdout` (verified against + // the installed `@clack/prompts` source) — only override when a caller explicitly + // asks for stderr (e.g. a command whose own stdout is a machine-readable payload + // even in text mode). + const clackOutput = behavior.stream === "stderr" ? process.stderr : undefined; const value = yield* Effect.promise(() => effectiveMode === "autocomplete" ? autocomplete({ @@ -131,15 +137,20 @@ export const textOutputLayer = Layer.effect( ? { placeholder: behavior.placeholder } : {}), ...(behavior.maxItems !== undefined ? { maxItems: behavior.maxItems } : {}), + ...(clackOutput !== undefined ? { output: clackOutput } : {}), }) : select({ message, options: buildSelectOptions(options), ...(behavior.maxItems !== undefined ? { maxItems: behavior.maxItems } : {}), + ...(clackOutput !== undefined ? { output: clackOutput } : {}), }), ); if (isCancel(value)) { - cancel("Operation cancelled."); + cancel( + "Operation cancelled.", + clackOutput !== undefined ? { output: clackOutput } : undefined, + ); return yield* Effect.interrupt; } return value; diff --git a/apps/cli/src/shared/output/output.layer.unit.test.ts b/apps/cli/src/shared/output/output.layer.unit.test.ts index e3f8346f97..6317b5c79b 100644 --- a/apps/cli/src/shared/output/output.layer.unit.test.ts +++ b/apps/cli/src/shared/output/output.layer.unit.test.ts @@ -55,7 +55,7 @@ vi.mock("@clack/prompts", () => ({ select: (a: unknown) => mockClack.select(a), autocomplete: (a: unknown) => mockClack.autocomplete(a), multiselect: (a: unknown) => mockClack.multiselect(a), - cancel: (a: unknown) => mockClack.cancel(a), + cancel: (a: unknown, b?: unknown) => mockClack.cancel(a, b), isCancel: (a: unknown) => mockClack.isCancel(a), })); @@ -404,6 +404,57 @@ describe("Output", () => { }).pipe(Effect.provide(layer)); }); + it.effect("promptSelect defaults to clack's own stdout when stream is unset", () => { + mockClack.select.mockResolvedValue("pro"); + return Effect.gen(function* () { + const out = yield* Output; + yield* out.promptSelect("Select a plan", [{ value: "pro", label: "Pro" }]); + expect(mockClack.select).toHaveBeenCalledWith( + expect.not.objectContaining({ output: expect.anything() }), + ); + }).pipe(Effect.provide(layer)); + }); + + // Go's own interactive picker always writes to stderr (`internal/utils/prompt.go`'s + // `PromptChoice`: `tea.WithOutput(os.Stderr)`, "Interactive prompts should always be + // written to stderr") — but clack's `select()`/`autocomplete()` default to stdout, which + // would corrupt a command whose own stdout is a machine-readable payload even in text + // mode (e.g. `gen bearer-jwt`'s signed token — Codex review finding, CLI-1961). `{ stream: + // "stderr" }` is the opt-in escape hatch such a command passes. + it.effect('promptSelect routes the picker to stderr when stream: "stderr" is requested', () => { + mockClack.select.mockResolvedValue("pro"); + return Effect.gen(function* () { + const out = yield* Output; + yield* out.promptSelect("Select a plan", [{ value: "pro", label: "Pro" }], { + stream: "stderr", + }); + expect(mockClack.select).toHaveBeenCalledWith( + expect.objectContaining({ output: process.stderr }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "promptSelect routes a cancelled stderr-routed picker's cancel message to stderr too", + () => { + mockClack.select.mockResolvedValue(Symbol("clack-cancel")); + mockClack.isCancel.mockReturnValueOnce(true); + return Effect.gen(function* () { + const out = yield* Output; + const exit = yield* Effect.exit( + out.promptSelect("Select a plan", [{ value: "pro", label: "Pro" }], { + stream: "stderr", + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(mockClack.cancel).toHaveBeenCalledWith( + "Operation cancelled.", + expect.objectContaining({ output: process.stderr }), + ); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("promptSelect uses autocomplete for long lists in auto mode", () => { mockClack.autocomplete.mockResolvedValue("project-11"); return Effect.gen(function* () { diff --git a/apps/cli/src/shared/output/output.service.ts b/apps/cli/src/shared/output/output.service.ts index 54baf347f0..8026f793b3 100644 --- a/apps/cli/src/shared/output/output.service.ts +++ b/apps/cli/src/shared/output/output.service.ts @@ -24,6 +24,16 @@ interface OutputSelectBehavior { readonly autocompleteThreshold?: number; readonly placeholder?: string; readonly maxItems?: number; + /** + * Which stream the interactive picker itself renders to. Defaults to `"stdout"` + * (clack's own default, matching every existing caller). Pass `"stderr"` for a + * command whose own stdout is a machine-readable payload even in text mode (e.g. + * `gen bearer-jwt`'s signed token) — matching Go's own convention of always + * rendering interactive prompts to stderr (`internal/utils/prompt.go`'s + * `PromptChoice`: `tea.WithOutput(os.Stderr)`, "Interactive prompts should always + * be written to stderr"). + */ + readonly stream?: "stdout" | "stderr"; } /** From 38ecf1dd7ad59ea5ebee1e23e52e48c63d81a715 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 11:08:46 +0100 Subject: [PATCH 06/12] fix(cli): reject out-of-range --exp zone offsets instead of signing null claims (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's time.Parse(time.RFC3339, ...) rejects an out-of-range zone offset (e.g. --exp 2030-01-01T00:00:00+99:99: "time zone offset minute out of range" — verified directly against the Go standard library), but legacyParseBearerJwtExp's calendar check never looked at the offset at all: the regexp matched, isValidRfc3339Calendar passed (it only checks the LOCAL time components), and Date.parse silently returned NaN. NaN then flowed into exp/iat, JSON.stringify(NaN) === "null", and the command signed a token with null exp/iat claims instead of failing — a security-relevant divergence, since it defeats expiration entirely rather than erroring loudly. Go's own range check (time/format.go:1267-1278) is intentionally lenient in one direction: "> rather than >=, as some people do write offsets of 24 hours or 60 minutes" — so +24:00 parses successfully in Go while +99:99 does not. A naive `if (!Number.isFinite(Date.parse(...))) throw` fallback would get this backwards (Date.parse itself rejects +24:00, which Go accepts), so this reimplements Go's t.addSec(-zoneOffset) arithmetic directly: the local wall-clock components (already validated), interpreted as UTC, minus the signed offset in seconds — byte-verified against the real binary for both the accept and reject boundaries. Codex review finding (chatgpt-codex-connector), CLI-1961. --- .../gen/bearer-jwt/bearer-jwt.flags.ts | 103 +++++++++++++----- .../bearer-jwt/bearer-jwt.flags.unit.test.ts | 32 ++++++ 2 files changed, 110 insertions(+), 25 deletions(-) diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts index e23c517733..7d8c7f2741 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts @@ -2,7 +2,7 @@ import { legacyParseGoDuration } from "../../../shared/legacy-go-duration.ts"; import { legacyBearerJwtErrorMessage } from "./bearer-jwt.errors.ts"; const RFC3339_PATTERN = - /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:(Z)|([+-])(\d{2}):(\d{2}))$/; const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; @@ -46,45 +46,98 @@ function isValidRfc3339Calendar( ); } +function rfc3339FlagError(trimmedValue: string): Error { + return new Error( + `invalid argument "${trimmedValue}" for "--exp" flag: invalid time format \`${trimmedValue}\` must be one of: \`2006-01-02T15:04:05Z07:00\``, + ); +} + /** * Go's `--exp` flag (`TimeVar(&expiry, "exp", time.Time{}, []string{time.RFC3339}, ...)`, * `apps/cli-go/cmd/gen.go:178`) calls `time.Parse(time.RFC3339, val)` inside pflag's * `Value.Set`, which runs during `cmd.ParseFlags` — BEFORE `RunE` (verified against the - * real binary, CLI-1961). Two behaviors verified directly against pflag's own source - * (`time.go:24-44`) rather than assumed: + * real binary, CLI-1961). Behaviors verified directly against pflag's/Go's own source + * rather than assumed: * - `Value.Set` trims the input with `strings.TrimSpace` BEFORE ever calling * `time.Parse`, so `--exp " 2030-01-01T00:00:00Z "` (surrounding whitespace) * parses successfully — the trimmed value is used both for parsing and for the * error message below (Go re-embeds ITS OWN already-trimmed `s`, never the * original untrimmed argument). - * - A parse failure — whether syntactic OR a Go-rejected out-of-range calendar - * component (`isValidRfc3339Calendar` above) — raises the exact same wrapped - * message: `invalid argument "" for "--exp" flag: invalid time format - * \`\` must be one of: \`2006-01-02T15:04:05Z07:00\`` (a single format, - * since `--exp` registers only RFC3339). + * - A parse failure — whether syntactic, a Go-rejected out-of-range calendar + * component (`isValidRfc3339Calendar` above), or an out-of-range zone offset + * (below) — raises the exact same wrapped message: `invalid argument "" for + * "--exp" flag: invalid time format \`\` must be one of: + * \`2006-01-02T15:04:05Z07:00\`` (a single format, since `--exp` registers only + * RFC3339). + * - `time.Parse`'s own numeric zone-offset range check (`time/format.go:1267-1278`) + * rejects an offset hour/minute that OVERFLOWS a 2-digit field's max plausible + * value using `>` rather than `>=` ("as some people do write offsets of 24 hours + * or 60 minutes", per Go's own comment) — so `+24:00` parses successfully but + * `+99:99` (Codex review finding, CLI-1961) does not. Verified directly against + * the Go standard library. `Date.parse` cannot be reused for the final + * timestamp once an offset is present: it rejects `+24:00`/`+60`-minute offsets + * that Go accepts, and — more importantly — silently returns `NaN` for a + * genuinely-invalid offset like `+99:99` instead of throwing, which would let a + * malformed `--exp` mint a token with `exp`/`iat` claims that JSON-serialize as + * `null` (`JSON.stringify(NaN) === "null"`) rather than failing the command. + * Reimplements Go's `t.addSec(-zoneOffset)` (`format.go:1392`) directly instead: + * the local wall-clock components, interpreted as UTC, minus the signed offset + * in seconds. * Returns Unix seconds on success. */ export function legacyParseBearerJwtExp(value: string): number { const trimmedValue = value.trim(); const match = RFC3339_PATTERN.exec(trimmedValue); - const isValid = - match !== null && - isValidRfc3339Calendar( - Number(match[1]), - Number(match[2]), - Number(match[3]), - Number(match[4]), - Number(match[5]), - Number(match[6]), - ); - if (!isValid) { - throw new Error( - `invalid argument "${trimmedValue}" for "--exp" flag: invalid time format \`${trimmedValue}\` must be one of: \`2006-01-02T15:04:05Z07:00\``, - ); + if (match === null) { + throw rfc3339FlagError(trimmedValue); + } + const [ + , + year, + month, + day, + hour, + minute, + second, + isUtc, + offsetSign, + offsetHourStr, + offsetMinuteStr, + ] = match; + if ( + !isValidRfc3339Calendar( + Number(year), + Number(month), + Number(day), + Number(hour), + Number(minute), + Number(second), + ) + ) { + throw rfc3339FlagError(trimmedValue); } - // Every component is now confirmed in range, so `Date.parse` can no longer silently - // roll anything over — it's safe to reuse for the actual timestamp computation. - return Math.floor(Date.parse(trimmedValue) / 1000); + + let offsetSeconds = 0; + if (isUtc === undefined) { + const offsetHour = Number(offsetHourStr); + const offsetMinute = Number(offsetMinuteStr); + if (offsetHour > 24 || offsetMinute > 60) { + throw rfc3339FlagError(trimmedValue); + } + offsetSeconds = (offsetHour * 60 + offsetMinute) * 60; + if (offsetSign === "-") offsetSeconds = -offsetSeconds; + } + + const localAsUtcSeconds = + Date.UTC( + Number(year), + Number(month) - 1, + Number(day), + Number(hour), + Number(minute), + Number(second), + ) / 1000; + return localAsUtcSeconds - offsetSeconds; } /** diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts index d1f1565096..1011c838f8 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts @@ -67,6 +67,38 @@ describe("legacyParseBearerJwtExp", () => { 'invalid argument "notatime" for "--exp" flag: invalid time format `notatime` must be one of: `2006-01-02T15:04:05Z07:00`', ); }); + + it("rejects an out-of-range zone offset instead of silently signing a null exp/iat (CLI-1961 Codex review finding)", () => { + // Before this fix: the calendar check never looked at the offset at all, so + // `+99:99` passed validation, `Date.parse` returned `NaN`, and the caller signed + // a token whose `exp`/`iat` claims serialized as JSON `null` + // (`JSON.stringify(NaN) === "null"`) instead of failing the command — verified + // against the real binary, which rejects this exact input during flag parsing. + expect(() => legacyParseBearerJwtExp("2030-01-01T00:00:00+99:99")).toThrow( + 'invalid argument "2030-01-01T00:00:00+99:99" for "--exp" flag: invalid time format `2030-01-01T00:00:00+99:99` must be one of: `2006-01-02T15:04:05Z07:00`', + ); + }); + + it("tolerates a 24-hour/60-minute offset the same way Go's time.Parse does (`>` not `>=`)", () => { + // Go's own comment (`time/format.go:1267-1269`): "The range test use > rather + // than >=, as some people do write offsets of 24 hours or 60 minutes" — verified + // directly against the Go standard library. + expect(legacyParseBearerJwtExp("2030-01-01T00:00:00+24:00")).toBe( + legacyParseBearerJwtExp("2030-01-01T00:00:00Z") - 24 * 60 * 60, + ); + expect(legacyParseBearerJwtExp("2030-01-01T00:00:00+00:60")).toBe( + legacyParseBearerJwtExp("2030-01-01T00:00:00Z") - 60 * 60, + ); + }); + + it("rejects an offset that overflows even Go's 24-hour/60-minute tolerance", () => { + expect(() => legacyParseBearerJwtExp("2030-01-01T00:00:00+25:00")).toThrow( + '"--exp" flag: invalid time format', + ); + expect(() => legacyParseBearerJwtExp("2030-01-01T00:00:00+00:61")).toThrow( + '"--exp" flag: invalid time format', + ); + }); }); describe("legacyParseBearerJwtValidFor", () => { From dfa0595b05a4435c55a8fc6232310ccae96c3bb3 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 12:25:53 +0100 Subject: [PATCH 07/12] fix(cli): preserve --exp fractions, reject overflow/malformed JWK fields, and unblock the bearer-jwt TTY picker under json output (review) Four confirmed Codex review findings on gen bearer-jwt, each verified against the real apps/cli-go binary/stdlib: - bearer-jwt.flags.ts / bearer-jwt.claims.ts: fractional --exp seconds were dropped during parsing before the iat = exp - validFor arithmetic ran. Go's time.Parse preserves fractional seconds even for RFC3339 (a documented parse-only extension) and only truncates the final exp/iat via jwt.NewNumericDate. Preserve the fraction through legacyParseBearerJwtExp and floor exp/iat only at the end in legacyBuildBearerJwtClaims. - bearer-jwt.claims.ts: an out-of-range JSON number in --payload (e.g. {"extra":1e309}) was accepted by JSON.parse as Infinity and later serialized as null, silently changing a custom claim. Go's json.Unmarshal into jwt.MapClaims rejects it outright. Scan for the first non-finite number literal once the payload's top-level shape is confirmed to be an object (matching Go's exact priority over the existing array/scalar type-mismatch check). - bearer-jwt.signing-key.ts: malformed optional JWK fields (key_ops with a non-string element, ext as a non-bool, etc.) were silently dropped instead of failing the way Go's json.Unmarshal into config.JWK does. Generalized to every field this normalizer reads, wrapped with each call site's own Go-matching error prefix (Branch A's "failed to parse JWK: %w", the signing_keys_path file's "failed to decode signing keys: failed to parse response body: %w"). - bearer-jwt.signing-key.ts: the TTY key picker aborted with NonInteractiveError under --output-format=json/stream-json, even though Go has no such flag and always prompts on a real TTY, and this command's stdout is the raw token unconditionally in every format. Try the ambient Output first (preserving every existing mock-driven test), and only retry through a real textOutputLayer instance on NonInteractiveError. --- .../gen/bearer-jwt/bearer-jwt.claims.ts | 96 +++++++- .../bearer-jwt/bearer-jwt.claims.unit.test.ts | 59 +++++ .../gen/bearer-jwt/bearer-jwt.flags.ts | 20 +- .../bearer-jwt/bearer-jwt.flags.unit.test.ts | 14 ++ .../bearer-jwt/bearer-jwt.integration.test.ts | 112 +++++++++ .../gen/bearer-jwt/bearer-jwt.signing-key.ts | 218 ++++++++++++++++-- 6 files changed, 491 insertions(+), 28 deletions(-) diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts index 5b78adde02..f091cc3236 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts @@ -20,7 +20,12 @@ import { legacyGoJsonKindName } from "../../../shared/legacy-go-json.ts"; export interface LegacyBearerJwtClaimsInput { readonly role: string; readonly sub: Option.Option; - /** Unix seconds from `--exp` (RFC3339) — `Option.none()` when the flag was not given. */ + /** + * Unix seconds from `--exp` (RFC3339), WITHOUT flooring — `Option.none()` when the + * flag was not given. May carry a sub-second fraction; see + * {@link legacyParseBearerJwtExp}'s own doc comment for why that fraction must + * survive until the final `exp`/`iat` computation below. + */ readonly expiresAt: Option.Option; /** * `--valid-for`, parsed from Go-duration syntax into seconds WITHOUT flooring — @@ -45,6 +50,15 @@ export interface LegacyBearerJwtClaimsInput { * the real binary (CLI-1961): `--exp 2030-01-01T00:00:00Z --valid-for 1.5s` yields * `iat=1893455998` — flooring the 1.5s duration to 1s first (as this port previously * did) would wrongly yield `1893455999`. + * - `expiresAt` itself may ALSO carry a sub-second fraction (see + * {@link legacyParseBearerJwtExp}'s own doc comment for why `--exp`'s fraction + * survives parsing) — the same "floor only the final result" rule applies to it: + * `iat` is computed from the UNFLOORED `expiresAt` minus `validForSeconds`, and + * `exp` is floored separately from that same unfloored value, rather than flooring + * `expiresAt` up front and losing its fraction before either computation runs. + * Verified against the real binary (CLI-1961): `--exp 2030-01-01T00:00:00.9Z + * --valid-for 1.2s` yields `iat=1893455999`, not the `1893455998` that flooring + * `expiresAt` before the subtraction would produce. * - `role` is ALWAYS present (`json:"role"`, no `omitempty`), even `--role ""`. * - `is_anonymous` is set only when `role` case-insensitively equals `"authenticated"` * AND `--sub` was not given (`strings.EqualFold` + `len(claims.Subject) == 0`) — an @@ -68,8 +82,9 @@ export function legacyBuildBearerJwtClaims( iat = input.nowSeconds; exp = Math.floor(iat + input.validForSeconds); } else { - exp = input.expiresAt.value; - iat = Math.floor(exp - input.validForSeconds); + const rawExp = input.expiresAt.value; + exp = Math.floor(rawExp); + iat = Math.floor(rawExp - input.validForSeconds); } const claims: Record = { @@ -150,6 +165,54 @@ const JSON_NUMBER_PATTERN = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/; /** Go's literal keywords, keyed by their first byte — matched char-by-char below, same as `encoding/json`'s scanner. */ const GO_JSON_LITERALS: Record = { n: "null", t: "true", f: "false" }; +/** + * Scans an ALREADY syntactically-valid JSON document (this only ever runs after + * `JSON.parse` on `value` has itself succeeded, so every string is properly closed + * and every number token is well-formed) for the first number literal that overflows + * a float64, e.g. `1e309` — returns that literal's exact source text, or `undefined` + * if every number in the document is finite. + * + * `JSON.parse` silently converts an overflowing literal to `Infinity`/`-Infinity` + * (verified: `JSON.parse('{"extra":1e309}')` yields `{ extra: Infinity }`, which + * {@link legacyEncodeBearerJwtClaims}'s Go-compatible encoder then serializes as + * `null`, per `encoding/json`'s own float64-to-JSON-number behavior for non-finite + * values) — but Go's `json.Unmarshal` into `jwt.MapClaims` (which decodes every JSON + * number as a Go `float64`) fails outright: `strconv.ParseFloat` returns a range + * error for the same literal, and `encoding/json` surfaces that as `"json: cannot + * unmarshal number 1e309 into Go value of type float64"`. Verified against the real + * binary (CLI-1961): `--payload '{"extra":1e309}'` exits 1 with exactly that message + * (wrapped by the caller's `"failed to parse payload: %w"`), rather than silently + * signing a token with a `null` custom claim. + * + * A left-to-right scan that skips over string contents (object keys and string + * values, via {@link findJsonStringEnd}) and matches a full number token at every + * other digit/`-` position reproduces Go's own decode order (a single-pass, + * depth-first token scan) closely enough to report the same FIRST offending literal + * Go's decoder would stop at, without needing a full recursive-descent parse. + */ +function findFirstNonFiniteJsonNumberLiteral(value: string): string | undefined { + let i = 0; + while (i < value.length) { + const ch = value[i]!; + if (ch === '"') { + // `value` is known-valid JSON, so every string closes; skip over it whole so + // digits inside a string value are never mistaken for a number token. + i = findJsonStringEnd(value, i)!; + continue; + } + if (ch === "-" || (ch >= "0" && ch <= "9")) { + const literal = JSON_NUMBER_PATTERN.exec(value.slice(i))![0]; + if (!Number.isFinite(Number(literal))) { + return literal; + } + i += literal.length; + continue; + } + i++; + } + return undefined; +} + /** * Reports Go's exact `"invalid character '' after top-level value"` when * `trimmed` has non-whitespace content after its first `validPrefixLength` @@ -249,8 +312,16 @@ function legacyGoJsonSyntaxErrorMessage(raw: string): string { * leave the destination untouched here in practice). A non-object, non-null, * non-array top-level scalar (string/number/bool) or an array raises Go's own * `"json: cannot unmarshal into Go value of type jwt.MapClaims"` runtime - * type-mismatch text. Throws a bare `Error`; the caller (`bearer-jwt.handler.ts`) - * wraps it with Go's `"failed to parse payload: %w"` prefix. + * type-mismatch text — checked BEFORE any number-overflow scan below, since Go + * rejects the top-level kind before ever attempting to decode a number inside it + * (verified against the real binary: a top-level overflowing scalar payload like + * `--payload '1e309'` still reports "cannot unmarshal number into Go value of type + * jwt.MapClaims", WITHOUT the literal). Once the top level genuinely is an object, an + * overflowing number ANYWHERE inside it, at any depth (e.g. `{"extra":1e309}` or + * `{"a":{"b":[1e309]}}`), raises Go's `"json: cannot unmarshal number into + * Go value of type float64"` instead — see + * {@link findFirstNonFiniteJsonNumberLiteral}. Throws a bare `Error`; the caller + * (`bearer-jwt.handler.ts`) wraps it with Go's `"failed to parse payload: %w"` prefix. */ export function legacyMergeBearerJwtPayload( claims: Record, @@ -270,6 +341,21 @@ export function legacyMergeBearerJwtPayload( `json: cannot unmarshal ${legacyGoJsonKindName(parsed)} into Go value of type jwt.MapClaims`, ); } + // Only reachable once the top-level shape is already a map — verified against the + // real binary: a top-level scalar/array payload gets the structural mismatch above + // even when it overflows (e.g. `--payload '1e309'` still reports "cannot unmarshal + // number into Go value of type jwt.MapClaims", WITHOUT the literal, because Go's + // decoder rejects the top-level kind before ever attempting to decode the number + // itself), whereas `--payload '[1e309]'` reports the array-kind mismatch. Once the + // top level genuinely is an object, Go recurses into every value at any depth + // (including inside nested arrays/objects) as `interface{}`, which is where an + // overflowing number actually gets decoded as `float64` and fails. + const overflowingLiteral = findFirstNonFiniteJsonNumberLiteral(payload); + if (overflowingLiteral !== undefined) { + throw new Error( + `json: cannot unmarshal number ${overflowingLiteral} into Go value of type float64`, + ); + } return { ...claims, ...(parsed as Record) }; } diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts index 71f5918895..6ea251f63d 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts @@ -59,6 +59,21 @@ describe("legacyBuildBearerJwtClaims", () => { expect(claims["iat"]).toBe(1_893_455_998); }); + it("preserves a fractional --exp through the iat subtraction, flooring only the final result (CLI-1961 Codex review finding)", () => { + // Verified against the real binary: `--exp 2030-01-01T00:00:00.9Z --valid-for + // 1.2s` yields `iat=1893455999`. Flooring `expiresAt` BEFORE the subtraction + // (this port's previous behavior) would wrongly yield `1893455998`. + const claims = legacyBuildBearerJwtClaims({ + role: "anon", + sub: Option.none(), + expiresAt: Option.some(1_893_456_000.9), + validForSeconds: 1.2, + nowSeconds: NOW, + }); + expect(claims["exp"]).toBe(1_893_456_000); + expect(claims["iat"]).toBe(1_893_455_999); + }); + it("sets is_anonymous when --sub is explicitly passed as an empty string (CLI-1961)", () => { // Go's gate is `len(claims.Subject) == 0` (`cmd/gen.go:195`) — an explicitly-passed // EMPTY `--sub ""` still counts as "no subject", not just an omitted flag. @@ -177,6 +192,50 @@ describe("legacyMergeBearerJwtPayload", () => { ); }); + it("rejects an overflowing number nested in an object payload instead of silently signing Infinity-as-null (CLI-1961 Codex review finding)", () => { + // Verified against the real binary: `--payload '{"extra":1e309}'` exits 1 with + // this exact message. `JSON.parse` alone would accept `1e309` as `Infinity`, + // which `legacyEncodeBearerJwtClaims`'s Go-compatible encoder then serializes as + // `null` — silently changing the claim instead of failing the command. + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, '{"extra":1e309}')).toThrow( + "json: cannot unmarshal number 1e309 into Go value of type float64", + ); + }); + + it("rejects an overflowing number nested arbitrarily deep (inside an array, inside an object)", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, '{"a":{"b":[1,2,1e309]}}')).toThrow( + "json: cannot unmarshal number 1e309 into Go value of type float64", + ); + }); + + it("reports the FIRST overflowing literal in document order when multiple numbers overflow", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, '{"a":1e400,"b":1e309}')).toThrow( + "json: cannot unmarshal number 1e400 into Go value of type float64", + ); + }); + + it("does not misreport a non-overflowing number as overflowing", () => { + const merged = legacyMergeBearerJwtPayload({ role: "anon" }, '{"extra":123.456}'); + expect(merged["extra"]).toBe(123.456); + }); + + it("prioritizes the top-level type-mismatch message over an overflowing scalar payload", () => { + // Verified against the real binary: a bare top-level overflowing scalar payload + // (`--payload '1e309'`) still reports the STRUCTURAL mismatch, WITHOUT the + // literal, because Go's decoder rejects the top-level kind before ever + // attempting to decode the number itself — the array/scalar top-level check + // must run BEFORE the overflow scan. + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "1e309")).toThrow( + "json: cannot unmarshal number into Go value of type jwt.MapClaims", + ); + }); + + it("prioritizes the top-level array-mismatch message over an overflowing number nested inside the array", () => { + expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "[1e309]")).toThrow( + "json: cannot unmarshal array into Go value of type jwt.MapClaims", + ); + }); + it("rejects trailing garbage after a valid value with Go's exact 'after top-level value' text", () => { expect(() => legacyMergeBearerJwtPayload({ role: "anon" }, "{}{}")).toThrow( "invalid character '{' after top-level value", diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts index 7d8c7f2741..e266e5fc75 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts @@ -2,7 +2,7 @@ import { legacyParseGoDuration } from "../../../shared/legacy-go-duration.ts"; import { legacyBearerJwtErrorMessage } from "./bearer-jwt.errors.ts"; const RFC3339_PATTERN = - /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:(Z)|([+-])(\d{2}):(\d{2}))$/; + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(?:(Z)|([+-])(\d{2}):(\d{2}))$/; const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; @@ -83,7 +83,19 @@ function rfc3339FlagError(trimmedValue: string): Error { * Reimplements Go's `t.addSec(-zoneOffset)` (`format.go:1392`) directly instead: * the local wall-clock components, interpreted as UTC, minus the signed offset * in seconds. - * Returns Unix seconds on success. + * - Go's `time.Parse` accepts fractional seconds after the whole-seconds field EVEN + * THOUGH `time.RFC3339`'s own layout has no fractional-seconds directive — this is + * a documented parse-only extension ("in the absence of a fractional second in the + * format, the fractional part will still be parsed if it is present"), verified + * directly against the Go standard library. The parsed instant carries that + * fraction at full (nanosecond) precision into the `iat = exp - validFor` + * arithmetic in `legacyBuildBearerJwtClaims`, which floors only the FINAL `exp`/ + * `iat` — so the fraction must survive this function's return value rather than + * being discarded here, matching `legacyParseBearerJwtValidFor`'s own + * no-early-flooring rule below. Verified against the real binary (CLI-1961): + * `--exp 2030-01-01T00:00:00.9Z --valid-for 1.2s` yields `iat=1893455999`, not the + * `1893455998` that dropping the `.9` fraction during parsing would produce. + * Returns Unix seconds (as a float that may carry a sub-second fraction) on success. */ export function legacyParseBearerJwtExp(value: string): number { const trimmedValue = value.trim(); @@ -99,6 +111,7 @@ export function legacyParseBearerJwtExp(value: string): number { hour, minute, second, + fraction, isUtc, offsetSign, offsetHourStr, @@ -137,7 +150,8 @@ export function legacyParseBearerJwtExp(value: string): number { Number(minute), Number(second), ) / 1000; - return localAsUtcSeconds - offsetSeconds; + const fractionalSeconds = fraction === undefined ? 0 : Number(`0.${fraction}`); + return localAsUtcSeconds + fractionalSeconds - offsetSeconds; } /** diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts index 1011c838f8..8a3c6f9d20 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts @@ -99,6 +99,20 @@ describe("legacyParseBearerJwtExp", () => { '"--exp" flag: invalid time format', ); }); + + it("preserves fractional seconds instead of dropping them during parsing (CLI-1961 Codex review finding)", () => { + // Go's `time.Parse(time.RFC3339, ...)` accepts (and preserves at full precision) + // fractional seconds even though `time.RFC3339`'s own layout has no fractional + // directive — verified against the Go standard library. Dropping the `.9` here + // (this port's previous behavior) would produce `1893456000` instead. + expect(legacyParseBearerJwtExp("2030-01-01T00:00:00.9Z")).toBeCloseTo(1_893_456_000.9, 9); + }); + + it("preserves a fractional offset the same way for a non-UTC zone", () => { + const withFraction = legacyParseBearerJwtExp("2030-01-01T00:00:00.5+05:00"); + const atZ = legacyParseBearerJwtExp("2030-01-01T00:00:00Z"); + expect(withFraction).toBeCloseTo(atZ - 5 * 60 * 60 + 0.5, 9); + }); }); describe("legacyParseBearerJwtValidFor", () => { diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts index 7dfc0822b5..bda32a7d2d 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts @@ -508,6 +508,118 @@ describe("legacy gen bearer-jwt integration", () => { }, ); + it.live( + "Branch A: rejects a pasted JWK with a non-string key_ops element (CLI-1961 Codex review finding)", + () => { + // Verified against the real binary: `{"kty":"oct","alg":"ES256","key_ops":["sign",1]}` + // exits 1 with this exact message — Go's `json.Unmarshal` into `config.JWK`'s + // `KeyOps []string` field fails outright on a non-string element rather than + // silently dropping the field the way this normalizer previously did. + const { layer } = setup({ + pipedAnswer: JSON.stringify({ kty: "oct", alg: "ES256", key_ops: ["sign", 1] }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtKeyParseError"); + expect(json).toContain( + "failed to parse JWK: json: cannot unmarshal number into Go struct field JWK.key_ops of type string", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch A: rejects a pasted JWK with ext given as a string instead of a bool (CLI-1961 Codex review finding)", + () => { + // Verified against the real binary: `{"kty":"oct","alg":"ES256","ext":"true"}` + // exits 1 with this exact message — Go's `json.Unmarshal` into `config.JWK`'s + // `Extractable *bool` field fails outright rather than silently dropping it. + const { layer } = setup({ + pipedAnswer: JSON.stringify({ kty: "oct", alg: "ES256", ext: "true" }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtKeyParseError"); + expect(json).toContain( + "failed to parse JWK: json: cannot unmarshal string into Go struct field JWK.ext of type bool", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("Branch A: rejects a pasted JWK with a non-string kid", () => { + const { layer } = setup({ + pipedAnswer: JSON.stringify({ kty: "oct", alg: "ES256", kid: 123 }), + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "failed to parse JWK: json: cannot unmarshal number into Go struct field JWK.kid of type string", + ); + } + }).pipe(Effect.provide(layer)); + }); + + it.live( + "Branch A: a null field value is treated as absent, not a type mismatch (Go's encoding/json no-op)", + () => { + const { layer, out } = setup({ + pipedAnswer: JSON.stringify({ ...generateEcJwk("null-ext-kid"), ext: null }), + }); + return Effect.gen(function* () { + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "null-ext-kid", + typ: "JWT", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: rejects a stored signing key with a non-string key_ops element (CLI-1961 Codex review finding)", + () => { + // Verified against the real binary: a `signing_keys_path` file entry with a + // malformed `key_ops` fails during config load with THIS wrap (matching the + // sibling `alg`-allowlist check's own wrap for the same call site), not + // silently dropping the field. + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeSigningKeys( + JSON.stringify([{ kty: "oct", alg: "ES256", kid: "k1", key_ops: ["sign", 1] }]), + ), + ); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtDecodeError"); + expect(json).toContain( + "failed to decode signing keys: failed to parse response body: json: cannot unmarshal number into Go struct field JWK.key_ops of type string", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + it.live( "Branch B: mints a token from the configured signing_keys_path's only key on a blank kid answer", () => { diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts index eb40c26663..7225647280 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts @@ -9,6 +9,7 @@ import { type LegacyJwk, } from "../../../shared/legacy-go-jwt.ts"; import { legacyGoJsonKindName } from "../../../shared/legacy-go-json.ts"; +import { textOutputLayer } from "../../../../shared/output/output.layer.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { Stdin } from "../../../../shared/runtime/stdin.service.ts"; import { Tty } from "../../../../shared/runtime/tty.service.ts"; @@ -51,19 +52,106 @@ const legacyConsolePromptText = Effect.fnUntraced(function* (label: string) { return input; }); +/** + * Go's own `legacyGoJsonKindName` (`legacy-go-json.ts`) is deliberately scoped to + * scalars only — every one of its existing call sites already excludes null/array/ + * object before reaching it. This file's per-field JWK checks below DO need to name a + * bare JSON object (`key_ops`'s elements, or a nested value under any field, can be an + * object — verified against the real binary: `--payload`-style `{}` inside `key_ops` + * reports `"...into Go struct field JWK.key_ops of type string"` with kind `object`), + * so this is a local superset rather than a change to that shared, narrower contract. + */ +function jwkFieldKindName(value: unknown): string { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + return "object"; + } + return legacyGoJsonKindName(value); +} + +/** + * Go's exact `encoding/json` struct-field type-mismatch text: `"json: cannot unmarshal + * into Go struct field JWK. of type "` — verified against the + * real binary (CLI-1961) for every field this file reads: `kty`/`kid`/`use`/`alg`/`n`/ + * `e`/`d`/`p`/`q`/`dp`/`dq`/`qi`/`crv`/`x`/`y` (`goType: "string"`), `key_ops` as a + * whole (`goType: "[]string"`) vs. one of its elements (`goType: "string"`, the same as + * any other string field), and `ext` (`goType: "bool"`). + */ +function jwkStructFieldTypeMismatch(field: string, value: unknown, goType: string): string { + return `json: cannot unmarshal ${jwkFieldKindName(value)} into Go struct field JWK.${field} of type ${goType}`; +} + +/** + * A JSON `null` for any `config.JWK` field is a documented Go `encoding/json` no-op — + * same as a `null` for the whole JWK (see `resolveSigningKeyFromStdinJwk`'s own doc + * comment) — so `null` must NOT be treated as a type mismatch here, only as "absent". + */ +function isAbsentJwkField(value: unknown): boolean { + return value === undefined || value === null; +} + +/** + * Reads an optional STRING field, throwing Go's exact struct-field type-mismatch text + * (see {@link jwkStructFieldTypeMismatch}) when the field is PRESENT with a non-string + * value — e.g. `{"kid":123}` or `{"ext":"true"}` — rather than silently treating a + * malformed field as absent the way this function previously did (Codex review + * finding, CLI-1961): Go's `json.Unmarshal` into `config.JWK` fails outright on any + * such field, so a mistyped optional field must never let this normalizer still mint a + * token as if the field had simply been omitted. + */ function readOptionalString(record: Record, field: string): string | undefined { const value = record[field]; - return typeof value === "string" ? value : undefined; + if (isAbsentJwkField(value)) { + return undefined; + } + if (typeof value !== "string") { + throw new Error(jwkStructFieldTypeMismatch(field, value, "string")); + } + return value; } +/** + * Reads the optional `key_ops` STRING ARRAY field, throwing Go's exact struct-field + * type-mismatch text (see {@link jwkStructFieldTypeMismatch}) when the field is + * PRESENT but is not an array (`goType: "[]string"`) or contains a non-string element + * (`goType: "string"`, Go decodes each element individually into the slice's element + * type) — same "never silently treat malformed as absent" rule as + * {@link readOptionalString} (Codex review finding, CLI-1961). + */ function readOptionalStringArray( record: Record, field: string, ): ReadonlyArray | undefined { const value = record[field]; - return Array.isArray(value) && value.every((entry) => typeof entry === "string") - ? (value as ReadonlyArray) - : undefined; + if (isAbsentJwkField(value)) { + return undefined; + } + if (!Array.isArray(value)) { + throw new Error(jwkStructFieldTypeMismatch(field, value, "[]string")); + } + for (const entry of value) { + if (typeof entry !== "string") { + throw new Error(jwkStructFieldTypeMismatch(field, entry, "string")); + } + } + return value as ReadonlyArray; +} + +/** + * Reads the optional `ext` BOOLEAN field (Go's `*bool`), throwing Go's exact + * struct-field type-mismatch text (see {@link jwkStructFieldTypeMismatch}) when the + * field is PRESENT with a non-boolean value — e.g. `{"ext":"true"}` or `{"ext":1}` — + * same "never silently treat malformed as absent" rule as {@link readOptionalString} + * (Codex review finding, CLI-1961). + */ +function readOptionalBoolean(record: Record, field: string): boolean | undefined { + const value = record[field]; + if (isAbsentJwkField(value)) { + return undefined; + } + if (typeof value !== "boolean") { + throw new Error(jwkStructFieldTypeMismatch(field, value, "bool")); + } + return value; } /** @@ -73,6 +161,20 @@ function readOptionalStringArray( * same way here. Every downstream consumer in this file works with the resulting * typed `LegacyJwk`, not the raw untrusted record, so key/kid/alg lookups stay * type-checked instead of re-guarding `Record` at every call site. + * + * Throws a bare `Error` (Go's own unwrapped `encoding/json` text) the moment any field + * above is present with the wrong JSON type — both call sites below (`normalizeStoredJwk` + * itself is synchronous) catch that throw and apply THEIR OWN Go-matching wrap: Branch + * A's pasted-JWK path wraps `"failed to parse JWK: %w"`, while the `signing_keys_path` + * file path wraps `"failed to decode signing keys: failed to parse response body: %w"` + * — the same two wraps this file's sibling checks (the JSON-decode / `alg` allowlist + * checks) already use for those exact same two call sites. Go decodes struct fields in + * the JSON document's own key order and stops at the FIRST mismatch; this function + * instead always checks in a fixed field order, so on a payload with MULTIPLE + * simultaneously-malformed fields the reported field may not always match Go's for + * that specific multi-error case — accepted gap, no fixture in `bearerjwt_test.go` + * covers ordering across more than one bad field at once, and every field is still + * correctly rejected either way. */ function normalizeStoredJwk(record: Record): LegacyJwk { const keyOps = readOptionalStringArray(record, "key_ops"); @@ -82,7 +184,7 @@ function normalizeStoredJwk(record: Record): LegacyJwk { use: readOptionalString(record, "use"), key_ops: keyOps !== undefined ? [...keyOps] : undefined, alg: readOptionalString(record, "alg"), - ext: typeof record["ext"] === "boolean" ? record["ext"] : undefined, + ext: readOptionalBoolean(record, "ext"), n: readOptionalString(record, "n"), e: readOptionalString(record, "e"), d: readOptionalString(record, "d"), @@ -148,7 +250,18 @@ const resolveSigningKeyFromStdinJwk = Effect.fnUntraced(function* () { }), ); } - return normalizeStoredJwk(record); + // `normalizeStoredJwk` throws Go's bare `encoding/json` struct-field type-mismatch + // text (see its own doc comment) the moment any OTHER field is malformed — e.g. + // `{"kty":"oct","alg":"ES256","key_ops":["sign",1]}` — wrapped here with the same + // `"failed to parse JWK: %w"` this Branch A path already uses above (Codex review + // finding, CLI-1961). + return yield* Effect.try({ + try: () => normalizeStoredJwk(record), + catch: (cause) => + new LegacyGenBearerJwtKeyParseError({ + message: `failed to parse JWK: ${legacyBearerJwtErrorMessage(cause)}`, + }), + }); }); /** @@ -172,6 +285,30 @@ const resolveSigningKeyFromStdinJwk = Effect.fnUntraced(function* () { * piped/captured token with picker UI and the "Selected key ID: ..." line for any * interactive user with a configured `signing_keys_path` (Codex review finding, * CLI-1961). + * + * The picker tries the AMBIENT `Output` first — this is what every test in this file + * mocks, and it's what a real `text` run already uses — and only on the AMBIENT + * `output.promptSelect`/`raw` failing with `NonInteractiveError` (the json/stream-json + * `Output` layers' unconditional behavior, `output.layer.ts`) does it retry through a + * FRESH, locally-provided {@link textOutputLayer} instance. This is the same rationale + * as `legacy/commands/migration/migration.prompt.ts`'s `legacyMigrationConfirm` + * (CLI-1974): Go's own `PromptChoice` has no concept of an output format at all and + * always prompts on a real TTY, and this command's stdout is the raw token + * unconditionally in EVERY format (see `bearer-jwt.handler.ts`'s doc comment) — there + * is no structured json/stream-json result here for an interactive widget to corrupt, + * unlike `legacy-project-ref.layer.ts`'s own `promptSelect` (which DOES stay gated + * behind `output.format`, because ITS caller's json/stream-json mode has a real + * machine payload an interactive prompt would otherwise interleave with). Verified + * against the real binary (CLI-1961, Codex review finding): the ambient + * json/stream-json `Output` layers' `promptSelect` unconditionally raises + * `NonInteractiveError`, which would abort this command entirely on a real TTY with a + * configured `signing_keys_path` and more than one stored key — a regression Go never + * had, since it has no `--output-format` flag to trip over. The try-then-fall-back + * shape (rather than unconditionally swapping to `textOutputLayer`) is deliberate: it + * keeps every existing mock-driven test exercising the SAME ambient `Output` path they + * already do, and only reaches the real, un-mockable `@clack/prompts` renderer in the + * one combination (`NonInteractiveError` from a genuinely non-text production layer) + * that requires it. */ const resolveSigningKeyFromConfigured = Effect.fnUntraced(function* ( availableKeys: ReadonlyArray, @@ -216,13 +353,38 @@ const resolveSigningKeyFromConfigured = Effect.fnUntraced(function* ( label: key.kid ?? "", hint: `${key.alg ?? ""} (${(key.key_ops ?? []).join(",")})`, })); - const chosen = yield* output.promptSelect("Select a signing key:", options, { stream: "stderr" }); - const chosenKey = availableKeys[Number(chosen)]!; - // Go: `fmt.Fprintln(os.Stderr, "Selected key ID:", choice.Summary)` (`bearerjwt.go:82`). - // `output.raw(..., "stderr")`, NOT `output.info` — `info` is clack's `log.info`, which - // defaults to stdout (see this function's doc comment above). - yield* output.raw(`Selected key ID: ${chosenKey.kid ?? ""}\n`, "stderr"); - return chosenKey; + // Try the AMBIENT `Output` first (this is what every test in this file mocks, and + // what a real `text` run already uses) — only on `NonInteractiveError` (the + // json/stream-json `Output` layers' `promptSelect`/`raw`, `output.layer.ts`) fall + // back to a REAL, locally-provided `textOutputLayer` instance so the picker still + // renders on a genuine TTY. `textOutputLayer` only needs `Tty` (already in scope), + // so this fallback is a purely local override; the token itself is still written + // through the AMBIENT `Output` later, in `bearer-jwt.handler.ts`, completely + // unaffected by it. See this function's doc comment above for why Go's own picker + // needs this at all. + const pickSigningKey = (pickerOutput: typeof Output.Service) => + Effect.gen(function* () { + const chosen = yield* pickerOutput.promptSelect("Select a signing key:", options, { + stream: "stderr", + }); + const chosenKey = availableKeys[Number(chosen)]!; + // Go: `fmt.Fprintln(os.Stderr, "Selected key ID:", choice.Summary)` (`bearerjwt.go:82`). + // `output.raw(..., "stderr")`, NOT `output.info` — `info` is clack's `log.info`, + // which defaults to stdout (see this function's doc comment above). + yield* pickerOutput.raw(`Selected key ID: ${chosenKey.kid ?? ""}\n`, "stderr"); + return chosenKey; + }); + return yield* pickSigningKey(output).pipe( + Effect.catchTag("NonInteractiveError", () => + Effect.provide( + Effect.gen(function* () { + const realOutput = yield* Output; + return yield* pickSigningKey(realOutput); + }), + textOutputLayer, + ), + ), + ); }); /** @@ -251,13 +413,29 @@ export const legacyResolveBearerJwtSigningKey = Effect.fnUntraced(function* (wor return yield* resolveSigningKeyFromStdinJwk(); } - const availableKeys: ReadonlyArray = paths.authEnabled - ? (yield* legacyReadSigningKeysFile( - paths.signingKeysPath.value.actualPath, - (message) => new LegacyGenBearerJwtReadError({ message }), - (message) => new LegacyGenBearerJwtDecodeError({ message }), - )).map(normalizeStoredJwk) - : [LEGACY_DEFAULT_SIGNING_KEY]; + let availableKeys: ReadonlyArray; + if (paths.authEnabled) { + const storedKeys = yield* legacyReadSigningKeysFile( + paths.signingKeysPath.value.actualPath, + (message) => new LegacyGenBearerJwtReadError({ message }), + (message) => new LegacyGenBearerJwtDecodeError({ message }), + ); + // `normalizeStoredJwk` throws Go's bare `encoding/json` struct-field type-mismatch + // text (see its own doc comment) the moment any stored key entry has a malformed + // field — e.g. `[{"kty":"oct","alg":"ES256","ext":"true"}]` — wrapped here with the + // SAME `"failed to decode signing keys: failed to parse response body: %w"` this + // file's sibling `alg`-allowlist check (inside `legacyReadSigningKeysFile`) already + // uses for this exact call site (Codex review finding, CLI-1961). + availableKeys = yield* Effect.try({ + try: () => storedKeys.map(normalizeStoredJwk), + catch: (cause) => + new LegacyGenBearerJwtDecodeError({ + message: `failed to decode signing keys: failed to parse response body: ${legacyBearerJwtErrorMessage(cause)}`, + }), + }); + } else { + availableKeys = [LEGACY_DEFAULT_SIGNING_KEY]; + } return yield* resolveSigningKeyFromConfigured(availableKeys); }); From f0457f5752370c441972d9ba683a2481f6613646 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 13:35:20 +0100 Subject: [PATCH 08/12] fix(cli): fix exp/valid-for precision loss, RFC3339 comma/early-year, and array JWK entries (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five Codex review findings against Go parity, each verified empirically against the Go standard library / real binary: - bearer-jwt.flags.ts / bearer-jwt.claims.ts: `legacyParseBearerJwtExp` collapsed an epoch-scale whole-second count and a nanosecond fraction into a single float, which silently rounds a near-second fraction (e.g. `.999999999`) UP into the next second — a full second later than Go's `jwt.NewNumericDate`, which truncates DOWN. Now returns an exact `{ wholeSeconds, nanos }` pair, combined via the new `legacyAddSecondsAndFloor` using integer nanosecond arithmetic. - bearer-jwt.claims.ts / bearer-jwt.handler.ts: when `--exp` is omitted, `exp` was computed from an already-floored `now`, shortening the token's lifetime by up to a second whenever `--valid-for` has a sub-second component. `nowInstant` is now built unfloored from `Date.now()` and combined the same exact way. - bearer-jwt.flags.ts: the `--exp` regexp only accepted `.` before fractional digits; Go's `time.Parse(time.RFC3339, ...)` also accepts `,`. - bearer-jwt.flags.ts: `Date.UTC`/`new Date(...)` apply JS's legacy two-digit-year remapping to any year in `[0, 99]` (`0001` silently became `1901`), even though Go parses that year literally. Builds the instant via `setUTCFullYear`/`setUTCHours` instead, which has no such special case. - gen.signing-keys-config.ts: `isRecord`'s `typeof value === "object"` check also matched JSON arrays, so a `signing_keys_path` entry like `[]` passed as a "record" and a later valid key could still be selected — Go's decode straight into `[]config.JWK` rejects any array-shaped element outright. --- .../gen/bearer-jwt/bearer-jwt.claims.ts | 54 ++++--- .../bearer-jwt/bearer-jwt.claims.unit.test.ts | 61 ++++++-- .../gen/bearer-jwt/bearer-jwt.flags.ts | 116 ++++++++++++--- .../bearer-jwt/bearer-jwt.flags.unit.test.ts | 136 ++++++++++++++++-- .../gen/bearer-jwt/bearer-jwt.handler.ts | 12 +- .../bearer-jwt/bearer-jwt.integration.test.ts | 37 ++++- .../commands/gen/gen.signing-keys-config.ts | 12 +- 7 files changed, 357 insertions(+), 71 deletions(-) diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts index f091cc3236..b03466a251 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts @@ -1,6 +1,7 @@ import { Option } from "effect"; import { encodeGoStructJsonBody } from "../../../shared/legacy-go-output.encoders.ts"; import { legacyGoJsonKindName } from "../../../shared/legacy-go-json.ts"; +import { legacyAddSecondsAndFloor, type LegacyBearerJwtInstant } from "./bearer-jwt.flags.ts"; /** * Pure claims-building logic for `gen bearer-jwt`, ported from Go's `parseClaims` @@ -21,20 +22,32 @@ export interface LegacyBearerJwtClaimsInput { readonly role: string; readonly sub: Option.Option; /** - * Unix seconds from `--exp` (RFC3339), WITHOUT flooring — `Option.none()` when the - * flag was not given. May carry a sub-second fraction; see - * {@link legacyParseBearerJwtExp}'s own doc comment for why that fraction must - * survive until the final `exp`/`iat` computation below. + * The parsed `--exp` instant (RFC3339), WITHOUT flooring — `Option.none()` when the + * flag was not given. An exact {@link LegacyBearerJwtInstant}, not a single float — + * see that type's own doc comment for why a single `number` cannot carry an + * epoch-scale whole-second count and nanosecond precision together without silent + * rounding (CLI-1961 Codex review finding). */ - readonly expiresAt: Option.Option; + readonly expiresAt: Option.Option; /** * `--valid-for`, parsed from Go-duration syntax into seconds WITHOUT flooring — * see {@link legacyParseBearerJwtValidFor}'s own doc comment for why sub-second * precision must survive until the final `exp`/`iat` computation below. */ readonly validForSeconds: number; - /** `Date.now()`-derived Unix seconds, injected so callers (and tests) control "now". */ - readonly nowSeconds: number; + /** + * `Date.now()`-derived instant, injected so callers (and tests) control "now" — an + * exact {@link LegacyBearerJwtInstant} built directly from `Date.now()`'s integer + * milliseconds (see `bearer-jwt.handler.ts`), NOT pre-floored to whole seconds. + * Flooring it before this module ever sees it would compute `exp = now + validFor` + * from an already-truncated `now`, shortening the token's lifetime by up to a second + * whenever `--valid-for` has a sub-second component (CLI-1961 Codex review finding: + * a run at `HH:MM:SS.900` with `--valid-for 200ms` must land in the NEXT second, + * matching Go's `now.Add(validFor)` on the raw fractional time followed by truncating + * `iat`/`exp` separately — verified against the real binary via `golang-jwt/jwt/v5`'s + * `NewNumericDate`/`time.Time.Add`). + */ + readonly nowInstant: LegacyBearerJwtInstant; } /** @@ -50,15 +63,16 @@ export interface LegacyBearerJwtClaimsInput { * the real binary (CLI-1961): `--exp 2030-01-01T00:00:00Z --valid-for 1.5s` yields * `iat=1893455998` — flooring the 1.5s duration to 1s first (as this port previously * did) would wrongly yield `1893455999`. - * - `expiresAt` itself may ALSO carry a sub-second fraction (see - * {@link legacyParseBearerJwtExp}'s own doc comment for why `--exp`'s fraction - * survives parsing) — the same "floor only the final result" rule applies to it: - * `iat` is computed from the UNFLOORED `expiresAt` minus `validForSeconds`, and - * `exp` is floored separately from that same unfloored value, rather than flooring - * `expiresAt` up front and losing its fraction before either computation runs. - * Verified against the real binary (CLI-1961): `--exp 2030-01-01T00:00:00.9Z - * --valid-for 1.2s` yields `iat=1893455999`, not the `1893455998` that flooring - * `expiresAt` before the subtraction would produce. + * - `expiresAt`/`nowInstant` are exact {@link LegacyBearerJwtInstant}s, not floats (see + * that type's own doc comment) — `legacyAddSecondsAndFloor` combines an instant with + * `validForSeconds` using exact integer nanosecond arithmetic and returns the + * correctly-floored whole-second result in one step, so neither branch below ever + * adds an epoch-scale whole-second count directly to a sub-second float (the CLI-1961 + * Codex review finding that plain float addition can do: `--exp + * 2030-01-01T00:00:00.999999999Z` must floor to `1893456000`, not round UP to + * `1893456001`). Verified against the real binary (CLI-1961): `--exp + * 2030-01-01T00:00:00.9Z --valid-for 1.2s` yields `iat=1893455999`, not the + * `1893455998` that flooring `expiresAt` before the subtraction would produce. * - `role` is ALWAYS present (`json:"role"`, no `omitempty`), even `--role ""`. * - `is_anonymous` is set only when `role` case-insensitively equals `"authenticated"` * AND `--sub` was not given (`strings.EqualFold` + `len(claims.Subject) == 0`) — an @@ -79,12 +93,12 @@ export function legacyBuildBearerJwtClaims( let exp: number; let iat: number; if (Option.isNone(input.expiresAt)) { - iat = input.nowSeconds; - exp = Math.floor(iat + input.validForSeconds); + iat = input.nowInstant.wholeSeconds; + exp = legacyAddSecondsAndFloor(input.nowInstant, input.validForSeconds); } else { const rawExp = input.expiresAt.value; - exp = Math.floor(rawExp); - iat = Math.floor(rawExp - input.validForSeconds); + exp = rawExp.wholeSeconds; + iat = legacyAddSecondsAndFloor(rawExp, -input.validForSeconds); } const claims: Record = { diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts index 6ea251f63d..337730f5a3 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts @@ -7,6 +7,7 @@ import { } from "./bearer-jwt.claims.ts"; const NOW = 1_700_000_000; +const NOW_INSTANT = { wholeSeconds: NOW, nanos: 0 }; describe("legacyBuildBearerJwtClaims", () => { it("always includes role, even an empty string, with no omitempty", () => { @@ -15,7 +16,7 @@ describe("legacyBuildBearerJwtClaims", () => { sub: Option.none(), expiresAt: Option.none(), validForSeconds: 1800, - nowSeconds: NOW, + nowInstant: NOW_INSTANT, }); expect(claims["role"]).toBe(""); }); @@ -26,7 +27,7 @@ describe("legacyBuildBearerJwtClaims", () => { sub: Option.none(), expiresAt: Option.none(), validForSeconds: 1800, - nowSeconds: NOW, + nowInstant: NOW_INSTANT, }); expect(claims["iat"]).toBe(NOW); expect(claims["exp"]).toBe(NOW + 1800); @@ -36,9 +37,9 @@ describe("legacyBuildBearerJwtClaims", () => { const claims = legacyBuildBearerJwtClaims({ role: "anon", sub: Option.none(), - expiresAt: Option.some(2_000_000_000), + expiresAt: Option.some({ wholeSeconds: 2_000_000_000, nanos: 0 }), validForSeconds: 1800, - nowSeconds: NOW, + nowInstant: NOW_INSTANT, }); expect(claims["exp"]).toBe(2_000_000_000); expect(claims["iat"]).toBe(2_000_000_000 - 1800); @@ -51,9 +52,9 @@ describe("legacyBuildBearerJwtClaims", () => { const claims = legacyBuildBearerJwtClaims({ role: "anon", sub: Option.none(), - expiresAt: Option.some(1_893_456_000), + expiresAt: Option.some({ wholeSeconds: 1_893_456_000, nanos: 0 }), validForSeconds: 1.5, - nowSeconds: NOW, + nowInstant: NOW_INSTANT, }); expect(claims["exp"]).toBe(1_893_456_000); expect(claims["iat"]).toBe(1_893_455_998); @@ -66,14 +67,48 @@ describe("legacyBuildBearerJwtClaims", () => { const claims = legacyBuildBearerJwtClaims({ role: "anon", sub: Option.none(), - expiresAt: Option.some(1_893_456_000.9), + expiresAt: Option.some({ wholeSeconds: 1_893_456_000, nanos: 900_000_000 }), validForSeconds: 1.2, - nowSeconds: NOW, + nowInstant: NOW_INSTANT, }); expect(claims["exp"]).toBe(1_893_456_000); expect(claims["iat"]).toBe(1_893_455_999); }); + it("floors exp down (never rounds up) for a near-second nanosecond --exp fraction (CLI-1961 Codex review finding)", () => { + // Verified against the Go standard library + `golang-jwt/jwt/v5`'s + // `NewNumericDate`: `--exp 2030-01-01T00:00:00.999999999Z` yields Go `exp=1893456000` + // — a naive float addition of `wholeSeconds + 0.999999999` (this port's previous + // behavior) rounds UP to the exact integer `1893456001` in plain JS arithmetic. + const claims = legacyBuildBearerJwtClaims({ + role: "anon", + sub: Option.none(), + expiresAt: Option.some({ wholeSeconds: 1_893_456_000, nanos: 999_999_999 }), + validForSeconds: 1800, + nowInstant: NOW_INSTANT, + }); + expect(claims["exp"]).toBe(1_893_456_000); + }); + + it("adds a sub-second --valid-for to the unfloored current time when --exp is omitted (CLI-1961 Codex review finding)", () => { + // Verified against the real binary via `golang-jwt/jwt/v5`'s `NewNumericDate` + + // `time.Time.Add`: Go computes `exp = now.Add(validFor)` on the RAW fractional + // `now`, then truncates `iat`/`exp` separately — a run at `X.900` with + // `--valid-for 200ms` must land in the NEXT second (`exp = X + 1`), not stay in the + // current one. Computing `exp` from an already-floored `iat` (this port's previous + // behavior) would wrongly keep `exp = X`, shortening the token's lifetime to + // effectively zero. + const claims = legacyBuildBearerJwtClaims({ + role: "anon", + sub: Option.none(), + expiresAt: Option.none(), + validForSeconds: 0.2, + nowInstant: { wholeSeconds: NOW, nanos: 900_000_000 }, + }); + expect(claims["iat"]).toBe(NOW); + expect(claims["exp"]).toBe(NOW + 1); + }); + it("sets is_anonymous when --sub is explicitly passed as an empty string (CLI-1961)", () => { // Go's gate is `len(claims.Subject) == 0` (`cmd/gen.go:195`) — an explicitly-passed // EMPTY `--sub ""` still counts as "no subject", not just an omitted flag. @@ -82,7 +117,7 @@ describe("legacyBuildBearerJwtClaims", () => { sub: Option.some(""), expiresAt: Option.none(), validForSeconds: 1800, - nowSeconds: NOW, + nowInstant: NOW_INSTANT, }); expect(claims["is_anonymous"]).toBe(true); expect("sub" in claims).toBe(false); @@ -94,7 +129,7 @@ describe("legacyBuildBearerJwtClaims", () => { sub: Option.none(), expiresAt: Option.none(), validForSeconds: 1800, - nowSeconds: NOW, + nowInstant: NOW_INSTANT, }); expect(claims["is_anonymous"]).toBe(true); // Role keeps its original casing. @@ -107,7 +142,7 @@ describe("legacyBuildBearerJwtClaims", () => { sub: Option.some("user-1"), expiresAt: Option.none(), validForSeconds: 1800, - nowSeconds: NOW, + nowInstant: NOW_INSTANT, }); expect(claims["is_anonymous"]).toBeUndefined(); expect(claims["sub"]).toBe("user-1"); @@ -119,7 +154,7 @@ describe("legacyBuildBearerJwtClaims", () => { sub: Option.none(), expiresAt: Option.none(), validForSeconds: 1800, - nowSeconds: NOW, + nowInstant: NOW_INSTANT, }); expect(claims["is_anonymous"]).toBeUndefined(); }); @@ -130,7 +165,7 @@ describe("legacyBuildBearerJwtClaims", () => { sub: Option.none(), expiresAt: Option.none(), validForSeconds: 1800, - nowSeconds: NOW, + nowInstant: NOW_INSTANT, }); expect("sub" in claims).toBe(false); }); diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts index e266e5fc75..cdbd1459db 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts @@ -1,8 +1,71 @@ import { legacyParseGoDuration } from "../../../shared/legacy-go-duration.ts"; import { legacyBearerJwtErrorMessage } from "./bearer-jwt.errors.ts"; +// The fractional-seconds separator accepts EITHER `.` or `,` — Go's `time.Parse` +// (`time/format.go`'s `nextStdChunk`/digit-parsing loop) treats both as introducing a +// fractional second for any layout element, verified directly against the Go standard +// library: `time.Parse(time.RFC3339, "2030-01-01T00:00:00,5Z")` succeeds with the same +// nanosecond result as the `.5` spelling (CLI-1961 Codex review finding). const RFC3339_PATTERN = - /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(?:(Z)|([+-])(\d{2}):(\d{2}))$/; + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:[.,](\d+))?(?:(Z)|([+-])(\d{2}):(\d{2}))$/; + +/** + * A Unix instant split into an exact whole-second floor and a non-negative + * nanosecond remainder in `[0, 1e9)` — mirrors Go's own `time.Time` (a + * whole-seconds field plus a separate nanoseconds field) instead of + * collapsing both into a single `number`. A single float CANNOT hold both an + * epoch-scale whole-second count (~2e9, already ~31 bits) and full + * nanosecond precision (9 decimal digits) without silent rounding: verified + * directly (CLI-1961 Codex review finding, `bearer-jwt.flags.ts:154`), + * `1_893_456_000 + 0.999999999` rounds UP to the exact integer + * `1_893_456_001` in plain JS float addition — a full second later than + * Go's `jwt.NewNumericDate`, which truncates the real (nanosecond-preserving) + * `time.Time` DOWN to `1_893_456_000`. Both fields here are plain safe + * integers (`wholeSeconds` is many orders of magnitude below + * `Number.MAX_SAFE_INTEGER`; `nanos` is always `< 1e9`), so every operation on + * this type — see {@link legacyAddSecondsAndFloor} — is exact integer + * arithmetic, never float rounding. + */ +export interface LegacyBearerJwtInstant { + readonly wholeSeconds: number; + readonly nanos: number; +} + +const NANOS_PER_SECOND = 1_000_000_000; + +/** + * Adds a (possibly fractional, possibly negative) duration in seconds to an + * exact {@link LegacyBearerJwtInstant} and returns the correctly-floored + * whole-second result — mirrors Go's exact nanosecond-precision `time.Time` + * arithmetic followed by `jwt.NewNumericDate`'s truncate-to-seconds + * (`golang-jwt/jwt/v5`'s `types.go:38`), without ever adding an epoch-scale + * whole-second count directly to a sub-second float (see + * {@link LegacyBearerJwtInstant}'s own doc comment for why that rounds + * incorrectly). `deltaSeconds` itself (`--valid-for`, parsed by + * {@link legacyParseBearerJwtValidFor}) stays a plain float — its own + * magnitude is never epoch-scale, so splitting it into whole/fractional parts + * here is exact enough — only the addition against an epoch-scale instant + * needs the exact-integer treatment. + */ +export function legacyAddSecondsAndFloor( + instant: LegacyBearerJwtInstant, + deltaSeconds: number, +): number { + const deltaWhole = Math.floor(deltaSeconds); + const deltaNanos = Math.round((deltaSeconds - deltaWhole) * NANOS_PER_SECOND); + let wholeSeconds = instant.wholeSeconds + deltaWhole; + let nanos = instant.nanos + deltaNanos; + if (nanos < 0) { + const borrow = Math.ceil(-nanos / NANOS_PER_SECOND); + wholeSeconds -= borrow; + nanos += borrow * NANOS_PER_SECOND; + } else if (nanos >= NANOS_PER_SECOND) { + const carry = Math.floor(nanos / NANOS_PER_SECOND); + wholeSeconds += carry; + nanos -= carry * NANOS_PER_SECOND; + } + return wholeSeconds; +} const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; @@ -87,17 +150,23 @@ function rfc3339FlagError(trimmedValue: string): Error { * THOUGH `time.RFC3339`'s own layout has no fractional-seconds directive — this is * a documented parse-only extension ("in the absence of a fractional second in the * format, the fractional part will still be parsed if it is present"), verified - * directly against the Go standard library. The parsed instant carries that - * fraction at full (nanosecond) precision into the `iat = exp - validFor` - * arithmetic in `legacyBuildBearerJwtClaims`, which floors only the FINAL `exp`/ - * `iat` — so the fraction must survive this function's return value rather than - * being discarded here, matching `legacyParseBearerJwtValidFor`'s own + * directly against the Go standard library. Extra digits beyond nanosecond (9-digit) + * precision are TRUNCATED, not rounded — verified directly against the Go standard + * library: `.9999999995` (10 digits) parses to nanosecond `999999999`, not a + * rounded-up `1000000000` that would carry into the next second. The parsed instant + * carries that fraction at full (nanosecond) precision into the `iat = exp - + * validFor` arithmetic in `legacyBuildBearerJwtClaims`, which floors only the FINAL + * `exp`/`iat` — so the fraction must survive this function's return value rather + * than being discarded here, matching `legacyParseBearerJwtValidFor`'s own * no-early-flooring rule below. Verified against the real binary (CLI-1961): * `--exp 2030-01-01T00:00:00.9Z --valid-for 1.2s` yields `iat=1893455999`, not the * `1893455998` that dropping the `.9` fraction during parsing would produce. - * Returns Unix seconds (as a float that may carry a sub-second fraction) on success. + * Returns the parsed instant as an exact {@link LegacyBearerJwtInstant} — NOT a single + * float — on success. See that type's own doc comment for why a single `number` cannot + * hold both an epoch-scale whole-second count and nanosecond precision without silent + * rounding (CLI-1961 Codex review finding, this file's own former line 154). */ -export function legacyParseBearerJwtExp(value: string): number { +export function legacyParseBearerJwtExp(value: string): LegacyBearerJwtInstant { const trimmedValue = value.trim(); const match = RFC3339_PATTERN.exec(trimmedValue); if (match === null) { @@ -141,17 +210,26 @@ export function legacyParseBearerJwtExp(value: string): number { if (offsetSign === "-") offsetSeconds = -offsetSeconds; } - const localAsUtcSeconds = - Date.UTC( - Number(year), - Number(month) - 1, - Number(day), - Number(hour), - Number(minute), - Number(second), - ) / 1000; - const fractionalSeconds = fraction === undefined ? 0 : Number(`0.${fraction}`); - return localAsUtcSeconds + fractionalSeconds - offsetSeconds; + // `setUTCFullYear`/`setUTCHours` here, NOT `Date.UTC(...)`/`new Date(...)` — those + // two apply JS's legacy two-digit-year remapping (`Date.UTC(1, 0, 1, ...)` silently + // becomes 1901, not year 1) to any year in `[0, 99]`, which is a genuinely valid + // 4-digit RFC3339 year Go's `time.Parse` accepts LITERALLY (verified against the Go + // standard library: `0001-01-01T00:00:00Z` parses to Go year 1, Unix + // `-62135596800`) — CLI-1961 Codex review finding. `Date.prototype.setUTCFullYear` + // has no such special case at any year, per ECMA-262 (unlike the `Date.UTC`/`Date` + // constructor forms), so building the instant via the epoch `Date` and setter calls + // avoids the remapping entirely while still being exact (always a multiple of 1000, + // since only whole-second components are passed in) — and `offsetSeconds` above is + // always an exact integer number of seconds (a whole hour/minute offset), so + // `wholeSeconds` needs no fractional handling at all. Only the fractional-second + // digits themselves need a dedicated integer field: truncate (not round) to 9 + // digits, matching Go's own truncation of excess fractional digits verified above. + const parsedDate = new Date(0); + parsedDate.setUTCFullYear(Number(year), Number(month) - 1, Number(day)); + parsedDate.setUTCHours(Number(hour), Number(minute), Number(second), 0); + const wholeSeconds = parsedDate.getTime() / 1000 - offsetSeconds; + const nanos = fraction === undefined ? 0 : Number(fraction.slice(0, 9).padEnd(9, "0")); + return { wholeSeconds, nanos }; } /** diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts index 8a3c6f9d20..82f9361034 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts @@ -1,9 +1,16 @@ import { describe, expect, it } from "vitest"; -import { legacyParseBearerJwtExp, legacyParseBearerJwtValidFor } from "./bearer-jwt.flags.ts"; +import { + legacyAddSecondsAndFloor, + legacyParseBearerJwtExp, + legacyParseBearerJwtValidFor, +} from "./bearer-jwt.flags.ts"; describe("legacyParseBearerJwtExp", () => { it("parses a UTC RFC3339 timestamp to Unix seconds", () => { - expect(legacyParseBearerJwtExp("2020-01-01T00:00:00Z")).toBe(1_577_836_800); + expect(legacyParseBearerJwtExp("2020-01-01T00:00:00Z")).toEqual({ + wholeSeconds: 1_577_836_800, + nanos: 0, + }); }); it("honors a non-zero numeric offset", () => { @@ -12,7 +19,7 @@ describe("legacyParseBearerJwtExp", () => { // (CLI-1961): 2030-01-01T00:00:00+05:00 -> exp 1893438000, ...Z -> exp 1893456000. const withOffset = legacyParseBearerJwtExp("2030-01-01T00:00:00+05:00"); const atZ = legacyParseBearerJwtExp("2030-01-01T00:00:00Z"); - expect(withOffset).toBe(atZ - 5 * 60 * 60); + expect(withOffset).toEqual({ wholeSeconds: atZ.wholeSeconds - 5 * 60 * 60, nanos: 0 }); }); it("rejects a malformed value with pflag's exact wrapped message", () => { @@ -41,7 +48,10 @@ describe("legacyParseBearerJwtExp", () => { expect(() => legacyParseBearerJwtExp("1900-02-29T00:00:00Z")).toThrow( 'invalid argument "1900-02-29T00:00:00Z" for "--exp" flag:', ); - expect(legacyParseBearerJwtExp("2000-02-29T00:00:00Z")).toBe(951782400); + expect(legacyParseBearerJwtExp("2000-02-29T00:00:00Z")).toEqual({ + wholeSeconds: 951782400, + nanos: 0, + }); }); it("rejects an out-of-range hour/minute/second the same way Go's time.Parse does", () => { @@ -57,7 +67,7 @@ describe("legacyParseBearerJwtExp", () => { }); it("trims surrounding whitespace before parsing, matching pflag's strings.TrimSpace", () => { - expect(legacyParseBearerJwtExp(" 2030-01-01T00:00:00Z ")).toBe( + expect(legacyParseBearerJwtExp(" 2030-01-01T00:00:00Z ")).toEqual( legacyParseBearerJwtExp("2030-01-01T00:00:00Z"), ); }); @@ -83,12 +93,14 @@ describe("legacyParseBearerJwtExp", () => { // Go's own comment (`time/format.go:1267-1269`): "The range test use > rather // than >=, as some people do write offsets of 24 hours or 60 minutes" — verified // directly against the Go standard library. - expect(legacyParseBearerJwtExp("2030-01-01T00:00:00+24:00")).toBe( - legacyParseBearerJwtExp("2030-01-01T00:00:00Z") - 24 * 60 * 60, - ); - expect(legacyParseBearerJwtExp("2030-01-01T00:00:00+00:60")).toBe( - legacyParseBearerJwtExp("2030-01-01T00:00:00Z") - 60 * 60, - ); + expect(legacyParseBearerJwtExp("2030-01-01T00:00:00+24:00")).toEqual({ + wholeSeconds: legacyParseBearerJwtExp("2030-01-01T00:00:00Z").wholeSeconds - 24 * 60 * 60, + nanos: 0, + }); + expect(legacyParseBearerJwtExp("2030-01-01T00:00:00+00:60")).toEqual({ + wholeSeconds: legacyParseBearerJwtExp("2030-01-01T00:00:00Z").wholeSeconds - 60 * 60, + nanos: 0, + }); }); it("rejects an offset that overflows even Go's 24-hour/60-minute tolerance", () => { @@ -104,14 +116,84 @@ describe("legacyParseBearerJwtExp", () => { // Go's `time.Parse(time.RFC3339, ...)` accepts (and preserves at full precision) // fractional seconds even though `time.RFC3339`'s own layout has no fractional // directive — verified against the Go standard library. Dropping the `.9` here - // (this port's previous behavior) would produce `1893456000` instead. - expect(legacyParseBearerJwtExp("2030-01-01T00:00:00.9Z")).toBeCloseTo(1_893_456_000.9, 9); + // (this port's previous behavior) would produce nanos `0` instead of `900_000_000`. + expect(legacyParseBearerJwtExp("2030-01-01T00:00:00.9Z")).toEqual({ + wholeSeconds: 1_893_456_000, + nanos: 900_000_000, + }); }); it("preserves a fractional offset the same way for a non-UTC zone", () => { const withFraction = legacyParseBearerJwtExp("2030-01-01T00:00:00.5+05:00"); const atZ = legacyParseBearerJwtExp("2030-01-01T00:00:00Z"); - expect(withFraction).toBeCloseTo(atZ - 5 * 60 * 60 + 0.5, 9); + expect(withFraction).toEqual({ + wholeSeconds: atZ.wholeSeconds - 5 * 60 * 60, + nanos: 500_000_000, + }); + }); + + it("preserves a near-second nanosecond fraction as an exact integer instead of rounding it into the next second (CLI-1961 Codex review finding)", () => { + // Verified directly: a naive `wholeSeconds + Number('0.999999999')` float addition + // rounds UP to the exact integer `wholeSeconds + 1` in plain JS float arithmetic — + // Go's `time.Time` keeps the nanoseconds in a separate integer field and + // `jwt.NewNumericDate`'s `Truncate` floors DOWN, so the true parsed instant must + // report `nanos: 999_999_999` here, not silently become `wholeSeconds + 1, nanos: 0`. + expect(legacyParseBearerJwtExp("2030-01-01T00:00:00.999999999Z")).toEqual({ + wholeSeconds: 1_893_456_000, + nanos: 999_999_999, + }); + }); + + it("truncates (not rounds) fractional digits beyond nanosecond precision, matching Go's time.Parse", () => { + // Verified directly against the Go standard library: `.9999999995` (10 digits) + // parses to nanosecond `999999999`, not a rounded-up `1000000000` that would carry + // into the next second. + expect(legacyParseBearerJwtExp("2030-01-01T00:00:00.9999999995Z")).toEqual({ + wholeSeconds: 1_893_456_000, + nanos: 999_999_999, + }); + }); + + it("accepts a comma as the fractional-seconds separator, matching Go's time.Parse (CLI-1961 Codex review finding)", () => { + // Verified directly against the Go standard library: `time.Parse(time.RFC3339, + // "2030-01-01T00:00:00,5Z")` succeeds with the same nanosecond result as the `.5` + // spelling — Go's parser accepts either `.` or `,` as the fractional-seconds + // separator for any layout element. + expect(legacyParseBearerJwtExp("2030-01-01T00:00:00,5Z")).toEqual( + legacyParseBearerJwtExp("2030-01-01T00:00:00.5Z"), + ); + }); + + it("accepts a comma fraction alongside a non-UTC zone offset too", () => { + expect(legacyParseBearerJwtExp("2030-01-01T00:00:00,5+05:00")).toEqual( + legacyParseBearerJwtExp("2030-01-01T00:00:00.5+05:00"), + ); + }); + + it("parses an early (0000-0099) RFC3339 year literally instead of applying JS's two-digit-year remapping (CLI-1961 Codex review finding)", () => { + // Verified directly against the Go standard library: `0001-01-01T00:00:00Z` parses + // to Go year 1, Unix `-62135596800` — `Date.UTC`/`new Date(...)`'s legacy + // two-digit-year special case (year `1` silently becomes `1901`) does NOT apply + // here, since this is a genuine 4-digit RFC3339 year, not a 2-digit shorthand. + expect(legacyParseBearerJwtExp("0001-01-01T00:00:00Z")).toEqual({ + wholeSeconds: -62_135_596_800, + nanos: 0, + }); + expect(legacyParseBearerJwtExp("0099-01-01T00:00:00Z")).toEqual({ + wholeSeconds: -59_042_995_200, + nanos: 0, + }); + expect(legacyParseBearerJwtExp("0000-01-01T00:00:00Z")).toEqual({ + wholeSeconds: -62_167_219_200, + nanos: 0, + }); + }); + + it("still parses years at and above 0100 the same way as before (no regression from the year-remapping fix)", () => { + expect(legacyParseBearerJwtExp("0100-01-01T00:00:00Z")).toEqual({ + wholeSeconds: -59_011_459_200, + nanos: 0, + }); }); }); @@ -143,3 +225,29 @@ describe("legacyParseBearerJwtValidFor", () => { ); }); }); + +describe("legacyAddSecondsAndFloor", () => { + it("adds a whole-second delta with no carry", () => { + expect(legacyAddSecondsAndFloor({ wholeSeconds: 100, nanos: 0 }, 5)).toBe(105); + }); + + it("carries into the next second when nanos overflow 1e9", () => { + // Mirrors the CLI-1961 Codex review finding (`--exp` omitted, sub-second + // `--valid-for`): a `now` of `X.900` plus a `0.2s` delta must land in the NEXT + // second (`X + 1`), not stay in the current one. + expect(legacyAddSecondsAndFloor({ wholeSeconds: 100, nanos: 900_000_000 }, 0.2)).toBe(101); + }); + + it("borrows from the previous second when the combined nanos go negative", () => { + expect(legacyAddSecondsAndFloor({ wholeSeconds: 100, nanos: 200_000_000 }, -0.5)).toBe(99); + }); + + it("never rounds an epoch-scale whole-second count up via float addition (CLI-1961 Codex review finding)", () => { + // The exact regression this helper exists to prevent: naive + // `wholeSeconds + fraction` float addition at epoch scale rounds a near-second + // fraction UP into the next integer. + expect(legacyAddSecondsAndFloor({ wholeSeconds: 1_893_456_000, nanos: 999_999_999 }, 0)).toBe( + 1_893_456_000, + ); + }); +}); diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.handler.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.handler.ts index 41912c428b..b439b612f0 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.handler.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.handler.ts @@ -67,13 +67,21 @@ export const legacyGenBearerJwt = Effect.fn("legacy.gen.bearer-jwt")(function* ( } const role = flags.role.value; - const nowSeconds = Math.floor(Date.now() / 1000); + // Built directly from `Date.now()`'s integer milliseconds, NOT floored to whole + // seconds — see `LegacyBearerJwtClaimsInput.nowInstant`'s own doc comment for why + // pre-flooring here would shorten a sub-second `--valid-for`'s effective lifetime + // (CLI-1961 Codex review finding). + const nowMs = Date.now(); + const nowInstant = { + wholeSeconds: Math.floor(nowMs / 1000), + nanos: (nowMs % 1000) * 1_000_000, + }; const baseClaims = legacyBuildBearerJwtClaims({ role, sub: flags.sub, expiresAt: flags.exp, validForSeconds: flags.validFor, - nowSeconds, + nowInstant, }); const claims = yield* Effect.try({ try: () => legacyMergeBearerJwtPayload(baseClaims, flags.payload), diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts index bda32a7d2d..2330077b90 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts @@ -203,7 +203,10 @@ describe("legacy gen bearer-jwt integration", () => { it.live("computes exp from an explicit --exp, with iat = exp - validFor", () => { const { layer, out } = setup(); return Effect.gen(function* () { - yield* legacyGenBearerJwt({ ...baseFlags, exp: Option.some(2_000_000_000) }); + yield* legacyGenBearerJwt({ + ...baseFlags, + exp: Option.some({ wholeSeconds: 2_000_000_000, nanos: 0 }), + }); const [, payload] = tokenFrom(out).split("."); const claims = decodeSegment(payload ?? "") as Record; expect(claims["exp"]).toBe(2_000_000_000); @@ -222,7 +225,7 @@ describe("legacy gen bearer-jwt integration", () => { return Effect.gen(function* () { yield* legacyGenBearerJwt({ ...baseFlags, - exp: Option.some(1_893_456_000), + exp: Option.some({ wholeSeconds: 1_893_456_000, nanos: 0 }), validFor: 1.5, }); const [, payload] = tokenFrom(out).split("."); @@ -709,6 +712,36 @@ describe("legacy gen bearer-jwt integration", () => { }, ); + it.live( + "Branch B: rejects a nested array entry in signing_keys_path instead of partially accepting a later valid key (CLI-1961 Codex review finding)", + () => { + // Go decodes `signing_keys_path` straight into `[]config.JWK` + // (`fetcher.ParseJSON[[]JWK]`) — an array-shaped element can never unmarshal into + // the `config.JWK` struct, so the WHOLE decode fails, verified directly against + // `encoding/json`: `json.Unmarshal([]byte('[[], {"kty":"EC","kid":"k2"}]'), + // &[]JWK{})` returns `"json: cannot unmarshal array into Go value of type + // config.JWK"`. Before this fix, `isRecord`'s `typeof value === "object"` check + // also matched arrays (arrays are `typeof "object"` in JS), so `[]` passed as a + // "record" and a later valid key (`k2`) could still be selected and signed. + const { layer } = setup(); + return Effect.gen(function* () { + const validKey = generateEcJwk("k2"); + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([[], validKey]))); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtDecodeError"); + expect(json).toContain("failed to decode signing keys: expected a JSON array of objects"); + } + }).pipe(Effect.provide(layer)); + }, + ); + it.live( "Branch C: TTY with zero configured signing keys fails with Go's exact 'user aborted' text", () => { diff --git a/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts index a19879732c..24ad03c6d4 100644 --- a/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts +++ b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts @@ -36,8 +36,18 @@ interface LegacyGenSigningKeysConfigPaths { }>; } +/** + * `typeof value === "object"` is also `true` for a JSON array — without excluding + * `Array.isArray(value)`, a `signing_keys_path` entry shaped like `[]` (or any nested + * array) would pass this check and be accepted as a JWK-shaped record. Go's own decode + * (`fetcher.ParseJSON[[]JWK]`, straight into `[]config.JWK`) genuinely rejects an + * array-shaped element with `"json: cannot unmarshal array into Go value of type + * config.JWK"` — verified directly against `encoding/json` (CLI-1961 Codex review + * finding): `[[], {"kty":"EC","kid":"k2"}]` fails Go's decode outright, it does not + * partially accept `k2` the way this check would without the array exclusion. + */ function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; + return typeof value === "object" && value !== null && !Array.isArray(value); } /** From 7b1803a568faec7efdae5f1e6fbbab5d4deea6b6 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 14:45:08 +0100 Subject: [PATCH 09/12] fix(cli): reject padded JWK fields, Go-escape JWT header, preserve numeric claim order, and gate signing-key read on auth.enabled (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - legacy-go-jwt.ts: pre-validate EC/RSA JWK numeric fields as raw unpadded base64url before importing, matching Go's base64.RawURLEncoding rejection of padding (verified against the real binary). - legacy-go-jwt.ts: serialize the JWT header with encodeGoJsonCompact instead of JSON.stringify, matching Go's HTML-escaping json.Marshal. - legacy-go-json.ts / legacy-go-output.encoders.ts: sortKeysDeep now builds a Map instead of a plain object, since plain objects silently reorder integer-like string keys into numeric order on enumeration, undoing Go's lexicographic jwt.MapClaims key sort for numeric-looking claim names. - signing-key.handler.ts: gate the signing-keys file read on auth.enabled, matching gen bearer-jwt's existing gate and Go's real (if surprising) behavior verified against the binary: with auth disabled, Go never reads the configured file at all, so --append clobbers real file content with the default key instead of failing on a malformed file. - legacy-go-duration.ts: accept the Greek-mu (U+03BC) microsecond spelling alongside us/µs, matching Go's time.ParseDuration unitMap. One Codex review finding (null entries in a signing_keys_path array) was rejected: verified against the real binary that Go's config generateAPIKeys step fails outright on a null-first-entry array during config load, before key selection is ever reached — the claimed "still lets a non-tty user select k2" behavior does not occur. --- .../bearer-jwt/bearer-jwt.claims.unit.test.ts | 11 ++++ .../bearer-jwt/bearer-jwt.flags.unit.test.ts | 6 ++ .../gen/bearer-jwt/bearer-jwt.signing-key.ts | 8 ++- .../commands/gen/gen.signing-keys-config.ts | 15 ++++- .../gen/signing-key/signing-key.handler.ts | 33 +++++++---- .../signing-key.integration.test.ts | 56 ++++++++++++++++++ .../src/legacy/shared/legacy-go-duration.ts | 9 ++- .../shared/legacy-go-duration.unit.test.ts | 10 +++- apps/cli/src/legacy/shared/legacy-go-json.ts | 26 +++++++- .../legacy/shared/legacy-go-json.unit.test.ts | 12 ++++ apps/cli/src/legacy/shared/legacy-go-jwt.ts | 59 ++++++++++++++++++- .../legacy/shared/legacy-go-jwt.unit.test.ts | 41 +++++++++++++ .../shared/legacy-go-output.encoders.ts | 12 +++- .../legacy-go-output.encoders.unit.test.ts | 17 ++++++ 14 files changed, 288 insertions(+), 27 deletions(-) diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts index 337730f5a3..9b207b60ce 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts @@ -398,4 +398,15 @@ describe("legacyEncodeBearerJwtClaims", () => { const claims = { role: "anon", custom: { z: 1, a: 2 } }; expect(legacyEncodeBearerJwtClaims(claims)).toBe('{"custom":{"a":2,"z":1},"role":"anon"}'); }); + + it("keeps Go's true lexicographic order for numeric-looking custom claim keys (CLI-1961 Codex review finding)", () => { + // Go signs a real `jwt.MapClaims` map via `encoding/json`, which sorts string keys + // purely lexicographically ("10" before "2") — verified directly against the Go + // standard library. A plain JS object always reorders integer-like string keys into + // ascending NUMERIC order on enumeration regardless of insertion order, which would + // otherwise silently re-sort "10"/"2" back to "2" before "10" and change the signed + // bytes for these inputs. + const claims = { role: "anon", 10: "a", 2: "b" }; + expect(legacyEncodeBearerJwtClaims(claims)).toBe('{"10":"a","2":"b","role":"anon"}'); + }); }); diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts index 82f9361034..6a803a251b 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts @@ -224,6 +224,12 @@ describe("legacyParseBearerJwtValidFor", () => { 'invalid argument " 30m " for "--valid-for" flag: time: invalid duration " 30m "', ); }); + + it("accepts the Greek-mu microsecond spelling, matching Go's time.ParseDuration (CLI-1961 Codex review finding)", () => { + // Go's `unitMap` accepts "us", "µs" (U+00B5), and "μs" (U+03BC Greek mu) alike — + // verified directly against the Go standard library. + expect(legacyParseBearerJwtValidFor("1μs")).toBe(0.000_001); + }); }); describe("legacyAddSecondsAndFloor", () => { diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts index 7225647280..8295c62452 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts @@ -399,9 +399,11 @@ const resolveSigningKeyFromConfigured = Effect.fnUntraced(function* ( * disabled and a path IS configured, the kid-prompt branch still runs, but the actual * available keys stay the built-in default (the file is never read) — a real user * hitting this combination sees a misleading kid prompt they can never satisfy with - * their own file's kids. `gen signing-key`'s OWN sibling resolver deliberately does - * NOT replicate this gate (see `gen.signing-keys-config.ts`'s doc comment) — it reads - * unconditionally, matching that command's own existing, unrelated Go source. + * their own file's kids. `gen signing-key`'s OWN sibling resolver + * ({@link legacyGenSigningKey}'s `loadSigningKeysConfig`) needs and replicates this exact + * same gate — it goes through the identical `flags.LoadConfig` -> `Config.Validate` Go + * pipeline as this command, so it is subject to the same auth-disabled quirk (CLI-1961 + * Codex review finding; see `gen.signing-keys-config.ts`'s `authEnabled` doc comment). */ export const legacyResolveBearerJwtSigningKey = Effect.fnUntraced(function* (workdir: string) { const paths = yield* legacyResolveSigningKeysConfigPaths( diff --git a/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts index 24ad03c6d4..bf4ddf74f9 100644 --- a/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts +++ b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts @@ -24,9 +24,18 @@ interface LegacyGenSigningKeysConfigPaths { readonly configDisplayPath: string; /** * `[auth].enabled` from the resolved config (default `true`). Go's `Config.Validate` - * only reads `[auth].signing_keys_path`'s file INSIDE `if c.Auth.Enabled` — callers - * that must replicate that gate (`gen bearer-jwt`'s `getSigningKey`) branch on this; - * `gen signing-key` does not (see its own doc comment for why that's intentional). + * only reads `[auth].signing_keys_path`'s file INSIDE `if c.Auth.Enabled` + * (`config.go:1087-1116`) — EVERY caller that reaches this file's read through Go's + * `flags.LoadConfig` (both `gen bearer-jwt`'s `bearerjwt.Run` and `gen signing-key`'s + * `signingkeys.Run` call it, `bearerjwt.go:20` / `signingkeys.go:111`) is subject to the + * SAME gate — there is no separate, ungated Go code path for `gen signing-key`. Verified + * against the real binary (CLI-1961 Codex review finding): with `auth.enabled = false` + * and a configured `signing_keys_path` file, `gen signing-key --append` never reads that + * file's real content at all — it appends to (and a subsequent write clobbers) Go's own + * `NewConfig()` default single-key array instead, discarding whatever was actually on + * disk. Both `gen bearer-jwt`'s `getSigningKey` ({@link legacyResolveBearerJwtSigningKey}) + * and `gen signing-key` ({@link legacyGenSigningKey}) branch on this field for exactly that + * reason. */ readonly authEnabled: boolean; /** `Option.some` when `[auth].signing_keys_path` is configured (non-empty). */ diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts index ad38564709..b164ce134a 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.handler.ts @@ -7,6 +7,7 @@ import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { findGitRootPath } from "../../../../shared/git/git-root.ts"; import { legacyLoadProjectEnv } from "../../../shared/legacy-db-config.toml-read.ts"; import { LegacyDebugLogger } from "../../../shared/legacy-debug-logger.service.ts"; +import { LEGACY_DEFAULT_SIGNING_KEY } from "../../../shared/legacy-go-jwt.ts"; import { legacyPromptYesNo } from "../../../../shared/legacy/legacy-prompt-yes-no.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import { legacyResolveYesWithProjectEnv } from "../../../../shared/legacy/global-flags.ts"; @@ -142,13 +143,19 @@ const generatePrivateKey = Effect.fnUntraced(function* (algorithm: SigningAlgori } satisfies SigningKeyJwk; }); -// `gen signing-key` reads its configured signing-keys file UNCONDITIONALLY once -// `[auth].signing_keys_path` resolves to a non-empty path — unlike `gen bearer-jwt`, -// it does NOT gate that read on `[auth].enabled` (Go's `signingkeys.go` has this same -// unconditional-read shape; only `gen bearer-jwt`'s `getSigningKey` needs the -// `authEnabled` gate, since it's the one call site that must reproduce Go's -// `Config.Validate`-skips-the-file-when-auth-disabled quirk — see that command's -// own `gen.signing-keys-config.ts` usage for the contrast). +// `gen signing-key` goes through the exact same `flags.LoadConfig` -> `Config.Validate` +// pipeline as `gen bearer-jwt` (`signingkeys.go:111` vs `bearerjwt.go:20`) — there is no +// separate, ungated Go code path for this command. Go's `Config.Validate` only reads +// `[auth].signing_keys_path`'s file INSIDE `if c.Auth.Enabled` (`config.go:1087-1116`), so +// with auth disabled `utils.Config.Auth.SigningKeys` never advances past `NewConfig()`'s own +// default single-key array — meaning `--append` appends to (and a subsequent overwrite +// clobbers) that phantom default set, NOT the file's real content. Verified against the real +// binary (CLI-1961 Codex review finding): with `auth.enabled = false` and a configured +// `signing_keys_path` pointing at a file containing a real custom key, `gen signing-key +// --append` overwrote the file with the default ES256 key plus the newly generated one, +// discarding the original entry entirely — surprising, but this is what Go actually does, so +// this must gate the read on `paths.authEnabled` exactly like `gen bearer-jwt`'s own +// `legacyResolveBearerJwtSigningKey` already does. const loadSigningKeysConfig = Effect.fnUntraced(function* (cwd: string) { const paths = yield* legacyResolveSigningKeysConfigPaths( cwd, @@ -162,11 +169,13 @@ const loadSigningKeysConfig = Effect.fnUntraced(function* (cwd: string) { } const { actualPath, displayPath } = paths.signingKeysPath.value; - const existingKeys = yield* legacyReadSigningKeysFile( - actualPath, - (message) => new LegacyGenSigningKeyReadError({ message }), - (message) => new LegacyGenSigningKeyDecodeError({ message }), - ); + const existingKeys = paths.authEnabled + ? yield* legacyReadSigningKeysFile( + actualPath, + (message) => new LegacyGenSigningKeyReadError({ message }), + (message) => new LegacyGenSigningKeyDecodeError({ message }), + ) + : [{ ...LEGACY_DEFAULT_SIGNING_KEY }]; return { configDisplayPath: paths.configDisplayPath, configured: Option.some({ actualPath, displayPath, existingKeys }), diff --git a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts index f57d9082fc..6525ac0c8c 100644 --- a/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts @@ -30,6 +30,7 @@ import { TelemetryRuntime } from "../../../../shared/telemetry/runtime.service.t import { makeTelemetryIdentity } from "../../../../shared/telemetry/identity.ts"; import { legacyGenCommand } from "../gen.command.ts"; import { legacyGenSigningKey } from "./signing-key.handler.ts"; +import { LEGACY_DEFAULT_SIGNING_KEY } from "../../../shared/legacy-go-jwt.ts"; const tempRoot = useLegacyTempWorkdir("supabase-gen-signing-key-int-"); @@ -348,6 +349,61 @@ describe("legacy gen signing-key integration", () => { }).pipe(Effect.provide(layer)); }); + // CLI-1961 Codex review finding: Go's `Config.Validate` only reads/decodes the configured + // `signing_keys_path` file INSIDE `if c.Auth.Enabled` — `gen signing-key` goes through the + // exact same `flags.LoadConfig` pipeline as `gen bearer-jwt` (both call it), so it is subject + // to the same gate. Verified against the real binary: with `auth.enabled = false`, a + // malformed signing-keys file is never read at all, so the command succeeds instead of + // failing on a decode error. + it.live("does not fail on a malformed signing keys file when [auth] enabled is false", () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nenabled = false\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), "not valid json {\n"), + ); + + const exit = yield* Effect.exit(legacyGenSigningKey({ algorithm: "ES256", append: true })); + expect(Exit.isFailure(exit)).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + // Same finding, the more surprising half: Go's `Config.Auth.SigningKeys` never advances past + // `NewConfig()`'s own default single-key array when auth is disabled, so `--append` appends + // to (and the resulting write clobbers) that phantom default set, NOT the file's real + // content — verified against the real binary: appending under `auth.enabled = false` against + // a file containing a genuine custom key overwrote it with the default ES256 key plus the + // newly generated one, discarding the original entry entirely. + it.live( + "appends to (and overwrites with) the built-in default key, ignoring the real file content, when [auth] enabled is false", + () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nenabled = false\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeFile( + join(tempRoot.current, "supabase", "signing_keys.json"), + `${JSON.stringify([{ kty: "EC", kid: "existing-key", x: "existing-x" }])}\n`, + ), + ); + + yield* legacyGenSigningKey({ algorithm: "ES256", append: true }); + + const saved = yield* Effect.tryPromise(() => + readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), + ); + const parsed = JSON.parse(saved) as ReadonlyArray>; + expect(parsed).toHaveLength(2); + expect(parsed[0]?.kid).toBe(LEGACY_DEFAULT_SIGNING_KEY.kid); + expect(parsed.some((key) => key["kid"] === "existing-key")).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + it.live("fails when the configured signing keys file is not a JSON array of objects", () => { const { layer } = setup(); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/shared/legacy-go-duration.ts b/apps/cli/src/legacy/shared/legacy-go-duration.ts index 8fbee02b37..447be8131a 100644 --- a/apps/cli/src/legacy/shared/legacy-go-duration.ts +++ b/apps/cli/src/legacy/shared/legacy-go-duration.ts @@ -41,7 +41,7 @@ const MAX_INT64_NS = 9223372036854775807n; /** * Port of Go `time.ParseDuration`. Returns nanoseconds as a number. Accepts * the same grammar Go does: a possibly-signed sequence of decimal numbers, - * each with a unit suffix (`"ns"`, `"us"`/`"µs"`, `"ms"`, `"s"`, `"m"`, `"h"`), + * each with a unit suffix (`"ns"`, `"us"`/`"µs"`/`"μs"`, `"ms"`, `"s"`, `"m"`, `"h"`), * e.g. `"5s"`, `"1h30m"`, `"300ms"`. Throws on invalid input, matching Go's * own `errors.New("time: invalid duration ...")` failure mode — including * overflowing `math.MaxInt64` nanoseconds and a fractional remainder that @@ -114,7 +114,12 @@ export function legacyParseGoDuration(value: string): number { if (s.startsWith("ns")) { unitNs = 1n; s = s.slice(2); - } else if (s.startsWith("us") || s.startsWith("µs")) { + } else if (s.startsWith("us") || s.startsWith("µs") || s.startsWith("μs")) { + // Go's `unitMap` (`time/format.go:1615-1622`) has THREE microsecond spellings: + // "us", "µs" (U+00B5 MICRO SIGN), and "μs" (U+03BC GREEK SMALL LETTER MU) — verified + // directly against the Go standard library: `time.ParseDuration("1μs")` succeeds + // identically to `"1µs"`. The Greek-mu spelling was previously missing here + // (CLI-1961 Codex review finding). unitNs = NS_PER_US_BIG; s = s.slice(2); } else if (s.startsWith("ms")) { diff --git a/apps/cli/src/legacy/shared/legacy-go-duration.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-duration.unit.test.ts index 551f5518ad..f6a95178ae 100644 --- a/apps/cli/src/legacy/shared/legacy-go-duration.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-duration.unit.test.ts @@ -34,13 +34,21 @@ describe("legacyParseGoDuration", () => { // digits, skip the "missing unit" guard (since `s` was still non-empty), // and silently match the unit anyway — returning 0 instead of erroring like // Go's real `time.ParseDuration`. - it.each(["s", "m", "h", "ms", "us", "µs", "ns"])( + it.each(["s", "m", "h", "ms", "us", "µs", "μs", "ns"])( 'rejects a bare unit with no preceding digit ("%s")', (input) => { expect(() => legacyParseGoDuration(input)).toThrow(`time: invalid duration "${input}"`); }, ); + // Go's `unitMap` (`time/format.go:1615-1622`) has THREE microsecond spellings: + // "us", "µs" (U+00B5 MICRO SIGN), and "μs" (U+03BC GREEK SMALL LETTER MU) — verified + // directly against the Go standard library. The Greek-mu spelling was previously + // missing here (CLI-1961 Codex review finding). + it.each(["us", "µs", "μs"])('accepts every microsecond unit spelling ("1%s")', (unit) => { + expect(legacyParseGoDuration(`1${unit}`)).toBe(1_000); + }); + it('rejects a bare unit following a valid unit ("1hs")', () => { expect(() => legacyParseGoDuration("1hs")).toThrow('time: invalid duration "1hs"'); }); diff --git a/apps/cli/src/legacy/shared/legacy-go-json.ts b/apps/cli/src/legacy/shared/legacy-go-json.ts index 924460785f..4f56f366b5 100644 --- a/apps/cli/src/legacy/shared/legacy-go-json.ts +++ b/apps/cli/src/legacy/shared/legacy-go-json.ts @@ -6,8 +6,17 @@ * Unlike `legacy-go-output.encoders.ts`'s `encodeGoJson`, this encoder does NOT * sort object keys — Go serializes structs in field-declaration order, so the * caller builds plain objects whose key insertion order is the Go struct order - * (JS preserves string-key insertion order). `omitempty` is likewise the - * caller's responsibility: simply omit the key. + * (JS preserves string-key insertion order, EXCEPT for integer-like keys — see + * the `Map` handling below). `omitempty` is likewise the caller's responsibility: + * simply omit the key. + * + * A caller that needs Go's true lexicographic map-key order (e.g. + * `legacy-go-output.encoders.ts`'s `sortKeysDeep`, for a genuine Go map like + * `jwt.MapClaims`) must pass a `Map` rather than a plain object at + * that level: a plain object silently reorders integer-like string keys ("2", "10") + * into ascending NUMERIC order on enumeration, regardless of insertion order, which + * would undo a lexicographic sort for any numeric-looking key. `Map` iteration order + * is true insertion order for every key shape, so this walker special-cases it. * * The two behaviours `JSON.stringify(x, null, 2)` gets wrong for Go parity are: * 1. HTML escaping — Go's default encoder escapes `<`, `>`, `&` as @@ -93,7 +102,18 @@ function walk(value: unknown, depth: number, pretty: boolean): string { const items = value.map((item) => indent + walk(item, depth + 1, pretty)); return `[${open}${items.join(separator)}${close}${closeIndent}]`; } - const entries = Object.entries(value as Record); + // A plain object silently reorders integer-like string keys ("2", "10") into ascending + // NUMERIC order on any enumeration (`Object.keys`/`Object.entries`), regardless of insertion + // order (ECMA-262 `OrdinaryOwnPropertyKeys`) — Go's `encoding/json` has no such special case, + // so a real Go map's string keys sort purely lexicographically (`"10"` before `"2"`). Callers + // that need that exact order (e.g. `legacy-go-output.encoders.ts`'s `sortKeysDeep`) pass a + // `Map` instead of a plain object specifically to carry the sort through intact — `Map` + // iteration order is true insertion order for every key shape, unlike a plain object + // (CLI-1961 Codex review finding: `{"10":"a","2":"b"}` must stay "10" before "2"). + const entries = + value instanceof Map + ? [...(value as Map).entries()] + : Object.entries(value as Record); if (entries.length === 0) return "{}"; const colon = pretty ? ": " : ":"; const lines = entries.map( diff --git a/apps/cli/src/legacy/shared/legacy-go-json.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-json.unit.test.ts index 3f81693d85..b54ff21ec2 100644 --- a/apps/cli/src/legacy/shared/legacy-go-json.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-json.unit.test.ts @@ -75,6 +75,18 @@ describe("encodeGoJsonCompact", () => { expect(encodeGoJsonCompact([])).toBe("[]"); expect(encodeGoJsonCompact(null)).toBe("null"); }); + + it("iterates a Map in true insertion order, unlike a plain object with integer-like keys", () => { + // A plain object always reorders integer-like string keys ("2", "10") into ascending + // NUMERIC order on enumeration, regardless of insertion order — a `Map` does not, which + // is exactly why `legacy-go-output.encoders.ts`'s `sortKeysDeep` builds one to carry a + // lexicographic sort through to this walker intact (CLI-1961 Codex review finding). + const map = new Map([ + ["10", "a"], + ["2", "b"], + ]); + expect(encodeGoJsonCompact(map)).toBe('{"10":"a","2":"b"}'); + }); }); describe("legacyGoJsonKindName", () => { diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.ts index daf76af219..6a2b90a5a9 100644 --- a/apps/cli/src/legacy/shared/legacy-go-jwt.ts +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.ts @@ -1,4 +1,5 @@ import { createHmac, createPrivateKey, createSign } from "node:crypto"; +import { encodeGoJsonCompact } from "./legacy-go-json.ts"; /** * RFC 7517 JWK fields Go's `JWK` struct round-trips (`pkg/config/auth.go:88-108`, @@ -201,6 +202,53 @@ function assertSupportedKty(jwk: LegacyJwk): void { } } +/** + * Go's `jwkToECDSAPrivateKey`/`jwkToRSAPrivateKey` (`apps/cli-go/pkg/config/apikeys.go:132-185`) + * decode every numeric field with `base64.RawURLEncoding.DecodeString` immediately after the + * kty/curve check above — and that decoder genuinely REJECTS `=`-padded input (`RawURLEncoding` + * has no pad character at all), unlike Node's own JWK importer + * (`createPrivateKey({format:"jwk"})`), which happily accepts a padded coordinate and signs a + * token Go would have refused to produce (verified empirically: Go's decoder raises + * `illegal base64 data at input byte 43` for a padded 32-byte P-256 coordinate; Node's importer + * raises nothing at all and returns a usable key) — CLI-1961 Codex review finding. + * + * Runs in Go's exact per-field order (EC: x, y, d; RSA: n, e, d, p, q) so the FIRST invalid field + * matches Go's own first-failure-wins decode order. Reproduces + * `encoding/base64`'s `CorruptInputError` text exactly: the reported byte offset is the index of + * the first character outside the `RawURLEncoding` alphabet (`A-Za-z0-9-_` — a padding `=` is + * such a character, since this encoding has no pad character to special-case), or + * `value.length - 1` for an otherwise-valid string whose length is impossible for base64 + * (`length % 4 === 1`) — both verified directly against the Go standard library's + * `decodeQuantum`. An absent field is Go's own zero value (`""`), which decodes cleanly to zero + * bytes, so `undefined` is skipped here rather than treated as invalid. + */ +function assertDecodableJwkNumericFields(jwk: LegacyJwk): void { + const assertField = (label: string, value: string | undefined): void => { + if (value === undefined) return; + for (let i = 0; i < value.length; i++) { + if (!/^[A-Za-z0-9_-]$/.test(value[i]!)) { + throw new Error(`failed to decode ${label}: illegal base64 data at input byte ${i}`); + } + } + if (value.length % 4 === 1) { + throw new Error( + `failed to decode ${label}: illegal base64 data at input byte ${value.length - 1}`, + ); + } + }; + if (jwk.kty === "EC") { + assertField("x coordinate", jwk.x); + assertField("y coordinate", jwk.y); + assertField("private key", jwk.d); + return; + } + assertField("modulus", jwk.n); + assertField("exponent", jwk.e); + assertField("private exponent", jwk.d); + assertField("first prime factor", jwk.p); + assertField("second prime factor", jwk.q); +} + /** * Go has NO explicit cross-check between `jwk.Algorithm` and `jwk.KeyType` before * signing: `jwkToPrivateKey` only validates kty/curve (see {@link assertSupportedKty}), @@ -244,10 +292,19 @@ function assertKeyMatchesAlgorithm(jwk: LegacyJwk, algorithm: LegacySupportedJwt * `dsaEncoding: "ieee-p1363"` is required for ES256: Node's default ECDSA * signature output is DER-encoded, which is not the raw (r‖s) format JWS * requires — verified by round-tripping through `jose`'s `jwtVerify`. + * + * The header is serialized with {@link encodeGoJsonCompact}, NOT `JSON.stringify` — Go's + * `token.SignedString` marshals the header via `encoding/json`'s default `json.Marshal` + * (`golang-jwt/jwt/v5`'s `Token.SigningString`), which HTML-escapes `<`/`>`/`&` (verified + * directly against the Go standard library: `json.Marshal` of a `kid` containing those + * characters produces `<`/`>`/`&`, where `JSON.stringify` leaves them literal) — + * a `kid` with any of those characters would otherwise sign different header bytes (and thus a + * different signature) than Go for identical input (CLI-1961 Codex review finding). */ export function legacySignJwtWithJwk(jwk: LegacyJwk, payloadJson: string): string { try { assertSupportedKty(jwk); + assertDecodableJwkNumericFields(jwk); } catch (cause) { throw new Error( `failed to convert JWK to private key: ${cause instanceof Error ? cause.message : String(cause)}`, @@ -271,7 +328,7 @@ export function legacySignJwtWithJwk(jwk: LegacyJwk, payloadJson: string): strin jwk.kid !== undefined && jwk.kid.length > 0 ? { alg: algorithm, kid: jwk.kid, typ: "JWT" } : { alg: algorithm, typ: "JWT" }; - const headerEncoded = base64UrlEncode(JSON.stringify(header)); + const headerEncoded = base64UrlEncode(encodeGoJsonCompact(header)); const payloadEncoded = base64UrlEncode(payloadJson); const data = `${headerEncoded}.${payloadEncoded}`; diff --git a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts index b93be385f6..c1f7048e3f 100644 --- a/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-jwt.unit.test.ts @@ -201,6 +201,33 @@ describe("legacyGenerateAsymmetricGoJwt", () => { "failed to convert JWK to private key: unsupported curve: ", ); }); + + it("rejects a padded EC coordinate instead of signing a token Go would refuse to produce (CLI-1961 Codex review finding)", () => { + // Go's `jwkToECDSAPrivateKey` decodes x/y/d with `base64.RawURLEncoding.DecodeString`, + // which genuinely rejects `=` padding — verified directly against the real binary: + // the exact same padded x coordinate produces + // "failed to convert JWK to private key: failed to decode x coordinate: illegal base64 + // data at input byte 43". Node's own `createPrivateKey({format:"jwk"})` accepts the + // padding and would otherwise sign successfully, minting a token Go could never produce. + const jwk = generateEcJwk("ec-kid"); + const padded = { ...jwk, x: `${jwk.x}=` }; + expect(() => legacyGenerateAsymmetricGoJwt(padded, "anon")).toThrow( + /^failed to convert JWK to private key: failed to decode x coordinate: illegal base64 data at input byte \d+$/, + ); + }); + + it("rejects a padded RSA modulus the same way", () => { + const jwk = generateRsaJwk("rsa-kid"); + const padded = { ...jwk, n: `${jwk.n}=` }; + expect(() => legacyGenerateAsymmetricGoJwt(padded, "anon")).toThrow( + /^failed to convert JWK to private key: failed to decode modulus: illegal base64 data at input byte \d+$/, + ); + }); + + it("still signs successfully for unpadded (correctly-encoded) coordinates", () => { + const jwk = generateEcJwk("ec-kid"); + expect(() => legacyGenerateAsymmetricGoJwt(jwk, "anon")).not.toThrow(); + }); }); describe("legacySignJwtWithJwk", () => { @@ -218,6 +245,20 @@ describe("legacySignJwtWithJwk", () => { const { payload: verified } = await jwtVerify(token, publicKey); expect(verified).toEqual({ role: "postgres", "sb-role": "mgmt-api & co" }); }); + + it("HTML-escapes the kid in the header like Go's json.Marshal, unlike JSON.stringify (CLI-1961 Codex review finding)", () => { + // Go's `token.SignedString` marshals the protected header via `encoding/json`'s + // default `json.Marshal`, which HTML-escapes `<`/`>`/`&` — verified directly against + // the Go standard library. A plain `JSON.stringify` leaves those characters literal, + // which would sign different header bytes (and thus a different signature) than Go + // for an otherwise-identical kid. + const jwk = generateEcJwk("ac&d"); + const token = legacySignJwtWithJwk(jwk, '{"role":"anon"}'); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toBe( + '{"alg":"ES256","kid":"a\\u003cb\\u003ec\\u0026d","typ":"JWT"}', + ); + }); }); describe("legacyAssertDecodableJwkAlgorithm", () => { diff --git a/apps/cli/src/legacy/shared/legacy-go-output.encoders.ts b/apps/cli/src/legacy/shared/legacy-go-output.encoders.ts index 423cee70fa..22cecab957 100644 --- a/apps/cli/src/legacy/shared/legacy-go-output.encoders.ts +++ b/apps/cli/src/legacy/shared/legacy-go-output.encoders.ts @@ -46,13 +46,21 @@ export function encodeGoJson( function sortKeysDeep(value: unknown): unknown { if (Array.isArray(value)) return value.map(sortKeysDeep); if (value === null || typeof value !== "object") return value; - const sorted: Record = {}; + // A plain object silently reorders integer-like string keys ("2", "10") into ascending + // NUMERIC order on any subsequent enumeration (`Object.keys`/`Object.entries` in + // `legacy-go-json.ts`'s `walk`), regardless of what order they're inserted in here — Go's + // `encoding/json` has no such special case: a real Go map's string keys sort purely + // lexicographically ("10" before "2"). Building a `Map` instead of a plain object carries + // this sort through to `walk` intact, since `Map` iteration order is true insertion order + // for every key shape (CLI-1961 Codex review finding: `{"10":"a","2":"b"}` must stay "10" + // before "2" all the way through to the final encoded output). + const sorted = new Map(); for (const key of Object.keys(value as Record).sort()) { const child = (value as Record)[key]; // JSON.stringify used to drop undefined properties; the Go-faithful walker // renders them as null, so drop them here to keep the old key surface. if (child === undefined) continue; - sorted[key] = sortKeysDeep(child); + sorted.set(key, sortKeysDeep(child)); } return sorted; } diff --git a/apps/cli/src/legacy/shared/legacy-go-output.encoders.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-output.encoders.unit.test.ts index 9b245558ea..3dffeb73f5 100644 --- a/apps/cli/src/legacy/shared/legacy-go-output.encoders.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-output.encoders.unit.test.ts @@ -98,6 +98,23 @@ describe("encodeGoJson", () => { ], "name": "x" } +`, + ); + }); + + it("keeps Go's true lexicographic order for numeric-looking keys (CLI-1961 Codex review finding)", () => { + // A plain JS object always reorders integer-like string keys ("2", "10") into + // ascending NUMERIC order on enumeration, regardless of insertion order — Go's + // `encoding/json` has no such special case for a real Go map, so "10" sorts before + // "2" lexicographically. `sortKeysDeep` must build a `Map` (not a plain object) to + // carry that sort through to the final encoded output intact. + const out = encodeGoJson({ 10: "a", 2: "b", role: "anon" }); + expect(out).toBe( + `{ + "10": "a", + "2": "b", + "role": "anon" +} `, ); }); From d7365fffac9d35f9facc61a7228456d475da942a Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 16:35:37 +0100 Subject: [PATCH 10/12] fix(cli): reject duplicate-JWK-field bypass and resolve signing_keys_path env() from Go's full dotenv cascade (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - assertNoMalformedDuplicateJwkField (gen.signing-keys-config.ts) re-validates a JWK object's raw source text for a known field repeated with an earlier, Go-rejecting occurrence that JSON.parse alone would silently discard (kept only the last, valid one) before any type check ever ran — closing the gap for both the pasted-JWK Branch A path and signing_keys_path file entries. Verified against the real binary that Go's encoding/json still errors on the first bad occurrence even when a later duplicate is individually valid, and that config.Algorithm's TextUnmarshaler-backed allowlist check behaves differently (an earlier failing occurrence stops Go from ever attempting a later one) than a plain scalar field's type check (which Go still attempts). - legacyResolveSigningKeysConfigPaths now resolves a Go-accurate ProjectEnvironment (matching legacyLoadLocalProjectContext's established stop/status pattern) before loading config.toml, so signing_keys_path = "env(KEYS_PATH)" resolves against Go's full loadNestedEnv cascade (.env.[.local] plus the project-root directory), not just @supabase/config's narrower default supabase/.env(.local) load. Fixes CLI-1961 Codex review findings on bearer-jwt.signing-key.ts:180 and gen.signing-keys-config.ts:74. --- .../bearer-jwt/bearer-jwt.integration.test.ts | 166 ++++++++ .../gen/bearer-jwt/bearer-jwt.signing-key.ts | 135 ++---- .../commands/gen/gen.signing-keys-config.ts | 400 +++++++++++++++++- 3 files changed, 587 insertions(+), 114 deletions(-) diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts index 2330077b90..e282f29ec2 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts @@ -102,6 +102,18 @@ async function writeSigningKeys(contents: string) { await writeFile(join(tempRoot.current, "supabase", "signing_keys.json"), contents); } +/** + * Writes `supabase/.env.development` — a file Go's `loadNestedEnv` reads (selected by + * `SUPABASE_ENV`, defaulting to `"development"`) but `@supabase/config`'s OWN default + * env resolution does NOT (`legacyResolveSigningKeysConfigPaths` must resolve a + * Go-accurate `ProjectEnvironment` and thread it through explicitly — CLI-1961 Codex + * review finding). + */ +async function writeSupabaseEnvDevelopment(contents: string) { + await mkdir(join(tempRoot.current, "supabase"), { recursive: true }); + await writeFile(join(tempRoot.current, "supabase", ".env.development"), contents); +} + const baseFlags: LegacyGenBearerJwtFlags = { role: Option.some("anon"), sub: Option.none(), @@ -573,6 +585,76 @@ describe("legacy gen bearer-jwt integration", () => { }).pipe(Effect.provide(layer)); }); + it.live( + "Branch A: rejects a pasted JWK with a duplicate kid where the earlier occurrence is malformed (CLI-1961 Codex review finding)", + () => { + // Verified against the real binary: `json.Unmarshal` into `config.JWK` decodes + // struct fields in the object's OWN source order and errors on the FIRST + // type-mismatch it finds — even though `JSON.parse` alone would silently keep + // only the LAST occurrence (a valid `"k1"`) and never see the earlier `1` at all. + const { layer } = setup({ + pipedAnswer: '{"kty":"oct","alg":"ES256","kid":1,"kid":"k1"}', + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "failed to parse JWK: json: cannot unmarshal number into Go struct field JWK.kid of type string", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch A: rejects a pasted JWK with a duplicate alg where the earlier occurrence fails the allowlist, even though the later one is allowed (CLI-1961 Codex review finding)", + () => { + // Verified against the real binary: `config.Algorithm`'s `UnmarshalText` (the + // RS256/ES256 allowlist) returning an error for the FIRST `alg` occurrence + // ("HS256") stops Go's decoder from ever attempting the second ("ES256") — the + // overall `json.Unmarshal` still fails with the allowlist error, even though + // `JSON.parse` alone would keep only the later, individually-valid "ES256". + const { layer } = setup({ + pipedAnswer: '{"kty":"oct","alg":"HS256","alg":"ES256"}', + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "failed to parse JWK: must be one of [RS256 ES256]", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch A: accepts a pasted JWK with a duplicate alg where EVERY occurrence is individually valid and allowed", + () => { + // Contrast with the previous test: Go's decoder only stops attempting later + // occurrences once an EARLIER one fails — when every occurrence independently + // succeeds, the LAST one wins normally, same as any other duplicated field. + const { layer, out } = setup({ + pipedAnswer: JSON.stringify({ ...generateEcJwk("dup-alg-kid"), alg: "ES256" }).replace( + '"alg":"ES256"', + '"alg":"RS256","alg":"ES256"', + ), + }); + return Effect.gen(function* () { + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "dup-alg-kid", + typ: "JWT", + }); + }).pipe(Effect.provide(layer)); + }, + ); + it.live( "Branch A: a null field value is treated as absent, not a type mismatch (Go's encoding/json no-op)", () => { @@ -623,6 +705,60 @@ describe("legacy gen bearer-jwt integration", () => { }, ); + it.live( + "Branch B: rejects a stored signing key with a duplicate kid where the earlier occurrence is malformed (CLI-1961 Codex review finding)", + () => { + // Same gap as Branch A's pasted-JWK duplicate-kid test, but for a + // `signing_keys_path` file entry: `legacyReadSigningKeysFile` must check each + // element's OWN raw source text, not the already-`JSON.parse`d (duplicate-key + // collapsed) record. + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeSigningKeys('[{"kty":"oct","alg":"ES256","kid":1,"kid":"k1"}]'), + ); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtDecodeError"); + expect(json).toContain( + "failed to decode signing keys: failed to parse response body: json: cannot unmarshal number into Go struct field JWK.kid of type string", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: rejects a stored signing key with a duplicate alg where the earlier occurrence fails the allowlist (CLI-1961 Codex review finding)", + () => { + const { layer } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => + writeSigningKeys('[{"kty":"oct","kid":"k1","alg":"HS256","alg":"ES256"}]'), + ); + + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyGenBearerJwtDecodeError"); + expect(json).toContain( + "failed to decode signing keys: failed to parse response body: must be one of [RS256 ES256]", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + it.live( "Branch B: mints a token from the configured signing_keys_path's only key on a blank kid answer", () => { @@ -645,6 +781,36 @@ describe("legacy gen bearer-jwt integration", () => { }, ); + it.live( + "Branch B: resolves signing_keys_path = env(KEYS_PATH) from supabase/.env.development, a file @supabase/config's own default env resolution doesn't read (CLI-1961 Codex review finding)", + () => { + // Go's `Config.Load` runs `loadNestedEnv` (which selects `.env.`, + // defaulting to "development") BEFORE its TOML decoder ever resolves `env(...)` + // references — so a `KEYS_PATH` set only in `supabase/.env.development` is visible + // to Go's `signing_keys_path = "env(KEYS_PATH)"` resolution. `@supabase/config`'s + // own default env loader (used whenever no `projectEnv` is explicitly threaded + // through) only reads plain `supabase/.env`/`.env.local` and would otherwise leave + // the literal string "env(KEYS_PATH)" unexpanded, and this call would fail trying + // to open a file with THAT literal name. + const jwk = generateEcJwk("ec-kid"); + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "env(KEYS_PATH)"\n'), + ); + yield* Effect.tryPromise(() => + writeSupabaseEnvDevelopment("KEYS_PATH=./signing_keys.json\n"), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([jwk]))); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ alg: "ES256", kid: "ec-kid", typ: "JWT" }); + }).pipe(Effect.provide(layer)); + }, + ); + it.live( "Branch B: fails with Go's exact wrapped message for an unsupported key type (bearerjwt_test.go parity)", () => { diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts index 8295c62452..00e1217b52 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts @@ -1,7 +1,11 @@ import { Effect, Option } from "effect"; import { + assertNoMalformedDuplicateJwkField, legacyReadSigningKeysFile, legacyResolveSigningKeysConfigPaths, + readOptionalBoolean, + readOptionalString, + readOptionalStringArray, } from "../gen.signing-keys-config.ts"; import { legacyAssertDecodableJwkAlgorithm, @@ -52,108 +56,6 @@ const legacyConsolePromptText = Effect.fnUntraced(function* (label: string) { return input; }); -/** - * Go's own `legacyGoJsonKindName` (`legacy-go-json.ts`) is deliberately scoped to - * scalars only — every one of its existing call sites already excludes null/array/ - * object before reaching it. This file's per-field JWK checks below DO need to name a - * bare JSON object (`key_ops`'s elements, or a nested value under any field, can be an - * object — verified against the real binary: `--payload`-style `{}` inside `key_ops` - * reports `"...into Go struct field JWK.key_ops of type string"` with kind `object`), - * so this is a local superset rather than a change to that shared, narrower contract. - */ -function jwkFieldKindName(value: unknown): string { - if (value !== null && typeof value === "object" && !Array.isArray(value)) { - return "object"; - } - return legacyGoJsonKindName(value); -} - -/** - * Go's exact `encoding/json` struct-field type-mismatch text: `"json: cannot unmarshal - * into Go struct field JWK. of type "` — verified against the - * real binary (CLI-1961) for every field this file reads: `kty`/`kid`/`use`/`alg`/`n`/ - * `e`/`d`/`p`/`q`/`dp`/`dq`/`qi`/`crv`/`x`/`y` (`goType: "string"`), `key_ops` as a - * whole (`goType: "[]string"`) vs. one of its elements (`goType: "string"`, the same as - * any other string field), and `ext` (`goType: "bool"`). - */ -function jwkStructFieldTypeMismatch(field: string, value: unknown, goType: string): string { - return `json: cannot unmarshal ${jwkFieldKindName(value)} into Go struct field JWK.${field} of type ${goType}`; -} - -/** - * A JSON `null` for any `config.JWK` field is a documented Go `encoding/json` no-op — - * same as a `null` for the whole JWK (see `resolveSigningKeyFromStdinJwk`'s own doc - * comment) — so `null` must NOT be treated as a type mismatch here, only as "absent". - */ -function isAbsentJwkField(value: unknown): boolean { - return value === undefined || value === null; -} - -/** - * Reads an optional STRING field, throwing Go's exact struct-field type-mismatch text - * (see {@link jwkStructFieldTypeMismatch}) when the field is PRESENT with a non-string - * value — e.g. `{"kid":123}` or `{"ext":"true"}` — rather than silently treating a - * malformed field as absent the way this function previously did (Codex review - * finding, CLI-1961): Go's `json.Unmarshal` into `config.JWK` fails outright on any - * such field, so a mistyped optional field must never let this normalizer still mint a - * token as if the field had simply been omitted. - */ -function readOptionalString(record: Record, field: string): string | undefined { - const value = record[field]; - if (isAbsentJwkField(value)) { - return undefined; - } - if (typeof value !== "string") { - throw new Error(jwkStructFieldTypeMismatch(field, value, "string")); - } - return value; -} - -/** - * Reads the optional `key_ops` STRING ARRAY field, throwing Go's exact struct-field - * type-mismatch text (see {@link jwkStructFieldTypeMismatch}) when the field is - * PRESENT but is not an array (`goType: "[]string"`) or contains a non-string element - * (`goType: "string"`, Go decodes each element individually into the slice's element - * type) — same "never silently treat malformed as absent" rule as - * {@link readOptionalString} (Codex review finding, CLI-1961). - */ -function readOptionalStringArray( - record: Record, - field: string, -): ReadonlyArray | undefined { - const value = record[field]; - if (isAbsentJwkField(value)) { - return undefined; - } - if (!Array.isArray(value)) { - throw new Error(jwkStructFieldTypeMismatch(field, value, "[]string")); - } - for (const entry of value) { - if (typeof entry !== "string") { - throw new Error(jwkStructFieldTypeMismatch(field, entry, "string")); - } - } - return value as ReadonlyArray; -} - -/** - * Reads the optional `ext` BOOLEAN field (Go's `*bool`), throwing Go's exact - * struct-field type-mismatch text (see {@link jwkStructFieldTypeMismatch}) when the - * field is PRESENT with a non-boolean value — e.g. `{"ext":"true"}` or `{"ext":1}` — - * same "never silently treat malformed as absent" rule as {@link readOptionalString} - * (Codex review finding, CLI-1961). - */ -function readOptionalBoolean(record: Record, field: string): boolean | undefined { - const value = record[field]; - if (isAbsentJwkField(value)) { - return undefined; - } - if (typeof value !== "boolean") { - throw new Error(jwkStructFieldTypeMismatch(field, value, "bool")); - } - return value; -} - /** * Narrows an untrusted JSON record (a `signing_keys_path` file entry, or a pasted * stdin JWK) into `LegacyJwk`'s shape — every field Go's own `config.JWK` struct @@ -174,7 +76,12 @@ function readOptionalBoolean(record: Record, field: string): bo * simultaneously-malformed fields the reported field may not always match Go's for * that specific multi-error case — accepted gap, no fixture in `bearerjwt_test.go` * covers ordering across more than one bad field at once, and every field is still - * correctly rejected either way. + * correctly rejected either way. `readOptionalString`/`readOptionalStringArray`/ + * `readOptionalBoolean` are hoisted to `gen.signing-keys-config.ts` (the `gen` family + * root) rather than defined locally: `assertNoMalformedDuplicateJwkField` there needs + * the exact same per-field checks to catch a DUPLICATED field whose earlier occurrence + * `JSON.parse` alone would have silently discarded (CLI-1961 Codex review finding) — + * see both call sites below, and that function's own doc comment for the mechanics. */ function normalizeStoredJwk(record: Record): LegacyJwk { const keyOps = readOptionalStringArray(record, "key_ops"); @@ -255,8 +162,22 @@ const resolveSigningKeyFromStdinJwk = Effect.fnUntraced(function* () { // `{"kty":"oct","alg":"ES256","key_ops":["sign",1]}` — wrapped here with the same // `"failed to parse JWK: %w"` this Branch A path already uses above (Codex review // finding, CLI-1961). + // + // `assertNoMalformedDuplicateJwkField` runs FIRST, against `input` — the untouched + // pasted text — rather than `record`: by the time `parsed`/`record` exist, `JSON.parse` + // has ALREADY collapsed any duplicate top-level key down to its last occurrence (plain + // JS object-literal semantics), silently discarding evidence of an earlier occurrence + // Go's own decode would still reject — e.g. a pasted `{"kid":1,"kid":"k"}` parses to + // `record.kid === "k"` with no trace `1` was ever there, yet `json.Unmarshal` into + // `config.JWK` still errors on the `1` and never reaches `normalizeStoredJwk`'s + // equivalent of the merged, valid `"k"` (verified against the real binary, CLI-1961 + // Codex review finding — see that function's own doc comment for the full mechanics, + // including the `alg` field's distinct allowlist-driven behavior). return yield* Effect.try({ - try: () => normalizeStoredJwk(record), + try: () => { + assertNoMalformedDuplicateJwkField(input); + return normalizeStoredJwk(record); + }, catch: (cause) => new LegacyGenBearerJwtKeyParseError({ message: `failed to parse JWK: ${legacyBearerJwtErrorMessage(cause)}`, @@ -427,7 +348,11 @@ export const legacyResolveBearerJwtSigningKey = Effect.fnUntraced(function* (wor // field — e.g. `[{"kty":"oct","alg":"ES256","ext":"true"}]` — wrapped here with the // SAME `"failed to decode signing keys: failed to parse response body: %w"` this // file's sibling `alg`-allowlist check (inside `legacyReadSigningKeysFile`) already - // uses for this exact call site (Codex review finding, CLI-1961). + // uses for this exact call site (Codex review finding, CLI-1961). A DUPLICATE + // malformed field (e.g. `[{"kid":1,"kid":"k1",...}]`) is already rejected earlier, + // by `legacyReadSigningKeysFile` itself calling `assertNoMalformedDuplicateJwkField` + // against each element's own raw source text — `storedKeys` here is guaranteed + // free of that gap by the time this runs (CLI-1961 Codex review finding). availableKeys = yield* Effect.try({ try: () => storedKeys.map(normalizeStoredJwk), catch: (cause) => diff --git a/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts index bf4ddf74f9..d46bd412b6 100644 --- a/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts +++ b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts @@ -1,6 +1,8 @@ -import { loadProjectConfig } from "@supabase/config"; +import { loadProjectConfig, loadProjectEnvironment } from "@supabase/config"; import { Effect, FileSystem, Option, Path } from "effect"; import { legacyAssertDecodableJwkAlgorithm } from "../../shared/legacy-go-jwt.ts"; +import { legacyGoJsonKindName } from "../../shared/legacy-go-json.ts"; +import { legacyResolveProjectEnvironmentValues } from "../../shared/legacy-project-environment.ts"; /** * Shared `[auth].signing_keys_path` config-loading logic for the `gen` command @@ -59,6 +61,352 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** + * Go's own `legacyGoJsonKindName` (`legacy-go-json.ts`) is deliberately scoped to + * scalars only — every one of its existing call sites already excludes null/array/ + * object before reaching it. This file's per-field JWK checks below DO need to name a + * bare JSON object (`key_ops`'s elements, or a nested value under any field, can be an + * object — verified against the real binary: `--payload`-style `{}` inside `key_ops` + * reports `"...into Go struct field JWK.key_ops of type string"` with kind `object`), + * so this is a local superset rather than a change to that shared, narrower contract. + */ +function jwkFieldKindName(value: unknown): string { + if (value !== null && typeof value === "object" && !Array.isArray(value)) { + return "object"; + } + return legacyGoJsonKindName(value); +} + +/** + * Go's exact `encoding/json` struct-field type-mismatch text: `"json: cannot unmarshal + * into Go struct field JWK. of type "` — verified against the + * real binary (CLI-1961) for every field this file reads: `kty`/`kid`/`use`/`alg`/`n`/ + * `e`/`d`/`p`/`q`/`dp`/`dq`/`qi`/`crv`/`x`/`y` (`goType: "string"`), `key_ops` as a + * whole (`goType: "[]string"`) vs. one of its elements (`goType: "string"`, the same as + * any other string field), and `ext` (`goType: "bool"`). + */ +function jwkStructFieldTypeMismatch(field: string, value: unknown, goType: string): string { + return `json: cannot unmarshal ${jwkFieldKindName(value)} into Go struct field JWK.${field} of type ${goType}`; +} + +/** + * A JSON `null` for any `config.JWK` field is a documented Go `encoding/json` no-op — + * same as a `null` for the whole JWK (see `bearer-jwt.signing-key.ts`'s + * `resolveSigningKeyFromStdinJwk` doc comment) — so `null` must NOT be treated as a type + * mismatch here, only as "absent". + */ +function isAbsentJwkField(value: unknown): boolean { + return value === undefined || value === null; +} + +/** + * Reads an optional STRING field, throwing Go's exact struct-field type-mismatch text + * (see {@link jwkStructFieldTypeMismatch}) when the field is PRESENT with a non-string + * value — e.g. `{"kid":123}` or `{"ext":"true"}` — rather than silently treating a + * malformed field as absent. Go's `json.Unmarshal` into `config.JWK` fails outright on + * any such field, so a mistyped optional field must never let a caller mint a token as + * if the field had simply been omitted. Hoisted here (rather than living only in + * `bearer-jwt.signing-key.ts`) because {@link assertNoMalformedDuplicateJwkField} — used + * by BOTH `gen signing-key` and `gen bearer-jwt` via {@link legacyReadSigningKeysFile} — + * needs the exact same per-field check (CLI-1961 Codex review finding). + */ +export function readOptionalString( + record: Record, + field: string, +): string | undefined { + const value = record[field]; + if (isAbsentJwkField(value)) { + return undefined; + } + if (typeof value !== "string") { + throw new Error(jwkStructFieldTypeMismatch(field, value, "string")); + } + return value; +} + +/** + * Reads the optional `key_ops` STRING ARRAY field, throwing Go's exact struct-field + * type-mismatch text (see {@link jwkStructFieldTypeMismatch}) when the field is + * PRESENT but is not an array (`goType: "[]string"`) or contains a non-string element + * (`goType: "string"`, Go decodes each element individually into the slice's element + * type) — same "never silently treat malformed as absent" rule as + * {@link readOptionalString}. See that function's doc comment for why this is exported + * from the family root rather than kept local to `bearer-jwt.signing-key.ts`. + */ +export function readOptionalStringArray( + record: Record, + field: string, +): ReadonlyArray | undefined { + const value = record[field]; + if (isAbsentJwkField(value)) { + return undefined; + } + if (!Array.isArray(value)) { + throw new Error(jwkStructFieldTypeMismatch(field, value, "[]string")); + } + for (const entry of value) { + if (typeof entry !== "string") { + throw new Error(jwkStructFieldTypeMismatch(field, entry, "string")); + } + } + return value as ReadonlyArray; +} + +/** + * Reads the optional `ext` BOOLEAN field (Go's `*bool`), throwing Go's exact + * struct-field type-mismatch text (see {@link jwkStructFieldTypeMismatch}) when the + * field is PRESENT with a non-boolean value — e.g. `{"ext":"true"}` or `{"ext":1}` — + * same "never silently treat malformed as absent" rule as {@link readOptionalString}. + * See that function's doc comment for why this is exported from the family root. + */ +export function readOptionalBoolean( + record: Record, + field: string, +): boolean | undefined { + const value = record[field]; + if (isAbsentJwkField(value)) { + return undefined; + } + if (typeof value !== "boolean") { + throw new Error(jwkStructFieldTypeMismatch(field, value, "bool")); + } + return value; +} + +/** + * Every plain-`string` `config.JWK` field EXCEPT `alg` — `alg`'s own `config.Algorithm` + * type additionally implements `encoding.TextUnmarshaler` (the RS256/ES256 allowlist, + * `pkg/config/auth.go:80-86`), which changes ITS duplicate-key mechanics enough that + * {@link assertNoMalformedDuplicateJwkField} checks it separately — see that function's + * doc comment. + */ +const JWK_PLAIN_STRING_FIELDS = [ + "kty", + "kid", + "use", + "n", + "e", + "d", + "p", + "q", + "dp", + "dq", + "qi", + "crv", + "x", + "y", +] as const; + +/** + * Advances past exactly one JSON value starting at `text[start]` (after any leading + * whitespace), returning the index of the first character after that value — a minimal + * span-only JSON tokenizer (it never builds a JS value) shared by + * {@link splitJsonArrayElementTexts} and `findTopLevelObjectFieldOccurrences`. Tracks + * string-literal state (including `\"` escapes) so structural characters (`{}[]:,`) + * inside a string never affect nesting depth — a naive brace/bracket counter would + * otherwise mis-parse a value like `"a,b}c"`. Only ever called on text that has ALREADY + * parsed successfully as a whole via `JSON.parse` (a duplicate key is a semantic oddity + * `JSON.parse` tolerates, not a syntax error), so this can assume well-formed JSON + * grammar throughout — the `i === start` guards below are defense-in-depth against a + * hang, not a correctness requirement for well-formed input. + */ +function skipJsonValue(text: string, start: number): number { + let i = start; + while (i < text.length && /\s/.test(text[i] ?? "")) i++; + const skipString = () => { + i++; // opening quote + while (i < text.length) { + const c = text[i]; + if (c === "\\") { + i += 2; + continue; + } + i++; + if (c === '"') break; + } + }; + const ch = text[i]; + if (ch === '"') { + skipString(); + return i; + } + if (ch === "{" || ch === "[") { + const close = ch === "{" ? "}" : "]"; + let depth = 1; + i++; + while (i < text.length && depth > 0) { + const c = text[i]; + if (c === '"') { + skipString(); + continue; + } + if (c === ch) depth++; + else if (c === close) depth--; + i++; + } + return i; + } + // number / true / false / null. + while (i < text.length && !",}] \n\r\t".includes(text[i] ?? "")) i++; + return i === start ? start + 1 : i; // never stall on an unexpected character. +} + +/** + * Splits a JSON *array* literal's own top-level elements into their exact source + * substrings — respecting nested strings/objects/arrays so a comma or bracket inside a + * nested value never splits an element early — WITHOUT ever re-serializing them through + * `JSON.stringify` (which could reorder/reformat, and can't reproduce a source-only + * artifact like a duplicate key at all). {@link legacyReadSigningKeysFile} uses this to + * recover each `signing_keys_path` entry's OWN untouched text for + * {@link assertNoMalformedDuplicateJwkField} — `JSON.parse`, which the array as a WHOLE + * already went through for the ordinary shape checks in that function, has by that + * point already discarded the very duplicate-key evidence that check exists to find + * (CLI-1961 Codex review finding). + */ +function splitJsonArrayElementTexts(arrayText: string): ReadonlyArray { + const result: Array = []; + let i = 0; + while (i < arrayText.length && /\s/.test(arrayText[i] ?? "")) i++; + if (arrayText[i] !== "[") return result; + i++; + while (i < arrayText.length && /\s/.test(arrayText[i] ?? "")) i++; + if (arrayText[i] === "]") return result; + while (i < arrayText.length) { + while (i < arrayText.length && /\s/.test(arrayText[i] ?? "")) i++; + const start = i; + i = skipJsonValue(arrayText, i); + result.push(arrayText.slice(start, i)); + while (i < arrayText.length && /\s/.test(arrayText[i] ?? "")) i++; + if (arrayText[i] === ",") { + i++; + continue; + } + break; + } + return result; +} + +/** + * Returns every top-level field of a JSON *object* literal, keyed by field name, with + * ALL occurrences' raw source substrings preserved in true source order — including + * duplicates `JSON.parse` would silently collapse down to just the last one. See + * {@link assertNoMalformedDuplicateJwkField}'s doc comment for why this matters. + */ +function findTopLevelObjectFieldOccurrences( + objectText: string, +): ReadonlyMap> { + const result = new Map>(); + let i = 0; + while (i < objectText.length && /\s/.test(objectText[i] ?? "")) i++; + if (objectText[i] !== "{") return result; + i++; + while (i < objectText.length && /\s/.test(objectText[i] ?? "")) i++; + if (objectText[i] === "}") return result; + while (i < objectText.length) { + while (i < objectText.length && /\s/.test(objectText[i] ?? "")) i++; + const keyStart = i; + i = skipJsonValue(objectText, i); + const key = JSON.parse(objectText.slice(keyStart, i)) as string; + while (i < objectText.length && /\s/.test(objectText[i] ?? "")) i++; + if (objectText[i] === ":") i++; + while (i < objectText.length && /\s/.test(objectText[i] ?? "")) i++; + const valueStart = i; + i = skipJsonValue(objectText, i); + const valueText = objectText.slice(valueStart, i); + const existing = result.get(key); + if (existing === undefined) result.set(key, [valueText]); + else existing.push(valueText); + while (i < objectText.length && /\s/.test(objectText[i] ?? "")) i++; + if (objectText[i] === ",") { + i++; + continue; + } + break; + } + return result; +} + +/** + * Detects a JWK-shaped object literal's raw source text having a KNOWN `config.JWK` + * field repeated with an EARLIER occurrence Go's real decode would reject, even when the + * LAST occurrence — the only one `JSON.parse` actually keeps, per plain JS + * object-literal semantics — is perfectly valid on its own. `JSON.parse` collapsing + * `{"kid":1,"kid":"k"}` down to `{kid: "k"}` erases the very evidence + * `readOptionalString`/etc. would need to catch the earlier `1`. + * + * Verified against the real binary (CLI-1961 Codex review finding) with two genuinely + * different mechanics depending on the field: + * + * - **Plain `string`/`[]string`/`*bool` fields** (every field here except `alg`): Go's + * `encoding/json` decodes duplicate occurrences in source order and ALWAYS continues + * past a type-mismatched occurrence to try the next one (a later valid occurrence DOES + * get written into the struct) — but `Unmarshal`'s own returned error is always the + * FIRST mismatch found, regardless of what a later occurrence does. Net effect: if ANY + * occurrence of a plain field mismatches, `Unmarshal` errors, full stop — which is + * exactly what iterating every occurrence through the same {@link readOptionalString}/ + * {@link readOptionalStringArray}/{@link readOptionalBoolean} the merged value already + * goes through, in source order, reproduces (first thrown wins, same as Go's first + * saved error). + * - **`alg`**: `config.Algorithm` additionally implements `encoding.TextUnmarshaler` + * (the RS256/ES256 allowlist). Once an EARLIER occurrence's `UnmarshalText` itself + * returns a non-nil error (i.e. a validly-typed but disallowed string, like `"HS256"`), + * Go's decoder never even ATTEMPTS a later occurrence of `alg` — confirmed directly: + * `json.Unmarshal` of `{"alg":"HS256","alg":"ES256"}` into a `config.JWK`-shaped struct + * still errors `"must be one of [RS256 ES256]"`, and the field never advances past the + * first, disallowed value, even though `"ES256"` alone would have been fine and is the + * value `JSON.parse` alone would have kept. A bare JSON-type mismatch on `alg` (e.g. a + * number) behaves like the plain-field case above instead — Go's outer struct-field + * type check runs BEFORE `UnmarshalText` is ever reached, and does not block later + * occurrences the way `UnmarshalText`'s OWN error does. So `alg` needs both checks, in + * order, per occurrence: {@link readOptionalString} (type) then + * {@link legacyAssertDecodableJwkAlgorithm} (allowlist) — first thrown wins, matching + * Go's first-saved-error exactly for this field too. + * + * Checks known fields in a fixed order (not the object's own source order) — an accepted + * gap already documented on `bearer-jwt.signing-key.ts`'s `normalizeStoredJwk` for the + * analogous "multiple simultaneously-malformed DISTINCT fields" case, which this + * inherits: every genuinely malformed duplicate is still rejected, just not always + * attributed to Go's exact first field when more than one is wrong at once. + * + * A no-op (never throws, never even builds `findTopLevelObjectFieldOccurrences`'s full + * map unnecessarily) for an object with no duplicated known field — the overwhelmingly + * common case, where every value `readOptionalString`/etc. would need to inspect is + * exactly the one they already inspect via the merged value downstream. + */ +export function assertNoMalformedDuplicateJwkField(objectText: string): void { + const occurrences = findTopLevelObjectFieldOccurrences(objectText); + + const alg = occurrences.get("alg"); + if (alg !== undefined && alg.length >= 2) { + for (const rawValue of alg) { + const checked = readOptionalString({ alg: JSON.parse(rawValue) }, "alg"); + legacyAssertDecodableJwkAlgorithm(checked); + } + } + + for (const field of JWK_PLAIN_STRING_FIELDS) { + const values = occurrences.get(field); + if (values === undefined || values.length < 2) continue; + for (const rawValue of values) { + readOptionalString({ [field]: JSON.parse(rawValue) }, field); + } + } + + const keyOps = occurrences.get("key_ops"); + if (keyOps !== undefined && keyOps.length >= 2) { + for (const rawValue of keyOps) { + readOptionalStringArray({ key_ops: JSON.parse(rawValue) }, "key_ops"); + } + } + + const ext = occurrences.get("ext"); + if (ext !== undefined && ext.length >= 2) { + for (const rawValue of ext) { + readOptionalBoolean({ ext: JSON.parse(rawValue) }, "ext"); + } + } +} + /** * Resolves `supabase/config.toml`'s display path and `[auth].signing_keys_path`'s * actual/display path — no file I/O on the keys path itself (see @@ -70,7 +418,33 @@ export const legacyResolveSigningKeysConfigPaths = Effect.fnUntraced(function* < onConfigParseError: (message: string) => E, ) { const path = yield* Path.Path; + // Go's `Config.Load` runs its `loadNestedEnv` dotenv cascade (`config.go:786-793`) + // BEFORE `loadFromFile` ever decodes `env(...)` TOML references (`LoadEnvHook`, + // `config.go:735-738`) — and that cascade reaches `.env.[.local]` files + // AND the project-root directory (`/.env`), not just `supabase/.env`/ + // `.env.local`. `loadProjectConfig`'s OWN internal env resolution (used whenever + // `options.projectEnv` is omitted, `@supabase/config`'s `loadProjectEnvironment`) only + // covers that narrower `supabase/`-dir, env-agnostic half — so `[auth].signing_keys_path + // = "env(KEYS_PATH)"` with `KEYS_PATH` set only in `.env.development`/`/.env` + // would otherwise stay literally unexpanded here even though Go's CLI resolves and + // signs with it fine (verified against the real Go source: CLI-1961 Codex review + // finding). Fills the exact same gap `legacy-local-project-context.ts`'s + // `legacyLoadLocalProjectContext` already fills for `stop`/`status`, via the same + // two-step resolution. + const projectEnv = yield* loadProjectEnvironment({ + cwd, + baseEnv: process.env, + search: false, + skipEnvLocal: (process.env["SUPABASE_ENV"] || "development") === "test", + }).pipe( + Effect.mapError((cause) => onConfigParseError(`failed to read config: ${String(cause)}`)), + ); + const projectEnvValues = yield* Effect.try({ + try: () => legacyResolveProjectEnvironmentValues(projectEnv, cwd), + catch: (cause) => onConfigParseError(`failed to read config: ${String(cause)}`), + }); const loaded = yield* loadProjectConfig(cwd, { + projectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, goViperCompat: true, // `cwd` here is the ALREADY-resolved `LegacyCliConfig.workdir` (Go's own ancestor // climb, `ChangeWorkDir`/`getProjectRoot`, already ran once to produce it — see @@ -139,13 +513,16 @@ export const legacyResolveSigningKeysConfigPaths = Effect.fnUntraced(function* < * `gen signing-key` behavior (not a literal Go error string; Go's decode failures come from * `encoding/json`'s own type-mismatch errors, which `readJwkArray`'s two checks approximate). * - * The `alg` allowlist check below IS a literal Go error string, unlike the shape checks + * The `alg` allowlist check and the duplicate-field check below ARE literal Go error strings + * (or reproductions of Go's exact struct-field type-mismatch text), unlike the shape checks * above: Go's `fetcher.ParseJSON[[]JWK]` (`pkg/fetcher/http.go:144-151`) decodes straight - * into `[]config.JWK`, and `config.Algorithm.UnmarshalText` (`pkg/config/auth.go:80-86`) - * runs automatically during that decode for every element with a string `alg`, rejecting - * anything other than `RS256`/`ES256` — wrapped here as `"failed to decode signing keys: - * failed to parse response body: %w"`, matching `ParseJSON`'s own wrap on top of - * `Config.Validate`'s. + * into `[]config.JWK`, running the full `encoding/json` struct decode — including + * `config.Algorithm.UnmarshalText` (`pkg/config/auth.go:80-86`) — for every element, wrapped + * here as `"failed to decode signing keys: failed to parse response body: %w"`, matching + * `ParseJSON`'s own wrap on top of `Config.Validate`'s. {@link assertNoMalformedDuplicateJwkField} + * closes the gap where an element has a duplicate top-level field whose earlier occurrence + * `JSON.parse` alone would have discarded before either check ever saw it (CLI-1961 Codex + * review finding). */ export const legacyReadSigningKeysFile = Effect.fnUntraced(function* ( actualPath: string, @@ -172,10 +549,15 @@ export const legacyReadSigningKeysFile = Effect.fnUntraced(function* ( ); } } - for (const item of decoded as ReadonlyArray>) { - const alg = item["alg"]; + const elementTexts = splitJsonArrayElementTexts(raw); + for (const [index, item] of (decoded as ReadonlyArray>).entries()) { + const elementText = elementTexts[index]; try { + const alg = item["alg"]; legacyAssertDecodableJwkAlgorithm(typeof alg === "string" ? alg : undefined); + if (elementText !== undefined) { + assertNoMalformedDuplicateJwkField(elementText); + } } catch (cause) { return yield* Effect.fail( onDecodeError( From 0fab9271f6019a668f296709f1a4885d59659524 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 17:52:46 +0100 Subject: [PATCH 11/12] fix(cli): accept Go-decodable null entries, trailing JSON bytes, negative zero, and MinInt64 durations (review) - gen.signing-keys-config.ts: a null signing_keys_path array element now normalizes to a zero-value JWK regardless of position, matching Go's json.Unmarshal; only rejecting the whole file broke Go-valid files where the first key is valid (generateAPIKeys succeeds, non-TTY kid match still resolves). - gen.signing-keys-config.ts: legacyReadSigningKeysFile now parses only the first JSON value in signing_keys_path, matching fetcher.ParseJSON's single json.Decoder.Decode call, which ignores trailing bytes instead of requiring the whole file to be exactly one JSON value. - gen.signing-keys-config.ts: a null key_ops element now decodes to "" (Go's []string zero value) instead of a type-mismatch error; GenerateAsymmetricJWT never reads key_ops so this never affected signing in Go either way. - legacy-go-json.ts: the shared JSON walker now preserves negative zero's sign (encoding/json marshals float64 -0 as "-0"; JSON.stringify(-0) prints "0"), fixing a payload/signature divergence for gen bearer-jwt --payload. - legacy-go-output.encoders.ts: sortKeysDeep now sorts object keys with Go's byte/code-point order (via legacy-go-struct-output.encoders.ts's goStringCompare, now exported) instead of JS's UTF-16 code-unit order, which diverges once an astral character meets a high-BMP one. - legacy-go-duration.ts: legacyParseGoDuration now mirrors time.ParseDuration's exact two-tier overflow bound (uint64 accumulator allowed up to 1<<63, stricter 1<<63-1 ceiling only for non-negative results), so the real math.MinInt64 duration parses instead of being rejected one bound early. Verified each against the real apps/cli-go binary/Go stdlib (scratch probes and, for the signing-keys-path cases, the compiled binary directly). --- .../bearer-jwt/bearer-jwt.integration.test.ts | 95 +++++++++++++++++++ .../commands/gen/gen.signing-keys-config.ts | 67 ++++++++++--- .../src/legacy/shared/legacy-go-duration.ts | 24 ++++- .../shared/legacy-go-duration.unit.test.ts | 21 ++++ apps/cli/src/legacy/shared/legacy-go-json.ts | 14 ++- .../legacy/shared/legacy-go-json.unit.test.ts | 12 +++ .../shared/legacy-go-output.encoders.ts | 14 ++- .../legacy-go-output.encoders.unit.test.ts | 13 +++ .../legacy-go-struct-output.encoders.ts | 12 ++- 9 files changed, 254 insertions(+), 18 deletions(-) diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts index e282f29ec2..a3a6d42f0f 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts @@ -908,6 +908,101 @@ describe("legacy gen bearer-jwt integration", () => { }, ); + it.live( + "Branch B: accepts a null entry AFTER a valid key in signing_keys_path, signing with an exact kid match (CLI-1961 Codex review finding)", + () => { + // Distinct from the earlier-rejected null-BEFORE-valid-key finding on this PR: + // there, `SigningKeys[0]` is the null-decoded zero-value JWK, so Go's own + // `generateAPIKeys` fails signing before kid selection is ever reached. Here + // the valid key is FIRST, so `generateAPIKeys` succeeds — but a BLANK kid + // answer still fails, because Go's own exact-KeyID-match loop runs BEFORE the + // blank-input fallback and the null-decoded second entry's OWN kid is `""`, + // an exact match for a blank answer (same quirk this file's "an exact kid + // match on a key with an empty kid wins ahead of the blank-input + // fallback-to-first" test already covers) — verified against the real binary: + // a blank answer against `[validKey, null]` fails identically to this port + // with `"failed to convert JWK to private key: unsupported key type: "`. An + // EXPLICIT exact-kid answer for the valid key still signs successfully in + // both, which is what the finding's "a non-TTY user can still select + // validKey" actually depends on. Verified against the real binary: + // `json.Unmarshal` accepts `[validKey, null]`, decoding the trailing `null` + // into a zero-value `config.JWK` rather than failing the whole array. + const validKey = generateEcJwk("valid-kid"); + const { layer, out } = setup({ pipedAnswer: "valid-kid" }); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([validKey, null]))); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "valid-kid", + typ: "JWT", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: ignores trailing bytes after the first JSON value in signing_keys_path, matching Go's single Decode (CLI-1961 Codex review finding)", + () => { + // Go's `fetcher.ParseJSON[[]JWK]` (`pkg/fetcher/http.go:144-151`) is a single + // `json.Decoder.Decode` call, which reads exactly one JSON value and never + // checks for trailing bytes. Verified against the real binary: a + // `signing_keys_path` file containing a valid array followed by a second, + // syntactically-valid JSON value still lets Go sign with the first array's key. + const validKey = generateEcJwk("valid-kid"); + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(`${JSON.stringify([validKey])} []`)); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "valid-kid", + typ: "JWT", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch B: accepts a stored signing key with a null key_ops element, matching Go's zero-value decode (CLI-1961 Codex review finding)", + () => { + // `key_ops` is never read by Go's `GenerateAsymmetricJWT` (it only inspects + // `kty`/`Algorithm`/the key-material fields), and `json.Unmarshal` decodes a + // `null` element of a `[]string` as that element's zero value (`""`), not a + // type mismatch — verified against the real binary (CLI-1961 Codex review + // finding). + const jwk = { ...generateEcJwk("null-key-ops-kid"), key_ops: ["sign", null] }; + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([jwk]))); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "null-key-ops-kid", + typ: "JWT", + }); + }).pipe(Effect.provide(layer)); + }, + ); + it.live( "Branch C: TTY with zero configured signing keys fails with Go's exact 'user aborted' text", () => { diff --git a/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts index d46bd412b6..03d9913f49 100644 --- a/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts +++ b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts @@ -127,11 +127,21 @@ export function readOptionalString( /** * Reads the optional `key_ops` STRING ARRAY field, throwing Go's exact struct-field * type-mismatch text (see {@link jwkStructFieldTypeMismatch}) when the field is - * PRESENT but is not an array (`goType: "[]string"`) or contains a non-string element - * (`goType: "string"`, Go decodes each element individually into the slice's element - * type) — same "never silently treat malformed as absent" rule as + * PRESENT but is not an array (`goType: "[]string"`) or contains a non-string, + * non-null element (`goType: "string"`, Go decodes each element individually into + * the slice's element type) — same "never silently treat malformed as absent" rule as * {@link readOptionalString}. See that function's doc comment for why this is exported * from the family root rather than kept local to `bearer-jwt.signing-key.ts`. + * + * A `null` ELEMENT (e.g. `"key_ops":["sign",null]`) is its own separate zero-value + * case, one level deeper than {@link isAbsentJwkField}'s "the whole field is absent": + * Go's `encoding/json` decodes a `null` slice element into `[]string` as that + * element's zero value (`""`), not a type mismatch. Verified against the real binary + * (CLI-1961 Codex review finding): `json.Unmarshal(["sign", null], &[]string{})` + * yields `["sign", ""]` with no error — and `key_ops` is never even READ by + * `GenerateAsymmetricJWT` (it only inspects `kty`/`Algorithm`/the key-material + * fields), so a JWK with a `null` `key_ops` element still signs successfully in Go, + * where this function previously rejected it outright. */ export function readOptionalStringArray( record: Record, @@ -144,12 +154,15 @@ export function readOptionalStringArray( if (!Array.isArray(value)) { throw new Error(jwkStructFieldTypeMismatch(field, value, "[]string")); } - for (const entry of value) { + return value.map((entry) => { + if (entry === null) { + return ""; + } if (typeof entry !== "string") { throw new Error(jwkStructFieldTypeMismatch(field, entry, "string")); } - } - return value as ReadonlyArray; + return entry; + }); } /** @@ -534,7 +547,18 @@ export const legacyReadSigningKeysFile = Effect.fnUntraced(function* ( .readFileString(actualPath) .pipe(Effect.mapError((cause) => onReadError(`failed to read signing keys: ${String(cause)}`))); const decoded = yield* Effect.try({ - try: () => JSON.parse(raw), + // Go's `fetcher.ParseJSON[[]JWK]` (`pkg/fetcher/http.go:144-151`) is a single + // `json.Decoder.Decode` call, which reads exactly ONE JSON value and never checks + // for trailing bytes — content after that first value (even further + // syntactically-valid JSON, e.g. a `signing_keys_path` file containing + // `"[validKey] []"`) is silently ignored, not an error. Plain `JSON.parse` + // requires the ENTIRE string to be exactly one value and throws on anything left + // over, so parse only the first value's own source span — reusing the same + // {@link skipJsonValue} span-scanner {@link splitJsonArrayElementTexts} already + // uses below — to match Go's decode-once-ignore-the-rest behavior. Verified + // against the real binary (CLI-1961 Codex review finding): Go still signs with + // `validKey` from a `signing_keys_path` file containing `[validKey] []`. + try: () => JSON.parse(raw.slice(0, skipJsonValue(raw, 0))), catch: (cause) => onDecodeError(`failed to decode signing keys: ${String(cause)}`), }); if (!Array.isArray(decoded)) { @@ -542,18 +566,38 @@ export const legacyReadSigningKeysFile = Effect.fnUntraced(function* ( onDecodeError("failed to decode signing keys: expected a JSON array"), ); } + // A bare `null` ARRAY ELEMENT (as opposed to `isAbsentJwkField`'s "a FIELD is + // absent") is Go's own `encoding/json` zero-value case, not a type mismatch: a + // `null` decoded into `config.JWK` (a struct, not a pointer) leaves every field at + // its zero value, same as `bearer-jwt.signing-key.ts`'s `resolveSigningKeyFromStdinJwk` + // already documents for a pasted `null` JWK. So `null` must normalize to `{}` + // (an empty record — {@link readOptionalString}/etc. treat every field as absent) + // rather than fail this shape check outright, regardless of WHERE in the array it + // appears. Verified against the real binary (CLI-1961 Codex review finding): with + // `signing_keys_path` decoding to `[validKey, null]`, `Config.Validate` succeeds + // (`generateAPIKeys` signs with `SigningKeys[0]`, which is `validKey`), and a + // non-TTY `gen bearer-jwt` can still select `validKey` by kid or blank-input + // fallback — a `[null, validKey]` ordering is different (already adjudicated on + // this PR): `SigningKeys[0]` there is the null-decoded zero-value JWK, so + // `generateAPIKeys` itself fails signing before selection is ever reached — but + // that later, ALREADY-REJECTED failure is Go's own downstream signing behavior, not + // a reason for this decode step to reject either ordering up front. for (const item of decoded) { - if (!isRecord(item)) { + if (item !== null && !isRecord(item)) { return yield* Effect.fail( onDecodeError("failed to decode signing keys: expected a JSON array of objects"), ); } } const elementTexts = splitJsonArrayElementTexts(raw); - for (const [index, item] of (decoded as ReadonlyArray>).entries()) { + const normalized: Array> = []; + for (const [index, item] of ( + decoded as ReadonlyArray | null> + ).entries()) { + const record = item === null ? {} : item; const elementText = elementTexts[index]; try { - const alg = item["alg"]; + const alg = record["alg"]; legacyAssertDecodableJwkAlgorithm(typeof alg === "string" ? alg : undefined); if (elementText !== undefined) { assertNoMalformedDuplicateJwkField(elementText); @@ -565,6 +609,7 @@ export const legacyReadSigningKeysFile = Effect.fnUntraced(function* ( ), ); } + normalized.push(record); } - return decoded as ReadonlyArray; + return normalized as ReadonlyArray; }); diff --git a/apps/cli/src/legacy/shared/legacy-go-duration.ts b/apps/cli/src/legacy/shared/legacy-go-duration.ts index 447be8131a..69603c6d16 100644 --- a/apps/cli/src/legacy/shared/legacy-go-duration.ts +++ b/apps/cli/src/legacy/shared/legacy-go-duration.ts @@ -34,10 +34,22 @@ const NS_PER_MS_BIG = 1_000_000n; const NS_PER_US_BIG = 1_000n; // Go's `time.Duration` ceiling (`math.MaxInt64` nanoseconds, ~292.47 years) — `time.ParseDuration` -// rejects any value whose accumulated nanosecond count would exceed this. Go's real max parseable -// duration is `2562047h47m16.854775807s`. +// rejects any POSITIVE value whose accumulated nanosecond count would exceed this. Go's real max +// parseable duration is `2562047h47m16.854775807s`. const MAX_INT64_NS = 9223372036854775807n; +// Go's `time.ParseDuration` (`src/time/format.go`) accumulates into a `uint64`, checking +// `d > 1<<63` (NOT `1<<63-1`, i.e. `MAX_INT64_NS`) both per-term and on the running total, and +// only applies the STRICTER `d > 1<<63-1` check afterwards, and only when the parsed value is NOT +// negated. A magnitude of exactly `1<<63` therefore survives parsing when the input is negative — +// `-Duration(d)` on `d == 1<<63` wraps via `int64` two's-complement into exactly `math.MinInt64`, +// Go's own minimum representable duration (`-2562047h47m16.854775808s`) — but is rejected when the +// input has no sign, since a positive `time.Duration` can never reach `1<<63` itself. Verified +// against the real `time` package (CLI-1961 Codex review finding): `time.ParseDuration( +// "-9223372036854775808ns")` succeeds and returns `math.MinInt64`, while the unsigned form +// `"9223372036854775808ns"` (identical magnitude, no leading `-`) is rejected as an overflow. +const UINT64_ACCUMULATOR_BOUND_NS = 1n << 63n; + /** * Port of Go `time.ParseDuration`. Returns nanoseconds as a number. Accepts * the same grammar Go does: a possibly-signed sequence of decimal numbers, @@ -143,10 +155,16 @@ export function legacyParseGoDuration(value: string): number { // `0`, not `1`. `BigInt` division truncates toward zero unconditionally, so // `(frac * unitNs) / post` matches that exactly, without any intermediate float64 rounding. total += n * unitNs + (frac * unitNs) / post; - if (total > MAX_INT64_NS) { + if (total > UINT64_ACCUMULATOR_BOUND_NS) { throw new Error(`time: invalid duration "${orig}"`); } } + // Only a positive result gets the stricter post-loop bound — see + // `UINT64_ACCUMULATOR_BOUND_NS`'s doc comment for why a negative result is allowed to reach + // one nanosecond further (down to exactly `math.MinInt64`). + if (!neg && total > MAX_INT64_NS) { + throw new Error(`time: invalid duration "${orig}"`); + } return Number(neg ? -total : total); } diff --git a/apps/cli/src/legacy/shared/legacy-go-duration.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-duration.unit.test.ts index f6a95178ae..f613559952 100644 --- a/apps/cli/src/legacy/shared/legacy-go-duration.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-duration.unit.test.ts @@ -111,6 +111,27 @@ describe("legacyParseGoDuration", () => { it("accepts a duration exactly at Go's true math.MaxInt64 ceiling in nanoseconds", () => { expect(() => legacyParseGoDuration("9223372036854775807ns")).not.toThrow(); }); + + // Go's `time.ParseDuration` accumulates into a `uint64` and only rejects `d > 1<<63` + // (NOT `1<<63-1`) during parsing — a magnitude of exactly `1<<63` (one MORE than + // `math.MaxInt64`) survives the loop and, once negated, lands exactly on + // `math.MinInt64`. Verified against the real `time` package (CLI-1961 Codex review + // finding): `time.ParseDuration("-9223372036854775808ns")` succeeds. + it("accepts Go's exact minimum representable negative duration (math.MinInt64 ns)", () => { + expect(legacyParseGoDuration("-9223372036854775808ns")).toBe(-9223372036854775808); + }); + + it("accepts the equivalent hours/minutes/seconds form of math.MinInt64 ns", () => { + expect(legacyParseGoDuration("-2562047h47m16.854775808s")).toBe(-9223372036854775808); + }); + + // One nanosecond further negative than `math.MinInt64` has no valid `int64` + // representation at all — Go rejects this too, not just the positive overflow. + it("rejects a duration 1ns past math.MinInt64", () => { + expect(() => legacyParseGoDuration("-9223372036854775809ns")).toThrow( + 'time: invalid duration "-9223372036854775809ns"', + ); + }); }); describe("legacyFormatGoDuration", () => { diff --git a/apps/cli/src/legacy/shared/legacy-go-json.ts b/apps/cli/src/legacy/shared/legacy-go-json.ts index 4f56f366b5..e9e169960a 100644 --- a/apps/cli/src/legacy/shared/legacy-go-json.ts +++ b/apps/cli/src/legacy/shared/legacy-go-json.ts @@ -87,8 +87,18 @@ function walk(value: unknown, depth: number, pretty: boolean): string { case "number": // Finite numbers from JSON parsing render identically to Go for the // integer and ordinary-float cases relevant here; defer to JSON.stringify - // for the canonical shortest representation. - return Number.isFinite(value) ? JSON.stringify(value) : "null"; + // for the canonical shortest representation — EXCEPT negative zero, which + // `JSON.stringify(-0)` collapses to `"0"` (ECMA-262's `Number::toString` + // prints no sign for negative zero) while Go's `encoding/json` marshals a + // `float64` negative zero as `-0`. Reachable via `gen bearer-jwt`'s + // `--payload '{"extra":-0}'` (or an underflowing literal like `-1e-10000`): + // `json.Unmarshal` into `jwt.MapClaims` (a real `map[string]any`) decodes + // the number as `float64(-0)`, and the signed payload segment carries that + // sign through to `-0` — verified against the real binary (CLI-1961 Codex + // review finding): the compiled Go CLI's signed token payload literally + // contains `"extra":-0`. Special-case it so the signed bytes match. + if (!Number.isFinite(value)) return "null"; + return Object.is(value, -0) ? "-0" : JSON.stringify(value); case "boolean": return value ? "true" : "false"; } diff --git a/apps/cli/src/legacy/shared/legacy-go-json.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-json.unit.test.ts index b54ff21ec2..b16b802795 100644 --- a/apps/cli/src/legacy/shared/legacy-go-json.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-json.unit.test.ts @@ -76,6 +76,18 @@ describe("encodeGoJsonCompact", () => { expect(encodeGoJsonCompact(null)).toBe("null"); }); + // `JSON.stringify(-0)` collapses to `"0"` (ECMA-262 prints no sign for negative + // zero), but Go's `encoding/json` marshals a `float64` negative zero as `-0` — + // reachable through `gen bearer-jwt --payload`'s `json.Unmarshal` into a real Go + // map. Verified against the real binary (CLI-1961 Codex review finding): the + // compiled Go CLI's signed token payload for `--payload '{"extra":-0}'` literally + // contains `"extra":-0`. + it("preserves negative zero's sign, unlike plain JSON.stringify", () => { + expect(encodeGoJsonCompact({ extra: -0 })).toBe('{"extra":-0}'); + expect(encodeGoJsonCompact({ extra: Number("-1e-10000") })).toBe('{"extra":-0}'); + expect(encodeGoJsonCompact({ extra: 0 })).toBe('{"extra":0}'); + }); + it("iterates a Map in true insertion order, unlike a plain object with integer-like keys", () => { // A plain object always reorders integer-like string keys ("2", "10") into ascending // NUMERIC order on enumeration, regardless of insertion order — a `Map` does not, which diff --git a/apps/cli/src/legacy/shared/legacy-go-output.encoders.ts b/apps/cli/src/legacy/shared/legacy-go-output.encoders.ts index 22cecab957..d5ce060236 100644 --- a/apps/cli/src/legacy/shared/legacy-go-output.encoders.ts +++ b/apps/cli/src/legacy/shared/legacy-go-output.encoders.ts @@ -2,6 +2,7 @@ import { stringify as stringifyToml } from "smol-toml"; import { stringify as stringifyYaml } from "yaml"; import { encodeGoJsonCompact, encodeGoJsonIndented } from "./legacy-go-json.ts"; +import { goStringCompare } from "./legacy-go-struct-output.encoders.ts"; /** * Reproduces Go's `json.Encoder` output (`utils.EncodeOutput` with `-o json`): @@ -55,7 +56,18 @@ function sortKeysDeep(value: unknown): unknown { // for every key shape (CLI-1961 Codex review finding: `{"10":"a","2":"b"}` must stay "10" // before "2" all the way through to the final encoded output). const sorted = new Map(); - for (const key of Object.keys(value as Record).sort()) { + // `.sort()` with no comparator uses JS default string comparison, which orders by + // UTF-16 code unit — NOT the same as Go's byte/code-point order once an astral + // character (U+10000+, a surrogate PAIR in UTF-16) meets a high-BMP one (U+E000- + // U+FFFF, a single code unit numerically ABOVE the astral character's leading + // surrogate). `goStringCompare` (hoisted from `legacy-go-struct-output.encoders.ts`, + // which already needed it for TOML/struct-map key sorting) reproduces Go's real + // `encoding/json` map-key order instead (CLI-1961 Codex review finding: verified + // against the real binary that `json.Marshal` of a map keyed by U+E000 and U+10000 + // emits the U+E000 key first — the reverse of plain JS `.sort()` on those two keys — + // which matters here because `gen bearer-jwt`'s `--payload` custom claims flow + // through this same `sortKeysDeep` via `encodeGoStructJsonBody` before signing). + for (const key of Object.keys(value as Record).sort(goStringCompare)) { const child = (value as Record)[key]; // JSON.stringify used to drop undefined properties; the Go-faithful walker // renders them as null, so drop them here to keep the old key surface. diff --git a/apps/cli/src/legacy/shared/legacy-go-output.encoders.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-output.encoders.unit.test.ts index 3dffeb73f5..112661110d 100644 --- a/apps/cli/src/legacy/shared/legacy-go-output.encoders.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-output.encoders.unit.test.ts @@ -118,6 +118,19 @@ describe("encodeGoJson", () => { `, ); }); + + it("sorts keys by Go's byte/code-point order, not JS's UTF-16 code-unit order (CLI-1961 Codex review finding)", () => { + // U+E000 is a single UTF-16 code unit (0xE000); U+10000 is a surrogate PAIR whose + // leading unit (0xD800) is numerically SMALLER than 0xE000 — so plain JS `.sort()` + // (which compares UTF-16 code units) puts the astral key FIRST, while Go's real + // `encoding/json` (byte/code-point order) puts the high-BMP key first instead. + // Verified against the real binary: `json.Marshal` of a Go map keyed by these two + // strings emits the U+E000 key first. + const highBmp = String.fromCodePoint(0xe000); + const astral = String.fromCodePoint(0x10000); + const out = encodeGoJson({ [astral]: 2, [highBmp]: 1 }); + expect(out).toBe(`{\n "${highBmp}": 1,\n "${astral}": 2\n}\n`); + }); }); describe("encodeYaml", () => { diff --git a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts index 04c179fcd3..95402530bf 100644 --- a/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts +++ b/apps/cli/src/legacy/shared/legacy-go-struct-output.encoders.ts @@ -504,8 +504,18 @@ function yamlMapEntries( * `keyList.Less` compares runes — both equal Unicode code-point order, which * differs from JS `<` (UTF-16 code-unit order) when an astral character meets * a high-BMP one (e.g. Go sorts U+E000 before U+1F600, UTF-16 the reverse). + * + * Exported for `legacy-go-output.encoders.ts`'s `sortKeysDeep` - + * `encoding/json`'s map-key sort is the exact same Go byte/code-point + * order, so `gen bearer-jwt`'s `--payload` custom-claims object (and every + * other `encodeGoJson`/`encodeGoStructJsonBody` caller) needs this same + * comparator instead of a second, divergent copy (CLI-1961 Codex review + * finding: verified against the real binary that `json.Marshal` of a map + * with a U+E000 key and a U+10000 key emits the U+E000 key FIRST, while + * plain JS `Object.keys(...).sort()` on the same two keys yields the + * reverse order). */ -function goStringCompare(a: string, b: string): number { +export function goStringCompare(a: string, b: string): number { let i = 0; while (i < a.length && i < b.length) { const ac = a.codePointAt(i) as number; From f676173df73ce0023c9e855c20c48cd950bad7c2 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 5 Aug 2026 18:48:33 +0100 Subject: [PATCH 12/12] fix(cli): match Go's float64 duration rounding and case-insensitive JWK fields (review) - legacy-go-duration.ts: mirror Go's time.ParseDuration float64-mediated fraction conversion instead of an exact BigInt division, so a long fractional --valid-for (e.g. 18+ nines) rounds up to match Go instead of truncating down, which previously shifted the final floored iat/exp by a full second. - gen.signing-keys-config.ts / bearer-jwt.signing-key.ts: resolve JWK fields case-insensitively (resolveJwkFieldValue), matching encoding/json's case-insensitive struct-tag matching, for both readOptionalString/Array/ Boolean and the early alg-allowlist pre-checks; extend the existing duplicate-field detection to group case-variant spellings together too. --- .../bearer-jwt/bearer-jwt.integration.test.ts | 97 ++++++++++++++++++- .../gen/bearer-jwt/bearer-jwt.signing-key.ts | 7 +- .../commands/gen/gen.signing-keys-config.ts | 70 +++++++++++-- .../src/legacy/shared/legacy-go-duration.ts | 25 ++++- .../shared/legacy-go-duration.unit.test.ts | 11 +++ 5 files changed, 193 insertions(+), 17 deletions(-) diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts index a3a6d42f0f..4386d80b45 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts @@ -41,7 +41,7 @@ const LEGACY_DEFAULT_SIGNING_KEY_PUBLIC = { y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", }; -function generateEcJwk(kid: string) { +function generateEcJwk(kid: string): Record { const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-256" }); const jwk = privateKey.export({ format: "jwk" }) as Record; return { ...jwk, kty: "EC", alg: "ES256", kid }; @@ -655,6 +655,64 @@ describe("legacy gen bearer-jwt integration", () => { }, ); + it.live( + "Branch A: accepts a pasted JWK with Go-decodable case-variant field names (CLI-1961 Codex review finding)", + () => { + // Go's `encoding/json` matches `config.JWK`'s struct fields case-insensitively — + // verified against the real binary: `{"KTY":"EC","ALG":"ES256",...}` decodes + // identically to the all-lowercase spelling, including `alg` despite its extra + // `encoding.TextUnmarshaler` allowlist hook. A previous version of this + // normalizer only read exact lowercase property names, silently treating a + // case-variant field as absent and rejecting a key Go's real decode accepts. + const jwk = generateEcJwk("case-variant-kid"); + const caseVariantJwk = { + KTY: jwk.kty, + ALG: jwk.alg, + KID: jwk.kid, + CRV: jwk.crv, + X: jwk.x, + Y: jwk.y, + D: jwk.d, + }; + const { layer, out } = setup({ pipedAnswer: JSON.stringify(caseVariantJwk) }); + return Effect.gen(function* () { + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "case-variant-kid", + typ: "JWT", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live( + "Branch A: rejects a pasted JWK with a case-variant duplicate kid where the earlier occurrence is malformed (CLI-1961 Codex review finding)", + () => { + // Same mechanism as the exact-case duplicate-kid test above, but the earlier + // malformed occurrence spells the field "KID" while the later, valid one spells + // it "kid" — Go's case-insensitive struct-field matching means both feed the + // SAME `config.JWK.KeyID` field, so the earlier malformed occurrence still fails + // the overall decode, exactly like a same-case duplicate does. Verified against + // the real binary: `{"kty":"oct","alg":"ES256","KID":1,"kid":"k1"}` fails with + // this exact message even though `kid`'s own later, valid occurrence is "k1". + const { layer } = setup({ + pipedAnswer: '{"kty":"oct","alg":"ES256","KID":1,"kid":"k1"}', + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyGenBearerJwt(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "failed to parse JWK: json: cannot unmarshal number into Go struct field JWK.kid of type string", + ); + } + }).pipe(Effect.provide(layer)); + }, + ); + it.live( "Branch A: a null field value is treated as absent, not a type mismatch (Go's encoding/json no-op)", () => { @@ -781,6 +839,43 @@ describe("legacy gen bearer-jwt integration", () => { }, ); + it.live( + "Branch B: accepts a stored signing key with Go-decodable case-variant field names (CLI-1961 Codex review finding)", + () => { + // Same fix as Branch A's pasted-JWK case-variant test, but for a + // `signing_keys_path` file entry — `normalizeStoredJwk`'s field lookups go + // through the SAME case-insensitive `resolveJwkFieldValue` helper, and the + // `alg` allowlist pre-check in `legacyReadSigningKeysFile` needs the identical + // fix (CLI-1961 Codex review finding). + const jwk = generateEcJwk("case-variant-stored-kid"); + const caseVariantJwk = { + KTY: jwk.kty, + ALG: jwk.alg, + KID: jwk.kid, + CRV: jwk.crv, + X: jwk.x, + Y: jwk.y, + D: jwk.d, + }; + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), + ); + yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([caseVariantJwk]))); + + yield* legacyGenBearerJwt(baseFlags); + const token = tokenFrom(out); + const [header] = token.split("."); + expect(decodeSegment(header ?? "")).toEqual({ + alg: "ES256", + kid: "case-variant-stored-kid", + typ: "JWT", + }); + }).pipe(Effect.provide(layer)); + }, + ); + it.live( "Branch B: resolves signing_keys_path = env(KEYS_PATH) from supabase/.env.development, a file @supabase/config's own default env resolution doesn't read (CLI-1961 Codex review finding)", () => { diff --git a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts index 00e1217b52..6ed0042600 100644 --- a/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts @@ -6,6 +6,7 @@ import { readOptionalBoolean, readOptionalString, readOptionalStringArray, + resolveJwkFieldValue, } from "../gen.signing-keys-config.ts"; import { legacyAssertDecodableJwkAlgorithm, @@ -147,7 +148,11 @@ const resolveSigningKeyFromStdinJwk = Effect.fnUntraced(function* () { ); } const record = parsed as Record; - const alg = record["alg"]; + // Case-insensitive lookup (`resolveJwkFieldValue`) — Go's `alg` allowlist check + // (`config.Algorithm.UnmarshalText`) runs at JSON-decode time regardless of the + // key's casing (CLI-1961 Codex review finding); see that function's doc comment + // in `gen.signing-keys-config.ts`. + const alg = resolveJwkFieldValue(record, "alg"); try { legacyAssertDecodableJwkAlgorithm(typeof alg === "string" ? alg : undefined); } catch (cause) { diff --git a/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts index 03d9913f49..36f8ac5ed3 100644 --- a/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts +++ b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts @@ -99,6 +99,40 @@ function isAbsentJwkField(value: unknown): boolean { return value === undefined || value === null; } +/** + * Go's `encoding/json` struct-field matching is case-insensitive — the decoder + * "match[es] incoming object keys to the keys used by Marshal (either the struct field + * name or its tag), preferring an exact match but also accepting a case-insensitive + * match" — and `config.JWK` gets NO field-specific exemption from this: verified + * directly against the real `config.JWK` struct for every field this file reads + * (`kty`, `kid`, `use`, `key_ops`, `alg`, `ext`, `n`, `e`, `d`, `p`, `q`, `dp`, `dq`, + * `qi`, `crv`, `x`, `y`), including `alg` despite its extra `encoding.TextUnmarshaler` + * hook — `{"KTY":"EC","ALG":"ES256"}` decodes identically to the all-lowercase + * spelling. A plain `record[field]` index (JS property access is always exact-case) + * would otherwise treat `"KTY"`/`"ALG"`/etc. as absent instead of decoding them, + * incorrectly rejecting keys Go's real decoder accepts (CLI-1961 Codex review finding). + * + * When MULTIPLE case-variant spellings of the same field are present, Go's decoder + * processes JSON keys strictly in SOURCE order and overwrites the struct field on + * each match, so whichever case-variant key comes LAST in the object wins — verified + * directly: `{"KTY":"EC","kty":"RSA"}` decodes to `kty:"RSA"`, and `{"kty":"EC", + * "KTY":"RSA"}` also decodes to `kty:"RSA"` (the later key, regardless of casing, + * always wins). This only GENERALIZES `JSON.parse`'s own already-relied-on + * last-value-wins behavior for a same-case duplicate key to cross-case duplicates + * too — it never changes the answer for an object with no case-variant duplicates. + */ +export function resolveJwkFieldValue(record: Record, field: string): unknown { + let value: unknown; + let found = false; + for (const key of Object.keys(record)) { + if (key.toLowerCase() === field) { + value = record[key]; + found = true; + } + } + return found ? value : undefined; +} + /** * Reads an optional STRING field, throwing Go's exact struct-field type-mismatch text * (see {@link jwkStructFieldTypeMismatch}) when the field is PRESENT with a non-string @@ -108,13 +142,15 @@ function isAbsentJwkField(value: unknown): boolean { * if the field had simply been omitted. Hoisted here (rather than living only in * `bearer-jwt.signing-key.ts`) because {@link assertNoMalformedDuplicateJwkField} — used * by BOTH `gen signing-key` and `gen bearer-jwt` via {@link legacyReadSigningKeysFile} — - * needs the exact same per-field check (CLI-1961 Codex review finding). + * needs the exact same per-field check (CLI-1961 Codex review finding). Looks the field + * up case-insensitively via {@link resolveJwkFieldValue} to match Go's own + * case-insensitive struct-field matching (CLI-1961 Codex review finding). */ export function readOptionalString( record: Record, field: string, ): string | undefined { - const value = record[field]; + const value = resolveJwkFieldValue(record, field); if (isAbsentJwkField(value)) { return undefined; } @@ -147,7 +183,7 @@ export function readOptionalStringArray( record: Record, field: string, ): ReadonlyArray | undefined { - const value = record[field]; + const value = resolveJwkFieldValue(record, field); if (isAbsentJwkField(value)) { return undefined; } @@ -176,7 +212,7 @@ export function readOptionalBoolean( record: Record, field: string, ): boolean | undefined { - const value = record[field]; + const value = resolveJwkFieldValue(record, field); if (isAbsentJwkField(value)) { return undefined; } @@ -300,10 +336,21 @@ function splitJsonArrayElementTexts(arrayText: string): ReadonlyArray { } /** - * Returns every top-level field of a JSON *object* literal, keyed by field name, with - * ALL occurrences' raw source substrings preserved in true source order — including - * duplicates `JSON.parse` would silently collapse down to just the last one. See - * {@link assertNoMalformedDuplicateJwkField}'s doc comment for why this matters. + * Returns every top-level field of a JSON *object* literal, keyed by the field name + * LOWERCASED, with ALL occurrences' raw source substrings preserved in true source + * order — including duplicates `JSON.parse` would silently collapse down to just the + * last one, AND case-variant "duplicates" of the same `config.JWK` field (e.g. `{"KID": + * 1,"kid":"k"}`) that a same-case-only grouping would otherwise miss entirely: Go's + * `encoding/json` matches struct fields case-insensitively (see + * {@link resolveJwkFieldValue}'s doc comment), so `KID` and `kid` here both feed the + * SAME struct field and must be checked together, in true relative source order, for + * {@link assertNoMalformedDuplicateJwkField} to catch a malformed earlier occurrence + * regardless of which case variant it used (verified against the real binary, + * CLI-1961 Codex review finding: `{"KID":123,"kid":"validkid"}` still errors + * `"...JWK.kid..."` in Go, even though `kid`'s own final, valid occurrence comes + * later). Grouping by lowercase here — rather than post-hoc merging per-key arrays + * after the fact — keeps every occurrence in exactly the order it appeared in the + * source, regardless of which case variant it used. */ function findTopLevelObjectFieldOccurrences( objectText: string, @@ -319,7 +366,7 @@ function findTopLevelObjectFieldOccurrences( while (i < objectText.length && /\s/.test(objectText[i] ?? "")) i++; const keyStart = i; i = skipJsonValue(objectText, i); - const key = JSON.parse(objectText.slice(keyStart, i)) as string; + const key = (JSON.parse(objectText.slice(keyStart, i)) as string).toLowerCase(); while (i < objectText.length && /\s/.test(objectText[i] ?? "")) i++; if (objectText[i] === ":") i++; while (i < objectText.length && /\s/.test(objectText[i] ?? "")) i++; @@ -597,7 +644,10 @@ export const legacyReadSigningKeysFile = Effect.fnUntraced(function* ( const record = item === null ? {} : item; const elementText = elementTexts[index]; try { - const alg = record["alg"]; + // Case-insensitive lookup (`resolveJwkFieldValue`) — Go's `alg` allowlist check + // (`config.Algorithm.UnmarshalText`) runs at JSON-decode time regardless of the + // key's casing (CLI-1961 Codex review finding); see that function's doc comment. + const alg = resolveJwkFieldValue(record, "alg"); legacyAssertDecodableJwkAlgorithm(typeof alg === "string" ? alg : undefined); if (elementText !== undefined) { assertNoMalformedDuplicateJwkField(elementText); diff --git a/apps/cli/src/legacy/shared/legacy-go-duration.ts b/apps/cli/src/legacy/shared/legacy-go-duration.ts index 69603c6d16..88b149d21d 100644 --- a/apps/cli/src/legacy/shared/legacy-go-duration.ts +++ b/apps/cli/src/legacy/shared/legacy-go-duration.ts @@ -150,11 +150,26 @@ export function legacyParseGoDuration(value: string): number { throw new Error(`time: unknown unit in duration "${orig}"`); } - // Go converts the fractional remainder via `uint64(float64(f) * (float64(unit)/scale))` — - // a float64->uint64 conversion, which truncates toward zero, not rounds: `"0.5ns"` becomes - // `0`, not `1`. `BigInt` division truncates toward zero unconditionally, so - // `(frac * unitNs) / post` matches that exactly, without any intermediate float64 rounding. - total += n * unitNs + (frac * unitNs) / post; + // Go converts the fractional remainder via `uint64(float64(f) * (float64(unit)/scale))` + // (`time/format.go`'s `ParseDuration`) — an intermediate float64 MULTIPLICATION, THEN a + // float64->uint64 conversion that truncates toward zero. That intermediate float64 step + // means this is NOT equivalent to an exact BigInt division: once `frac` exceeds float64's + // 53-bit integer precision, rounding `frac` itself up to the nearest representable double + // can push the product past the next integer, so Go's result rounds UP to a full unit where + // an exact-BigInt computation would truncate DOWN — verified against the real `time` + // package (CLI-1961 Codex review finding): `time.ParseDuration("0.999999999999999999s")` + // (18 nines) returns exactly `1_000_000_000` ns, a full second, not the `999_999_999` an + // exact-BigInt truncation produces. Converting `frac`/`unitNs`/`post` to `Number` before + // multiplying reproduces Go's float64 step — and its rounding — bit-for-bit: both Go's + // `float64(uint64)` and JS's `BigInt`->`Number` conversion round to the nearest + // representable double (IEEE 754 round-to-nearest-even), so the same magnitude rounds the + // same way in both languages. `Math.trunc` mirrors the truncating `uint64(...)` conversion + // (both operands here are always non-negative, so truncation and floor coincide). + let term = n * unitNs; + if (frac > 0n) { + term += BigInt(Math.trunc(Number(frac) * (Number(unitNs) / Number(post)))); + } + total += term; if (total > UINT64_ACCUMULATOR_BOUND_NS) { throw new Error(`time: invalid duration "${orig}"`); } diff --git a/apps/cli/src/legacy/shared/legacy-go-duration.unit.test.ts b/apps/cli/src/legacy/shared/legacy-go-duration.unit.test.ts index f613559952..a9069048f9 100644 --- a/apps/cli/src/legacy/shared/legacy-go-duration.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-go-duration.unit.test.ts @@ -87,6 +87,17 @@ describe("legacyParseGoDuration", () => { expect(legacyParseGoDuration("1.9ns")).toBe(1); }); + // Go converts the fractional remainder through an intermediate float64 multiplication + // (`uint64(float64(f) * (float64(unit)/scale))`) BEFORE truncating to `uint64` — not an + // exact-precision division. Once the fraction has enough digits to exceed float64's 53-bit + // integer precision, rounding the fraction itself up can push the float64 product past the + // next integer, so Go's real result rounds UP to a full second here — verified against the + // real `time` package (CLI-1961 Codex review finding): an exact BigInt division would instead + // truncate DOWN to `999_999_999`. + it('rounds a long fractional remainder up like Go\'s float64 conversion ("0.999999999999999999s")', () => { + expect(legacyParseGoDuration("0.999999999999999999s")).toBe(1_000_000_000); + }); + // Go's `time.Duration` is bounded by `math.MaxInt64` nanoseconds // (~292.47 years); `time.ParseDuration` rejects any value whose // accumulated nanosecond count would exceed it.