Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions infra/relay/src/auth/DpopProofs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,30 @@ describe("DpopProofReplay", () => {
Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))),
);
});

it.effect("retains the prune cutoff and database failure", () => {
const cause = new Error("database unavailable");
const fakeDb = {
delete: (table: unknown) => {
expect(table).toBe(relayDpopProofs);
return {
where: () => Effect.fail(cause),
};
},
} as unknown as RelayDb.RelayDb["Service"];

return Effect.gen(function* () {
const replay = yield* DpopProofs.DpopProofReplay;
const error = yield* Effect.flip(replay.pruneExpired);

expect(error).toMatchObject({
_tag: "DpopProofReplayPersistenceError",
operation: "prune-expired",
});
expect(Date.parse(error.expiresBefore ?? "")).not.toBeNaN();
expect(error.cause).toBe(cause);
}).pipe(
Effect.provide(DpopProofs.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)))),
);
});
});
41 changes: 33 additions & 8 deletions infra/relay/src/auth/DpopProofs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,16 @@ import { relayDpopProofs } from "../persistence/schema.ts";
export class DpopProofReplayPersistenceError extends Schema.TaggedErrorClass<DpopProofReplayPersistenceError>()(
"DpopProofReplayPersistenceError",
{
operation: Schema.Literals(["consume", "prune-expired"]),
thumbprint: Schema.optionalKey(Schema.String),
jti: Schema.optionalKey(Schema.String),
iat: Schema.optionalKey(Schema.Number),
expiresBefore: Schema.optionalKey(Schema.String),
cause: Schema.Defect(),
},
) {
override get message(): string {
return "Failed to persist DPoP proof replay state";
return `Failed to persist DPoP proof replay state during '${this.operation}'`;
}
}

Expand Down Expand Up @@ -58,10 +63,21 @@ const make = Effect.gen(function* () {
createdAt,
})
.onConflictDoNothing()
.returning({ jti: relayDpopProofs.jti });
.returning({ jti: relayDpopProofs.jti })
.pipe(
Effect.mapError(
(cause) =>
new DpopProofReplayPersistenceError({
operation: "consume",
thumbprint: input.thumbprint,
jti: input.jti,
iat: input.iat,
cause,
}),
),
);
return inserted.length > 0;
},
Effect.mapError((cause) => new DpopProofReplayPersistenceError({ cause })),
);

const verifyAndConsume: DpopProofReplay["Service"]["verifyAndConsume"] = Effect.fn(
Expand Down Expand Up @@ -114,11 +130,20 @@ const make = Effect.gen(function* () {
const pruneExpired: DpopProofReplay["Service"]["pruneExpired"] = Effect.gen(function* () {
const now = DateTime.formatIso(yield* DateTime.now);
yield* Effect.annotateCurrentSpan({ "relay.dpop_prune.before": now });
yield* db.delete(relayDpopProofs).where(lt(relayDpopProofs.expiresAt, now));
}).pipe(
Effect.withSpan("relay.dpop_proofs.prune_expired"),
Effect.mapError((cause) => new DpopProofReplayPersistenceError({ cause })),
);
yield* db
.delete(relayDpopProofs)
.where(lt(relayDpopProofs.expiresAt, now))
.pipe(
Effect.mapError(
(cause) =>
new DpopProofReplayPersistenceError({
operation: "prune-expired",
expiresBefore: now,
cause,
}),
),
);
}).pipe(Effect.withSpan("relay.dpop_proofs.prune_expired"));

return DpopProofReplay.of({
verifyAndConsume,
Expand Down
14 changes: 11 additions & 3 deletions infra/relay/src/auth/DpopProofs.verifyAndConsume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ describe("DpopProofReplay.verifyAndConsume", () => {
iat: Math.floor(now.epochMilliseconds / 1_000),
jti: "proof-persistence-failure",
});
const cause = "database unavailable";
const cause = { _tag: "DatabaseUnavailable" } as const;

return Effect.gen(function* () {
const replay = yield* DpopProofs.DpopProofReplay;
Expand All @@ -177,8 +177,16 @@ describe("DpopProofReplay.verifyAndConsume", () => {
}),
);

expect(error).toEqual(new DpopProofs.DpopProofReplayPersistenceError({ cause }));
}).pipe(Effect.provide(layer(() => Effect.fail({ _tag: cause }))));
expect(error).toMatchObject({
_tag: "DpopProofReplayPersistenceError",
operation: "consume",
thumbprint: proof.thumbprint,
jti: "proof-persistence-failure",
iat: Math.floor(now.epochMilliseconds / 1_000),
});
expect(error.cause).toBe(cause);
expect(error).not.toHaveProperty("proof");
}).pipe(Effect.provide(layer(() => Effect.fail(cause))));
});

it.effect("accepts proofs bound to the access token hash", () => {
Expand Down
82 changes: 82 additions & 0 deletions infra/relay/src/environments/EnvironmentCredentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,88 @@ import { relayEnvironmentCredentials } from "../persistence/schema.ts";
import * as EnvironmentCredentials from "./EnvironmentCredentials.ts";

describe("EnvironmentCredentials", () => {
it.effect("reports the credential creation persistence stage and preserves its cause", () => {
const cause = new Error("database unavailable");
const fakeDb = {
insert: (table: unknown) => {
expect(table).toBe(relayEnvironmentCredentials);
return {
values: () => Effect.void,
};
},
update: (table: unknown) => {
expect(table).toBe(relayEnvironmentCredentials);
return {
set: () => ({
where: () => Effect.fail(cause),
}),
};
},
} as unknown as RelayDb.RelayDb["Service"];

return Effect.gen(function* () {
const credentials = yield* EnvironmentCredentials.EnvironmentCredentials;
const error = yield* Effect.flip(
credentials.create({
environmentId: "env_test",
environmentPublicKey: "sensitive-public-key-material",
}),
);

expect(error).toMatchObject({
_tag: "EnvironmentCredentialCreatePersistenceError",
stage: "revoke-previous-credentials",
environmentId: "env_test",
});
expect(error.credentialId).toMatch(/^[0-9a-f]{64}$/);
expect(error.cause).toBe(cause);
expect(error).not.toHaveProperty("environmentPublicKey");
}).pipe(
Effect.provide(
EnvironmentCredentials.layer.pipe(
Layer.provide(NodeCryptoLayer.layer),
Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)),
),
),
);
});

it.effect("does not retain credential tokens when lookup persistence fails", () => {
const cause = new Error("database unavailable");
const token = "t3env_sensitive-credential-token";
const fakeDb = {
select: () => ({
from: (table: unknown) => {
expect(table).toBe(relayEnvironmentCredentials);
return {
where: () => ({
limit: () => Effect.fail(cause),
}),
};
},
}),
} as unknown as RelayDb.RelayDb["Service"];

return Effect.gen(function* () {
const credentials = yield* EnvironmentCredentials.EnvironmentCredentials;
const error = yield* Effect.flip(credentials.authenticate(token));

expect(error).toMatchObject({
_tag: "EnvironmentCredentialAuthenticatePersistenceError",
stage: "lookup-credential",
});
expect(error.cause).toBe(cause);
expect(error).not.toHaveProperty("token");
}).pipe(
Effect.provide(
EnvironmentCredentials.layer.pipe(
Layer.provide(NodeCryptoLayer.layer),
Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb)),
),
),
);
});

it.effect(
"creates opaque credentials and revokes only older credentials for the same key",
() => {
Expand Down
Loading
Loading