| 1 | import { |
| 2 | RelayEnvironmentLinkProofPayload, |
| 3 | RelayEnvironmentLinkProofInvalidReason, |
| 4 | type RelayEnvironmentLinkRequest, |
| 5 | } from "@t3tools/contracts/relay"; |
| 6 | import { |
| 7 | decodeRelayJwt, |
| 8 | normalizeRelayIssuer, |
| 9 | RELAY_LINK_PROOF_TYP, |
| 10 | verifyRelayJwt, |
| 11 | } from "@t3tools/shared/relayJwt"; |
| 12 | import * as Context from "effect/Context"; |
| 13 | import * as DateTime from "effect/DateTime"; |
| 14 | import * as Effect from "effect/Effect"; |
| 15 | import * as Layer from "effect/Layer"; |
| 16 | import * as Schema from "effect/Schema"; |
| 17 | |
| 18 | import * as DpopProofs from "../auth/DpopProofs.ts"; |
| 19 | import * as RelayTokens from "../auth/RelayTokens.ts"; |
| 20 | import * as EnvironmentCredentials from "./EnvironmentCredentials.ts"; |
| 21 | import * as EnvironmentLinks from "./EnvironmentLinks.ts"; |
| 22 | import * as ManagedEndpointProvider from "./ManagedEndpointProvider.ts"; |
| 23 | import * as RelayConfiguration from "../Config.ts"; |
| 24 | |
| 25 | export class EnvironmentLinkProofExpired extends Schema.TaggedError<EnvironmentLinkProofExpired>()( |
| 26 | "EnvironmentLinkProofExpired", |
| 27 | { |
| 28 | userId: Schema.String, |
| 29 | environmentId: Schema.String, |
| 30 | expiresAt: Schema.String, |
| 31 | }, |
| 32 | ) { |
| 33 | override get message(): string { |
| 34 | return `Environment '${this.environmentId}' link proof expired at ${this.expiresAt}`; |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | export class EnvironmentLinkProofInvalid extends Schema.TaggedError<EnvironmentLinkProofInvalid>()( |
| 39 | "EnvironmentLinkProofInvalid", |
| 40 | { |
| 41 | userId: Schema.String, |
| 42 | environmentId: Schema.String, |
| 43 | reason: RelayEnvironmentLinkProofInvalidReason, |
| 44 | stage: Schema.Literals([ |
| 45 | "decode_token", |
| 46 | "decode_payload", |
| 47 | "verify_proof", |
| 48 | "authorize_capabilities", |
| 49 | "validate_descriptor", |
| 50 | "verify_challenge", |
| 51 | "validate_expiration", |
| 52 | "consume_proof_nonce", |
| 53 | "consume_challenge_nonce", |
| 54 | "validate_origin", |
| 55 | "validate_endpoint", |
| 56 | ]), |
| 57 | cause: Schema.optional(Schema.Defect()), |
| 58 | }, |
| 59 | ) { |
| 60 | override get message(): string { |
| 61 | return `Environment '${this.environmentId}' link proof is invalid during ${this.stage}: ${this.reason}`; |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | export type EnvironmentLinkError = |
| 66 | | EnvironmentLinkProofExpired |
| 67 | | EnvironmentLinkProofInvalid |
| 68 | | DpopProofs.DpopProofReplayPersistenceError |
| 69 | | EnvironmentLinks.EnvironmentLinkUpsertPersistenceError |
| 70 | | EnvironmentCredentials.EnvironmentCredentialCreatePersistenceError |
| 71 | | ManagedEndpointProvider.ManagedEndpointProviderError; |
| 72 | |
| 73 | export class EnvironmentLinker extends Context.Service< |
| 74 | EnvironmentLinker, |
| 75 | { |
| 76 | readonly link: (input: { |
| 77 | readonly userId: string; |
| 78 | readonly request: RelayEnvironmentLinkRequest; |
| 79 | }) => Effect.Effect< |
| 80 | { |
| 81 | readonly environmentId: RelayEnvironmentLinkProofPayload["environmentId"]; |
| 82 | readonly endpoint: RelayEnvironmentLinkProofPayload["endpoint"]; |
| 83 | readonly endpointRuntime: |
| 84 | | ManagedEndpointProvider.ManagedEndpointProvisioningResult["runtime"] |
| 85 | | null; |
| 86 | readonly environmentCredential: string; |
| 87 | }, |
| 88 | EnvironmentLinkError |
| 89 | >; |
| 90 | } |
| 91 | >()("t3code-relay/environments/EnvironmentLinker") {} |
| 92 | |
| 93 | const decodeProof = Schema.decodeUnknownEffect(RelayEnvironmentLinkProofPayload); |
| 94 | |
| 95 | function proofAuthorizesRequestedCapabilities( |
| 96 | proof: RelayEnvironmentLinkProofPayload, |
| 97 | request: RelayEnvironmentLinkRequest, |
| 98 | ): boolean { |
| 99 | const scopes = new Set(proof.scopes); |
| 100 | if (request.managedTunnelsEnabled && !scopes.has("managed_tunnels")) { |
| 101 | return false; |
| 102 | } |
| 103 | return !( |
| 104 | (request.notificationsEnabled || request.liveActivitiesEnabled) && |
| 105 | !scopes.has("agent_activity_notifications") |
| 106 | ); |
| 107 | } |
| 108 | |
| 109 | function isSecureManagedEndpoint(endpoint: RelayEnvironmentLinkProofPayload["endpoint"]): boolean { |
| 110 | try { |
| 111 | const httpUrl = new URL(endpoint.httpBaseUrl); |
| 112 | const wsUrl = new URL(endpoint.wsBaseUrl); |
| 113 | return httpUrl.protocol === "https:" && wsUrl.protocol === "wss:"; |
| 114 | } catch { |
| 115 | return false; |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | function normalizeHostname(hostname: string): string { |
| 120 | return hostname |
| 121 | .trim() |
| 122 | .toLowerCase() |
| 123 | .replace(/^\[(.*)\]$/u, "$1"); |
| 124 | } |
| 125 | |
| 126 | function isLoopbackManagedTunnelOrigin( |
| 127 | origin: RelayEnvironmentLinkProofPayload["origin"], |
| 128 | ): boolean { |
| 129 | const hostname = normalizeHostname(origin.localHttpHost); |
| 130 | return ( |
| 131 | (hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost") && |
| 132 | Number.isInteger(origin.localHttpPort) && |
| 133 | origin.localHttpPort > 0 && |
| 134 | origin.localHttpPort <= 65_535 |
| 135 | ); |
| 136 | } |
| 137 | |
| 138 | const make = Effect.gen(function* () { |
| 139 | const links = yield* EnvironmentLinks.EnvironmentLinks; |
| 140 | const credentials = yield* EnvironmentCredentials.EnvironmentCredentials; |
| 141 | const managedEndpointProvider = yield* ManagedEndpointProvider.ManagedEndpointProvider; |
| 142 | const proofReplay = yield* DpopProofs.DpopProofReplay; |
| 143 | const relayTokens = yield* RelayTokens.RelayTokens; |
| 144 | const config = yield* RelayConfiguration.RelayConfiguration; |
| 145 | |
| 146 | return EnvironmentLinker.of({ |
| 147 | link: Effect.fn("relay.environment_linker.link")(function* (input) { |
| 148 | const now = yield* DateTime.now; |
| 149 | const nowSeconds = Math.floor(now.epochMilliseconds / 1_000); |
| 150 | const unverified = yield* Effect.try({ |
| 151 | try: () => decodeRelayJwt(input.request.proof), |
| 152 | catch: (cause) => |
| 153 | new EnvironmentLinkProofInvalid({ |
| 154 | userId: input.userId, |
| 155 | environmentId: "unknown", |
| 156 | reason: "invalid_signature_or_scope", |
| 157 | stage: "decode_token", |
| 158 | cause, |
| 159 | }), |
| 160 | }); |
| 161 | const candidate = yield* decodeProof(unverified).pipe( |
| 162 | Effect.mapError( |
| 163 | (cause) => |
| 164 | new EnvironmentLinkProofInvalid({ |
| 165 | userId: input.userId, |
| 166 | environmentId: "unknown", |
| 167 | reason: "invalid_signature_or_scope", |
| 168 | stage: "decode_payload", |
| 169 | cause, |
| 170 | }), |
| 171 | ), |
| 172 | ); |
| 173 | yield* Effect.annotateCurrentSpan({ |
| 174 | "relay.environment_id": candidate.environmentId, |
| 175 | "relay.link.notifications_enabled": input.request.notificationsEnabled, |
| 176 | "relay.link.live_activities_enabled": input.request.liveActivitiesEnabled, |
| 177 | "relay.link.managed_tunnels_enabled": input.request.managedTunnelsEnabled, |
| 178 | }); |
| 179 | if (candidate.exp <= nowSeconds) { |
| 180 | return yield* new EnvironmentLinkProofExpired({ |
| 181 | userId: input.userId, |
| 182 | environmentId: candidate.environmentId, |
| 183 | expiresAt: DateTime.formatIso(DateTime.makeUnsafe(candidate.exp * 1_000)), |
| 184 | }); |
| 185 | } |
| 186 | const issuer = `t3-env:${candidate.environmentId}`; |
| 187 | const relayIssuer = normalizeRelayIssuer(config.relayIssuer); |
| 188 | const verified = yield* verifyRelayJwt({ |
| 189 | publicKey: candidate.environmentPublicKey, |
| 190 | token: input.request.proof, |
| 191 | typ: RELAY_LINK_PROOF_TYP, |
| 192 | issuer, |
| 193 | audience: relayIssuer, |
| 194 | nowEpochSeconds: nowSeconds, |
| 195 | }).pipe( |
| 196 | Effect.flatMap(decodeProof), |
| 197 | Effect.mapError( |
| 198 | (cause) => |
| 199 | new EnvironmentLinkProofInvalid({ |
| 200 | userId: input.userId, |
| 201 | environmentId: candidate.environmentId, |
| 202 | reason: "invalid_signature_or_scope", |
| 203 | stage: "verify_proof", |
| 204 | cause, |
| 205 | }), |
| 206 | ), |
| 207 | ); |
| 208 | if ( |
| 209 | verified.sub !== verified.environmentId || |
| 210 | !proofAuthorizesRequestedCapabilities(verified, input.request) |
| 211 | ) { |
| 212 | return yield* new EnvironmentLinkProofInvalid({ |
| 213 | userId: input.userId, |
| 214 | environmentId: candidate.environmentId, |
| 215 | reason: "invalid_signature_or_scope", |
| 216 | stage: "authorize_capabilities", |
| 217 | }); |
| 218 | } |
| 219 | if (verified.descriptor.environmentId !== verified.environmentId) { |
| 220 | return yield* new EnvironmentLinkProofInvalid({ |
| 221 | userId: input.userId, |
| 222 | environmentId: verified.environmentId, |
| 223 | reason: "descriptor_mismatch", |
| 224 | stage: "validate_descriptor", |
| 225 | }); |
| 226 | } |
| 227 | const challenge = yield* relayTokens.verifyLinkChallenge({ |
| 228 | token: verified.challenge, |
| 229 | userId: input.userId, |
| 230 | request: { |
| 231 | notificationsEnabled: input.request.notificationsEnabled, |
| 232 | liveActivitiesEnabled: input.request.liveActivitiesEnabled, |
| 233 | managedTunnelsEnabled: input.request.managedTunnelsEnabled, |
| 234 | }, |
| 235 | nowEpochSeconds: nowSeconds, |
| 236 | }); |
| 237 | if (challenge === null) { |
| 238 | return yield* new EnvironmentLinkProofInvalid({ |
| 239 | userId: input.userId, |
| 240 | environmentId: verified.environmentId, |
| 241 | reason: "challenge_invalid", |
| 242 | stage: "verify_challenge", |
| 243 | }); |
| 244 | } |
| 245 | const expiresAt = DateTime.make(verified.exp * 1_000); |
| 246 | if (expiresAt._tag === "None") { |
| 247 | return yield* new EnvironmentLinkProofInvalid({ |
| 248 | userId: input.userId, |
| 249 | environmentId: verified.environmentId, |
| 250 | reason: "invalid_signature_or_scope", |
| 251 | stage: "validate_expiration", |
| 252 | }); |
| 253 | } |
| 254 | const consumedNonce = yield* proofReplay.consume({ |
| 255 | thumbprint: verified.environmentPublicKey, |
| 256 | jti: verified.jti, |
| 257 | iat: verified.iat, |
| 258 | expiresAt: expiresAt.value, |
| 259 | }); |
| 260 | if (!consumedNonce) { |
| 261 | return yield* new EnvironmentLinkProofInvalid({ |
| 262 | userId: input.userId, |
| 263 | environmentId: verified.environmentId, |
| 264 | reason: "replayed_nonce", |
| 265 | stage: "consume_proof_nonce", |
| 266 | }); |
| 267 | } |
| 268 | const consumedChallenge = yield* proofReplay.consume({ |
| 269 | thumbprint: "relay-environment-link-challenge", |
| 270 | jti: challenge.jti, |
| 271 | iat: challenge.iat, |
| 272 | expiresAt: expiresAt.value, |
| 273 | }); |
| 274 | if (!consumedChallenge) { |
| 275 | return yield* new EnvironmentLinkProofInvalid({ |
| 276 | userId: input.userId, |
| 277 | environmentId: verified.environmentId, |
| 278 | reason: "challenge_invalid", |
| 279 | stage: "consume_challenge_nonce", |
| 280 | }); |
| 281 | } |
| 282 | if (input.request.managedTunnelsEnabled && !isLoopbackManagedTunnelOrigin(verified.origin)) { |
| 283 | return yield* new EnvironmentLinkProofInvalid({ |
| 284 | userId: input.userId, |
| 285 | environmentId: verified.environmentId, |
| 286 | reason: "origin_not_allowed", |
| 287 | stage: "validate_origin", |
| 288 | }); |
| 289 | } |
| 290 | // Downgrading a managed link to publish-only must release the tunnel and |
| 291 | // DNS that were provisioned for it — nothing else cleans them up until a |
| 292 | // full unlink. Best effort: a cleanup failure must not block the link |
| 293 | // itself, and the provider treats an absent allocation as already |
| 294 | // deprovisioned, so retrying on every non-tunnel link is cheap. |
| 295 | if (!input.request.managedTunnelsEnabled) { |
| 296 | yield* managedEndpointProvider |
| 297 | .deprovision({ |
| 298 | userId: input.userId, |
| 299 | environmentId: verified.environmentId, |
| 300 | }) |
| 301 | .pipe( |
| 302 | Effect.tapError((error) => |
| 303 | Effect.logWarning("managed endpoint deprovision on publish-only link failed", { |
| 304 | environmentId: verified.environmentId, |
| 305 | errorTag: error._tag, |
| 306 | }), |
| 307 | ), |
| 308 | Effect.ignore, |
| 309 | ); |
| 310 | } |
| 311 | const provisioned = input.request.managedTunnelsEnabled |
| 312 | ? yield* managedEndpointProvider.provision({ |
| 313 | userId: input.userId, |
| 314 | environmentId: verified.environmentId, |
| 315 | origin: verified.origin, |
| 316 | }) |
| 317 | : null; |
| 318 | const endpoint = provisioned?.endpoint ?? verified.endpoint; |
| 319 | // The secure-endpoint requirement only matters when the relay advertises |
| 320 | // this endpoint for other devices to reach (managed tunnel). Publish-only |
| 321 | // links are reached out of band (e.g. Tailscale) and their stored endpoint |
| 322 | // is never used for routing, so a nominal endpoint is acceptable. |
| 323 | if (input.request.managedTunnelsEnabled && !isSecureManagedEndpoint(endpoint)) { |
| 324 | return yield* new EnvironmentLinkProofInvalid({ |
| 325 | userId: input.userId, |
| 326 | environmentId: verified.environmentId, |
| 327 | reason: "endpoint_not_secure", |
| 328 | stage: "validate_endpoint", |
| 329 | }); |
| 330 | } |
| 331 | yield* links.upsert({ ...input, proof: verified, endpoint }); |
| 332 | const environmentCredential = yield* credentials.create({ |
| 333 | environmentId: verified.environmentId, |
| 334 | environmentPublicKey: verified.environmentPublicKey, |
| 335 | }); |
| 336 | return { |
| 337 | environmentId: verified.environmentId, |
| 338 | endpoint, |
| 339 | endpointRuntime: provisioned?.runtime ?? null, |
| 340 | environmentCredential, |
| 341 | }; |
| 342 | }), |
| 343 | }); |
| 344 | }); |
| 345 | |
| 346 | export const layer = Layer.effect(EnvironmentLinker, make); |