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
131 changes: 70 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,59 @@ 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.isFalse(
verifyDpopProof({
proof,
method: "GET",
url: "https://example.com/oauth/token",
nowEpochSeconds: 101,
expectedThumbprint: thumbprint,
}),
).toMatchObject({ ok: false });
expect(
}).ok,
);
assert.isFalse(
verifyDpopProof({
proof,
method: "POST",
url: "https://example.com/other",
nowEpochSeconds: 101,
expectedThumbprint: thumbprint,
}),
).toMatchObject({ ok: false });
expect(
}).ok,
);
assert.isFalse(
verifyDpopProof({
proof,
method: "POST",
url: "https://example.com/oauth/token",
nowEpochSeconds: 101,
expectedThumbprint: "other-thumbprint",
}),
).toMatchObject({ ok: false });
expect(
}).ok,
);
assert.isFalse(
verifyDpopProof({
proof,
method: "POST",
url: "https://example.com/oauth/token",
nowEpochSeconds: 1_000,
expectedThumbprint: thumbprint,
}),
).toMatchObject({ ok: false });
}).ok,
);
});

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

expect(
assert.isTrue(
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,
);

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

const mismatchedAccessTokenHash = verifyDpopProof({
proof: accessTokenProof,
method: "POST",
url: "https://example.com/v1/environments/env/connect",
nowEpochSeconds: 101,
expectedThumbprint: thumbprint,
expectedAccessToken: "other-access-token",
});
if (mismatchedAccessTokenHash.ok) {
assert.fail("Expected mismatched access token hash to be rejected.");
}
assert.equal(mismatchedAccessTokenHash.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 +175,15 @@ describe("verifyDpopProof", () => {
publicJwk,
});

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

it("rejects DPoP public JWK headers that expose private key material", () => {
Expand All @@ -192,14 +199,16 @@ 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 private JWK material to be rejected.");
}
assert.equal(result.reason, "Invalid DPoP JWT header.");
});
});
85 changes: 39 additions & 46 deletions packages/shared/src/dpop.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { p256 } from "@noble/curves/nist";
import { sha256 } from "@noble/hashes/sha2";
import * as Encoding from "effect/Encoding";
import * as Option from "effect/Option";
import * as Result from "effect/Result";
import * as Schema from "effect/Schema";

Expand All @@ -17,21 +18,27 @@ export const DpopPublicJwk = Schema.Struct({
y: Schema.String.check(Schema.isNonEmpty()),
});
export type DpopPublicJwk = typeof DpopPublicJwk.Type;
const isDpopPublicJwk = Schema.is(DpopPublicJwk);

interface DpopJwtHeader {
readonly typ: string;
readonly alg: string;
readonly jwk: DpopPublicJwk;
}
const DpopJwtHeader = Schema.Struct({
typ: Schema.Literal(DPOP_TYP),
alg: Schema.Literal(DPOP_ALG),
jwk: DpopPublicJwk,
});
type DpopJwtHeader = typeof DpopJwtHeader.Type;

const DpopJwtPayload = Schema.Struct({
htm: Schema.String.check(Schema.isNonEmpty()),
htu: Schema.String.check(Schema.isNonEmpty()),
jti: Schema.String.check(Schema.isNonEmpty()),
iat: Schema.Int,
ath: Schema.optional(Schema.String),
});
type DpopJwtPayload = typeof DpopJwtPayload.Type;

interface DpopJwtPayload {
readonly htm: string;
readonly htu: string;
readonly jti: string;
readonly iat: number;
readonly ath?: string;
}
const decodeDpopJwtHeaderJson = Schema.decodeUnknownOption(Schema.fromJsonString(DpopJwtHeader), {
onExcessProperty: "preserve",
});
const decodeDpopJwtPayloadJson = Schema.decodeUnknownOption(Schema.fromJsonString(DpopJwtPayload));

