Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/a2a-action-route-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@agent-native/core": minor
---

Export first-class A2A auth primitives for the HTTP action route so workspaces
stop reaching into core internals:

- `verifyA2AToken` (and `A2ATokenPayload`) from `@agent-native/core/a2a` — the
same verifier the `/_agent-native/a2a` endpoint uses, including org-level
fallback secrets. Apps no longer need to reimplement a partial HS256 verifier.
- `AGENT_RUN_OWNER_CONTEXT_KEY`, `seedAgentRunOwnerContext`, and
`AgentRunOwnerContext` from `@agent-native/core/server` — a typed contract for
pre-seeding the resolved caller, replacing the hardcoded context-key string.
- A new `actionRouteAuth` option on `createAgentChatPlugin` (and `ActionRouteAuthAdapter`
/ `actionRouteAuth` on `mountActionRoutes`). Its `resolveCaller` runs before
the `getSession` chain on `/_agent-native/actions/*`, letting apps accept A2A
JWTs declaratively instead of intercepting Nitro's `request` hook. Returning
`null` defers to the existing framework auth chain; throwing hard-rejects the
request with a 401 so an invalid credential can't fall through to a
same-origin session cookie. A resolved caller's org comes exclusively from
the verified credential — the adapter-returned `orgId`
(`ActionRouteResolvedCaller`) or the owner-email membership lookup — never
from ambient session/org cookie state, so a request carrying both a valid
A2A bearer and an unrelated browser cookie can't execute under the cookie
user's org. A2A writes stay org-scoped.

All additive — existing callers are unaffected.
3 changes: 2 additions & 1 deletion packages/core/src/a2a/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Server (H3/Nitro)
export { mountA2A } from "./server.js";
export { mountA2A, verifyA2AToken } from "./server.js";
export type { A2ATokenPayload } from "./server.js";
export { generateAgentCard } from "./agent-card.js";

// Client
Expand Down
172 changes: 172 additions & 0 deletions packages/core/src/a2a/server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,178 @@ describe("mountA2A auth", () => {
});
});

describe("verifyA2AToken (exported)", () => {
const originalEnv = { ...process.env };

beforeEach(() => {
vi.resetModules();
getA2ASecretByDomainMock.mockReset();
process.env = { ...originalEnv, NODE_ENV: "production" };
});

afterEach(() => {
process.env = originalEnv;
});

async function signToken(
secret: string,
claims: Record<string, unknown>,
exp: string | number = "15m",
): Promise<string> {
return new jose.SignJWT(claims)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(exp)
.sign(new TextEncoder().encode(secret));
}

it("verifies a token signed with the shared A2A_SECRET", async () => {
process.env.A2A_SECRET = "shared-global-secret";
const { verifyA2AToken } = await import("./server.js");
const token = await signToken("shared-global-secret", {
sub: "[email protected]",
});

// Event is optional: no audience claim, no org lookup needed here.
const result = await verifyA2AToken(token);

expect(result).toEqual({ email: "[email protected]", orgDomain: null });
});

it("falls back to the org-level secret via org_domain (shared secret absent)", async () => {
delete process.env.A2A_SECRET;
getA2ASecretByDomainMock.mockResolvedValueOnce("org-a2a-secret");
const { verifyA2AToken } = await import("./server.js");
const token = await signToken("org-a2a-secret", {
sub: "[email protected]",
org_domain: "builder.io",
});

const result = await verifyA2AToken(token);

expect(getA2ASecretByDomainMock).toHaveBeenCalledWith("builder.io");
expect(result).toEqual({
email: "[email protected]",
orgDomain: "builder.io",
});
});

it("rejects a token whose signature matches no candidate secret", async () => {
process.env.A2A_SECRET = "shared-global-secret";
getA2ASecretByDomainMock.mockResolvedValueOnce(undefined);
const { verifyA2AToken } = await import("./server.js");
const token = await signToken("some-other-secret", {
sub: "[email protected]",
org_domain: "builder.io",
});

const result = await verifyA2AToken(token);

expect(result).toEqual({ email: null, orgDomain: null });
});

it("rejects an expired token", async () => {
process.env.A2A_SECRET = "shared-global-secret";
const { verifyA2AToken } = await import("./server.js");
const token = await signToken(
"shared-global-secret",
{ sub: "[email protected]" },
Math.floor(Date.now() / 1000) - 60,
);

const result = await verifyA2AToken(token);

expect(result).toEqual({ email: null, orgDomain: null });
});

it("returns null identity when no secret is configured", async () => {
delete process.env.A2A_SECRET;
const { verifyA2AToken } = await import("./server.js");
const token = await signToken("anything", { sub: "[email protected]" });

const result = await verifyA2AToken(token);

expect(result).toEqual({ email: null, orgDomain: null });
});

it("does not throw on a malformed token", async () => {
process.env.A2A_SECRET = "shared-global-secret";
const { verifyA2AToken } = await import("./server.js");

const result = await verifyA2AToken("not-a-jwt");

expect(result).toEqual({ email: null, orgDomain: null });
});

it("rejects a correctly-signed token whose aud targets another service (no derivable audience)", async () => {
// The signature is valid, but the token was minted for a different
// receiver. With no APP_URL/URL and no request event, this receiver can't
// derive its own audience — it must fail closed rather than accept a
// foreign-audience token just because the shared secret matches.
process.env.A2A_SECRET = "shared-global-secret";
delete process.env.APP_URL;
delete process.env.URL;
delete process.env.DEPLOY_URL;
delete process.env.BETTER_AUTH_URL;
const { verifyA2AToken } = await import("./server.js");
const token = await signToken("shared-global-secret", {
sub: "[email protected]",
aud: "https://attacker.example",
});

const result = await verifyA2AToken(token);

expect(result).toEqual({ email: null, orgDomain: null });
});

it("still accepts a token WITHOUT an aud claim when no audience can be derived", async () => {
// Backward-compat: tokens minted before the audience claim shipped (and
// internal callers that don't set one) carry no `aud`, so there is nothing
// to check — the secret + exp checks still gate them.
process.env.A2A_SECRET = "shared-global-secret";
delete process.env.APP_URL;
delete process.env.URL;
delete process.env.DEPLOY_URL;
delete process.env.BETTER_AUTH_URL;
const { verifyA2AToken } = await import("./server.js");
const token = await signToken("shared-global-secret", {
sub: "[email protected]",
});

const result = await verifyA2AToken(token);

expect(result).toEqual({ email: "[email protected]", orgDomain: null });
});

it("accepts a token whose aud matches the receiver's derived audience", async () => {
process.env.A2A_SECRET = "shared-global-secret";
process.env.APP_URL = "https://receiver.example";
const { verifyA2AToken } = await import("./server.js");
const token = await signToken("shared-global-secret", {
sub: "[email protected]",
aud: "https://receiver.example",
});

const result = await verifyA2AToken(token);

expect(result).toEqual({ email: "[email protected]", orgDomain: null });
});

it("rejects a token whose aud does not match the receiver's derived audience", async () => {
process.env.A2A_SECRET = "shared-global-secret";
process.env.APP_URL = "https://receiver.example";
const { verifyA2AToken } = await import("./server.js");
const token = await signToken("shared-global-secret", {
sub: "[email protected]",
aud: "https://attacker.example",
});

const result = await verifyA2AToken(token);

expect(result).toEqual({ email: null, orgDomain: null });
});
});

