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
47 changes: 47 additions & 0 deletions apps/server/src/auth/PairingGrantStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ import { expect, it } from "@effect/vitest";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as TestClock from "effect/testing/TestClock";

import * as ServerConfig from "../config.ts";
import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts";
import { PersistenceSqlError } from "../persistence/Errors.ts";
import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts";
import * as PairingGrantStore from "./PairingGrantStore.ts";

Expand Down Expand Up @@ -33,6 +36,26 @@ const makePairingGrantStoreLayer = (
Layer.provide(makeServerConfigLayer(overrides)),
);

const makePairingGrantStoreTestLayer = (
overrides: Partial<AuthPairingLinks.AuthPairingLinkRepository["Service"]>,
) =>
Layer.effect(PairingGrantStore.PairingGrantStore, PairingGrantStore.make).pipe(
Layer.provide(
Layer.succeed(
AuthPairingLinks.AuthPairingLinkRepository,
AuthPairingLinks.AuthPairingLinkRepository.of({
create: () => Effect.void,
consumeAvailable: () => Effect.succeed(Option.none()),
listActive: () => Effect.succeed([]),
revoke: () => Effect.succeed(false),
getByCredential: () => Effect.succeed(Option.none()),
...overrides,
}),
),
),
Layer.provide(makeServerConfigLayer()),
);

it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
it.effect("issues pairing tokens in a short manual-entry format", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -186,4 +209,28 @@ it.layer(NodeServices.layer)("PairingGrantStore.layer", (it) => {
expect(revokedConsume._tag).toBe("UnavailableBootstrapCredentialError");
}).pipe(Effect.provide(makePairingGrantStoreLayer())),
);

it.effect("identifies consume-available failures and preserves their cause", () => {
const repositoryFailure = new PersistenceSqlError({
operation: "consume-pairing-link",
detail: "Database unavailable",
cause: new Error("database unavailable"),
});

return Effect.gen(function* () {
const pairingGrants = yield* PairingGrantStore.PairingGrantStore;
const error = yield* Effect.flip(pairingGrants.consume("credential"));

if (error._tag !== "BootstrapCredentialConsumeAvailableError") {
return yield* Effect.die(error);
}
expect(error.cause).toBe(repositoryFailure);
}).pipe(
Effect.provide(
makePairingGrantStoreTestLayer({
consumeAvailable: () => Effect.fail(repositoryFailure),
}),
),
);
});
});
160 changes: 112 additions & 48 deletions apps/server/src/auth/PairingGrantStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,22 +88,38 @@ export class ActivePairingLinksLoadError extends Schema.TaggedErrorClass<ActiveP
export class PairingLinkRevokeError extends Schema.TaggedErrorClass<PairingLinkRevokeError>()(
"PairingLinkRevokeError",
{
pairingLinkId: Schema.String,
cause: Schema.Defect(),
},
) {
override get message(): string {
return "Failed to revoke pairing link.";
return `Failed to revoke pairing link '${this.pairingLinkId}'.`;
}
}

export class PairingCredentialIssueError extends Schema.TaggedErrorClass<PairingCredentialIssueError>()(
"PairingCredentialIssueError",
{
pairingLinkId: Schema.String,
subject: Schema.String,
label: Schema.optional(Schema.String),
cause: Schema.Defect(),
},
) {
override get message(): string {
return "Failed to issue pairing credential.";
return `Failed to issue pairing credential '${this.pairingLinkId}' for '${this.subject}'.`;
}
}

