authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:19:39-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:19:39-07:00
logf98448e8721a491f5945f1857cbd22fa87a955ef
treea41de45db273b738324dc13414096f5ba3706b61
parentbfe61741b83dd7f2f66af7e7570ae0f67a16d631
signature Signed by PGP key B5690EEEBB952194

[codex] Structure relay JWT failures (#3270)

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 95 insertions(+), 19 deletions(-)

infra/relay/src/auth/RelayTokens.ts+2-13
......@@ -11,9 +11,9 @@ import {
1111import { encodeOAuthScope, parseAllowedOAuthScope } from "@t3tools/shared/oauthScope";
1212import {
1313 normalizeRelayIssuer,
14 RelayJwtError,
1415 signRelayJwt,
1516 verifyRelayJwt,
16 type RelayJwtError,
1717} from "@t3tools/shared/relayJwt";
1818import * as Context from "effect/Context";
1919import * as Effect from "effect/Effect";
......@@ -72,17 +72,6 @@ const allowedScopesByClientId: Record<
7272 [RelayWebClientId]: new Set([RelayEnvironmentConnectScope, RelayEnvironmentStatusScope]),
7373};
7474
75function relayJwtVerificationFailureReason(error: RelayJwtError): string {
76 const cause = error.cause;
77 if (typeof cause === "object" && cause !== null && "code" in cause) {
78 const code = (cause as { readonly code?: unknown }).code;
79 if (typeof code === "string" && code.length > 0) {
80 return code;
81 }
82 }
83 return cause instanceof Error && cause.name ? cause.name : "unknown";
84}
85
8675function resolveDpopAccessTokenScopes(input: {
8776 readonly clientId: RelayPublicClientId;
8877 readonly scope: string;
......@@ -211,7 +200,7 @@ const make = Effect.gen(function* () {
211200 Effect.tapError((error) =>
212201 Effect.annotateCurrentSpan(
213202 "relay.tokens.verification_failure",
214 relayJwtVerificationFailureReason(error),
203 RelayJwtError.diagnosticCode(error),
215204 ),
216205 ),
217206 Effect.flatMap(decodeDpopAccessTokenClaims),
packages/shared/src/relayJwt.test.ts created+58
......@@ -0,0 +1,58 @@
1import * as NodeCrypto from "node:crypto";
2
3import { describe, expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5
6import { RelayJwtError, signRelayJwt, verifyRelayJwt } from "./relayJwt.ts";
7
8describe("relayJwt", () => {
9 it.effect("preserves signing context and the JOSE cause", () =>
10 Effect.gen(function* () {
11 const error = yield* signRelayJwt({
12 privateKey: "not-a-private-key",
13 typ: "test-sign+jwt",
14 payload: { sub: "subject" },
15 }).pipe(Effect.flip);
16
17 expect(error.operation).toBe("sign");
18 expect(error.typ).toBe("test-sign+jwt");
19 expect(error.cause).toBeInstanceOf(Error);
20 expect(error.message).toBe('Failed to sign relay JWT of type "test-sign+jwt".');
21 }),
22 );
23
24 it.effect("preserves verification request context and the JOSE cause", () =>
25 Effect.gen(function* () {
26 const keyPair = NodeCrypto.generateKeyPairSync("ed25519", {
27 publicKeyEncoding: { format: "pem", type: "spki" },
28 privateKeyEncoding: { format: "pem", type: "pkcs8" },
29 });
30 const error = yield* verifyRelayJwt({
31 publicKey: keyPair.publicKey,
32 token: "not-a-jwt",
33 typ: "test-verify+jwt",
34 issuer: "https://issuer.example.test",
35 audience: "test-audience",
36 nowEpochSeconds: 100,
37 }).pipe(Effect.flip);
38
39 expect(error.operation).toBe("verify");
40 expect(error.typ).toBe("test-verify+jwt");
41 expect(error.issuer).toBe("https://issuer.example.test");
42 expect(error.audience).toBe("test-audience");
43 expect(error.cause).toBeInstanceOf(Error);
44 expect(error.message).toBe('Failed to verify relay JWT of type "test-verify+jwt".');
45 }),
46 );
47
48 it("extracts stable diagnostic codes without copying cause text into the error message", () => {
49 const error = new RelayJwtError({
50 operation: "verify",
51 typ: "test+jwt",
52 cause: { code: "ERR_JWT_EXPIRED", message: "sensitive library detail" },
53 });
54
55 expect(RelayJwtError.diagnosticCode(error)).toBe("ERR_JWT_EXPIRED");
56 expect(error.message).not.toContain("sensitive library detail");
57 });
58});
packages/shared/src/relayJwt.ts+35-6
......@@ -1,7 +1,8 @@
11import { decodeJwt, importPKCS8, importSPKI, jwtVerify, SignJWT, type JWTPayload } from "jose";
2import * as Data from "effect/Data";
32import * as DateTime from "effect/DateTime";
43import * as Effect from "effect/Effect";
4import * as Predicate from "effect/Predicate";
5import * as Schema from "effect/Schema";
56
67export const RELAY_LINK_PROOF_TYP = "t3-env-link+jwt";
78export const RELAY_MINT_REQUEST_TYP = "t3-cloud-mint+jwt";
......@@ -10,9 +11,30 @@ export const RELAY_MINT_RESPONSE_TYP = "t3-env-mint+jwt";
1011export const RELAY_HEALTH_RESPONSE_TYP = "t3-env-health+jwt";
1112export const RELAY_ACTIVITY_PUBLISH_TYP = "t3-env-activity+jwt";
1213
13export class RelayJwtError extends Data.TaggedError("RelayJwtError")<{
14 readonly cause: unknown;
15}> {}
14export class RelayJwtError extends Schema.TaggedErrorClass<RelayJwtError>()("RelayJwtError", {
15 operation: Schema.Literals(["sign", "verify"]),
16 typ: Schema.String,
17 issuer: Schema.optional(Schema.String),
18 audience: Schema.optional(Schema.String),
19 cause: Schema.Defect(),
20}) {
21 override get message(): string {
22 return `Failed to ${this.operation} relay JWT of type "${this.typ}".`;
23 }
24
25 static diagnosticCode(error: RelayJwtError): string {
26 if (
27 Predicate.isObject(error.cause) &&
28 Predicate.hasProperty(error.cause, "code") &&
29 Predicate.isString(error.cause.code) &&
30 error.cause.code.length > 0
31 ) {
32 return error.cause.code;
33 }
34
35 return error.cause instanceof Error && error.cause.name ? error.cause.name : "unknown";
36 }
37}
1638
1739export function normalizeRelayIssuer(value: string): string {
1840 return value.trim().replace(/\/+$/gu, "");
......@@ -38,7 +60,7 @@ export function signRelayJwt(input: {
3860 .setProtectedHeader({ alg: "EdDSA", typ: input.typ })
3961 .sign(key);
4062 },
41 catch: (cause) => new RelayJwtError({ cause }),
63 catch: (cause) => new RelayJwtError({ operation: "sign", typ: input.typ, cause }),
4264 });
4365}
4466
......@@ -65,6 +87,13 @@ export function verifyRelayJwt(input: {
6587 });
6688 return verified.payload;
6789 },
68 catch: (cause) => new RelayJwtError({ cause }),
90 catch: (cause) =>
91 new RelayJwtError({
92 operation: "verify",
93 typ: input.typ,
94 issuer: input.issuer,
95 audience: input.audience,
96 cause,
97 }),
6998 });
7099}