Skip to content
Draft
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
20 changes: 20 additions & 0 deletions apps/server/src/vcs/VcsProjectConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,24 @@ describe("VcsProjectConfig", () => {
}),
);
});

it.layer(TestLayer)("falls back to auto for invalid config JSON", (it) => {
it.effect("returns auto", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const root = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-vcs-config-test-",
});
const configDir = path.join(root, ".t3code");
yield* fileSystem.makeDirectory(configDir, { recursive: true });
yield* fileSystem.writeFileString(path.join(configDir, "vcs.json"), "{");

const config = yield* VcsProjectConfig.VcsProjectConfig;
const kind = yield* config.resolveKind({ cwd: root });

assert.equal(kind, "auto");
}),
);
});
});
41 changes: 15 additions & 26 deletions apps/server/src/vcs/VcsProjectConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";

Expand All @@ -15,16 +16,10 @@ const ProjectVcsConfig = Schema.Struct({
),
vcsKind: Schema.optional(VcsDriverKind),
});
const isProjectVcsConfig = Schema.is(ProjectVcsConfig);
const ProjectVcsConfigJson = Schema.fromJsonString(ProjectVcsConfig);
const decodeProjectVcsConfigJson = Schema.decodeUnknownOption(ProjectVcsConfigJson);

interface ProjectVcsConfigFile {
readonly vcs?:
| {
readonly kind?: VcsDriverKindType | undefined;
}
| undefined;
readonly vcsKind?: VcsDriverKindType | undefined;
}
type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type;

export interface VcsProjectConfigResolveInput {
readonly cwd: string;
Expand All @@ -45,14 +40,7 @@ function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto
return config.vcs?.kind ?? config.vcsKind ?? "auto";
}

function parseConfig(raw: string): ProjectVcsConfigFile | null {
try {
const parsed = JSON.parse(raw) as unknown;
return isProjectVcsConfig(parsed) ? parsed : null;
} catch {
return null;
}
}
const parseConfig = decodeProjectVcsConfigJson;

export const make = Effect.fn("makeVcsProjectConfig")(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand All @@ -63,12 +51,12 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
while (true) {
const candidate = path.join(current, ".t3code", "vcs.json");
if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) {
return candidate;
return Option.some(candidate);
}

const parent = path.dirname(current);
if (parent === current) {
return null;
return Option.none();
}
current = parent;
}
Expand All @@ -78,26 +66,27 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
configPath: string,
) {
const raw = yield* fileSystem.readFileString(configPath).pipe(
Effect.map(Option.some),
Effect.catch((error) =>
Effect.logWarning("failed to read VCS project config", {
configPath,
error,
}).pipe(Effect.as(null)),
}).pipe(Effect.as(Option.none<string>())),
),
);
if (raw === null) {
if (Option.isNone(raw)) {
return "auto" as const;
}

const parsed = parseConfig(raw);
if (parsed === null) {
const parsed = parseConfig(raw.value);
if (Option.isNone(parsed)) {
yield* Effect.logWarning("invalid VCS project config", {
configPath,
});
return "auto" as const;
}

return configuredKind(parsed);
return configuredKind(parsed.value);
});

const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn(
Expand All @@ -108,11 +97,11 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () {
}

const configPath = yield* findConfigPath(input.cwd);
if (configPath === null) {
if (Option.isNone(configPath)) {
return "auto";
}

return yield* readConfiguredKind(configPath);
return yield* readConfiguredKind(configPath.value);
});

return VcsProjectConfig.of({
Expand Down
138 changes: 77 additions & 61 deletions packages/shared/src/dpop.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as NodeCrypto from "node:crypto";

import { describe, expect, it } from "@effect/vitest";
import { assert, describe, it } from "@effect/vitest";

import {
computeDpopAccessTokenHash,
Expand Down Expand Up @@ -56,59 +56,63 @@ describe("verifyDpopProof", () => {

it("verifies an ES256 DPoP proof and returns the RFC 7638 thumbprint", () => {
const thumbprint = computeDpopJwkThumbprint(publicJwk);
expect(
verifyDpopProof({
proof,
method: "POST",
url: "https://example.com/oauth/token",
nowEpochSeconds: 101,
expectedThumbprint: thumbprint,
}),
).toMatchObject({
ok: true,
thumbprint,
jti: "proof-1",
const result = verifyDpopProof({
proof,
method: "POST",
url: "https://example.com/oauth/token",
nowEpochSeconds: 101,
expectedThumbprint: thumbprint,
});

if (!result.ok) {
assert.fail(result.reason);
}
assert.equal(result.thumbprint, thumbprint);
assert.equal(result.jti, "proof-1");
});

it("rejects method, URL, thumbprint, and time-window mismatches", () => {
const thumbprint = computeDpopJwkThumbprint(publicJwk);
expect(
assert.equal(
verifyDpopProof({
proof,
method: "GET",
url: "https://example.com/oauth/token",
nowEpochSeconds: 101,
expectedThumbprint: thumbprint,
}),
).toMatchObject({ ok: false });
expect(
}).ok,
false,
);
assert.equal(
verifyDpopProof({
proof,
method: "POST",
url: "https://example.com/other",
nowEpochSeconds: 101,
expectedThumbprint: thumbprint,
}),
).toMatchObject({ ok: false });
expect(
}).ok,
false,
);
assert.equal(
verifyDpopProof({
proof,
method: "POST",
url: "https://example.com/oauth/token",
nowEpochSeconds: 101,
expectedThumbprint: "other-thumbprint",
}),
).toMatchObject({ ok: false });
expect(
}).ok,
false,
);
assert.equal(
verifyDpopProof({
proof,
method: "POST",
url: "https://example.com/oauth/token",
nowEpochSeconds: 1_000,
expectedThumbprint: thumbprint,
}),
).toMatchObject({ ok: false });
}).ok,
false,
);
});

