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 {...@@ -11,9 +11,9 @@ import {
11import { encodeOAuthScope, parseAllowedOAuthScope } from "@t3tools/shared/oauthScope";11import { encodeOAuthScope, parseAllowedOAuthScope } from "@t3tools/shared/oauthScope";
12import {12import {
13 normalizeRelayIssuer,13 normalizeRelayIssuer,
14 RelayJwtError,
14 signRelayJwt,15 signRelayJwt,
15 verifyRelayJwt,16 verifyRelayJwt,
16 type RelayJwtError,
17} from "@t3tools/shared/relayJwt";17} from "@t3tools/shared/relayJwt";
18import * as Context from "effect/Context";18import * as Context from "effect/Context";
19import * as Effect from "effect/Effect";19import * as Effect from "effect/Effect";
...@@ -72,17 +72,6 @@ const allowedScopesByClientId: Record<...@@ -72,17 +72,6 @@ const allowedScopesByClientId: Record<
72 [RelayWebClientId]: new Set([RelayEnvironmentConnectScope, RelayEnvironmentStatusScope]),72 [RelayWebClientId]: new Set([RelayEnvironmentConnectScope, RelayEnvironmentStatusScope]),
73};73};
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
86function resolveDpopAccessTokenScopes(input: {75function resolveDpopAccessTokenScopes(input: {
87 readonly clientId: RelayPublicClientId;76 readonly clientId: RelayPublicClientId;
88 readonly scope: string;77 readonly scope: string;
...@@ -211,7 +200,7 @@ const make = Effect.gen(function* () {...@@ -211,7 +200,7 @@ const make = Effect.gen(function* () {
211 Effect.tapError((error) =>200 Effect.tapError((error) =>
212 Effect.annotateCurrentSpan(201 Effect.annotateCurrentSpan(
213 "relay.tokens.verification_failure",202 "relay.tokens.verification_failure",
214 relayJwtVerificationFailureReason(error),203 RelayJwtError.diagnosticCode(error),
215 ),204 ),
216 ),205 ),
217 Effect.flatMap(decodeDpopAccessTokenClaims),206 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 @@...@@ -1,7 +1,8 @@
1import { decodeJwt, importPKCS8, importSPKI, jwtVerify, SignJWT, type JWTPayload } from "jose";1import { decodeJwt, importPKCS8, importSPKI, jwtVerify, SignJWT, type JWTPayload } from "jose";
2import * as Data from "effect/Data";
3import * as DateTime from "effect/DateTime";2import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Predicate from "effect/Predicate";
5import * as Schema from "effect/Schema";
56
6export const RELAY_LINK_PROOF_TYP = "t3-env-link+jwt";7export const RELAY_LINK_PROOF_TYP = "t3-env-link+jwt";
7export const RELAY_MINT_REQUEST_TYP = "t3-cloud-mint+jwt";8export const RELAY_MINT_REQUEST_TYP = "t3-cloud-mint+jwt";
...@@ -10,9 +11,30 @@ export const RELAY_MINT_RESPONSE_TYP = "t3-env-mint+jwt";...@@ -10,9 +11,30 @@ export const RELAY_MINT_RESPONSE_TYP = "t3-env-mint+jwt";
10export const RELAY_HEALTH_RESPONSE_TYP = "t3-env-health+jwt";11export const RELAY_HEALTH_RESPONSE_TYP = "t3-env-health+jwt";
11export const RELAY_ACTIVITY_PUBLISH_TYP = "t3-env-activity+jwt";12export const RELAY_ACTIVITY_PUBLISH_TYP = "t3-env-activity+jwt";
1213
13export class RelayJwtError extends Data.TaggedError("RelayJwtError")<{14export class RelayJwtError extends Schema.TaggedErrorClass<RelayJwtError>()("RelayJwtError", {
14 readonly cause: unknown;15 operation: Schema.Literals(["sign", "verify"]),
15}> {}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
17export function normalizeRelayIssuer(value: string): string {39export function normalizeRelayIssuer(value: string): string {
18 return value.trim().replace(/\/+$/gu, "");40 return value.trim().replace(/\/+$/gu, "");
...@@ -38,7 +60,7 @@ export function signRelayJwt(input: {...@@ -38,7 +60,7 @@ export function signRelayJwt(input: {
38 .setProtectedHeader({ alg: "EdDSA", typ: input.typ })60 .setProtectedHeader({ alg: "EdDSA", typ: input.typ })
39 .sign(key);61 .sign(key);
40 },62 },
41 catch: (cause) => new RelayJwtError({ cause }),63 catch: (cause) => new RelayJwtError({ operation: "sign", typ: input.typ, cause }),
42 });64 });
43}65}
4466
...@@ -65,6 +87,13 @@ export function verifyRelayJwt(input: {...@@ -65,6 +87,13 @@ export function verifyRelayJwt(input: {
65 });87 });
66 return verified.payload;88 return verified.payload;
67 },89 },
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 }),
69 });98 });
70}99}