async function mountedAgentCardHandler(
config: A2AConfig,
routePrefix?: string,
Expand Down
43 changes: 34 additions & 9 deletions packages/core/src/a2a/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,11 @@ function warnA2AUnauthOnce(): void {
}

/**
* Verify an inbound A2A JWT signed with the shared A2A_SECRET.
* Returns the caller's email (from `sub` claim) if valid, null otherwise.
* Result of verifying an inbound A2A JWT. `email` is the caller identity from
* the token's `sub` claim (null when verification fails), `orgDomain` mirrors
* the verified `org_domain` claim when present.
*/
interface A2ATokenPayload {
export interface A2ATokenPayload {
email: string | null;
orgDomain: string | null;
}
Expand All @@ -58,9 +59,11 @@ function addSecretCandidate(
/**
* Resolve the audience (`aud`) value to expect in an inbound JWT. We use the
* receiver's app URL — it's the natural identifier of "who this token was
* minted for". Falls back to undefined when no app URL is configured, in
* which case the audience check is skipped (backward-compat with tokens
* minted before the audience claim shipped).
* minted for". Returns undefined when no app URL is configured and no request
* host is derivable; `verifyA2AToken` then rejects any token that carries an
* `aud` claim (fail closed — a correctly signed token minted for another
* service must not verify here). Only tokens without an `aud` claim (minted
* before the audience claim shipped) skip the audience check.
*/
function expectedJwtAudience(event: any | undefined): string | undefined {
const fromEnv =
Expand All @@ -80,9 +83,24 @@ function expectedJwtAudience(event: any | undefined): string | undefined {
return undefined;
}

async function verifyA2AToken(
/**
* Verify an inbound A2A bearer token (HS256) exactly as the
* `/_agent-native/a2a` endpoint does: it peeks at the unverified `org_domain`
* claim to build an ordered candidate-secret set (`process.env.A2A_SECRET`
* plus any org-level secret for that domain), then verifies the JWT — checking
* `aud`/`iss` when the token carries them and `exp` always. Returns the
* caller's email (`sub`) and org domain on success, or `{ email: null,
* orgDomain: null }` on any failure (malformed, bad signature, expired, or no
* secret configured), never throwing.
*
* Exported so workspaces can accept A2A callers on the HTTP action route with
* the same routine — including org-level fallback secrets — instead of
* reimplementing a partial verifier. Pass the H3 `event` to enable org-domain →
* org-secret lookup and audience derivation; it is optional.
*/
export async function verifyA2AToken(
Comment thread
builder-io-integration[bot] marked this conversation as resolved.
token: string,
event: any | undefined,
event?: any,
): Promise<A2ATokenPayload> {
// Step 1: Peek at JWT claims WITHOUT verification to get org_domain.
// This is safe because we only use org_domain to look up the secret,
Expand Down Expand Up @@ -132,8 +150,15 @@ async function verifyA2AToken(
try {
const verifyOptions: jose.JWTVerifyOptions = {};
if (unverifiedPayload && typeof unverifiedPayload.aud !== "undefined") {
// Fail closed: the token was minted for a specific audience, but this
// receiver can't derive its own expected audience (no APP_URL/URL and no
// usable request host). Accepting here would let a correctly-signed token
// whose `aud` targets ANOTHER service verify against a shared secret. A
// token that self-declares an audience must be checked against ours, so
// when we have nothing to check it against we reject rather than skip.
const aud = expectedJwtAudience(event);
if (aud) verifyOptions.audience = aud;
if (!aud) return { email: null, orgDomain: null };
verifyOptions.audience = aud;
}
if (
unverifiedPayload &&
Expand Down
Loading
Loading