it("requires the RFC 9449 access token hash when an access token is expected", () => {
Expand All @@ -122,40 +126,48 @@ describe("verifyDpopProof", () => {
accessToken: "clerk-access-token",
});

expect(
assert.equal(
verifyDpopProof({
proof: accessTokenProof,
method: "POST",
url: "https://example.com/v1/environments/env/connect",
nowEpochSeconds: 101,
expectedThumbprint: thumbprint,
expectedAccessToken: "clerk-access-token",
}),
).toMatchObject({ ok: true });
expect(
verifyDpopProof({
proof,
method: "POST",
url: "https://example.com/oauth/token",
nowEpochSeconds: 101,
expectedThumbprint: thumbprint,
expectedAccessToken: "clerk-access-token",
}),
).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." });
expect(
verifyDpopProof({
proof: accessTokenProof,
method: "POST",
url: "https://example.com/v1/environments/env/connect",
nowEpochSeconds: 101,
expectedThumbprint: thumbprint,
expectedAccessToken: "other-access-token",
}),
).toMatchObject({ ok: false, reason: "DPoP access token hash mismatch." });
}).ok,
true,
);

const missingHash = verifyDpopProof({
proof,
method: "POST",
url: "https://example.com/oauth/token",
nowEpochSeconds: 101,
expectedThumbprint: thumbprint,
expectedAccessToken: "clerk-access-token",
});
if (missingHash.ok) {
assert.fail("Expected DPoP proof without an access token hash to fail.");
}
assert.equal(missingHash.reason, "DPoP access token hash mismatch.");

const mismatchedHash = verifyDpopProof({
proof: accessTokenProof,
method: "POST",
url: "https://example.com/v1/environments/env/connect",
nowEpochSeconds: 101,
expectedThumbprint: thumbprint,
expectedAccessToken: "other-access-token",
});
if (mismatchedHash.ok) {
assert.fail("Expected DPoP proof with a mismatched access token hash to fail.");
}
assert.equal(mismatchedHash.reason, "DPoP access token hash mismatch.");
});

it("normalizes htu by excluding query and fragment components per RFC 9449", () => {
expect(normalizeDpopHtu("https://example.com/v1/environments/env/connect?foo=bar#frag")).toBe(
assert.equal(
normalizeDpopHtu("https://example.com/v1/environments/env/connect?foo=bar#frag"),
"https://example.com/v1/environments/env/connect",
);

Expand All @@ -168,15 +180,16 @@ describe("verifyDpopProof", () => {
publicJwk,
});

expect(
assert.equal(
verifyDpopProof({
proof: queryProof,
method: "POST",
url: "https://example.com/v1/environments/env/connect?foo=bar#frag",
nowEpochSeconds: 101,
expectedThumbprint: thumbprint,
}),
).toMatchObject({ ok: true });
}).ok,
true,
);
});

it("rejects DPoP public JWK headers that expose private key material", () => {
Expand All @@ -192,14 +205,17 @@ describe("verifyDpopProof", () => {
publicJwk: privateJwk,
});

expect(
verifyDpopProof({
proof: proofWithPrivateJwk,
method: "POST",
url: "https://example.com/oauth/token",
nowEpochSeconds: 101,
expectedThumbprint: thumbprint,
}),
).toMatchObject({ ok: false, reason: "Invalid DPoP JWT header." });
const result = verifyDpopProof({
proof: proofWithPrivateJwk,
method: "POST",
url: "https://example.com/oauth/token",
nowEpochSeconds: 101,
expectedThumbprint: thumbprint,
});

if (result.ok) {
assert.fail("Expected DPoP proof with private JWK material to fail.");
}
assert.equal(result.reason, "Invalid DPoP JWT header.");
});
});
Loading
Loading