diff --git a/README.md b/README.md index 7d0a5cd..14867aa 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ one deployment and one D1 database. No application smart contract is required. - The browser uses Authorization Code + PKCE with any compatible OIDC provider. - Browser tokens are stored in `localStorage`; Agent Wallet has no login cookie or server session. - The Worker validates human access tokens against the configured OIDC issuer/JWKS and keys users by `(iss, sub)`. -- Realmroot is optional. When used as the authorization server for Agent Wallet's `native` API Resource mode, browser and Agent tokens share one issuer. The Agent token's top-level `sub` is the authorizing user, its RFC 8693 `act` identifies the stable Agent with `sub_profile: ai_agent`, and `cnf.jkt` binds the token to DPoP. The Wallet resolves the issuer's discovered `agent_profile_uri_template` for the Agent's public name and picture. +- Realmroot is optional. When used as the authorization server for Agent Wallet's `native` API Resource mode, browser and Agent tokens share one issuer. The Agent token's top-level `sub` is the authorizing user, its RFC 8693 `act.iss` and `act.sub` identify the stable Agent, and `cnf.jkt` binds the token to DPoP. The Wallet resolves the issuer's discovered `agent_profile_uri_template` for the Agent's public name and picture. - Every Agent payment request requires a fresh DPoP proof. Replayed proofs are rejected. - The API publishes RFC 9728 Protected Resource Metadata at `/.well-known/oauth-protected-resource/api`. Authentication challenges link diff --git a/server/auth.ts b/server/auth.ts index 9674120..41749c4 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -25,6 +25,8 @@ export interface AgentPrincipal { scopes: string[] } +const realmrootCliClientId = 'realmroot-cli' + function bearer(request: Request, scheme: 'Bearer' | 'DPoP') { const value = request.headers.get('authorization') const match = value?.match(new RegExp(`^${scheme}\\s+(.+)$`, 'i')) @@ -82,6 +84,9 @@ export async function authenticateAgent( throw agentUnauthorized('Agent access token is invalid.') }) if (protectedHeader.typ !== 'at+jwt') throw agentUnauthorized('Agent access token type is invalid.') + if (payload.client_id !== realmrootCliClientId) { + throw agentUnauthorized('Agent access token client is invalid.') + } const grantedScopes = scopes(payload) if (!grantedScopes.includes(requiredScope)) { @@ -135,17 +140,10 @@ async function discoverKeySet(issuer: string) { } function resolveRealmrootAgent(payload: JWTPayload, issuer: string) { - const agent = payload.act as - | { - iss?: unknown - sub?: unknown - sub_profile?: unknown - } - | undefined + const agent = payload.act as { iss?: unknown; sub?: unknown } | undefined if ( agent?.iss !== issuer || - typeof agent.sub !== 'string' || - agent.sub_profile !== 'ai_agent' + typeof agent.sub !== 'string' ) { throw agentUnauthorized('A delegated Realmroot Agent access token is required.') } diff --git a/tests/wallet.test.ts b/tests/wallet.test.ts index 31b9cd2..592f065 100644 --- a/tests/wallet.test.ts +++ b/tests/wallet.test.ts @@ -1581,14 +1581,14 @@ describe('Agent Wallet', () => { expect(response.headers.get('www-authenticate')).toContain('invalid_token') }) - it('rejects an Agent actor without the ai_agent subject profile', async () => { + it('rejects an Agent actor from a different issuer', async () => { const token = await humanToken() await provisionAndGrant(token) const agentToken = await createAgentToken( true, ['wallet:read', 'wallet:budget:request', 'wallet:x402:pay'], audience, - 'person', + 'https://untrusted.example/api/auth', ) const response = await pay(agentToken, paymentRequired('25000')) @@ -1600,6 +1600,26 @@ describe('Agent Wallet', () => { }) }) + it('rejects an Agent token issued to a different client', async () => { + const token = await humanToken() + await provisionAndGrant(token) + const agentToken = await createAgentToken( + true, + ['wallet:read', 'wallet:budget:request', 'wallet:x402:pay'], + audience, + agentIssuer, + 'another-client', + ) + + const response = await pay(agentToken, paymentRequired('25000')) + + expect(response.status).toBe(401) + expect(await response.json()).toMatchObject({ + error: 'unauthorized', + message: 'Agent access token client is invalid.', + }) + }) + it('does not let another user approve an Agent budget request', async () => { const otherToken = await humanToken('user-2') const agentToken = await createAgentToken() @@ -1635,17 +1655,18 @@ async function createAgentToken( delegated = true, grantedScopes = ['wallet:read', 'wallet:budget:request', 'wallet:x402:pay'], tokenAudience = audience, - subjectProfile = 'ai_agent', + actorIssuer = agentIssuer, + clientId = 'realmroot-cli', ) { const thumbprint = await calculateJwkThumbprint(dpopPublicJwk) return new SignJWT({ + client_id: clientId, scope: grantedScopes.join(' '), cnf: { jkt: thumbprint }, act: delegated ? { - iss: agentIssuer, + iss: actorIssuer, sub: agentSubject, - sub_profile: subjectProfile, } : undefined, })