From f98448e8721a491f5945f1857cbd22fa87a955ef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:19:39 -0700 Subject: [PATCH] [codex] Structure relay JWT failures (#3270) Co-authored-by: codex --- infra/relay/src/auth/RelayTokens.ts | 15 +------ packages/shared/src/relayJwt.test.ts | 58 ++++++++++++++++++++++++++++ packages/shared/src/relayJwt.ts | 41 +++++++++++++++++--- 3 files changed, 95 insertions(+), 19 deletions(-) create mode 100644 packages/shared/src/relayJwt.test.ts diff --git a/infra/relay/src/auth/RelayTokens.ts b/infra/relay/src/auth/RelayTokens.ts index 6c726ffa82695011f69c89a3d5b6ea11f2009aba..bf48980907aa27ea408177996533612a81a65afe 100644 --- a/infra/relay/src/auth/RelayTokens.ts +++ b/infra/relay/src/auth/RelayTokens.ts @@ -11,9 +11,9 @@ import { import { encodeOAuthScope, parseAllowedOAuthScope } from "@t3tools/shared/oauthScope"; import { normalizeRelayIssuer, + RelayJwtError, signRelayJwt, verifyRelayJwt, - type RelayJwtError, } from "@t3tools/shared/relayJwt"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -72,17 +72,6 @@ const allowedScopesByClientId: Record< [RelayWebClientId]: new Set([RelayEnvironmentConnectScope, RelayEnvironmentStatusScope]), }; -function relayJwtVerificationFailureReason(error: RelayJwtError): string { - const cause = error.cause; - if (typeof cause === "object" && cause !== null && "code" in cause) { - const code = (cause as { readonly code?: unknown }).code; - if (typeof code === "string" && code.length > 0) { - return code; - } - } - return cause instanceof Error && cause.name ? cause.name : "unknown"; -} - function resolveDpopAccessTokenScopes(input: { readonly clientId: RelayPublicClientId; readonly scope: string; @@ -211,7 +200,7 @@ const make = Effect.gen(function* () { Effect.tapError((error) => Effect.annotateCurrentSpan( "relay.tokens.verification_failure", - relayJwtVerificationFailureReason(error), + RelayJwtError.diagnosticCode(error), ), ), Effect.flatMap(decodeDpopAccessTokenClaims), diff --git a/packages/shared/src/relayJwt.test.ts b/packages/shared/src/relayJwt.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e863af484e2866b3252952dfbfb350b898e1be5 --- /dev/null +++ b/packages/shared/src/relayJwt.test.ts @@ -0,0 +1,58 @@ +import * as NodeCrypto from "node:crypto"; + +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { RelayJwtError, signRelayJwt, verifyRelayJwt } from "./relayJwt.ts"; + +describe("relayJwt", () => { + it.effect("preserves signing context and the JOSE cause", () => + Effect.gen(function* () { + const error = yield* signRelayJwt({ + privateKey: "not-a-private-key", + typ: "test-sign+jwt", + payload: { sub: "subject" }, + }).pipe(Effect.flip); + + expect(error.operation).toBe("sign"); + expect(error.typ).toBe("test-sign+jwt"); + expect(error.cause).toBeInstanceOf(Error); + expect(error.message).toBe('Failed to sign relay JWT of type "test-sign+jwt".'); + }), + ); + + it.effect("preserves verification request context and the JOSE cause", () => + Effect.gen(function* () { + const keyPair = NodeCrypto.generateKeyPairSync("ed25519", { + publicKeyEncoding: { format: "pem", type: "spki" }, + privateKeyEncoding: { format: "pem", type: "pkcs8" }, + }); + const error = yield* verifyRelayJwt({ + publicKey: keyPair.publicKey, + token: "not-a-jwt", + typ: "test-verify+jwt", + issuer: "https://issuer.example.test", + audience: "test-audience", + nowEpochSeconds: 100, + }).pipe(Effect.flip); + + expect(error.operation).toBe("verify"); + expect(error.typ).toBe("test-verify+jwt"); + expect(error.issuer).toBe("https://issuer.example.test"); + expect(error.audience).toBe("test-audience"); + expect(error.cause).toBeInstanceOf(Error); + expect(error.message).toBe('Failed to verify relay JWT of type "test-verify+jwt".'); + }), + ); + + it("extracts stable diagnostic codes without copying cause text into the error message", () => { + const error = new RelayJwtError({ + operation: "verify", + typ: "test+jwt", + cause: { code: "ERR_JWT_EXPIRED", message: "sensitive library detail" }, + }); + + expect(RelayJwtError.diagnosticCode(error)).toBe("ERR_JWT_EXPIRED"); + expect(error.message).not.toContain("sensitive library detail"); + }); +}); diff --git a/packages/shared/src/relayJwt.ts b/packages/shared/src/relayJwt.ts index 20d55a530e3f8ee55325d27e027291d42757928e..9e848bedfb027ba6a39065d791777967c51e269e 100644 --- a/packages/shared/src/relayJwt.ts +++ b/packages/shared/src/relayJwt.ts @@ -1,7 +1,8 @@ import { decodeJwt, importPKCS8, importSPKI, jwtVerify, SignJWT, type JWTPayload } from "jose"; -import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; export const RELAY_LINK_PROOF_TYP = "t3-env-link+jwt"; export const RELAY_MINT_REQUEST_TYP = "t3-cloud-mint+jwt"; @@ -10,9 +11,30 @@ export const RELAY_MINT_RESPONSE_TYP = "t3-env-mint+jwt"; export const RELAY_HEALTH_RESPONSE_TYP = "t3-env-health+jwt"; export const RELAY_ACTIVITY_PUBLISH_TYP = "t3-env-activity+jwt"; -export class RelayJwtError extends Data.TaggedError("RelayJwtError")<{ - readonly cause: unknown; -}> {} +export class RelayJwtError extends Schema.TaggedErrorClass()("RelayJwtError", { + operation: Schema.Literals(["sign", "verify"]), + typ: Schema.String, + issuer: Schema.optional(Schema.String), + audience: Schema.optional(Schema.String), + cause: Schema.Defect(), +}) { + override get message(): string { + return `Failed to ${this.operation} relay JWT of type "${this.typ}".`; + } + + static diagnosticCode(error: RelayJwtError): string { + if ( + Predicate.isObject(error.cause) && + Predicate.hasProperty(error.cause, "code") && + Predicate.isString(error.cause.code) && + error.cause.code.length > 0 + ) { + return error.cause.code; + } + + return error.cause instanceof Error && error.cause.name ? error.cause.name : "unknown"; + } +} export function normalizeRelayIssuer(value: string): string { return value.trim().replace(/\/+$/gu, ""); @@ -38,7 +60,7 @@ export function signRelayJwt(input: { .setProtectedHeader({ alg: "EdDSA", typ: input.typ }) .sign(key); }, - catch: (cause) => new RelayJwtError({ cause }), + catch: (cause) => new RelayJwtError({ operation: "sign", typ: input.typ, cause }), }); } @@ -65,6 +87,13 @@ export function verifyRelayJwt(input: { }); return verified.payload; }, - catch: (cause) => new RelayJwtError({ cause }), + catch: (cause) => + new RelayJwtError({ + operation: "verify", + typ: input.typ, + issuer: input.issuer, + audience: input.audience, + cause, + }), }); } -- 2.54.0