authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:09:30-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:09:30-07:00
log04f82ae1f3c50f2c00bbb9c018c768b113b70ae7
tree7bb59453435d50856ec7a0e478a8bb35d5b0d20b
parentce0c20b840f9e9d49532c3480113e1e54201fab0
signature Signed by PGP key B5690EEEBB952194

[codex] Structure relay environment link errors (#3334)

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

2 files changed, 82 insertions(+), 12 deletions(-)

infra/relay/src/environments/EnvironmentLinker.test.ts+25
......@@ -10,6 +10,7 @@ import * as Effect from "effect/Effect";
1010import * as Layer from "effect/Layer";
1111import * as Redacted from "effect/Redacted";
1212import * as Result from "effect/Result";
13import * as Schema from "effect/Schema";
1314
1415import * as DpopProofs from "../auth/DpopProofs.ts";
1516import * as RelayTokens from "../auth/RelayTokens.ts";
......@@ -45,6 +46,7 @@ const config = RelayConfiguration.RelayConfiguration.of({
4546 managedEndpointBaseDomain: undefined,
4647 managedEndpointNamespace: undefined,
4748});
49const isEnvironmentLinkProofInvalid = Schema.is(EnvironmentLinker.EnvironmentLinkProofInvalid);
4850
4951function signTestJwt(payload: object, typ: string, privateKey: string): string {
5052 const header = Buffer.from(JSON.stringify({ alg: "EdDSA", typ })).toString("base64url");
......@@ -182,6 +184,18 @@ describe("EnvironmentLinker", () => {
182184 const linker = yield* EnvironmentLinker.EnvironmentLinker;
183185 const result = yield* Effect.result(linker.link({ userId: "user_123", request: tampered }));
184186 expect(Result.isFailure(result)).toBe(true);
187 if (Result.isFailure(result)) {
188 expect(isEnvironmentLinkProofInvalid(result.failure)).toBe(true);
189 if (isEnvironmentLinkProofInvalid(result.failure)) {
190 expect(result.failure).toMatchObject({
191 userId: "user_123",
192 environmentId: "env-link-test",
193 reason: "invalid_signature_or_scope",
194 stage: "verify_proof",
195 cause: { _tag: "RelayJwtError" },
196 });
197 }
198 }
185199 expect(persisted).toBe(false);
186200 }).pipe(
187201 Effect.provide(
......@@ -201,6 +215,17 @@ describe("EnvironmentLinker", () => {
201215 const linker = yield* EnvironmentLinker.EnvironmentLinker;
202216 const result = yield* Effect.result(linker.link({ userId: "user_123", request }));
203217 expect(Result.isFailure(result)).toBe(true);
218 if (Result.isFailure(result)) {
219 expect(isEnvironmentLinkProofInvalid(result.failure)).toBe(true);
220 if (isEnvironmentLinkProofInvalid(result.failure)) {
221 expect(result.failure).toMatchObject({
222 userId: "user_123",
223 environmentId: "env-link-test",
224 reason: "replayed_nonce",
225 stage: "consume_proof_nonce",
226 });
227 }
228 }
204229 }).pipe(Effect.provide(testLayer({ consume: () => Effect.succeed(false) }))),
205230 );
206231});
infra/relay/src/environments/EnvironmentLinker.ts+57-12
......@@ -25,23 +25,40 @@ import * as RelayConfiguration from "../Config.ts";
2525export class EnvironmentLinkProofExpired extends Schema.TaggedErrorClass<EnvironmentLinkProofExpired>()(
2626 "EnvironmentLinkProofExpired",
2727 {
28 userId: Schema.String,
29 environmentId: Schema.String,
2830 expiresAt: Schema.String,
2931 },
3032) {
3133 override get message(): string {
32 return `Environment link proof expired at ${this.expiresAt}`;
34 return `Environment '${this.environmentId}' link proof expired at ${this.expiresAt}`;
3335 }
3436}
3537
3638export class EnvironmentLinkProofInvalid extends Schema.TaggedErrorClass<EnvironmentLinkProofInvalid>()(
3739 "EnvironmentLinkProofInvalid",
3840 {
41 userId: Schema.String,
3942 environmentId: Schema.String,
4043 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()),
4158 },
4259) {
4360 override get message(): string {
44 return `Environment '${this.environmentId}' link proof is invalid: ${this.reason}`;
61 return `Environment '${this.environmentId}' link proof is invalid during ${this.stage}: ${this.reason}`;
4562 }
4663}
4764
......@@ -132,20 +149,27 @@ const make = Effect.gen(function* () {
132149 const nowSeconds = Math.floor(now.epochMilliseconds / 1_000);
133150 const unverified = yield* Effect.try({
134151 try: () => decodeRelayJwt(input.request.proof),
135 catch: () =>
152 catch: (cause) =>
136153 new EnvironmentLinkProofInvalid({
154 userId: input.userId,
137155 environmentId: "unknown",
138156 reason: "invalid_signature_or_scope",
157 stage: "decode_token",
158 cause,
139159 }),
140160 });
141 const decoded = yield* decodeProof(unverified).pipe(Effect.option);
142 if (decoded._tag === "None") {
143 return yield* new EnvironmentLinkProofInvalid({
144 environmentId: "unknown",
145 reason: "invalid_signature_or_scope",
146 });
147 }
148 const candidate = decoded.value;
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 );
149173 yield* Effect.annotateCurrentSpan({
150174 "relay.environment_id": candidate.environmentId,
151175 "relay.link.notifications_enabled": input.request.notificationsEnabled,
......@@ -154,6 +178,8 @@ const make = Effect.gen(function* () {
154178 });
155179 if (candidate.exp <= nowSeconds) {
156180 return yield* new EnvironmentLinkProofExpired({
181 userId: input.userId,
182 environmentId: candidate.environmentId,
157183 expiresAt: DateTime.formatIso(DateTime.makeUnsafe(candidate.exp * 1_000)),
158184 });
159185 }
......@@ -169,10 +195,13 @@ const make = Effect.gen(function* () {
169195 }).pipe(
170196 Effect.flatMap(decodeProof),
171197 Effect.mapError(
172 () =>
198 (cause) =>
173199 new EnvironmentLinkProofInvalid({
200 userId: input.userId,
174201 environmentId: candidate.environmentId,
175202 reason: "invalid_signature_or_scope",
203 stage: "verify_proof",
204 cause,
176205 }),
177206 ),
178207 );
......@@ -181,14 +210,18 @@ const make = Effect.gen(function* () {
181210 !proofAuthorizesRequestedCapabilities(verified, input.request)
182211 ) {
183212 return yield* new EnvironmentLinkProofInvalid({
213 userId: input.userId,
184214 environmentId: candidate.environmentId,
185215 reason: "invalid_signature_or_scope",
216 stage: "authorize_capabilities",
186217 });
187218 }
188219 if (verified.descriptor.environmentId !== verified.environmentId) {
189220 return yield* new EnvironmentLinkProofInvalid({
221 userId: input.userId,
190222 environmentId: verified.environmentId,
191223 reason: "descriptor_mismatch",
224 stage: "validate_descriptor",
192225 });
193226 }
194227 const challenge = yield* relayTokens.verifyLinkChallenge({
......@@ -203,15 +236,19 @@ const make = Effect.gen(function* () {
203236 });
204237 if (challenge === null) {
205238 return yield* new EnvironmentLinkProofInvalid({
239 userId: input.userId,
206240 environmentId: verified.environmentId,
207241 reason: "challenge_invalid",
242 stage: "verify_challenge",
208243 });
209244 }
210245 const expiresAt = DateTime.make(verified.exp * 1_000);
211246 if (expiresAt._tag === "None") {
212247 return yield* new EnvironmentLinkProofInvalid({
248 userId: input.userId,
213249 environmentId: verified.environmentId,
214250 reason: "invalid_signature_or_scope",
251 stage: "validate_expiration",
215252 });
216253 }
217254 const consumedNonce = yield* proofReplay.consume({
......@@ -222,8 +259,10 @@ const make = Effect.gen(function* () {
222259 });
223260 if (!consumedNonce) {
224261 return yield* new EnvironmentLinkProofInvalid({
262 userId: input.userId,
225263 environmentId: verified.environmentId,
226264 reason: "replayed_nonce",
265 stage: "consume_proof_nonce",
227266 });
228267 }
229268 const consumedChallenge = yield* proofReplay.consume({
......@@ -234,14 +273,18 @@ const make = Effect.gen(function* () {
234273 });
235274 if (!consumedChallenge) {
236275 return yield* new EnvironmentLinkProofInvalid({
276 userId: input.userId,
237277 environmentId: verified.environmentId,
238278 reason: "challenge_invalid",
279 stage: "consume_challenge_nonce",
239280 });
240281 }
241282 if (input.request.managedTunnelsEnabled && !isLoopbackManagedTunnelOrigin(verified.origin)) {
242283 return yield* new EnvironmentLinkProofInvalid({
284 userId: input.userId,
243285 environmentId: verified.environmentId,
244286 reason: "origin_not_allowed",
287 stage: "validate_origin",
245288 });
246289 }
247290 const provisioned = input.request.managedTunnelsEnabled
......@@ -254,8 +297,10 @@ const make = Effect.gen(function* () {
254297 const endpoint = provisioned?.endpoint ?? verified.endpoint;
255298 if (!isSecureManagedEndpoint(endpoint)) {
256299 return yield* new EnvironmentLinkProofInvalid({
300 userId: input.userId,
257301 environmentId: verified.environmentId,
258302 reason: "endpoint_not_secure",
303 stage: "validate_endpoint",
259304 });
260305 }
261306 yield* links.upsert({ ...input, proof: verified, endpoint });