From e9be370efc1247e5057b7f7b05abe5b70086a0cb Mon Sep 17 00:00:00 2001 From: Ugur Cekmez Date: Wed, 10 Jun 2026 22:43:50 +0300 Subject: [PATCH] fix(middleware): fail-closed JWT/OAuth auth adapters The auth adapters in `@eep-dev/middleware` (TypeScript) and `eep-middleware` (Python) turned attacker-controlled credentials into trusted EEP proofs: - `JWTAuthAdapter` base64-decoded the JWT payload and emitted `{type:"identity", method:"did_verified"}` and capability proofs WITHOUT verifying the signature or the `alg` header. An `alg: none` token, or any forged token, was accepted at face value. - The TypeScript `OAuthAuthAdapter` derived capability proofs straight from a client-supplied `X-OAuth-Scope` header (or `scope` query param), so a caller could assert any scope it wanted. Both adapters now fail closed: - `JWTAuthAdapter` rejects `alg: none` unconditionally, verifies HS256/384/512 signatures against a configured `secret` (constant-time compare), delegates asymmetric/custom algorithms to a `verifyToken` / `verify_token` callback, enforces `exp`/`nbf`/`iat` with a configurable clock-skew tolerance (60s default), and emits no proofs (warning once) when no verification material is configured. The algorithm router never lets an HMAC secret verify an asymmetric token, and never lets the asymmetric callback rubber-stamp an HS token. An explicit `algorithms` allowlist is supported. - `OAuthAuthAdapter` requires an RFC 7662 `introspect` callback and reads scope and subject only from the authorization server's response. A client-supplied `X-OAuth-Scope` header is ignored. The TypeScript `JWTAuthAdapterOptions` is extended (non-breaking) with `secret`, `verifyToken`, `algorithms`, and `clockToleranceSec`; `OAuthAuthAdapter` now takes a required `{ introspect }` option (mirrors `APIKeyAuthAdapter`'s required resolver). `EEPServer` defaults are unaffected (it uses `HeaderProofAuthAdapter`). Tests cover signed/forged/tampered/expired tokens, `alg: none`, algorithm confusion, the allowlist, callback delegation (sync + async), introspection states, and fail-closed construction. Coverage of the changed files is 100% (TS jwt.ts/oauth.ts; Python jwt.py at 100% statements + branches, package gate `--cov-fail-under=100` satisfied). READMEs and CHANGELOG updated. Surfaced by the EEP protocol audit. Signed-off-by: Ugur Cekmez --- CHANGELOG.md | 14 + packages/@eep-dev/middleware/README.md | 40 +++ .../@eep-dev/middleware/src/auth/jwt.test.ts | 272 ++++++++++++++--- packages/@eep-dev/middleware/src/auth/jwt.ts | 159 +++++++++- .../middleware/src/auth/oauth.test.ts | 103 +++++-- .../@eep-dev/middleware/src/auth/oauth.ts | 76 ++++- packages/@eep-dev/middleware/src/index.ts | 14 +- packages/eep-middleware-python/README.md | 14 + .../eep_middleware/auth/jwt.py | 145 ++++++++- .../eep-middleware-python/tests/test_auth.py | 286 +++++++++++++++++- 10 files changed, 1012 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b7f060..3817429 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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"}` diff --git a/packages/@eep-dev/middleware/README.md b/packages/@eep-dev/middleware/README.md index f5d80b7..8d6cf95 100644 --- a/packages/@eep-dev/middleware/README.md +++ b/packages/@eep-dev/middleware/README.md @@ -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)**. diff --git a/packages/@eep-dev/middleware/src/auth/jwt.test.ts b/packages/@eep-dev/middleware/src/auth/jwt.test.ts index 07ab06c..df2ac3a 100644 --- a/packages/@eep-dev/middleware/src/auth/jwt.test.ts +++ b/packages/@eep-dev/middleware/src/auth/jwt.test.ts @@ -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 { - 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 = { + HS256: "sha256", + HS384: "sha384", + HS512: "sha512" +}; + +/** Produce a real HS* JWT signed with `secret`. */ +function signHS( + payload: Record, + secret: string = SECRET, + alg: "HS256" | "HS384" | "HS512" = "HS256", + extraHeader: Record = {} +): 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, 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([]); }); }); diff --git a/packages/@eep-dev/middleware/src/auth/jwt.ts b/packages/@eep-dev/middleware/src/auth/jwt.ts index 569ce5a..e696f62 100644 --- a/packages/@eep-dev/middleware/src/auth/jwt.ts +++ b/packages/@eep-dev/middleware/src/auth/jwt.ts @@ -1,9 +1,37 @@ +import { createHmac, timingSafeEqual } from "crypto"; import type { GateProof } from "@eep-dev/gates"; import type { AuthAdapter, IncomingRequest } from "../core/request-handler.js"; +/** HS* algorithms this adapter can verify natively, mapped to their Node hash name. */ +const HMAC_ALGORITHMS: Record = { + HS256: "sha256", + HS384: "sha384", + HS512: "sha512" +}; + +export type HmacAlgorithm = keyof typeof HMAC_ALGORITHMS; + +/** + * Verified-claims callback for asymmetric (RSA / ECDSA / EdDSA) or otherwise custom tokens. + * It receives the raw compact JWT and MUST return the verified claim set, or `null`/`undefined` + * if the signature (or any claim the implementer checks) does not verify. The middleware never + * trusts a token the callback does not vouch for. + */ +export type JWTVerifyTokenFn = ( + token: string +) => Promise | null | undefined> | Record | null | undefined; + export type JWTAuthAdapterOptions = { didClaim?: string; capabilityClaim?: string; + /** Shared secret for HS256/HS384/HS512 verification. */ + secret?: string | Buffer; + /** Delegated verifier for non-HMAC algorithms (e.g. wrapping `jose`/`jsonwebtoken`). */ + verifyToken?: JWTVerifyTokenFn; + /** Explicit `alg` allowlist. `none` is always rejected regardless of this value. */ + algorithms?: string[]; + /** Clock skew tolerance (seconds) applied to `exp`/`nbf`/`iat`. Default 60. */ + clockToleranceSec?: number; }; function decodeBase64Url(segment: string): string { @@ -13,13 +41,43 @@ function decodeBase64Url(segment: string): string { return Buffer.from(fixed, "base64").toString("utf8"); } +/** Constant-time comparison that tolerates differing lengths without throwing. */ +function timingSafeEqualString(a: Buffer, b: Buffer): boolean { + if (a.length !== b.length) { + return false; + } + return timingSafeEqual(a, b); +} + export class JWTAuthAdapter implements AuthAdapter { private readonly didClaim: string; private readonly capabilityClaim: string; + private readonly secret?: Buffer; + private readonly verifyToken?: JWTVerifyTokenFn; + private readonly algorithms?: string[]; + private readonly clockToleranceSec: number; constructor(options: JWTAuthAdapterOptions = {}) { this.didClaim = options.didClaim ?? "sub"; this.capabilityClaim = options.capabilityClaim ?? "scope"; + this.secret = + options.secret === undefined + ? undefined + : Buffer.isBuffer(options.secret) + ? options.secret + : Buffer.from(options.secret, "utf8"); + this.verifyToken = options.verifyToken; + this.algorithms = options.algorithms; + this.clockToleranceSec = options.clockToleranceSec ?? 60; + + if (!this.secret && !this.verifyToken) { + // Fail closed: an adapter with no way to verify signatures must not mint + // `did_verified` proofs from attacker-controlled tokens. It will emit nothing. + console.warn( + "[eep] JWTAuthAdapter constructed without `secret` or `verifyToken`; it will reject all tokens. " + + "Configure an HS* secret or a verifyToken callback to enable authentication." + ); + } } async extractProofs(request: IncomingRequest): Promise { @@ -28,30 +86,101 @@ export class JWTAuthAdapter implements AuthAdapter { return []; } - const token = header.slice("Bearer ".length); + const token = header.slice("Bearer ".length).trim(); const parts = token.split("."); if (parts.length < 2) { return []; } + let jwtHeader: Record; + let payload: Record; try { - const payload = JSON.parse(decodeBase64Url(parts[1])) as Record; - const proofs: GateProof[] = []; - const didValue = payload[this.didClaim]; - if (typeof didValue === "string" && didValue.length > 0) { - proofs.push({ type: "identity", method: "did_verified", evidence: didValue }); - } + jwtHeader = JSON.parse(decodeBase64Url(parts[0])) as Record; + payload = JSON.parse(decodeBase64Url(parts[1])) as Record; + } catch { + return []; + } - const scopes = payload[this.capabilityClaim]; - if (typeof scopes === "string" && scopes.trim().length > 0) { - proofs.push({ - type: "capability", - declared_capabilities: scopes.split(" ").filter(Boolean) - }); + const alg = typeof jwtHeader.alg === "string" ? jwtHeader.alg : ""; + // `alg: none` (in any casing) is never acceptable for a token we are asked to trust. + if (!alg || alg.toLowerCase() === "none") { + return []; + } + if (this.algorithms && !this.algorithms.includes(alg)) { + return []; + } + + const claims = await this.verifyClaims(alg, parts, token, payload); + if (!claims) { + return []; + } + if (!this.passesTemporalChecks(claims)) { + return []; + } + + return this.claimsToProofs(claims); + } + + private async verifyClaims( + alg: string, + parts: string[], + token: string, + payload: Record + ): Promise | null> { + if (alg in HMAC_ALGORITHMS) { + if (!this.secret || parts.length !== 3) { + return null; } - return proofs; + const signingInput = `${parts[0]}.${parts[1]}`; + const expected = createHmac(HMAC_ALGORITHMS[alg], this.secret).update(signingInput).digest(); + const provided = Buffer.from(parts[2].replace(/-/g, "+").replace(/_/g, "/"), "base64"); + return timingSafeEqualString(expected, provided) ? payload : null; + } + + // Asymmetric / custom algorithm: delegate to the configured verifier (fail closed if none). + if (!this.verifyToken) { + return null; + } + try { + const verified = await this.verifyToken(token); + return verified ?? null; } catch { - return []; + return null; } } + + private passesTemporalChecks(claims: Record): boolean { + const now = Math.floor(Date.now() / 1000); + const tolerance = this.clockToleranceSec; + + if (typeof claims.exp === "number" && now > claims.exp + tolerance) { + return false; + } + if (typeof claims.nbf === "number" && now < claims.nbf - tolerance) { + return false; + } + if (typeof claims.iat === "number" && claims.iat > now + tolerance) { + return false; + } + return true; + } + + private claimsToProofs(claims: Record): GateProof[] { + const proofs: GateProof[] = []; + + const didValue = claims[this.didClaim]; + if (typeof didValue === "string" && didValue.length > 0) { + proofs.push({ type: "identity", method: "did_verified", evidence: didValue }); + } + + const scopes = claims[this.capabilityClaim]; + if (typeof scopes === "string" && scopes.trim().length > 0) { + proofs.push({ + type: "capability", + declared_capabilities: scopes.split(" ").filter(Boolean) + }); + } + + return proofs; + } } diff --git a/packages/@eep-dev/middleware/src/auth/oauth.test.ts b/packages/@eep-dev/middleware/src/auth/oauth.test.ts index daca53f..66ab6b5 100644 --- a/packages/@eep-dev/middleware/src/auth/oauth.test.ts +++ b/packages/@eep-dev/middleware/src/auth/oauth.test.ts @@ -1,36 +1,99 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { OAuthAuthAdapter } from "./oauth.js"; -describe("OAuthAuthAdapter", () => { - it("extracts capability proofs from header and query", async () => { +function bearer(token: string) { + return { method: "GET" as const, path: "/", headers: { authorization: `Bearer ${token}` } }; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("OAuthAuthAdapter — fail-closed introspection", () => { + it("warns once and emits no proofs when no introspect callback is configured", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + // @ts-expect-error — intentionally constructing without the required introspect callback. const adapter = new OAuthAuthAdapter(); - const fromHeader = await adapter.extractProofs({ + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toMatch(/OAuthAuthAdapter/); + + // A raw scope header must NOT be trusted any more. + const proofs = await adapter.extractProofs({ method: "GET", path: "/", - headers: { "x-oauth-scope": "a b" } + headers: { "x-oauth-scope": "admin.all" } }); - expect(fromHeader.length).toBe(1); + expect(proofs).toEqual([]); + }); - const fromQuery = await adapter.extractProofs({ - method: "GET", - path: "/", - headers: {}, - query: { scope: "x y" } + it("emits capability + identity proofs from an active introspection result", async () => { + const introspect = vi.fn(async (token: string) => { + expect(token).toBe("opaque-access-token"); + return { active: true, scope: "profile.read profile.write", sub: "did:web:alice.example" }; }); - expect(fromQuery.length).toBe(1); + const adapter = new OAuthAuthAdapter({ introspect }); + const proofs = await adapter.extractProofs(bearer("opaque-access-token")); + expect(proofs).toEqual([ + { type: "identity", method: "did_verified", evidence: "did:web:alice.example" }, + { type: "capability", declared_capabilities: ["profile.read", "profile.write"] } + ]); + }); - const none = await adapter.extractProofs({ + it("ignores a client-supplied x-oauth-scope header (only the AS response is trusted)", async () => { + const introspect = vi.fn(async () => ({ active: true, scope: "profile.read" })); + const adapter = new OAuthAuthAdapter({ introspect }); + const proofs = await adapter.extractProofs({ method: "GET", path: "/", - headers: {} + headers: { authorization: "Bearer t", "x-oauth-scope": "admin.all" } }); - expect(none).toEqual([]); + expect(proofs).toEqual([{ type: "capability", declared_capabilities: ["profile.read"] }]); + }); - const empty = await adapter.extractProofs({ - method: "GET", - path: "/", - headers: { "x-oauth-scope": " " } + it("emits no proofs when introspection reports the token inactive", async () => { + const introspect = vi.fn(async () => ({ active: false, scope: "admin.all" })); + const adapter = new OAuthAuthAdapter({ introspect }); + expect(await adapter.extractProofs(bearer("revoked"))).toEqual([]); + }); + + it("emits no proofs when introspection returns null", async () => { + const introspect = vi.fn(async () => null); + const adapter = new OAuthAuthAdapter({ introspect }); + expect(await adapter.extractProofs(bearer("unknown"))).toEqual([]); + }); + + it("emits no proofs when there is no bearer token to introspect", async () => { + const introspect = vi.fn(async () => ({ active: true, scope: "x" })); + const adapter = new OAuthAuthAdapter({ introspect }); + 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({ method: "GET", path: "/", headers: { authorization: "Bearer " } }) + ).toEqual([]); + expect(introspect).not.toHaveBeenCalled(); + }); + + it("handles an active result with empty / whitespace scope and no sub", async () => { + const introspect = vi.fn(async () => ({ active: true, scope: " " })); + const adapter = new OAuthAuthAdapter({ introspect }); + expect(await adapter.extractProofs(bearer("t"))).toEqual([]); + }); + + it("emits only the identity proof when scope is absent", async () => { + const introspect = vi.fn(async () => ({ active: true, sub: "did:web:only-id" })); + const adapter = new OAuthAuthAdapter({ introspect }); + expect(await adapter.extractProofs(bearer("t"))).toEqual([ + { type: "identity", method: "did_verified", evidence: "did:web:only-id" } + ]); + }); + + it("fails closed when the introspection callback throws", async () => { + const introspect = vi.fn(async () => { + throw new Error("introspection endpoint down"); }); - expect(empty).toEqual([]); + const adapter = new OAuthAuthAdapter({ introspect }); + expect(await adapter.extractProofs(bearer("t"))).toEqual([]); }); }); diff --git a/packages/@eep-dev/middleware/src/auth/oauth.ts b/packages/@eep-dev/middleware/src/auth/oauth.ts index fe3bfd4..59aa5d4 100644 --- a/packages/@eep-dev/middleware/src/auth/oauth.ts +++ b/packages/@eep-dev/middleware/src/auth/oauth.ts @@ -1,16 +1,82 @@ import type { GateProof } from "@eep-dev/gates"; import type { AuthAdapter, IncomingRequest } from "../core/request-handler.js"; +/** + * The subset of an RFC 7662 token-introspection response this adapter relies on. + * `active` is the authoritative signal; `scope`/`sub` are read only when `active === true`. + */ +export type OAuthIntrospectionResult = { + active: boolean; + /** Space-delimited granted scopes, as returned by the authorization server. */ + scope?: string; + /** The subject (agent DID) the authorization server bound the token to. */ + sub?: string; +}; + +export type OAuthIntrospectFn = ( + token: string +) => Promise | OAuthIntrospectionResult | null | undefined; + +export type OAuthAuthAdapterOptions = { + /** + * RFC 7662-style token introspection. Receives the bearer access token and MUST return the + * authorization server's decision. Scopes are taken from the response, never from a + * client-supplied header. + */ + introspect: OAuthIntrospectFn; +}; + export class OAuthAuthAdapter implements AuthAdapter { + private readonly introspect?: OAuthIntrospectFn; + + constructor(options?: OAuthAuthAdapterOptions) { + this.introspect = options?.introspect; + if (typeof this.introspect !== "function") { + // Fail closed: without introspection we cannot tell a forged scope from a granted one. + console.warn( + "[eep] OAuthAuthAdapter constructed without an `introspect` callback; it will reject all tokens. " + + "Provide an RFC 7662 token-introspection function to enable authentication." + ); + } + } + async extractProofs(request: IncomingRequest): Promise { - const scope = request.headers["x-oauth-scope"] ?? request.query?.scope; - if (!scope) { + if (typeof this.introspect !== "function") { return []; } - const scopes = scope.split(" ").map((item) => item.trim()).filter(Boolean); - if (scopes.length === 0) { + + const header = request.headers.authorization; + if (!header?.startsWith("Bearer ")) { return []; } - return [{ type: "capability", declared_capabilities: scopes }]; + const token = header.slice("Bearer ".length).trim(); + if (!token) { + return []; + } + + let result: OAuthIntrospectionResult | null | undefined; + try { + result = await this.introspect(token); + } catch { + return []; + } + if (!result || result.active !== true) { + return []; + } + + const proofs: GateProof[] = []; + + if (typeof result.sub === "string" && result.sub.length > 0) { + proofs.push({ type: "identity", method: "did_verified", evidence: result.sub }); + } + + if (typeof result.scope === "string" && result.scope.trim().length > 0) { + const scopes = result.scope.split(" ").map((item) => item.trim()).filter(Boolean); + if (scopes.length > 0) { + proofs.push({ type: "capability", declared_capabilities: scopes }); + } + } + + return proofs; } } diff --git a/packages/@eep-dev/middleware/src/index.ts b/packages/@eep-dev/middleware/src/index.ts index c4150c9..2c0b4d5 100644 --- a/packages/@eep-dev/middleware/src/index.ts +++ b/packages/@eep-dev/middleware/src/index.ts @@ -28,9 +28,19 @@ export { createFastifyPlugin } from "./adapters/fastify.js"; export { createEEPApp } from "./adapters/hono.js"; export { createEEPMiddleware } from "./adapters/koa.js"; -export { JWTAuthAdapter } from "./auth/jwt.js"; +export { + JWTAuthAdapter, + type JWTAuthAdapterOptions, + type JWTVerifyTokenFn, + type HmacAlgorithm +} from "./auth/jwt.js"; export { APIKeyAuthAdapter, type APIKeyResolver } from "./auth/api-key.js"; -export { OAuthAuthAdapter } from "./auth/oauth.js"; +export { + OAuthAuthAdapter, + type OAuthAuthAdapterOptions, + type OAuthIntrospectFn, + type OAuthIntrospectionResult +} from "./auth/oauth.js"; export { InMemoryEventBusAdapter } from "./event-bus/in-memory.js"; export { RedisEventBusAdapter, type RedisClientLike } from "./event-bus/redis.js"; diff --git a/packages/eep-middleware-python/README.md b/packages/eep-middleware-python/README.md index 9e89e18..7e27dea 100644 --- a/packages/eep-middleware-python/README.md +++ b/packages/eep-middleware-python/README.md @@ -111,6 +111,20 @@ rejected with a 400. - **DB:** `InMemoryDBAdapter`, `PostgresDBAdapter` - **Event bus:** `InMemoryEventBusAdapter`, `RedisEventBusAdapter` +`JWTAuthAdapter` fails closed: it verifies the JWT signature before emitting +`did_verified` / capability proofs, always rejects `alg: none`, and rejects expired +tokens. Configure a shared `secret` for HS256/384/512, or a `verify_token` callback +(sync or async, returning the verified claims or `None`) for asymmetric algorithms. +Without either it emits no proofs and warns once. + +```python +from eep_middleware.auth.jwt import JWTAuthAdapter + +auth = JWTAuthAdapter(secret=os.environ["JWT_SECRET"]) +# asymmetric, delegating verification to your JWT library: +# JWTAuthAdapter(verify_token=lambda token: my_jwt_lib.decode(token, public_key)) +``` + ## After `setup-cli` See **[integrate-eep-after-setup-cli.md](../../docs/guides/integrate-eep-after-setup-cli.md)** for aligning generated **`eep-generated/`** artifacts with runtime options. diff --git a/packages/eep-middleware-python/eep_middleware/auth/jwt.py b/packages/eep-middleware-python/eep_middleware/auth/jwt.py index 893f064..588d524 100644 --- a/packages/eep-middleware-python/eep_middleware/auth/jwt.py +++ b/packages/eep-middleware-python/eep_middleware/auth/jwt.py @@ -1,16 +1,76 @@ from __future__ import annotations import base64 +import binascii +import hashlib +import hmac +import inspect import json -from typing import Any +import time +import warnings +from typing import Any, Awaitable, Callable, Optional, Union + +# HS* algorithms this adapter can verify natively, mapped to their hashlib constructor. +_HMAC_ALGORITHMS: dict[str, Callable[[], "hashlib._Hash"]] = { + "HS256": hashlib.sha256, + "HS384": hashlib.sha384, + "HS512": hashlib.sha512, +} + +# Verified-claims callback for asymmetric (RSA / ECDSA / EdDSA) or otherwise custom tokens. +# It receives the raw compact JWT and MUST return the verified claim set (a dict) or ``None`` +# if the signature does not verify. Sync and async callables are both supported. +VerifyTokenFn = Callable[ + [str], Union[Optional[dict[str, Any]], Awaitable[Optional[dict[str, Any]]]] +] + + +def _b64url_decode(segment: str) -> bytes: + padding = "=" * ((4 - len(segment) % 4) % 4) + return base64.urlsafe_b64decode(segment + padding) class JWTAuthAdapter: - def __init__(self, did_claim: str = "sub", capability_claim: str = "scope") -> None: + """Extract EEP proofs from a bearer JWT, but only after verifying its signature. + + The adapter fails closed: ``alg: none`` is always rejected, and a token is trusted only + when its signature verifies against a configured HS* ``secret`` or a ``verify_token`` + callback. Without verification material it emits no proofs. + """ + + def __init__( + self, + did_claim: str = "sub", + capability_claim: str = "scope", + *, + secret: Union[str, bytes, None] = None, + verify_token: Optional[VerifyTokenFn] = None, + algorithms: Optional[list[str]] = None, + clock_tolerance_sec: int = 60, + ) -> None: self._did_claim = did_claim self._capability_claim = capability_claim + if secret is None: + self._secret: Optional[bytes] = None + elif isinstance(secret, bytes): + self._secret = secret + else: + self._secret = secret.encode("utf-8") + self._verify_token = verify_token + self._algorithms = algorithms + self._clock_tolerance_sec = clock_tolerance_sec - async def extract_proofs(self, headers: dict[str, str], query: dict[str, str] | None = None) -> list[dict[str, Any]]: + if self._secret is None and self._verify_token is None: + warnings.warn( + "JWTAuthAdapter constructed without `secret` or `verify_token`; it will reject all " + "tokens. Configure an HS* secret or a verify_token callback to enable authentication.", + UserWarning, + stacklevel=2, + ) + + async def extract_proofs( + self, headers: dict[str, str], query: dict[str, str] | None = None + ) -> list[dict[str, Any]]: auth_header = headers.get("authorization") if not auth_header or not auth_header.startswith("Bearer "): return [] @@ -21,20 +81,83 @@ async def extract_proofs(self, headers: dict[str, str], query: dict[str, str] | return [] try: - payload_segment = parts[1] - padding = "=" * ((4 - len(payload_segment) % 4) % 4) - payload_raw = base64.urlsafe_b64decode(payload_segment + padding).decode("utf-8") - payload = json.loads(payload_raw) - except Exception: + header = json.loads(_b64url_decode(parts[0]).decode("utf-8")) + payload = json.loads(_b64url_decode(parts[1]).decode("utf-8")) + except (ValueError, binascii.Error, UnicodeDecodeError): + return [] + + if not isinstance(header, dict) or not isinstance(payload, dict): + return [] + + alg = header.get("alg") + alg = alg if isinstance(alg, str) else "" + # `alg: none` (in any casing) is never acceptable for a token we are asked to trust. + if not alg or alg.lower() == "none": return [] + if self._algorithms is not None and alg not in self._algorithms: + return [] + + claims = await self._verify_claims(alg, parts, token, payload) + if claims is None: + return [] + if not self._passes_temporal_checks(claims): + return [] + + return self._claims_to_proofs(claims) + + async def _verify_claims( + self, alg: str, parts: list[str], token: str, payload: dict[str, Any] + ) -> Optional[dict[str, Any]]: + if alg in _HMAC_ALGORITHMS: + if self._secret is None or len(parts) != 3: + return None + signing_input = f"{parts[0]}.{parts[1]}".encode("utf-8") + expected = hmac.new(self._secret, signing_input, _HMAC_ALGORITHMS[alg]).digest() + try: + provided = _b64url_decode(parts[2]) + except (binascii.Error, ValueError): + return None + return payload if hmac.compare_digest(expected, provided) else None + # Asymmetric / custom algorithm: delegate to the configured verifier (fail closed if none). + if self._verify_token is None: + return None + try: + result = self._verify_token(token) + if inspect.isawaitable(result): + result = await result + except Exception: + return None + if not isinstance(result, dict): + return None + return result + + def _passes_temporal_checks(self, claims: dict[str, Any]) -> bool: + now = int(time.time()) + tolerance = self._clock_tolerance_sec + + exp = claims.get("exp") + if isinstance(exp, (int, float)) and now > exp + tolerance: + return False + nbf = claims.get("nbf") + if isinstance(nbf, (int, float)) and now < nbf - tolerance: + return False + iat = claims.get("iat") + if isinstance(iat, (int, float)) and iat > now + tolerance: + return False + return True + + def _claims_to_proofs(self, claims: dict[str, Any]) -> list[dict[str, Any]]: proofs: list[dict[str, Any]] = [] - did_value = payload.get(self._did_claim) + + did_value = claims.get(self._did_claim) if isinstance(did_value, str) and did_value: proofs.append({"type": "identity", "method": "did_verified", "evidence": did_value}) - scopes = payload.get(self._capability_claim) + scopes = claims.get(self._capability_claim) if isinstance(scopes, str) and scopes.strip(): - proofs.append({"type": "capability", "declared_capabilities": [item for item in scopes.split(" ") if item]}) + proofs.append( + {"type": "capability", "declared_capabilities": [item for item in scopes.split(" ") if item]} + ) return proofs diff --git a/packages/eep-middleware-python/tests/test_auth.py b/packages/eep-middleware-python/tests/test_auth.py index 9fe333b..24d128f 100644 --- a/packages/eep-middleware-python/tests/test_auth.py +++ b/packages/eep-middleware-python/tests/test_auth.py @@ -1,29 +1,291 @@ import base64 +import hashlib +import hmac import json +import time import pytest from eep_middleware.auth.api_key import APIKeyAuthAdapter from eep_middleware.auth.jwt import JWTAuthAdapter +SECRET = "test-shared-secret-at-least-32-bytes-long!" -def encode_token(payload: dict) -> str: - header = base64.urlsafe_b64encode(json.dumps({"alg": "none", "typ": "JWT"}).encode()).decode().rstrip("=") - body = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip("=") - return f"{header}.{body}.sig" +_HASHES = {"HS256": hashlib.sha256, "HS384": hashlib.sha384, "HS512": hashlib.sha512} + + +def b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).decode().rstrip("=") + + +def sign_hs(payload: dict, secret: str = SECRET, alg: str = "HS256", header_extra: dict | None = None) -> str: + header = {"alg": alg, "typ": "JWT"} + if header_extra: + header.update(header_extra) + h = b64url(json.dumps(header).encode()) + p = b64url(json.dumps(payload).encode()) + signing_input = f"{h}.{p}".encode() + sig = hmac.new(secret.encode(), signing_input, _HASHES[alg]).digest() + return f"{h}.{p}.{b64url(sig)}" + + +def token_with_alg(payload: dict, alg: str) -> str: + h = b64url(json.dumps({"alg": alg, "typ": "JWT"}).encode()) + p = b64url(json.dumps(payload).encode()) + return f"{h}.{p}.ZHVtbXk" + + +def bearer(token: str) -> dict[str, str]: + return {"authorization": f"Bearer {token}"} + + +@pytest.mark.asyncio +async def test_unconfigured_adapter_warns_and_denies_all_tokens() -> None: + with pytest.warns(UserWarning, match="JWTAuthAdapter"): + adapter = JWTAuthAdapter() + token = sign_hs({"sub": "did:web:alice.example", "scope": "profile.read profile.write"}) + assert await adapter.extract_proofs(bearer(token)) == [] @pytest.mark.asyncio -async def test_jwt_auth_adapter_extracts_proofs_and_handles_invalid_tokens() -> None: - adapter = JWTAuthAdapter() - token = encode_token({"sub": "did:web:alice.example", "scope": "profile.read profile.write"}) - proofs = await adapter.extract_proofs({"authorization": f"Bearer {token}"}) - assert len(proofs) == 2 +async def test_extracts_proofs_from_correctly_signed_token() -> None: + adapter = JWTAuthAdapter(secret=SECRET) + token = sign_hs({"sub": "did:web:alice.example", "scope": "profile.read profile.write"}) + proofs = await adapter.extract_proofs(bearer(token)) + assert proofs == [ + {"type": "identity", "method": "did_verified", "evidence": "did:web:alice.example"}, + {"type": "capability", "declared_capabilities": ["profile.read", "profile.write"]}, + ] + +@pytest.mark.asyncio +async def test_verifies_hs384_and_hs512() -> None: + adapter = JWTAuthAdapter(secret=SECRET) + assert len(await adapter.extract_proofs(bearer(sign_hs({"sub": "did:web:a"}, SECRET, "HS384")))) == 1 + assert len(await adapter.extract_proofs(bearer(sign_hs({"sub": "did:web:b"}, SECRET, "HS512")))) == 1 + + +@pytest.mark.asyncio +async def test_accepts_bytes_secret() -> None: + adapter = JWTAuthAdapter(secret=SECRET.encode()) + assert len(await adapter.extract_proofs(bearer(sign_hs({"sub": "did:web:bytes"})))) == 1 + + +@pytest.mark.asyncio +async def test_rejects_alg_none_even_with_secret() -> None: + adapter = JWTAuthAdapter(secret=SECRET) + header = b64url(json.dumps({"alg": "none", "typ": "JWT"}).encode()) + body = b64url(json.dumps({"sub": "did:web:attacker", "scope": "admin.all"}).encode()) + assert await adapter.extract_proofs(bearer(f"{header}.{body}.")) == [] + + +@pytest.mark.asyncio +async def test_rejects_token_with_non_string_alg() -> None: + adapter = JWTAuthAdapter(secret=SECRET) + header = b64url(json.dumps({"typ": "JWT"}).encode()) + body = b64url(json.dumps({"sub": "did:web:a"}).encode()) + assert await adapter.extract_proofs(bearer(f"{header}.{body}.sig")) == [] + + +@pytest.mark.asyncio +async def test_rejects_wrong_secret_and_tampered_payload() -> None: + adapter = JWTAuthAdapter(secret=SECRET) + wrong = sign_hs({"sub": "did:web:a"}, "another-secret-another-secret-1234567") + assert await adapter.extract_proofs(bearer(wrong)) == [] + + original = sign_hs({"sub": "did:web:alice.example", "scope": "profile.read"}) + header, _, sig = original.split(".") + forged_body = b64url(json.dumps({"sub": "did:web:attacker", "scope": "admin.all"}).encode()) + assert await adapter.extract_proofs(bearer(f"{header}.{forged_body}.{sig}")) == [] + + +@pytest.mark.asyncio +async def test_rejects_wrong_length_signature() -> None: + adapter = JWTAuthAdapter(secret=SECRET) + header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) + body = b64url(json.dumps({"sub": "did:web:a"}).encode()) + assert await adapter.extract_proofs(bearer(f"{header}.{body}.AAAA")) == [] + + +@pytest.mark.asyncio +async def test_rejects_asymmetric_alg_without_verifier() -> None: + adapter = JWTAuthAdapter(secret=SECRET) + token = token_with_alg({"sub": "did:web:attacker", "scope": "admin.all"}, "RS256") + assert await adapter.extract_proofs(bearer(token)) == [] + + +@pytest.mark.asyncio +async def test_honours_explicit_algorithms_allowlist() -> None: + adapter = JWTAuthAdapter(secret=SECRET, algorithms=["HS256"]) + assert len(await adapter.extract_proofs(bearer(sign_hs({"sub": "did:web:a"}, SECRET, "HS256")))) == 1 + assert await adapter.extract_proofs(bearer(sign_hs({"sub": "did:web:b"}, SECRET, "HS512"))) == [] + + +@pytest.mark.asyncio +async def test_rejects_expired_notbefore_and_future_iat() -> None: + adapter = JWTAuthAdapter(secret=SECRET) + now = int(time.time()) + assert await adapter.extract_proofs(bearer(sign_hs({"sub": "did:web:a", "exp": now - 3600}))) == [] + assert await adapter.extract_proofs(bearer(sign_hs({"sub": "did:web:a", "nbf": now + 3600}))) == [] + assert await adapter.extract_proofs(bearer(sign_hs({"sub": "did:web:a", "iat": now + 3600}))) == [] + + +@pytest.mark.asyncio +async def test_accepts_within_clock_tolerance() -> None: + adapter = JWTAuthAdapter(secret=SECRET, clock_tolerance_sec=120) + now = int(time.time()) + assert len(await adapter.extract_proofs(bearer(sign_hs({"sub": "did:web:a", "exp": now - 30})))) == 1 + + +@pytest.mark.asyncio +async def test_missing_non_bearer_and_malformed_tokens() -> None: + adapter = JWTAuthAdapter(secret=SECRET) assert await adapter.extract_proofs({}) == [] - assert await adapter.extract_proofs({"authorization": "Bearer bad"}) == [] - assert await adapter.extract_proofs({"authorization": "Bearer a.b@d.c"}) == [] - assert await adapter.extract_proofs({"authorization": "Bearer h.eyJhIjoiYiJ9.s"}) == [] + assert await adapter.extract_proofs({"authorization": "Basic abc"}) == [] + assert await adapter.extract_proofs(bearer("only-one-part")) == [] + assert await adapter.extract_proofs(bearer("two.parts")) == [] + assert await adapter.extract_proofs(bearer("bad.@.sig")) == [] + + +@pytest.mark.asyncio +async def test_emits_only_present_claims() -> None: + adapter = JWTAuthAdapter(secret=SECRET) + assert len(await adapter.extract_proofs(bearer(sign_hs({"sub": "did:web:a"})))) == 1 + assert len(await adapter.extract_proofs(bearer(sign_hs({"scope": "a b"})))) == 1 + assert await adapter.extract_proofs(bearer(sign_hs({"unrelated": True}))) == [] + + +@pytest.mark.asyncio +async def test_supports_custom_claim_names() -> None: + adapter = JWTAuthAdapter(secret=SECRET, did_claim="did", capability_claim="caps") + token = sign_hs({"did": "did:web:custom", "caps": "x y"}) + assert await adapter.extract_proofs(bearer(token)) == [ + {"type": "identity", "method": "did_verified", "evidence": "did:web:custom"}, + {"type": "capability", "declared_capabilities": ["x", "y"]}, + ] + + +@pytest.mark.asyncio +async def test_verify_token_async_delegation() -> None: + async def verify_token(_token: str): + return {"sub": "did:web:rsa", "scope": "read"} + + adapter = JWTAuthAdapter(verify_token=verify_token) + token = token_with_alg({"sub": "did:web:rsa", "scope": "read"}, "RS256") + assert await adapter.extract_proofs(bearer(token)) == [ + {"type": "identity", "method": "did_verified", "evidence": "did:web:rsa"}, + {"type": "capability", "declared_capabilities": ["read"]}, + ] + + +@pytest.mark.asyncio +async def test_verify_token_sync_delegation() -> None: + def verify_token(_token: str): + return {"sub": "did:web:es"} + + adapter = JWTAuthAdapter(verify_token=verify_token) + token = token_with_alg({"sub": "did:web:es"}, "ES256") + assert len(await adapter.extract_proofs(bearer(token))) == 1 + + +@pytest.mark.asyncio +async def test_verify_token_rejection_and_none() -> None: + async def verify_token(_token: str): + return None + + adapter = JWTAuthAdapter(verify_token=verify_token) + assert await adapter.extract_proofs(bearer(token_with_alg({"sub": "x"}, "EdDSA"))) == [] + + +@pytest.mark.asyncio +async def test_verify_token_not_consulted_for_alg_none() -> None: + calls: list[str] = [] + + async def verify_token(token: str): + calls.append(token) + return {"sub": "did:web:attacker"} + + adapter = JWTAuthAdapter(verify_token=verify_token) + header = b64url(json.dumps({"alg": "none"}).encode()) + body = b64url(json.dumps({"sub": "x"}).encode()) + assert await adapter.extract_proofs(bearer(f"{header}.{body}.")) == [] + assert calls == [] + + +@pytest.mark.asyncio +async def test_prefers_native_hmac_when_both_secret_and_verify_token() -> None: + async def verify_token(_token: str): + return {"sub": "did:web:should-not-be-used"} + + adapter = JWTAuthAdapter(secret=SECRET, verify_token=verify_token) + proofs = await adapter.extract_proofs(bearer(sign_hs({"sub": "did:web:hmac"}))) + assert proofs[0]["evidence"] == "did:web:hmac" + + +@pytest.mark.asyncio +async def test_verify_token_results_are_temporally_checked() -> None: + now = int(time.time()) + + async def verify_token(_token: str): + return {"sub": "did:web:rsa", "exp": now - 3600} + + adapter = JWTAuthAdapter(verify_token=verify_token) + assert await adapter.extract_proofs(bearer(token_with_alg({"sub": "x"}, "EdDSA"))) == [] + + +@pytest.mark.asyncio +async def test_verify_token_exception_fails_closed() -> None: + async def verify_token(_token: str): + raise RuntimeError("key resolution failed") + + adapter = JWTAuthAdapter(verify_token=verify_token) + assert await adapter.extract_proofs(bearer(token_with_alg({"sub": "x"}, "ES256"))) == [] + + +@pytest.mark.asyncio +async def test_rejects_non_dict_header_or_payload() -> None: + adapter = JWTAuthAdapter(secret=SECRET) + array_header = b64url(json.dumps([1, 2]).encode()) + body = b64url(json.dumps({"sub": "did:web:a"}).encode()) + assert await adapter.extract_proofs(bearer(f"{array_header}.{body}.sig")) == [] + + header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) + array_body = b64url(json.dumps([1, 2, 3]).encode()) + assert await adapter.extract_proofs(bearer(f"{header}.{array_body}.sig")) == [] + + +@pytest.mark.asyncio +async def test_hs_token_requires_secret_not_verify_token() -> None: + async def verify_token(_token: str): + return {"sub": "did:web:should-not-be-used"} + + adapter = JWTAuthAdapter(verify_token=verify_token) + # An HS256 token must never be accepted via the asymmetric verify_token path. + assert await adapter.extract_proofs(bearer(sign_hs({"sub": "did:web:a"}))) == [] + + +@pytest.mark.asyncio +async def test_rejects_two_part_token_without_signature() -> None: + adapter = JWTAuthAdapter(secret=SECRET) + header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) + body = b64url(json.dumps({"sub": "did:web:a"}).encode()) + assert await adapter.extract_proofs(bearer(f"{header}.{body}")) == [] + + +@pytest.mark.asyncio +async def test_rejects_hs_token_with_invalid_base64_signature() -> None: + adapter = JWTAuthAdapter(secret=SECRET) + header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()) + body = b64url(json.dumps({"sub": "did:web:a"}).encode()) + assert await adapter.extract_proofs(bearer(f"{header}.{body}.abc@")) == [] + + +@pytest.mark.asyncio +async def test_accepts_token_with_valid_nbf_and_iat() -> None: + adapter = JWTAuthAdapter(secret=SECRET) + now = int(time.time()) + token = sign_hs({"sub": "did:web:a", "nbf": now - 3600, "iat": now - 10, "exp": now + 3600}) + assert len(await adapter.extract_proofs(bearer(token))) == 1 @pytest.mark.asyncio