export type DpopVerificationResult =
| {
Expand All @@ -49,40 +56,24 @@ function base64UrlToBytes(value: string): Uint8Array {
return Result.getOrThrow(Encoding.decodeBase64Url(value));
}

function decodeBase64UrlJson(value: string): unknown {
return JSON.parse(Result.getOrThrow(Encoding.decodeBase64UrlString(value))) as unknown;
function decodeBase64UrlJsonOption<T>(
value: string,
decode: (input: unknown) => Option.Option<T>,
): Option.Option<T> {
const decoded = Encoding.decodeBase64UrlString(value);
return Result.isFailure(decoded) ? Option.none() : decode(decoded.success);
}

function isDpopJwtHeader(value: unknown): value is DpopJwtHeader {
if (typeof value !== "object" || value === null) {
return false;
function decodeDpopJwtHeader(value: string): Option.Option<DpopJwtHeader> {
const header = decodeBase64UrlJsonOption(value, decodeDpopJwtHeaderJson);
if (Option.isNone(header)) {
return Option.none();
}
const record = value as Record<string, unknown>;
return (
record.typ === DPOP_TYP &&
record.alg === DPOP_ALG &&
typeof record.jwk === "object" &&
record.jwk !== null &&
!("d" in record.jwk) &&
isDpopPublicJwk(record.jwk)
);
return "d" in header.value.jwk ? Option.none() : header;
}

function isDpopJwtPayload(value: unknown): value is DpopJwtPayload {
if (typeof value !== "object" || value === null) {
return false;
}
const record = value as Record<string, unknown>;
return (
typeof record.htm === "string" &&
record.htm.length > 0 &&
typeof record.htu === "string" &&
record.htu.length > 0 &&
typeof record.jti === "string" &&
record.jti.length > 0 &&
typeof record.iat === "number" &&
Number.isInteger(record.iat)
);
function decodeDpopJwtPayload(value: string): Option.Option<DpopJwtPayload> {
return decodeBase64UrlJsonOption(value, decodeDpopJwtPayloadJson);
}

function dpopThumbprintInput(jwk: DpopPublicJwk): string {
Expand Down Expand Up @@ -145,14 +136,16 @@ export function verifyDpopProof(input: {
}

try {
const header = decodeBase64UrlJson(parts[0]);
const payload = decodeBase64UrlJson(parts[1]);
if (!isDpopJwtHeader(header)) {
const headerOption = decodeDpopJwtHeader(parts[0]);
if (Option.isNone(headerOption)) {
return { ok: false, reason: "Invalid DPoP JWT header." };
}
if (!isDpopJwtPayload(payload)) {
const payloadOption = decodeDpopJwtPayload(parts[1]);
if (Option.isNone(payloadOption)) {
return { ok: false, reason: "Invalid DPoP JWT payload." };
}
const header = headerOption.value;
const payload = payloadOption.value;

const thumbprint = computeDpopJwkThumbprint(header.jwk);
if (input.expectedThumbprint && thumbprint !== input.expectedThumbprint) {
Expand Down
55 changes: 51 additions & 4 deletions packages/ssh/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Data from "effect/Data";
import * as Schema from "effect/Schema";

export class SshHostDiscoveryError extends Data.TaggedError("SshHostDiscoveryError")<{
readonly message: string;
Expand Down Expand Up @@ -36,10 +37,56 @@ export class SshHttpBridgeError extends Data.TaggedError("SshHttpBridgeError")<{
readonly cause?: unknown;
}> {}

export class SshReadinessError extends Data.TaggedError("SshReadinessError")<{
readonly message: string;
readonly cause?: unknown;
}> {}
export class SshReadinessProbeFailedError extends Schema.TaggedErrorClass<SshReadinessProbeFailedError>()(
"SshReadinessProbeFailedError",
{
requestUrl: Schema.String,
attempt: Schema.Number,
cause: Schema.Defect(),
},
) {
override get message(): string {
return `Backend readiness probe failed at ${this.requestUrl}.`;
}
}

export class SshReadinessProbeTimedOutError extends Schema.TaggedErrorClass<SshReadinessProbeTimedOutError>()(
"SshReadinessProbeTimedOutError",
{
requestUrl: Schema.String,
attempt: Schema.Number,
probeTimeoutMs: Schema.Number,
},
) {
override get message(): string {
return `Backend readiness probe exceeded ${this.probeTimeoutMs}ms at ${this.requestUrl}.`;
}
}

export class SshReadinessTimedOutError extends Schema.TaggedErrorClass<SshReadinessTimedOutError>()(
"SshReadinessTimedOutError",
{
baseUrl: Schema.String,
requestUrl: Schema.String,
timeoutMs: Schema.Number,
intervalMs: Schema.Number,
probeTimeoutMs: Schema.Number,
attempts: Schema.Number,
lastFailure: Schema.optional(Schema.Defect()),
},
) {
override get message(): string {
return `Timed out waiting ${this.timeoutMs}ms for backend readiness at ${this.baseUrl}.`;
}
}

export const SshReadinessError = Schema.Union([
SshReadinessProbeFailedError,
SshReadinessProbeTimedOutError,
SshReadinessTimedOutError,
]);
export type SshReadinessError = typeof SshReadinessError.Type;
export const isSshReadinessError = Schema.is(SshReadinessError);

export class SshPasswordPromptError extends Data.TaggedError("SshPasswordPromptError")<{
readonly message: string;
Expand Down
Loading
Loading