Skip to content
Source
Sign inSign up

relayJwt.ts

132 lines5.0 KB

1import type * as Signature from "effect/crypto/Signature";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as JoseError from "effect/jose/JoseError";
5import * as Jwk from "effect/jose/Jwk";
6import * as Jws from "effect/jose/Jws";
7import * as Jwt from "effect/jose/Jwt";
8import * as Redacted from "effect/Redacted";
9import * as Schema from "effect/Schema";
10​
11export const RELAY_LINK_PROOF_TYP = "t3-env-link+jwt";
12export const RELAY_MINT_REQUEST_TYP = "t3-cloud-mint+jwt";
13export const RELAY_HEALTH_REQUEST_TYP = "t3-cloud-health+jwt";
14export const RELAY_MINT_RESPONSE_TYP = "t3-env-mint+jwt";
15export const RELAY_HEALTH_RESPONSE_TYP = "t3-env-health+jwt";
16export const RELAY_ACTIVITY_PUBLISH_TYP = "t3-env-activity+jwt";
17export const RELAY_MANAGED_TUNNEL_RECOVERY_TYP = "t3-env-managed-tunnel-recovery+jwt";
18export const RELAY_HOOK_DELIVERY_TYP = "t3-relay-hook-delivery+jwt";
19export const RELAY_HOOK_DELIVERY_HEADER = "x-t3-relay-delivery";
20​
21export class RelayJwtError extends Schema.TaggedError<RelayJwtError>()("RelayJwtError", {
22 operation: Schema.Literals(["sign", "verify", "decode"]),
23 typ: Schema.optional(Schema.String),
24 issuer: Schema.optional(Schema.String),
25 audience: Schema.optional(Schema.String),
26 cause: Schema.Defect(),
27}) {
28 override get message(): string {
29 return this.typ === undefined
30 ? `Failed to ${this.operation} relay JWT.`
31 : `Failed to ${this.operation} relay JWT of type "${this.typ}".`;
32 }
33​
34 static diagnosticCode(error: RelayJwtError): string {
35 if (JoseError.isJoseError(error.cause)) {
36 const reason = error.cause.reason;
37 return reason._tag === "ClaimValidationFailed"
38 ? `${reason._tag}.${reason.claim}.${reason.reason}`
39 : reason._tag;
40 }
41 return Schema.isSchemaError(error.cause) ? "SchemaError" : "unknown";
42 }
44​
45export type RelayJwtClaims = Readonly<Record<string, Schema.Json>>;
46​
47export function normalizeRelayIssuer(value: string): string {
48 return value.trim().replace(/\/+$/gu, "");
50​
51/** Reads claims without verifying them. Never authorize on the result. */
52export const decodeRelayJwt = Effect.fn("relayJwt.decode")(function* (
53 token: string,
54): Effect.fn.Return<RelayJwtClaims, RelayJwtError> {
55 const unverified = yield* Jwt.decodeUnverified(token).pipe(
56 Effect.mapError((cause) => new RelayJwtError({ operation: "decode", cause })),
57 );
58 return unverified.claims;
59});
60​
61function normalizePem(value: string): string {
62 return value.replace(/\\n/gu, "\n").trim();
64​
65/** Parses a PKCS#8 PEM relay signing key once, for callers that sign repeatedly. */
66export const importRelayPrivateKey = (pem: string) =>
67 Jwk.importPkcs8(Redacted.make(normalizePem(pem)), "EdDSA");
68​
69/** Parses an SPKI PEM relay verification key once, for callers that verify repeatedly. */
70export const importRelayPublicKey = (pem: string) => Jwk.importSpki(normalizePem(pem), "EdDSA");
71​
72// A parsed key carries its own algorithm; relay JWTs are EdDSA only.
73const requireEdDsa = <K extends Jwk.PrivateKey | Jwk.PublicKey>(key: K) =>
74 key.algorithm === "EdDSA"
75 ? Effect.succeed(key)
76 : Effect.fail(new JoseError.JoseError({ reason: new JoseError.AlgorithmNotAllowed() }));
77​
78export const signRelayJwt = Effect.fn("relayJwt.sign")(function* (input: {
79 readonly privateKey: string | Jwk.PrivateKey;
80 readonly typ: string;
81 readonly payload: Readonly<Record<string, unknown>>;
82}): Effect.fn.Return<string, RelayJwtError, Signature.Signature> {
83 const privateKey = input.privateKey;
84 const token = yield* (
85 typeof privateKey === "string" ? importRelayPrivateKey(privateKey) : requireEdDsa(privateKey)
86 ).pipe(
87 Effect.flatMap((key) =>
88 Jws.sign(new TextEncoder().encode(JSON.stringify(input.payload)), key, { typ: input.typ }),
89 ),
90 Effect.mapError((cause) => new RelayJwtError({ operation: "sign", typ: input.typ, cause })),
91 );
92 return Redacted.value(token);
93});
94​
95/** Verifies an EdDSA relay JWT against the ambient `Clock` and decodes its claims. */
96export const verifyRelayJwt = Effect.fn("relayJwt.verify")(function* <A>(
97 schema: Schema.ConstraintDecoder<A>,
98 input: {
99 readonly publicKey: string | Jwk.PublicKey;
100 readonly token: string;
101 readonly typ: string;
102 readonly issuer: string;
103 readonly audience: string;
104 readonly maxTokenAge?: Duration.Input;
105 },
106): Effect.fn.Return<A, RelayJwtError, Signature.Signature> {
107 const publicKey = input.publicKey;
108 const verified = yield* (
109 typeof publicKey === "string" ? importRelayPublicKey(publicKey) : requireEdDsa(publicKey)
110 ).pipe(
111 Effect.flatMap((key) =>
112 Jwt.verify(schema, input.token, key, {
113 typ: input.typ,
114 issuer: input.issuer,
115 audience: input.audience,
116 maxTokenAge: input.maxTokenAge ?? "5 minutes",
117 clockTolerance: "60 seconds",
118 }),
119 ),
120 Effect.mapError(
121 (cause) =>
122 new RelayJwtError({
123 operation: "verify",
124 typ: input.typ,
125 issuer: input.issuer,
126 audience: input.audience,
127 cause,
128 }),
129 ),
130 );
131 return verified.claims;
132});