diff --git a/infra/relay/src/environments/EnvironmentLinker.test.ts b/infra/relay/src/environments/EnvironmentLinker.test.ts index 35dbd907dbe..f6bd1c6d977 100644 --- a/infra/relay/src/environments/EnvironmentLinker.test.ts +++ b/infra/relay/src/environments/EnvironmentLinker.test.ts @@ -10,6 +10,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; import * as DpopProofs from "../auth/DpopProofs.ts"; import * as RelayTokens from "../auth/RelayTokens.ts"; @@ -45,6 +46,7 @@ const config = RelayConfiguration.RelayConfiguration.of({ managedEndpointBaseDomain: undefined, managedEndpointNamespace: undefined, }); +const isEnvironmentLinkProofInvalid = Schema.is(EnvironmentLinker.EnvironmentLinkProofInvalid); function signTestJwt(payload: object, typ: string, privateKey: string): string { const header = Buffer.from(JSON.stringify({ alg: "EdDSA", typ })).toString("base64url"); @@ -182,6 +184,18 @@ describe("EnvironmentLinker", () => { const linker = yield* EnvironmentLinker.EnvironmentLinker; const result = yield* Effect.result(linker.link({ userId: "user_123", request: tampered })); expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(isEnvironmentLinkProofInvalid(result.failure)).toBe(true); + if (isEnvironmentLinkProofInvalid(result.failure)) { + expect(result.failure).toMatchObject({ + userId: "user_123", + environmentId: "env-link-test", + reason: "invalid_signature_or_scope", + stage: "verify_proof", + cause: { _tag: "RelayJwtError" }, + }); + } + } expect(persisted).toBe(false); }).pipe( Effect.provide( @@ -201,6 +215,17 @@ describe("EnvironmentLinker", () => { const linker = yield* EnvironmentLinker.EnvironmentLinker; const result = yield* Effect.result(linker.link({ userId: "user_123", request })); expect(Result.isFailure(result)).toBe(true); + if (Result.isFailure(result)) { + expect(isEnvironmentLinkProofInvalid(result.failure)).toBe(true); + if (isEnvironmentLinkProofInvalid(result.failure)) { + expect(result.failure).toMatchObject({ + userId: "user_123", + environmentId: "env-link-test", + reason: "replayed_nonce", + stage: "consume_proof_nonce", + }); + } + } }).pipe(Effect.provide(testLayer({ consume: () => Effect.succeed(false) }))), ); }); diff --git a/infra/relay/src/environments/EnvironmentLinker.ts b/infra/relay/src/environments/EnvironmentLinker.ts index 9cb422bd317..6a97eefffa0 100644 --- a/infra/relay/src/environments/EnvironmentLinker.ts +++ b/infra/relay/src/environments/EnvironmentLinker.ts @@ -25,23 +25,40 @@ import * as RelayConfiguration from "../Config.ts"; export class EnvironmentLinkProofExpired extends Schema.TaggedErrorClass()( "EnvironmentLinkProofExpired", { + userId: Schema.String, + environmentId: Schema.String, expiresAt: Schema.String, }, ) { override get message(): string { - return `Environment link proof expired at ${this.expiresAt}`; + return `Environment '${this.environmentId}' link proof expired at ${this.expiresAt}`; } } export class EnvironmentLinkProofInvalid extends Schema.TaggedErrorClass()( "EnvironmentLinkProofInvalid", { + userId: Schema.String, environmentId: Schema.String, reason: RelayEnvironmentLinkProofInvalidReason, + stage: Schema.Literals([ + "decode_token", + "decode_payload", + "verify_proof", + "authorize_capabilities", + "validate_descriptor", + "verify_challenge", + "validate_expiration", + "consume_proof_nonce", + "consume_challenge_nonce", + "validate_origin", + "validate_endpoint", + ]), + cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { - return `Environment '${this.environmentId}' link proof is invalid: ${this.reason}`; + return `Environment '${this.environmentId}' link proof is invalid during ${this.stage}: ${this.reason}`; } } @@ -132,20 +149,27 @@ const make = Effect.gen(function* () { const nowSeconds = Math.floor(now.epochMilliseconds / 1_000); const unverified = yield* Effect.try({ try: () => decodeRelayJwt(input.request.proof), - catch: () => + catch: (cause) => new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: "unknown", reason: "invalid_signature_or_scope", + stage: "decode_token", + cause, }), }); - const decoded = yield* decodeProof(unverified).pipe(Effect.option); - if (decoded._tag === "None") { - return yield* new EnvironmentLinkProofInvalid({ - environmentId: "unknown", - reason: "invalid_signature_or_scope", - }); - } - const candidate = decoded.value; + const candidate = yield* decodeProof(unverified).pipe( + Effect.mapError( + (cause) => + new EnvironmentLinkProofInvalid({ + userId: input.userId, + environmentId: "unknown", + reason: "invalid_signature_or_scope", + stage: "decode_payload", + cause, + }), + ), + ); yield* Effect.annotateCurrentSpan({ "relay.environment_id": candidate.environmentId, "relay.link.notifications_enabled": input.request.notificationsEnabled, @@ -154,6 +178,8 @@ const make = Effect.gen(function* () { }); if (candidate.exp <= nowSeconds) { return yield* new EnvironmentLinkProofExpired({ + userId: input.userId, + environmentId: candidate.environmentId, expiresAt: DateTime.formatIso(DateTime.makeUnsafe(candidate.exp * 1_000)), }); } @@ -169,10 +195,13 @@ const make = Effect.gen(function* () { }).pipe( Effect.flatMap(decodeProof), Effect.mapError( - () => + (cause) => new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: candidate.environmentId, reason: "invalid_signature_or_scope", + stage: "verify_proof", + cause, }), ), ); @@ -181,14 +210,18 @@ const make = Effect.gen(function* () { !proofAuthorizesRequestedCapabilities(verified, input.request) ) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: candidate.environmentId, reason: "invalid_signature_or_scope", + stage: "authorize_capabilities", }); } if (verified.descriptor.environmentId !== verified.environmentId) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "descriptor_mismatch", + stage: "validate_descriptor", }); } const challenge = yield* relayTokens.verifyLinkChallenge({ @@ -203,15 +236,19 @@ const make = Effect.gen(function* () { }); if (challenge === null) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "challenge_invalid", + stage: "verify_challenge", }); } const expiresAt = DateTime.make(verified.exp * 1_000); if (expiresAt._tag === "None") { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "invalid_signature_or_scope", + stage: "validate_expiration", }); } const consumedNonce = yield* proofReplay.consume({ @@ -222,8 +259,10 @@ const make = Effect.gen(function* () { }); if (!consumedNonce) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "replayed_nonce", + stage: "consume_proof_nonce", }); } const consumedChallenge = yield* proofReplay.consume({ @@ -234,14 +273,18 @@ const make = Effect.gen(function* () { }); if (!consumedChallenge) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "challenge_invalid", + stage: "consume_challenge_nonce", }); } if (input.request.managedTunnelsEnabled && !isLoopbackManagedTunnelOrigin(verified.origin)) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "origin_not_allowed", + stage: "validate_origin", }); } const provisioned = input.request.managedTunnelsEnabled @@ -254,8 +297,10 @@ const make = Effect.gen(function* () { const endpoint = provisioned?.endpoint ?? verified.endpoint; if (!isSecureManagedEndpoint(endpoint)) { return yield* new EnvironmentLinkProofInvalid({ + userId: input.userId, environmentId: verified.environmentId, reason: "endpoint_not_secure", + stage: "validate_endpoint", }); } yield* links.upsert({ ...input, proof: verified, endpoint });