-
Notifications
You must be signed in to change notification settings - Fork 349
feat(core): export A2A verification + owner-context seeding, add actionRouteAuth adapter for action routes #2047
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
steve8708
merged 4 commits into
BuilderIO:main
from
agencyftw:feat/a2a-action-route-auth
Jul 14, 2026
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
d823d8d
feat(core): export A2A verification + owner-context seeding, add acti…
ptahdunbar 5bbf206
fix(core): address review — fail closed on underivable audience, org …
ptahdunbar b350938
fix(core): derive adapter-caller org exclusively from the verified cr…
ptahdunbar 9104b61
docs(core): update expectedJwtAudience JSDoc to match fail-closed aud…
ptahdunbar File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.