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
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,10 @@ const token = await createResourceToken({ resource, authServer, agent, agentJkt
### Local development

```bash
# Generate keys, register with a person server, and publish them
npx @aauth/bootstrap --ps https://person.hello-beta.net
# Generate keys, configure a person server, and publish them
npx @aauth/bootstrap --ps <your-ps-url>
```

> **Note:** `https://person.hello-beta.net` is the Hellō Beta Person Server. Data is reset regularly, so don't store anything you need to keep. To run against a different PS (including your own), pass its URL via `--ps`.

See [`@aauth/bootstrap`](./bootstrap) for the full setup flow.

## Protocol Support
Expand Down
8 changes: 4 additions & 4 deletions bootstrap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ Part of [aauth-dev/packages-js](https://github.com/aauth-dev/packages-js). Proto
## Quick Start

```bash
# Generate keys, register with a person server, and walk through hosting setup
npx @aauth/bootstrap --ps https://person.hello-beta.net
# Generate keys, configure a person server, and walk through hosting setup
npx @aauth/bootstrap --ps <your-ps-url>
```

> **Note:** `https://person.hello-beta.net` is the Hellō Beta Person Server. Data is reset regularly, so don't store anything you need to keep. To run against a different PS (including your own), pass its URL via `--ps`.
The bootstrap flow detects available key backends (YubiKey PIV, macOS Secure Enclave, software), generates keys on the strongest available backend, configures a person server for your agent, and bundles agent skills that walk you through publishing keys on platforms like GitHub Pages, GitLab Pages, Cloudflare Pages, and Netlify.

The bootstrap flow detects available key backends (YubiKey PIV, macOS Secure Enclave, software), generates keys on the strongest available backend, registers your agent with a person server, and bundles agent skills that walk you through publishing keys on platforms like GitHub Pages, GitLab Pages, Cloudflare Pages, and Netlify.
Per [draft-hardt-aauth-bootstrap §Self-Hosted Enrollment](https://github.com/dickhardt/AAuth), publication of the JWKS is the enrollment — there is no separate enrollment step. Person binding to a user happens lazily on the agent's first authorized request, per [§Agent-Person Binding](https://github.com/dickhardt/AAuth) in the protocol spec.

## Commands

Expand Down
5 changes: 1 addition & 4 deletions bootstrap/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,6 @@
"directory": "bootstrap"
},
"dependencies": {
"@aauth/local-keys": "^0.8.0",
"@aauth/mcp-agent": "^0.8.0",
"@hellocoop/httpsig": "^1.1.3",
"open": "^11.0.0"
"@aauth/local-keys": "^0.8.0"
}
}
7 changes: 1 addition & 6 deletions bootstrap/skills/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,7 @@ The person server URL is included as the `ps` claim in agent tokens. Set it duri
npx @aauth/bootstrap add-agent <agent-url> --person-server <person-server-url>
```

The default person server is `https://person.hello-beta.net` — the Hellō Beta Person Server. If the user doesn't specify one, use the default:
```
npx @aauth/bootstrap add-agent <agent-url> --person-server https://person.hello-beta.net
```

**Note:** The Hellō Beta Person Server resets its data regularly, so don't store anything that needs to persist. To run against a different PS (including your own), pass its URL via `--person-server` (alias `--ps`).
The agent MUST be configured with a person server URL. **Do not assume a default** — if the user hasn't specified one, ask them which PS to use before proceeding.

### 4. Choose a hosting platform

Expand Down
169 changes: 169 additions & 0 deletions bootstrap/src/bootstrap-ps.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest'
import { readConfig, writeConfig, getAgentConfig } from '@aauth/local-keys'
import type { AAuthConfig } from '@aauth/local-keys'
import { bootstrapWithPS } from './bootstrap-ps.js'

const PS_URL = 'https://ps.example'
const AGENT_URL = 'https://agent.example'

const validMetadata = {
issuer: PS_URL,
token_endpoint: `${PS_URL}/aauth/token`,
jwks_uri: `${PS_URL}/.well-known/jwks.json`,
interaction_endpoint: `${PS_URL}/aauth/interact`,
}

function mockMetadataResponse(body: unknown, status = 200): Response {
return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}

describe('bootstrapWithPS', () => {
let originalConfig: AAuthConfig
let mockFetch: ReturnType<typeof vi.fn>

beforeAll(() => {
originalConfig = readConfig()
})

afterAll(() => {
writeConfig(originalConfig)
})

beforeEach(() => {
writeConfig({ agents: {} })
mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)
})

afterEach(() => {
vi.unstubAllGlobals()
})

it('fetches metadata from the correct well-known URL', async () => {
mockFetch.mockResolvedValueOnce(mockMetadataResponse(validMetadata))

await bootstrapWithPS({ agentUrl: AGENT_URL, personServerUrl: PS_URL })

expect(mockFetch).toHaveBeenCalledWith(`${PS_URL}/.well-known/aauth-person.json`)
})

it('writes agentId and personServerUrl to config on success', async () => {
mockFetch.mockResolvedValueOnce(mockMetadataResponse(validMetadata))

await bootstrapWithPS({ agentUrl: AGENT_URL, personServerUrl: PS_URL })

const agentConfig = getAgentConfig(AGENT_URL)
expect(agentConfig?.agentId).toBe('aauth:local@agent.example')
expect(agentConfig?.personServerUrl).toBe(PS_URL)
})

it('uses the provided `local` value in agentId', async () => {
mockFetch.mockResolvedValueOnce(mockMetadataResponse(validMetadata))

await bootstrapWithPS({ agentUrl: AGENT_URL, personServerUrl: PS_URL, local: 'work' })

expect(getAgentConfig(AGENT_URL)?.agentId).toBe('aauth:work@agent.example')
})

it('preserves existing key entries when writing the agent config', async () => {
writeConfig({
agents: {
[AGENT_URL]: {
keys: {
'kid-123': {
backend: 'yubikey-piv',
algorithm: 'ES256',
keyId: '9e',
deviceLabel: 'yubikey-5c-0775',
},
},
},
},
})
mockFetch.mockResolvedValueOnce(mockMetadataResponse(validMetadata))

await bootstrapWithPS({ agentUrl: AGENT_URL, personServerUrl: PS_URL })

const agentConfig = getAgentConfig(AGENT_URL)
expect(agentConfig?.keys['kid-123']).toBeDefined()
expect(agentConfig?.personServerUrl).toBe(PS_URL)
})

it('throws when metadata endpoint returns non-OK', async () => {
mockFetch.mockResolvedValueOnce(new Response('not found', { status: 404 }))

await expect(
bootstrapWithPS({ agentUrl: AGENT_URL, personServerUrl: PS_URL }),
).rejects.toThrow(/Failed to fetch PS metadata.*404/)
})

it('throws when metadata is missing issuer', async () => {
const { issuer, ...rest } = validMetadata
void issuer
mockFetch.mockResolvedValueOnce(mockMetadataResponse(rest))

await expect(
bootstrapWithPS({ agentUrl: AGENT_URL, personServerUrl: PS_URL }),
).rejects.toThrow(/missing required field: issuer/)
})

it('throws when metadata is missing token_endpoint', async () => {
const { token_endpoint, ...rest } = validMetadata
void token_endpoint
mockFetch.mockResolvedValueOnce(mockMetadataResponse(rest))

await expect(
bootstrapWithPS({ agentUrl: AGENT_URL, personServerUrl: PS_URL }),
).rejects.toThrow(/missing required field: token_endpoint/)
})

it('throws when metadata is missing jwks_uri', async () => {
const { jwks_uri, ...rest } = validMetadata
void jwks_uri
mockFetch.mockResolvedValueOnce(mockMetadataResponse(rest))

await expect(
bootstrapWithPS({ agentUrl: AGENT_URL, personServerUrl: PS_URL }),
).rejects.toThrow(/missing required field: jwks_uri/)
})

it('throws when issuer does not match the PS URL', async () => {
mockFetch.mockResolvedValueOnce(
mockMetadataResponse({ ...validMetadata, issuer: 'https://imposter.example' }),
)

await expect(
bootstrapWithPS({ agentUrl: AGENT_URL, personServerUrl: PS_URL }),
).rejects.toThrow(/issuer.*does not match URL/)
})

it('accepts trailing-slash differences between issuer and URL', async () => {
mockFetch.mockResolvedValueOnce(
mockMetadataResponse({ ...validMetadata, issuer: `${PS_URL}/` }),
)

await expect(
bootstrapWithPS({ agentUrl: AGENT_URL, personServerUrl: PS_URL }),
).resolves.not.toThrow()
})

it('strips trailing slash from PS URL when constructing metadata URL', async () => {
mockFetch.mockResolvedValueOnce(mockMetadataResponse(validMetadata))

await bootstrapWithPS({ agentUrl: AGENT_URL, personServerUrl: `${PS_URL}/` })

expect(mockFetch).toHaveBeenCalledWith(`${PS_URL}/.well-known/aauth-person.json`)
})

it('does NOT make a registration POST to the PS', async () => {
mockFetch.mockResolvedValueOnce(mockMetadataResponse(validMetadata))

await bootstrapWithPS({ agentUrl: AGENT_URL, personServerUrl: PS_URL })

// Only the metadata GET should have been issued.
expect(mockFetch).toHaveBeenCalledTimes(1)
})
})
Loading
Loading