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