Skip to content
Open
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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@ All notable changes to this repository are documented here. The format is loosel

### Security

- **`@eep-dev/middleware` (TypeScript) and `eep-middleware` (Python) — auth
adapters now fail closed and verify credentials.** The `JWTAuthAdapter`
previously decoded the JWT payload and emitted `did_verified` identity and
capability proofs **without checking the signature or `alg`**, so any
attacker-crafted (including `alg: none`) token granted whatever the claims
asserted. It now rejects `alg: none` unconditionally, verifies HS256/384/512
signatures against a configured `secret` (constant-time), delegates asymmetric
algorithms to a `verifyToken` / `verify_token` callback, enforces
`exp`/`nbf`/`iat` with a configurable clock-skew tolerance, and emits nothing
(warning once) when no verification material is configured. The TypeScript
`OAuthAuthAdapter` no longer trusts a client-supplied `X-OAuth-Scope` header;
it requires an RFC 7662 `introspect` callback and reads scope/subject only from
the authorization server's response. Surfaced by the EEP protocol audit.

- **`eep-middleware` (Python) — removed a gate bypass and added webhook SSRF
validation.** `EEPServer.resolve_gated_resource` previously granted premium
access to any request carrying a hard-coded `{"type":"payment","token":"tok_valid"}`
Expand Down
40 changes: 40 additions & 0 deletions packages/@eep-dev/middleware/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,46 @@ Exports:
- `createEEPApp` — Hono (`@eep-dev/middleware/hono`)
- `createEEPMiddleware` — Koa (`@eep-dev/middleware/koa`)

## Authentication adapters

Auth adapters turn inbound credentials into EEP **proofs**. They fail closed: a token is trusted
only after its signature (or the authorization server) is verified. An adapter that cannot verify
anything emits no proofs (and logs a one-time warning) rather than trusting attacker-controlled input.

- **`JWTAuthAdapter`** — verifies the JWT before emitting `did_verified` / capability proofs.
`alg: none` is always rejected, and expired / not-yet-valid tokens are rejected (60s default skew).
- HS256/384/512: pass a shared `secret`.
- RSA / ECDSA / EdDSA: pass a `verifyToken` callback (e.g. wrapping `jose`) that returns the
verified claims, or `null` if the token does not verify.
- With neither `secret` nor `verifyToken`, the adapter emits no proofs.

```typescript
import { JWTAuthAdapter } from "@eep-dev/middleware";

const auth = new JWTAuthAdapter({ secret: process.env.JWT_SECRET });
// asymmetric, delegating verification to your JWT library:
// new JWTAuthAdapter({ verifyToken: async (t) => (await jwtVerify(t, key)).payload });
```

- **`OAuthAuthAdapter`** — requires an RFC 7662 `introspect` callback. Granted scope and the subject
DID come from the authorization server's response, never from a client-supplied `X-OAuth-Scope` header.

```typescript
import { OAuthAuthAdapter } from "@eep-dev/middleware";

const auth = new OAuthAuthAdapter({
introspect: async (token) => {
const res = await fetch(introspectionUrl, {
method: "POST",
body: new URLSearchParams({ token })
});
return res.json(); // { active: boolean, scope?: string, sub?: string }
}
});
```

- **`APIKeyAuthAdapter`** — resolves an API key to `{ did, capabilities }` via your `resolver`.

## After `setup-cli`

Generate config with `@eep-dev/setup-cli`, then wire runtime using **[integrate-eep-after-setup-cli.md](../../../docs/guides/integrate-eep-after-setup-cli.md)**.
Expand Down
272 changes: 226 additions & 46 deletions packages/@eep-dev/middleware/src/auth/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,240 @@
import { describe, expect, it } from "vitest";
import { createHmac } from "crypto";
import { afterEach, describe, expect, it, vi } from "vitest";
import { JWTAuthAdapter } from "./jwt.js";

