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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 7 additions & 9 deletions server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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.')
}
Expand Down
31 changes: 26 additions & 5 deletions tests/wallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
Expand All @@ -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()
Expand Down Expand Up @@ -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,
})
Expand Down