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";...@@ -10,6 +10,7 @@ import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
11import * as Redacted from "effect/Redacted";11import * as Redacted from "effect/Redacted";
12import * as Result from "effect/Result";12import * as Result from "effect/Result";
13import * as Schema from "effect/Schema";
1314
14import * as DpopProofs from "../auth/DpopProofs.ts";15import * as DpopProofs from "../auth/DpopProofs.ts";
15import * as RelayTokens from "../auth/RelayTokens.ts";16import * as RelayTokens from "../auth/RelayTokens.ts";
...@@ -45,6 +46,7 @@ const config = RelayConfiguration.RelayConfiguration.of({...@@ -45,6 +46,7 @@ const config = RelayConfiguration.RelayConfiguration.of({
45 managedEndpointBaseDomain: undefined,46 managedEndpointBaseDomain: undefined,
46 managedEndpointNamespace: undefined,47 managedEndpointNamespace: undefined,
47});48});
49const isEnvironmentLinkProofInvalid = Schema.is(EnvironmentLinker.EnvironmentLinkProofInvalid);
4850
49function signTestJwt(payload: object, typ: string, privateKey: string): string {51function signTestJwt(payload: object, typ: string, privateKey: string): string {
50 const header = Buffer.from(JSON.stringify({ alg: "EdDSA", typ })).toString("base64url");52 const header = Buffer.from(JSON.stringify({ alg: "EdDSA", typ })).toString("base64url");
...@@ -182,6 +184,18 @@ describe("EnvironmentLinker", () => {...@@ -182,6 +184,18 @@ describe("EnvironmentLinker", () => {
182 const linker = yield* EnvironmentLinker.EnvironmentLinker;184 const linker = yield* EnvironmentLinker.EnvironmentLinker;
183 const result = yield* Effect.result(linker.link({ userId: "user_123", request: tampered }));185 const result = yield* Effect.result(linker.link({ userId: "user_123", request: tampered }));
184 expect(Result.isFailure(result)).toBe(true);186 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 }
185 expect(persisted).toBe(false);199 expect(persisted).toBe(false);
186 }).pipe(200 }).pipe(
187 Effect.provide(201 Effect.provide(
...@@ -201,6 +215,17 @@ describe("EnvironmentLinker", () => {...@@ -201,6 +215,17 @@ describe("EnvironmentLinker", () => {
201 const linker = yield* EnvironmentLinker.EnvironmentLinker;215 const linker = yield* EnvironmentLinker.EnvironmentLinker;
202 const result = yield* Effect.result(linker.link({ userId: "user_123", request }));216 const result = yield* Effect.result(linker.link({ userId: "user_123", request }));
203 expect(Result.isFailure(result)).toBe(true);217 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 }
204 }).pipe(Effect.provide(testLayer({ consume: () => Effect.succeed(false) }))),229 }).pipe(Effect.provide(testLayer({ consume: () => Effect.succeed(false) }))),
205 );230 );
206});231});
infra/relay/src/environments/EnvironmentLinker.ts+57-12
...@@ -25,23 +25,40 @@ import * as RelayConfiguration from "../Config.ts";...@@ -25,23 +25,40 @@ import * as RelayConfiguration from "../Config.ts";
25export class EnvironmentLinkProofExpired extends Schema.TaggedErrorClass<EnvironmentLinkProofExpired>()(25export class EnvironmentLinkProofExpired extends Schema.TaggedErrorClass<EnvironmentLinkProofExpired>()(
26 "EnvironmentLinkProofExpired",26 "EnvironmentLinkProofExpired",
27 {27 {
28 userId: Schema.String,
29 environmentId: Schema.String,
28 expiresAt: Schema.String,30 expiresAt: Schema.String,
29 },31 },
30) {32) {
31 override get message(): string {33 override get message(): string {
32 return `Environment link proof expired at ${this.expiresAt}`;34 return `Environment '${this.environmentId}' link proof expired at ${this.expiresAt}`;
33 }35 }
34}36}
3537
36export class EnvironmentLinkProofInvalid extends Schema.TaggedErrorClass<EnvironmentLinkProofInvalid>()(38export class EnvironmentLinkProofInvalid extends Schema.TaggedErrorClass<EnvironmentLinkProofInvalid>()(
37 "EnvironmentLinkProofInvalid",39 "EnvironmentLinkProofInvalid",
38 {40 {
41 userId: Schema.String,
39 environmentId: Schema.String,42 environmentId: Schema.String,
40 reason: RelayEnvironmentLinkProofInvalidReason,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()),
41 },58 },
42) {59) {
43 override get message(): string {60 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}`;
45 }62 }
46}63}
4764
...@@ -132,20 +149,27 @@ const make = Effect.gen(function* () {...@@ -132,20 +149,27 @@ const make = Effect.gen(function* () {
132 const nowSeconds = Math.floor(now.epochMilliseconds / 1_000);149 const nowSeconds = Math.floor(now.epochMilliseconds / 1_000);
133 const unverified = yield* Effect.try({150 const unverified = yield* Effect.try({
134 try: () => decodeRelayJwt(input.request.proof),151 try: () => decodeRelayJwt(input.request.proof),
135 catch: () =>152 catch: (cause) =>
136 new EnvironmentLinkProofInvalid({153 new EnvironmentLinkProofInvalid({
154 userId: input.userId,
137 environmentId: "unknown",155 environmentId: "unknown",
138 reason: "invalid_signature_or_scope",156 reason: "invalid_signature_or_scope",
157 stage: "decode_token",
158 cause,
139 }),159 }),
140 });160 });
141 const decoded = yield* decodeProof(unverified).pipe(Effect.option);161 const candidate = yield* decodeProof(unverified).pipe(
142 if (decoded._tag === "None") {162 Effect.mapError(
143 return yield* new EnvironmentLinkProofInvalid({163 (cause) =>
144 environmentId: "unknown",164 new EnvironmentLinkProofInvalid({
145 reason: "invalid_signature_or_scope",165 userId: input.userId,
146 });166 environmentId: "unknown",
147 }167 reason: "invalid_signature_or_scope",
148 const candidate = decoded.value;168 stage: "decode_payload",
169 cause,
170 }),
171 ),
172 );
149 yield* Effect.annotateCurrentSpan({173 yield* Effect.annotateCurrentSpan({
150 "relay.environment_id": candidate.environmentId,174 "relay.environment_id": candidate.environmentId,
151 "relay.link.notifications_enabled": input.request.notificationsEnabled,175 "relay.link.notifications_enabled": input.request.notificationsEnabled,
...@@ -154,6 +178,8 @@ const make = Effect.gen(function* () {...@@ -154,6 +178,8 @@ const make = Effect.gen(function* () {
154 });178 });
155 if (candidate.exp <= nowSeconds) {179 if (candidate.exp <= nowSeconds) {
156 return yield* new EnvironmentLinkProofExpired({180 return yield* new EnvironmentLinkProofExpired({
181 userId: input.userId,
182 environmentId: candidate.environmentId,
157 expiresAt: DateTime.formatIso(DateTime.makeUnsafe(candidate.exp * 1_000)),183 expiresAt: DateTime.formatIso(DateTime.makeUnsafe(candidate.exp * 1_000)),
158 });184 });
159 }185 }
...@@ -169,10 +195,13 @@ const make = Effect.gen(function* () {...@@ -169,10 +195,13 @@ const make = Effect.gen(function* () {
169 }).pipe(195 }).pipe(
170 Effect.flatMap(decodeProof),196 Effect.flatMap(decodeProof),
171 Effect.mapError(197 Effect.mapError(
172 () =>198 (cause) =>
173 new EnvironmentLinkProofInvalid({199 new EnvironmentLinkProofInvalid({
200 userId: input.userId,
174 environmentId: candidate.environmentId,201 environmentId: candidate.environmentId,
175 reason: "invalid_signature_or_scope",202 reason: "invalid_signature_or_scope",
203 stage: "verify_proof",
204 cause,
176 }),205 }),
177 ),206 ),
178 );207 );
...@@ -181,14 +210,18 @@ const make = Effect.gen(function* () {...@@ -181,14 +210,18 @@ const make = Effect.gen(function* () {
181 !proofAuthorizesRequestedCapabilities(verified, input.request)210 !proofAuthorizesRequestedCapabilities(verified, input.request)
182 ) {211 ) {
183 return yield* new EnvironmentLinkProofInvalid({212 return yield* new EnvironmentLinkProofInvalid({
213 userId: input.userId,
184 environmentId: candidate.environmentId,214 environmentId: candidate.environmentId,
185 reason: "invalid_signature_or_scope",215 reason: "invalid_signature_or_scope",
216 stage: "authorize_capabilities",
186 });217 });
187 }218 }
188 if (verified.descriptor.environmentId !== verified.environmentId) {219 if (verified.descriptor.environmentId !== verified.environmentId) {
189 return yield* new EnvironmentLinkProofInvalid({220 return yield* new EnvironmentLinkProofInvalid({
221 userId: input.userId,
190 environmentId: verified.environmentId,222 environmentId: verified.environmentId,
191 reason: "descriptor_mismatch",223 reason: "descriptor_mismatch",
224 stage: "validate_descriptor",
192 });225 });
193 }226 }
194 const challenge = yield* relayTokens.verifyLinkChallenge({227 const challenge = yield* relayTokens.verifyLinkChallenge({
...@@ -203,15 +236,19 @@ const make = Effect.gen(function* () {...@@ -203,15 +236,19 @@ const make = Effect.gen(function* () {
203 });236 });
204 if (challenge === null) {237 if (challenge === null) {
205 return yield* new EnvironmentLinkProofInvalid({238 return yield* new EnvironmentLinkProofInvalid({
239 userId: input.userId,
206 environmentId: verified.environmentId,240 environmentId: verified.environmentId,
207 reason: "challenge_invalid",241 reason: "challenge_invalid",
242 stage: "verify_challenge",
208 });243 });
209 }244 }
210 const expiresAt = DateTime.make(verified.exp * 1_000);245 const expiresAt = DateTime.make(verified.exp * 1_000);
211 if (expiresAt._tag === "None") {246 if (expiresAt._tag === "None") {
212 return yield* new EnvironmentLinkProofInvalid({247 return yield* new EnvironmentLinkProofInvalid({
248 userId: input.userId,
213 environmentId: verified.environmentId,249 environmentId: verified.environmentId,
214 reason: "invalid_signature_or_scope",250 reason: "invalid_signature_or_scope",
251 stage: "validate_expiration",
215 });252 });
216 }253 }
217 const consumedNonce = yield* proofReplay.consume({254 const consumedNonce = yield* proofReplay.consume({
...@@ -222,8 +259,10 @@ const make = Effect.gen(function* () {...@@ -222,8 +259,10 @@ const make = Effect.gen(function* () {
222 });259 });
223 if (!consumedNonce) {260 if (!consumedNonce) {
224 return yield* new EnvironmentLinkProofInvalid({261 return yield* new EnvironmentLinkProofInvalid({
262 userId: input.userId,
225 environmentId: verified.environmentId,263 environmentId: verified.environmentId,
226 reason: "replayed_nonce",264 reason: "replayed_nonce",
265 stage: "consume_proof_nonce",
227 });266 });
228 }267 }
229 const consumedChallenge = yield* proofReplay.consume({268 const consumedChallenge = yield* proofReplay.consume({
...@@ -234,14 +273,18 @@ const make = Effect.gen(function* () {...@@ -234,14 +273,18 @@ const make = Effect.gen(function* () {
234 });273 });
235 if (!consumedChallenge) {274 if (!consumedChallenge) {
236 return yield* new EnvironmentLinkProofInvalid({275 return yield* new EnvironmentLinkProofInvalid({
276 userId: input.userId,
237 environmentId: verified.environmentId,277 environmentId: verified.environmentId,
238 reason: "challenge_invalid",278 reason: "challenge_invalid",
279 stage: "consume_challenge_nonce",
239 });280 });
240 }281 }
241 if (input.request.managedTunnelsEnabled && !isLoopbackManagedTunnelOrigin(verified.origin)) {282 if (input.request.managedTunnelsEnabled && !isLoopbackManagedTunnelOrigin(verified.origin)) {
242 return yield* new EnvironmentLinkProofInvalid({283 return yield* new EnvironmentLinkProofInvalid({
284 userId: input.userId,
243 environmentId: verified.environmentId,285 environmentId: verified.environmentId,
244 reason: "origin_not_allowed",286 reason: "origin_not_allowed",
287 stage: "validate_origin",
245 });288 });
246 }289 }
247 const provisioned = input.request.managedTunnelsEnabled290 const provisioned = input.request.managedTunnelsEnabled
...@@ -254,8 +297,10 @@ const make = Effect.gen(function* () {...@@ -254,8 +297,10 @@ const make = Effect.gen(function* () {
254 const endpoint = provisioned?.endpoint ?? verified.endpoint;297 const endpoint = provisioned?.endpoint ?? verified.endpoint;
255 if (!isSecureManagedEndpoint(endpoint)) {298 if (!isSecureManagedEndpoint(endpoint)) {
256 return yield* new EnvironmentLinkProofInvalid({299 return yield* new EnvironmentLinkProofInvalid({
300 userId: input.userId,
257 environmentId: verified.environmentId,301 environmentId: verified.environmentId,
258 reason: "endpoint_not_secure",302 reason: "endpoint_not_secure",
303 stage: "validate_endpoint",
259 });304 });
260 }305 }
261 yield* links.upsert({ ...input, proof: verified, endpoint });306 yield* links.upsert({ ...input, proof: verified, endpoint });