function encode(payload: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
return `${header}.${body}.sig`;
const SECRET = "test-shared-secret-at-least-32-bytes-long!";

function b64url(input: string): string {
return Buffer.from(input, "utf8").toString("base64url");
}

const HMAC_HASH: Record<string, string> = {
HS256: "sha256",
HS384: "sha384",
HS512: "sha512"
};

/** Produce a real HS* JWT signed with `secret`. */
function signHS(
payload: Record<string, unknown>,
secret: string = SECRET,
alg: "HS256" | "HS384" | "HS512" = "HS256",
extraHeader: Record<string, unknown> = {}
): string {
const header = b64url(JSON.stringify({ alg, typ: "JWT", ...extraHeader }));
const body = b64url(JSON.stringify(payload));
const data = `${header}.${body}`;
const sig = createHmac(HMAC_HASH[alg], secret).update(data).digest("base64url");
return `${data}.${sig}`;
}

/** Produce a token with an arbitrary `alg` header and a dummy signature. */
function tokenWithAlg(payload: Record<string, unknown>, alg: string): string {
const header = b64url(JSON.stringify({ alg, typ: "JWT" }));
const body = b64url(JSON.stringify(payload));
return `${header}.${body}.ZHVtbXk`;
}

function bearer(token: string) {
return { method: "GET" as const, path: "/", headers: { authorization: `Bearer ${token}` } };
}

describe("JWTAuthAdapter", () => {
it("extracts identity and capabilities from bearer token", async () => {
const token = encode({ sub: "did:web:alice.example", scope: "profile.read profile.write" });
afterEach(() => {
vi.restoreAllMocks();
});

describe("JWTAuthAdapter — fail-closed verification", () => {
it("warns once and emits no proofs when neither secret nor verifyToken is configured", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const adapter = new JWTAuthAdapter();
const proofs = await adapter.extractProofs({
method: "GET",
path: "/",
headers: { authorization: `Bearer ${token}` }
});
expect(proofs.length).toBe(2);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn.mock.calls[0]?.[0]).toMatch(/JWTAuthAdapter/);

// Even a structurally valid (but unverifiable) token grants nothing.
const token = signHS({ sub: "did:web:alice.example", scope: "profile.read profile.write" });
expect(await adapter.extractProofs(bearer(token))).toEqual([]);
});

it("returns empty proofs for invalid tokens", async () => {
const adapter = new JWTAuthAdapter({ didClaim: "did", capabilityClaim: "caps" });
const missingHeader = await adapter.extractProofs({
method: "GET",
path: "/",
headers: {}
});
expect(missingHeader).toEqual([]);
it("extracts identity + capability proofs from a token correctly signed with the configured secret", async () => {
const adapter = new JWTAuthAdapter({ secret: SECRET });
const token = signHS({ sub: "did:web:alice.example", scope: "profile.read profile.write" });
const proofs = await adapter.extractProofs(bearer(token));
expect(proofs).toEqual([
{ type: "identity", method: "did_verified", evidence: "did:web:alice.example" },
{ type: "capability", declared_capabilities: ["profile.read", "profile.write"] }
]);
});

const malformed = await adapter.extractProofs({
method: "GET",
path: "/",
headers: { authorization: "Bearer bad-token" }
});
expect(malformed).toEqual([]);
it("verifies HS384 and HS512 tokens when alg is in the allowlist", async () => {
const adapter = new JWTAuthAdapter({ secret: SECRET });
const t384 = signHS({ sub: "did:web:a" }, SECRET, "HS384");
const t512 = signHS({ sub: "did:web:b" }, SECRET, "HS512");
expect(await adapter.extractProofs(bearer(t384))).toHaveLength(1);
expect(await adapter.extractProofs(bearer(t512))).toHaveLength(1);
});

const parseFailure = await adapter.extractProofs({
method: "GET",
path: "/",
headers: { authorization: "Bearer a.b@d.c" }
});
expect(parseFailure).toEqual([]);
it("rejects a token whose header carries no string alg", async () => {
const adapter = new JWTAuthAdapter({ secret: SECRET });
const header = b64url(JSON.stringify({ typ: "JWT" }));
const body = b64url(JSON.stringify({ sub: "did:web:a" }));
expect(await adapter.extractProofs(bearer(`${header}.${body}.sig`))).toEqual([]);
});

const noClaimsToken = encode({});
const noClaims = await adapter.extractProofs({
method: "GET",
path: "/",
headers: { authorization: `Bearer ${noClaimsToken}` }
});
expect(noClaims).toEqual([]);
it("rejects an alg:none token even when a secret is configured", async () => {
const adapter = new JWTAuthAdapter({ secret: SECRET });
const token = `${b64url(JSON.stringify({ alg: "none", typ: "JWT" }))}.${b64url(
JSON.stringify({ sub: "did:web:attacker", scope: "admin.all" })
)}.`;
expect(await adapter.extractProofs(bearer(token))).toEqual([]);
});

it("rejects a token signed with the wrong secret", async () => {
const adapter = new JWTAuthAdapter({ secret: SECRET });
const token = signHS({ sub: "did:web:alice.example" }, "the-wrong-secret-the-wrong-secret-xx");
expect(await adapter.extractProofs(bearer(token))).toEqual([]);
});

it("rejects an HS token whose signature is the wrong length", async () => {
const adapter = new JWTAuthAdapter({ secret: SECRET });
const header = b64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const body = b64url(JSON.stringify({ sub: "did:web:a" }));
// 3-byte signature can never equal a 32-byte HMAC-SHA256 digest.
const token = `${header}.${body}.AAAA`;
expect(await adapter.extractProofs(bearer(token))).toEqual([]);
});

it("accepts a Buffer secret", async () => {
const adapter = new JWTAuthAdapter({ secret: Buffer.from(SECRET, "utf8") });
const token = signHS({ sub: "did:web:buffer-secret" });
expect(await adapter.extractProofs(bearer(token))).toHaveLength(1);
});

it("rejects a token whose payload was tampered after signing", async () => {
const adapter = new JWTAuthAdapter({ secret: SECRET });
const original = signHS({ sub: "did:web:alice.example", scope: "profile.read" });
const [header, , sig] = original.split(".");
const forgedBody = b64url(JSON.stringify({ sub: "did:web:attacker", scope: "admin.all" }));
const tampered = `${header}.${forgedBody}.${sig}`;
expect(await adapter.extractProofs(bearer(tampered))).toEqual([]);
});

it("rejects an HS-allowlisted secret being used against an asymmetric alg (algorithm confusion)", async () => {
const adapter = new JWTAuthAdapter({ secret: SECRET });
// RS256 header, but no verifyToken configured → must not fall through to HMAC.
const token = tokenWithAlg({ sub: "did:web:attacker", scope: "admin.all" }, "RS256");
expect(await adapter.extractProofs(bearer(token))).toEqual([]);
});

it("honours an explicit algorithms allowlist", async () => {
const adapter = new JWTAuthAdapter({ secret: SECRET, algorithms: ["HS256"] });
const ok = signHS({ sub: "did:web:a" }, SECRET, "HS256");
const blocked = signHS({ sub: "did:web:b" }, SECRET, "HS512");
expect(await adapter.extractProofs(bearer(ok))).toHaveLength(1);
expect(await adapter.extractProofs(bearer(blocked))).toEqual([]);
});

it("rejects expired and not-yet-valid tokens", async () => {
const adapter = new JWTAuthAdapter({ secret: SECRET });
const now = Math.floor(Date.now() / 1000);
const expired = signHS({ sub: "did:web:a", exp: now - 3600 });
const notYet = signHS({ sub: "did:web:a", nbf: now + 3600 });
const future = signHS({ sub: "did:web:a", iat: now + 3600 });
expect(await adapter.extractProofs(bearer(expired))).toEqual([]);
expect(await adapter.extractProofs(bearer(notYet))).toEqual([]);
expect(await adapter.extractProofs(bearer(future))).toEqual([]);
});

it("accepts tokens within the configured clock tolerance", async () => {
const adapter = new JWTAuthAdapter({ secret: SECRET, clockToleranceSec: 120 });
const now = Math.floor(Date.now() / 1000);
const justExpired = signHS({ sub: "did:web:a", exp: now - 30 });
expect(await adapter.extractProofs(bearer(justExpired))).toHaveLength(1);
});

it("returns empty proofs for missing / non-bearer / malformed tokens", async () => {
const adapter = new JWTAuthAdapter({ secret: SECRET });
expect(await adapter.extractProofs({ method: "GET", path: "/", headers: {} })).toEqual([]);
expect(
await adapter.extractProofs({ method: "GET", path: "/", headers: { authorization: "Basic abc" } })
).toEqual([]);
expect(await adapter.extractProofs(bearer("only-one-part"))).toEqual([]);
expect(await adapter.extractProofs(bearer("two.parts"))).toEqual([]);
expect(await adapter.extractProofs(bearer("bad.@.sig"))).toEqual([]);
});

it("emits only the claims that are present", async () => {
const adapter = new JWTAuthAdapter({ secret: SECRET });
const didOnly = signHS({ sub: "did:web:a" });
const scopeOnly = signHS({ scope: "a b" });
const neither = signHS({ unrelated: true });
expect(await adapter.extractProofs(bearer(didOnly))).toHaveLength(1);
expect(await adapter.extractProofs(bearer(scopeOnly))).toHaveLength(1);
expect(await adapter.extractProofs(bearer(neither))).toEqual([]);
});

it("supports custom didClaim and capabilityClaim", async () => {
const adapter = new JWTAuthAdapter({ secret: SECRET, didClaim: "did", capabilityClaim: "caps" });
const token = signHS({ did: "did:web:custom", caps: "x y" });
const proofs = await adapter.extractProofs(bearer(token));
expect(proofs).toEqual([
{ type: "identity", method: "did_verified", evidence: "did:web:custom" },
{ type: "capability", declared_capabilities: ["x", "y"] }
]);
});
});

describe("JWTAuthAdapter — verifyToken delegation (asymmetric / custom)", () => {
it("uses the verifyToken result to extract proofs and never falls back to HMAC", async () => {
const verifyToken = vi.fn(async () => ({ sub: "did:web:rsa", scope: "read" }));
const adapter = new JWTAuthAdapter({ verifyToken });
const token = tokenWithAlg({ sub: "did:web:rsa", scope: "read" }, "RS256");
const proofs = await adapter.extractProofs(bearer(token));
expect(verifyToken).toHaveBeenCalledWith(token);
expect(proofs).toEqual([
{ type: "identity", method: "did_verified", evidence: "did:web:rsa" },
{ type: "capability", declared_capabilities: ["read"] }
]);
});

it("emits no proofs when verifyToken rejects the token", async () => {
const verifyToken = vi.fn(async () => null);
const adapter = new JWTAuthAdapter({ verifyToken });
const token = tokenWithAlg({ sub: "did:web:rsa" }, "ES256");
expect(await adapter.extractProofs(bearer(token))).toEqual([]);
expect(verifyToken).toHaveBeenCalledTimes(1);
});

it("rejects alg:none before consulting verifyToken", async () => {
const verifyToken = vi.fn(async () => ({ sub: "did:web:attacker" }));
const adapter = new JWTAuthAdapter({ verifyToken });
const token = `${b64url(JSON.stringify({ alg: "none" }))}.${b64url(JSON.stringify({ sub: "x" }))}.`;
expect(await adapter.extractProofs(bearer(token))).toEqual([]);
expect(verifyToken).not.toHaveBeenCalled();
});

it("prefers native HMAC for HS algs when both secret and verifyToken are configured", async () => {
const verifyToken = vi.fn(async () => ({ sub: "did:web:should-not-be-used" }));
const adapter = new JWTAuthAdapter({ secret: SECRET, verifyToken });
const token = signHS({ sub: "did:web:hmac" });
const proofs = await adapter.extractProofs(bearer(token));
expect(verifyToken).not.toHaveBeenCalled();
expect(proofs[0]).toMatchObject({ evidence: "did:web:hmac" });
});

it("enforces temporal claims on verifyToken results too", async () => {
const now = Math.floor(Date.now() / 1000);
const verifyToken = vi.fn(async () => ({ sub: "did:web:rsa", exp: now - 3600 }));
const adapter = new JWTAuthAdapter({ verifyToken });
const token = tokenWithAlg({ sub: "did:web:rsa", exp: now - 3600 }, "EdDSA");
expect(await adapter.extractProofs(bearer(token))).toEqual([]);
});

const noPaddingSegment = await adapter.extractProofs({
method: "GET",
path: "/",
headers: { authorization: "Bearer hdr.eyJhIjoiYiJ9.sig" }
it("fails closed when the verifyToken callback throws", async () => {
const verifyToken = vi.fn(async () => {
throw new Error("key resolution failed");
});
expect(noPaddingSegment).toEqual([]);
const adapter = new JWTAuthAdapter({ verifyToken });
const token = tokenWithAlg({ sub: "did:web:rsa" }, "ES256");
expect(await adapter.extractProofs(bearer(token))).toEqual([]);
});
});
Loading