export class PairingCredentialRandomGenerationError extends Schema.TaggedErrorClass<PairingCredentialRandomGenerationError>()(
"PairingCredentialRandomGenerationError",
{
operation: Schema.Literals(["generate-id", "generate-token"]),
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Failed to generate pairing credential data during '${this.operation}'.`;
}
}

Expand All @@ -118,11 +134,36 @@ export class BootstrapCredentialConsumeError extends Schema.TaggedErrorClass<Boo
}
}

export class BootstrapCredentialConsumeAvailableError extends Schema.TaggedErrorClass<BootstrapCredentialConsumeAvailableError>()(
"BootstrapCredentialConsumeAvailableError",
{
cause: Schema.Defect(),
},
) {
override get message(): string {
return "Failed to atomically consume an available bootstrap credential.";
}
}

export class BootstrapCredentialLookupError extends Schema.TaggedErrorClass<BootstrapCredentialLookupError>()(
"BootstrapCredentialLookupError",
{
cause: Schema.Defect(),
},
) {
override get message(): string {
return "Failed to look up bootstrap credential state.";
}
}

export const BootstrapCredentialInternalError = Schema.Union([
ActivePairingLinksLoadError,
PairingLinkRevokeError,
PairingCredentialIssueError,
PairingCredentialRandomGenerationError,
BootstrapCredentialConsumeError,
BootstrapCredentialConsumeAvailableError,
BootstrapCredentialLookupError,
]);
export type BootstrapCredentialInternalError = typeof BootstrapCredentialInternalError.Type;
export const isBootstrapCredentialInternalError = Schema.is(BootstrapCredentialInternalError);
Expand Down Expand Up @@ -207,7 +248,14 @@ export const make = Effect.gen(function* () {
const generatePairingToken = Effect.gen(function* () {
let credential = "";
while (credential.length < PAIRING_TOKEN_LENGTH) {
const bytes = yield* crypto.randomBytes(PAIRING_TOKEN_LENGTH);
const bytes = yield* crypto
.randomBytes(PAIRING_TOKEN_LENGTH)
.pipe(
Effect.mapError(
(cause) =>
new PairingCredentialRandomGenerationError({ operation: "generate-token", cause }),
),
);
for (const byte of bytes) {
if (byte >= PAIRING_TOKEN_REJECTION_LIMIT) {
continue;
Expand Down Expand Up @@ -287,58 +335,73 @@ export const make = Effect.gen(function* () {
const revoke: PairingGrantStore["Service"]["revoke"] = Effect.fn("PairingGrantStore.revoke")(
function* (id) {
const revokedAt = yield* DateTime.now;
const revoked = yield* pairingLinks.revoke({
id,
revokedAt,
});
const revoked = yield* pairingLinks
.revoke({
id,
revokedAt,
})
.pipe(Effect.mapError((cause) => new PairingLinkRevokeError({ pairingLinkId: id, cause })));
if (revoked) {
yield* emitRemoved(id);
}
return revoked;
},
Effect.mapError((cause) => new PairingLinkRevokeError({ cause })),
);

const issueOneTimeToken: PairingGrantStore["Service"]["issueOneTimeToken"] = Effect.fn(
"PairingGrantStore.issueOneTimeToken",
)(
function* (input) {
const id = yield* crypto.randomUUIDv4;
const credential = yield* generatePairingToken;
const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES;
const now = yield* DateTime.now;
const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) });
const issued: IssuedBootstrapCredential = {
id,
credential,
...(input?.label ? { label: input.label } : {}),
...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}),
expiresAt,
};
yield* pairingLinks.create({
)(function* (input) {
const id = yield* crypto.randomUUIDv4.pipe(
Effect.mapError(
(cause) => new PairingCredentialRandomGenerationError({ operation: "generate-id", cause }),
),
);
const credential = yield* generatePairingToken;
const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES;
const now = yield* DateTime.now;
const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) });
const issued: IssuedBootstrapCredential = {
id,
credential,
...(input?.label ? { label: input.label } : {}),
...(input?.proofKeyThumbprint ? { proofKeyThumbprint: input.proofKeyThumbprint } : {}),
expiresAt,
};
const subject = input?.subject ?? "one-time-token";
yield* pairingLinks
.create({
id,
credential,
method: "one-time-token",
scopes: input?.scopes ?? AuthStandardClientScopes,
subject: input?.subject ?? "one-time-token",
subject,
label: input?.label ?? null,
proofKeyThumbprint: input?.proofKeyThumbprint ?? null,
createdAt: now,
expiresAt: expiresAt,
});
yield* emitUpsert({
id,
credential,
scopes: input?.scopes ?? AuthStandardClientScopes,
subject: input?.subject ?? "one-time-token",
...(input?.label ? { label: input.label } : {}),
createdAt: now,
expiresAt,
});
return issued;
},
Effect.mapError((cause) => new PairingCredentialIssueError({ cause })),
);
})
.pipe(
Effect.mapError(
(cause) =>
new PairingCredentialIssueError({
pairingLinkId: id,
subject,
...(input?.label ? { label: input.label } : {}),
cause,
}),
),
);
yield* emitUpsert({
id,
credential,
scopes: input?.scopes ?? AuthStandardClientScopes,
subject: input?.subject ?? "one-time-token",
...(input?.label ? { label: input.label } : {}),
createdAt: now,
expiresAt,
});
return issued;
});

const consume: PairingGrantStore["Service"]["consume"] = Effect.fn("PairingGrantStore.consume")(
function* (credential, input) {
Expand Down Expand Up @@ -420,12 +483,14 @@ export const make = Effect.gen(function* () {
return yield* seededResult.error;
}

const consumed = yield* pairingLinks.consumeAvailable({
credential,
proofKeyThumbprint: input?.proofKeyThumbprint ?? null,
consumedAt: now,
now,
});
const consumed = yield* pairingLinks
.consumeAvailable({
credential,
proofKeyThumbprint: input?.proofKeyThumbprint ?? null,
consumedAt: now,
now,
})
.pipe(Effect.mapError((cause) => new BootstrapCredentialConsumeAvailableError({ cause })));

if (Option.isSome(consumed)) {
yield* emitRemoved(consumed.value.id);
Expand All @@ -441,7 +506,9 @@ export const make = Effect.gen(function* () {
} satisfies BootstrapGrant;
}

const matching = yield* pairingLinks.getByCredential({ credential });
const matching = yield* pairingLinks
.getByCredential({ credential })
.pipe(Effect.mapError((cause) => new BootstrapCredentialLookupError({ cause })));
if (Option.isNone(matching)) {
return yield* new UnknownBootstrapCredentialError({});
}
Expand All @@ -467,9 +534,6 @@ export const make = Effect.gen(function* () {

return yield* new UnavailableBootstrapCredentialError({});
},
Effect.mapError((cause) =>
isBootstrapCredentialError(cause) ? cause : new BootstrapCredentialConsumeError({ cause }),
),
);

return PairingGrantStore.of({
Expand Down
Loading