| 1 | /** |
| 2 | * Whether a webhook request really came through this environment's relay. |
| 3 | * |
| 4 | * The relay's delivery id, receive time and trace context arrive as plain |
| 5 | * headers, and the webhook URL can also be called directly, so they are only |
| 6 | * trusted alongside a proof the relay signed with its mint key. A request |
| 7 | * without a valid proof is handled as a direct request. |
| 8 | */ |
| 9 | import { RelayHookDeliveryProofPayload } from "@t3tools/contracts/relay"; |
| 10 | import { |
| 11 | normalizeRelayIssuer, |
| 12 | RELAY_HOOK_DELIVERY_HEADER, |
| 13 | RELAY_HOOK_DELIVERY_TYP, |
| 14 | verifyRelayJwt, |
| 15 | } from "@t3tools/shared/relayJwt"; |
| 16 | import * as Clock from "effect/Clock"; |
| 17 | import * as Context from "effect/Context"; |
| 18 | import * as Effect from "effect/Effect"; |
| 19 | import * as Layer from "effect/Layer"; |
| 20 | import * as Option from "effect/Option"; |
| 21 | import * as Schema from "effect/Schema"; |
| 22 | |
| 23 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 24 | import { CLOUD_MINT_PUBLIC_KEY, RELAY_ISSUER_SECRET, RELAY_URL_SECRET } from "../cloud/config.ts"; |
| 25 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| 26 | |
| 27 | /** Covers the relay's 24-hour hold plus a margin. */ |
| 28 | const MAX_PROOF_AGE_SECONDS = 25 * 60 * 60; |
| 29 | |
| 30 | const decodePayload = Schema.decodeUnknownOption(RelayHookDeliveryProofPayload); |
| 31 | const text = (bytes: Option.Option<Uint8Array>) => |
| 32 | Option.map(bytes, (value) => new TextDecoder().decode(value)); |
| 33 | |
| 34 | export class RelayDeliveryProof extends Context.Service< |
| 35 | RelayDeliveryProof, |
| 36 | { |
| 37 | /** The relay's own delivery id and receive time, when the request proves it came from the relay. */ |
| 38 | readonly verify: (input: { |
| 39 | readonly headers: Readonly<Record<string, string>>; |
| 40 | readonly hookId: string; |
| 41 | }) => Effect.Effect< |
| 42 | Option.Option<{ readonly deliveryId: string; readonly receivedAt: string }> |
| 43 | >; |
| 44 | } |
| 45 | >()("t3/scheduledTasks/RelayDeliveryProof") {} |
| 46 | |
| 47 | const make = Effect.gen(function* () { |
| 48 | const secrets = yield* ServerSecretStore.ServerSecretStore; |
| 49 | const environment = yield* ServerEnvironment.ServerEnvironment; |
| 50 | const verify: RelayDeliveryProof["Service"]["verify"] = (input) => |
| 51 | Effect.gen(function* () { |
| 52 | const proof = input.headers[RELAY_HOOK_DELIVERY_HEADER]; |
| 53 | const deliveryId = input.headers["x-t3-relay-delivery-id"]; |
| 54 | const receivedAt = input.headers["x-t3-relay-received-at"]; |
| 55 | if (proof === undefined || deliveryId === undefined || receivedAt === undefined) { |
| 56 | return Option.none(); |
| 57 | } |
| 58 | const publicKey = text(yield* secrets.get(CLOUD_MINT_PUBLIC_KEY)); |
| 59 | const issuer = Option.orElse(text(yield* secrets.get(RELAY_ISSUER_SECRET)), () => |
| 60 | Option.none<string>(), |
| 61 | ); |
| 62 | const relayUrl = text(yield* secrets.get(RELAY_URL_SECRET)); |
| 63 | const relayIssuer = Option.isSome(issuer) ? issuer : relayUrl; |
| 64 | // Not linked to T3 Connect: no relay can be delivering to us. |
| 65 | if (Option.isNone(publicKey) || Option.isNone(relayIssuer)) return Option.none(); |
| 66 | const environmentId = yield* environment.getEnvironmentId; |
| 67 | const payload = yield* verifyRelayJwt({ |
| 68 | publicKey: publicKey.value, |
| 69 | token: proof, |
| 70 | typ: RELAY_HOOK_DELIVERY_TYP, |
| 71 | issuer: normalizeRelayIssuer(relayIssuer.value), |
| 72 | audience: `t3-env:${environmentId}`, |
| 73 | nowEpochSeconds: Math.floor((yield* Clock.currentTimeMillis) / 1_000), |
| 74 | maxTokenAge: MAX_PROOF_AGE_SECONDS, |
| 75 | }).pipe(Effect.map(decodePayload), Effect.orElseSucceed(Option.none)); |
| 76 | // The proof must name exactly this delivery, so it cannot be lifted onto another one. |
| 77 | if ( |
| 78 | Option.isNone(payload) || |
| 79 | payload.value.environmentId !== environmentId || |
| 80 | payload.value.deliveryId !== deliveryId || |
| 81 | payload.value.receivedAt !== receivedAt || |
| 82 | payload.value.hookId !== input.hookId |
| 83 | ) { |
| 84 | return Option.none(); |
| 85 | } |
| 86 | return Option.some({ deliveryId, receivedAt }); |
| 87 | }).pipe(Effect.orElseSucceed(Option.none), Effect.withSpan("webhook.verifyRelayDelivery")); |
| 88 | return RelayDeliveryProof.of({ verify }); |
| 89 | }); |
| 90 | |
| 91 | export const layer = Layer.effect(RelayDeliveryProof, make); |