diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index defaffc3e2..e0dd0ad396 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -304,7 +304,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` | `ported` | [`../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..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 @@ -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,85 @@ ## 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`, 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`. +- `--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..b03466a251 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.ts @@ -0,0 +1,387 @@ +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` + * (`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; + /** + * 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; + /** + * `--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 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; +} + +/** + * 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`). + * - 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`. + * - `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 + * 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 + * 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.nowInstant.wholeSeconds; + exp = legacyAddSecondsAndFloor(input.nowInstant, input.validForSeconds); + } else { + const rawExp = input.expiresAt.value; + exp = rawExp.wholeSeconds; + iat = legacyAddSecondsAndFloor(rawExp, -input.validForSeconds); + } + + const claims: Record = { + role: input.role, + }; + const sub = Option.getOrUndefined(input.sub); + const subIsEmpty = sub === undefined || sub.length === 0; + if (input.role.toLowerCase() === "authenticated" && subIsEmpty) { + claims["is_anonymous"] = true; + } + 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; +} + +/** + * 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. + */ +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; +} + +/** + * 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" }; + +/** + * 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` + * 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"; + } + + 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]}')`; + } + } + return reportGoJsonTrailingGarbage(trimmed, literal.length); + } + + 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`; +} + +/** + * 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 — 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, + 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`, + ); + } + // 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) }; +} + +/** + * 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..9b207b60ce --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts @@ -0,0 +1,412 @@ +import { Option } from "effect"; +import { describe, expect, it } from "vitest"; +import { + legacyBuildBearerJwtClaims, + legacyEncodeBearerJwtClaims, + legacyMergeBearerJwtPayload, +} 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", () => { + const claims = legacyBuildBearerJwtClaims({ + role: "", + sub: Option.none(), + expiresAt: Option.none(), + validForSeconds: 1800, + nowInstant: NOW_INSTANT, + }); + 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, + nowInstant: NOW_INSTANT, + }); + 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({ wholeSeconds: 2_000_000_000, nanos: 0 }), + validForSeconds: 1800, + nowInstant: NOW_INSTANT, + }); + expect(claims["exp"]).toBe(2_000_000_000); + 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({ wholeSeconds: 1_893_456_000, nanos: 0 }), + validForSeconds: 1.5, + nowInstant: NOW_INSTANT, + }); + expect(claims["exp"]).toBe(1_893_456_000); + 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({ wholeSeconds: 1_893_456_000, nanos: 900_000_000 }), + validForSeconds: 1.2, + 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. + const claims = legacyBuildBearerJwtClaims({ + role: "authenticated", + sub: Option.some(""), + expiresAt: Option.none(), + validForSeconds: 1800, + nowInstant: NOW_INSTANT, + }); + 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", + sub: Option.none(), + expiresAt: Option.none(), + validForSeconds: 1800, + nowInstant: NOW_INSTANT, + }); + 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, + nowInstant: NOW_INSTANT, + }); + 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, + nowInstant: NOW_INSTANT, + }); + 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, + nowInstant: NOW_INSTANT, + }); + 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 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", + ); + }); + + 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("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 '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) — `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", + ); + }); +}); + +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"}'); + }); + + 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.command.ts b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts index f41e3d6725..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 @@ -1,28 +1,77 @@ +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 = { + // 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. 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..d2522fa035 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.errors.ts @@ -0,0 +1,103 @@ +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); +} + +/** + * 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", +)<{ + 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 `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"`. + */ +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..cdbd1459db --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.ts @@ -0,0 +1,259 @@ +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}))$/; + +/** + * 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]; + +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 + ); +} + +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). 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, 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. + * - 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. 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 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): LegacyBearerJwtInstant { + const trimmedValue = value.trim(); + const match = RFC3339_PATTERN.exec(trimmedValue); + if (match === null) { + throw rfc3339FlagError(trimmedValue); + } + const [ + , + year, + month, + day, + hour, + minute, + second, + fraction, + isUtc, + offsetSign, + offsetHourStr, + offsetMinuteStr, + ] = match; + if ( + !isValidRfc3339Calendar( + Number(year), + Number(month), + Number(day), + Number(hour), + Number(minute), + Number(second), + ) + ) { + throw rfc3339FlagError(trimmedValue); + } + + 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; + } + + // `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 }; +} + +/** + * 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. + * `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 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..6a803a251b --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from "vitest"; +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")).toEqual({ + wholeSeconds: 1_577_836_800, + nanos: 0, + }); + }); + + 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).toEqual({ wholeSeconds: atZ.wholeSeconds - 5 * 60 * 60, nanos: 0 }); + }); + + 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:', + ); + }); + + 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")).toEqual({ + wholeSeconds: 951782400, + nanos: 0, + }); + }); + + 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 ")).toEqual( + 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`', + ); + }); + + 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")).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", () => { + 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', + ); + }); + + 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 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).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, + }); + }); +}); + +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("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 "', + ); + }); + + 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", () => { + 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 24dd26251a..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 @@ -1,16 +1,106 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +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"; +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, + LegacyGenBearerJwtRoleRequiredError, + 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: + * + * 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 + * 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* () { + if (Option.isNone(flags.role)) { + return yield* Effect.fail( + new LegacyGenBearerJwtRoleRequiredError({ + message: `required flag(s) "role" not set`, + }), + ); + } + const role = flags.role.value; + + // 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, + nowInstant, + }); + 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..4386d80b45 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts @@ -0,0 +1,1457 @@ +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): Record { + 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; + /** Overrides `cliConfig.workdir` — defaults to `tempRoot.current`. */ + readonly workdir?: string; +} + +function setup(options: SetupOptions = {}) { + const out = mockOutput({ + format: "text", + interactive: options.stdinIsTty ?? false, + promptSelectResponses: options.promptSelectResponses, + }); + const api = mockLegacyPlatformApi(); + const cliConfig = mockLegacyCliConfig({ + workdir: options.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); +} + +/** + * 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(), + 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: Option.some("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: Option.some("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({ 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); + expect(claims["iat"]).toBe(2_000_000_000 - 1800); + }).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({ wholeSeconds: 1_893_456_000, nanos: 0 }), + 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* () { + yield* legacyGenBearerJwt({ + ...baseFlags, + role: Option.some("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)); + }); + + // 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", + () => { + 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 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", + () => { + // 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); + 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 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 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: 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: 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)", + () => { + 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: 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", + () => { + 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: 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)", + () => { + // 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)", + () => { + 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: 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 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 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", + () => { + // 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)", + () => { + 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: Option.some("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" }); + // 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)); + }, + ); + + 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.stderrText).toContain("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..6ed0042600 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts @@ -0,0 +1,373 @@ +import { Effect, Option } from "effect"; +import { + assertNoMalformedDuplicateJwkField, + legacyReadSigningKeysFile, + legacyResolveSigningKeysConfigPaths, + readOptionalBoolean, + readOptionalString, + readOptionalStringArray, + resolveJwkFieldValue, +} from "../gen.signing-keys-config.ts"; +import { + legacyAssertDecodableJwkAlgorithm, + LEGACY_DEFAULT_SIGNING_KEY, + 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"; +import { + legacyBearerJwtErrorMessage, + LegacyGenBearerJwtConfigParseError, + LegacyGenBearerJwtDecodeError, + LegacyGenBearerJwtKeyNotFoundError, + LegacyGenBearerJwtKeyParseError, + LegacyGenBearerJwtKeyPickerAbortedError, + 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; +}); + +/** + * 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. + * + * 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. `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"); + return { + kty: readOptionalString(record, "kty") ?? "", + kid: readOptionalString(record, "kid"), + use: readOptionalString(record, "use"), + key_ops: keyOps !== undefined ? [...keyOps] : undefined, + alg: readOptionalString(record, "alg"), + ext: readOptionalBoolean(record, "ext"), + 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 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 normalizeStoredJwk({}); + } + 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`, + }), + ); + } + const record = parsed as Record; + // 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) { + return yield* Effect.fail( + new LegacyGenBearerJwtKeyParseError({ + message: `failed to parse JWK: ${legacyBearerJwtErrorMessage(cause)}`, + }), + ); + } + // `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). + // + // `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: () => { + assertNoMalformedDuplicateJwkField(input); + return normalizeStoredJwk(record); + }, + catch: (cause) => + new LegacyGenBearerJwtKeyParseError({ + message: `failed to parse JWK: ${legacyBearerJwtErrorMessage(cause)}`, + }), + }); +}); + +/** + * 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: "). + * + * 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). + * + * 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, +) { + 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}` }), + ); + } + + 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), + label: key.kid ?? "", + hint: `${key.alg ?? ""} (${(key.key_ops ?? []).join(",")})`, + })); + // 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, + ), + ), + ); +}); + +/** + * 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 + * ({@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( + workdir, + (message) => new LegacyGenBearerJwtConfigParseError({ message }), + ); + + if (Option.isNone(paths.signingKeysPath)) { + return yield* resolveSigningKeyFromStdinJwk(); + } + + 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). 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) => + 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); +}); 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..36f8ac5ed3 --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts @@ -0,0 +1,665 @@ +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 + * 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 LegacyStoredSigningKeyJwk = 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` + * (`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). */ + readonly signingKeysPath: Option.Option<{ + readonly actualPath: string; + readonly displayPath: string; + }>; +} + +/** + * `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 && !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; +} + +/** + * 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 + * 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). 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 = resolveJwkFieldValue(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, + * 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, + field: string, +): ReadonlyArray | undefined { + const value = resolveJwkFieldValue(record, field); + if (isAbsentJwkField(value)) { + return undefined; + } + if (!Array.isArray(value)) { + throw new Error(jwkStructFieldTypeMismatch(field, value, "[]string")); + } + return value.map((entry) => { + if (entry === null) { + return ""; + } + if (typeof entry !== "string") { + throw new Error(jwkStructFieldTypeMismatch(field, entry, "string")); + } + return entry; + }); +} + +/** + * 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 = resolveJwkFieldValue(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 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, +): 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).toLowerCase(); + 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 + * {@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; + // 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 + // `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)}`)), + ), + ); + 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). + * + * 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`, 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, + 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({ + // 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)) { + return yield* Effect.fail( + 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 (item !== null && !isRecord(item)) { + return yield* Effect.fail( + onDecodeError("failed to decode signing keys: expected a JSON array of objects"), + ); + } + } + const elementTexts = splitJsonArrayElementTexts(raw); + const normalized: Array> = []; + for (const [index, item] of ( + decoded as ReadonlyArray | null> + ).entries()) { + const record = item === null ? {} : item; + const elementText = elementTexts[index]; + try { + // 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); + } + } catch (cause) { + return yield* Effect.fail( + onDecodeError( + `failed to decode signing keys: failed to parse response body: ${cause instanceof Error ? cause.message : String(cause)}`, + ), + ); + } + normalized.push(record); + } + return normalized 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..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 @@ -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"; @@ -8,12 +7,18 @@ 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"; 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 LegacyStoredSigningKeyJwk, +} from "../gen.signing-keys-config.ts"; import type { LegacyGenSigningKeyFlags } from "./signing-key.command.ts"; import { LegacyGenSigningKeyCancelledError, @@ -46,14 +51,12 @@ interface SigningKeyJwk { readonly qi?: string; } -type StoredSigningKeyJwk = Readonly>; - interface ResolvedSigningKeysConfig { readonly configDisplayPath: string; readonly configured: Option.Option<{ actualPath: string; displayPath: string; - existingKeys: ReadonlyArray; + existingKeys: ReadonlyArray; }>; } @@ -75,28 +78,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 +143,42 @@ const generatePrivateKey = Effect.fnUntraced(function* (algorithm: SigningAlgori } satisfies SigningKeyJwk; }); +// `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 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 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); + const { actualPath, displayPath } = paths.signingKeysPath.value; + const existingKeys = paths.authEnabled + ? yield* legacyReadSigningKeysFile( + actualPath, + (message) => new LegacyGenSigningKeyReadError({ message }), + (message) => new LegacyGenSigningKeyDecodeError({ message }), + ) + : [{ ...LEGACY_DEFAULT_SIGNING_KEY }]; 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/commands/gen/signing-key/signing-key.integration.test.ts b/apps/cli/src/legacy/commands/gen/signing-key/signing-key.integration.test.ts index 59e346fb75..11c13765b7 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-"); @@ -201,18 +202,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", @@ -339,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 af9485452b..4b5574e108 100644 --- a/apps/cli/src/legacy/shared/legacy-go-duration.ts +++ b/apps/cli/src/legacy/shared/legacy-go-duration.ts @@ -36,14 +36,26 @@ 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, - * 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 @@ -116,7 +128,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")) { @@ -135,15 +152,36 @@ 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; - if (total > MAX_INT64_NS) { + // 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}"`); } } + // 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 551f5518ad..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 @@ -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"'); }); @@ -79,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. @@ -103,6 +122,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 4887fa9d3a..e9e169960a 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 @@ -78,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"; } @@ -93,7 +112,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( @@ -119,3 +149,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..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 @@ -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", () => { @@ -70,4 +75,40 @@ describe("encodeGoJsonCompact", () => { expect(encodeGoJsonCompact([])).toBe("[]"); 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 + // 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", () => { + 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 96d68e640a..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`, @@ -159,61 +160,176 @@ 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 + * 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'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 `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. * - * 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 + * 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}), + * 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. + * + * 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`. + * + * 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 legacyGenerateAsymmetricGoJwt( - jwk: LegacyJwk, - role: "anon" | "service_role", -): string { +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)}`, + ); + } + 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 headerEncoded = base64UrlEncode(encodeGoJsonCompact(header)); + const payloadEncoded = base64UrlEncode(payloadJson); const data = `${headerEncoded}.${payloadEncoded}`; const privateKey = createPrivateKey({ @@ -230,3 +346,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..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 @@ -3,8 +3,10 @@ import { importJWK, jwtVerify } from "jose"; import { describe, expect, it } from "vitest"; import { + legacyAssertDecodableJwkAlgorithm, legacyGenerateAsymmetricGoJwt, legacyGenerateGoJwt, + legacySignJwtWithJwk, type LegacyJwk, } from "./legacy-go-jwt.ts"; @@ -153,27 +155,125 @@ 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", () => { const jwk = generateEcJwk(); const { crv: _crv, ...jwkWithoutCurve } = jwk; expect(() => legacyGenerateAsymmetricGoJwt(jwkWithoutCurve, "anon")).toThrow( - "unsupported curve: ", + "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", () => { + 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" }); + }); + + 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", () => { + 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]", ); }); }); 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..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`): @@ -46,13 +47,32 @@ 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 = {}; - for (const key of Object.keys(value as Record).sort()) { + // 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(); + // `.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. 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..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 @@ -101,6 +101,36 @@ describe("encodeGoJson", () => { `, ); }); + + 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" +} +`, + ); + }); + + 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; 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"; } /**