diff --git a/fern/apis/api/definition/api-keys.yml b/fern/apis/api/definition/api-keys.yml index ded3e80..10217a5 100644 --- a/fern/apis/api/definition/api-keys.yml +++ b/fern/apis/api/definition/api-keys.yml @@ -62,7 +62,7 @@ types: docs: ID of the inbox. PublicKeyScope: - docs: The immutable scope in which a public-key credential can approve AgentID sign-in. + docs: The immutable scope of a public-key credential. union: organization: OrganizationPublicKeyScope pod: PodPublicKeyScope @@ -82,9 +82,9 @@ types: PublicKeyCredential: docs: | - An AgentID sign-in credential. `type` and `api_key_id` are server-owned; - use `api_key_id` as the JWS `kid`. This response never contains a bearer - secret or private key. + A registered public-key credential for signed requests. `type` and + `api_key_id` are server-owned; `api_key_id` is the credential's JWS `kid`. + This response never contains a bearer secret or private key. properties: api_key_id: type: uuid @@ -108,9 +108,9 @@ types: CreatePublicKeyRequest: docs: | - Register only a public P-256 JWK. Credential type, `api_key_id`, sign-in - eligibility, permissions, and generation are server-owned and are not - request properties. + Register only a public P-256 JWK. Credential type, `api_key_id`, + permissions, and generation are server-owned and are not request + properties. properties: public_key: PublicJwk name: @@ -132,8 +132,8 @@ types: UpdatePublicKeyNameRequest: docs: | - Rename a public-key credential. Key material, ID, type, scope, sign-in - eligibility, permissions, generation, and expiry are immutable. + Rename a public-key credential. Key material, ID, type, scope, + permissions, generation, and expiry are immutable. properties: name: type: string @@ -150,7 +150,7 @@ types: docs: Public-key credentials only, ordered by creation time descending by default. RevokeAllAgentIdSignInKeysResponse: - docs: Permanent idempotency receipt for an organization-wide AgentID sign-in key revocation. + docs: Permanent idempotency receipt for an organization-wide public-key credential revocation. properties: previous_generation: type: integer diff --git a/fern/changelog/2026-09-05-agentid-approval-retired.mdx b/fern/changelog/2026-09-05-agentid-approval-retired.mdx new file mode 100644 index 0000000..92c55bb --- /dev/null +++ b/fern/changelog/2026-09-05-agentid-approval-retired.mdx @@ -0,0 +1,59 @@ +--- +tags: ["agentid", "api-keys", "breaking-change", "docs"] +--- + +## Summary + +AgentID sign-in now runs entirely through browser enrollment. The agent-signed +approval endpoint is retired, so an agent no longer generates, stores, or signs +with its own P-256 key to complete a sign-in: it enrolls the browser once with +ordinary authenticated HTTP, and every later sign-in completes from that browser. + +### Breaking changes + +⚠️ **`POST https://auth.agentid.com/authorize/approve` has been removed.** The +AgentID waiting page no longer prints a request id, and the agent-facing +approval guide is gone. Sign-ins that relied on a registered public key signing +the request id must move to +[AgentID browser enrollment](https://docs.agentmail.to/agentid-browser-enrollment). +Production recorded no agent-key approvals in the 90 days before this change. + +Public-key credentials themselves are unchanged. `POST /v0/api-keys/public-keys`, +the list, rename, and revoke operations, and +`POST /v0/api-keys/public-keys/agentid-sign-in/revoke-all` keep working as the +lifecycle for registered signing keys; those keys are now signed-request +credentials rather than AgentID sign-in credentials. + +**Migration guide:** + + +```python title="Python" +import httpx + +# enroll the browser waiting on the agentid page; transaction_jti comes from that page's action +response = httpx.post( + "https://api.agentmail.to/v0/inboxes/agent@yourdomain.agentmail.to/browser-credentials/enrollments", + headers={"Authorization": "Bearer am_us_..."}, + json={"transaction_jti": ""}, + timeout=10, +) +response.raise_for_status() +``` + +```typescript title="TypeScript" +// enroll the browser waiting on the agentid page; transaction_jti comes from that page's action +const response = await fetch( + 'https://api.agentmail.to/v0/inboxes/agent%40yourdomain.agentmail.to/browser-credentials/enrollments', + { + method: 'POST', + headers: { Authorization: 'Bearer am_us_...', 'Content-Type': 'application/json' }, + body: JSON.stringify({ transaction_jti: '' }), + }, +) +if (!response.ok) throw new Error(`Enrollment failed: ${response.status}`) +``` + + + + Follow the [AgentID browser enrollment guide](https://docs.agentmail.to/agentid-browser-enrollment) for the origin checks an agent must perform before submitting a `transaction_jti`. + diff --git a/fern/docs.yml b/fern/docs.yml index d825eca..d4d6da3 100644 --- a/fern/docs.yml +++ b/fern/docs.yml @@ -102,6 +102,9 @@ redirects: - source: /integrations/moltbot destination: /integrations/openclaw permanent: true + - source: /agentid-public-key-authentication + destination: /agentid-browser-enrollment + permanent: true - source: /integrations/ai-onboarding destination: /integrations/agent-onboarding permanent: true @@ -320,9 +323,6 @@ navigation: - page: AgentID Browser Enrollment icon: fa-solid fa-shield-halved path: pages/guides/agentid-browser-enrollment.mdx - - page: AgentID Public-Key Authentication - icon: fa-solid fa-key - path: pages/guides/agentid-public-key-authentication.mdx - page: Sending & Receiving Email icon: fa-solid fa-right-left path: pages/guides/sending-receiving-email.mdx diff --git a/fern/pages/guides/agentid-browser-enrollment.mdx b/fern/pages/guides/agentid-browser-enrollment.mdx index 0112675..b53bbb3 100644 --- a/fern/pages/guides/agentid-browser-enrollment.mdx +++ b/fern/pages/guides/agentid-browser-enrollment.mdx @@ -314,8 +314,12 @@ qualification. Embedded WebViews must be qualified in the real host application with its configured persistent data store and a full host restart; nonpersistent data stores are unsupported. -## Migration from public-key authentication - -Browser enrollment is the current path for persistent browser approval. The -[AgentID public-key flow](/agentid-public-key-authentication) remains supported -during migration, and no deprecation date is being announced with this release. +## Retired public-key approval + +Browser enrollment is the only agent-driven AgentID sign-in. The earlier +public-key approval flow is retired: `POST https://auth.agentid.com/authorize/approve` +no longer exists, and the waiting page no longer prints a request id for an +agent to sign. Public-key credentials registered through +`POST /v0/api-keys/public-keys` remain valid as signed-request keys, but they +no longer sign an agent in to AgentID. An agent that still holds one should +enroll its browser as described above. diff --git a/fern/pages/guides/agentid-public-key-authentication.mdx b/fern/pages/guides/agentid-public-key-authentication.mdx deleted file mode 100644 index ba76a28..0000000 --- a/fern/pages/guides/agentid-public-key-authentication.mdx +++ /dev/null @@ -1,420 +0,0 @@ ---- -title: AgentID Public-Key Authentication -subtitle: Register a scoped P-256 key and sign one AgentID approval without exposing the private key. -slug: agentid-public-key-authentication -description: Generate and store a P-256 key, register its public JWK, and submit a strict signed AgentID approval. ---- - -AgentID public-key credentials let an agent prove possession of a P-256 private -key while approving an AgentID sign-in. Registration uses an existing AgentMail -bearer API key. Approval uses only a compact signature and never sends that -bearer key or the private key to AgentID. - - - For organizations using US production, [AgentID browser - enrollment](/agentid-browser-enrollment) is the current path for persistent - browser approval. It is not available in EU production. This public-key flow - remains supported during migration; no deprecation date has been announced. - - - - Generate and use the private key in a keystore, HSM, KMS, or a small trusted - signing process. Give the model an opaque signing capability, not the private - JWK, PEM, environment variable, tool output, log entry, prompt, trace, or - conversation history. - - -## Protocol at a glance - -1. Generate a P-256 key pair in trusted code and persist the private key in your - keystore. -2. Export only `{kty: "EC", crv: "P-256", x, y}` and register it at - `POST /v0/api-keys/public-keys` with an existing AgentMail bearer API key. -3. Store the returned `api_key_id` beside the private-key handle. It is the JWS - `kid`; do not compute or choose it yourself. -4. For one pending authorization transaction, sign exactly `{jti, inbox_id}` - with ES256 and protected `typ: agentid-approval+jwt`. -5. POST exactly `{assertion, inbox_id}` to - `https://auth.agentid.com/authorize/approve` without bearer authorization. - Success is `204 No Content`. - -Public-key credentials are only AgentID sign-in credentials. They cannot replace -an AgentMail bearer API key for normal REST API calls. - -## Generate and register a key - -The registration endpoint rejects private `d`, unknown JWK members, non-P-256 -curves, malformed coordinates, and coordinates that are not on P-256. The -server computes the RFC 7638 SHA-256 fingerprint and returns it; compare or log -the public fingerprint when you need an audit handle, never the private key. - - - The generated SDK methods in this guide require an AgentMail Python and - TypeScript SDK release that includes the public-key credential endpoints. If - your installed client does not expose these methods yet, use the REST API or - upgrade after that release is published. - - -After that SDK release, install the Python example dependencies with -`pip install agentmail cryptography PyJWT httpx`, or the TypeScript dependencies -with `npm install agentmail jose`. - - - -```python title="Python" -from __future__ import annotations - -import base64 -from datetime import datetime -from typing import Any, Dict, Literal, Optional, TypedDict, Union - -from agentmail import AgentMail -from cryptography.hazmat.primitives.asymmetric import ec - - -class OrganizationScope(TypedDict): - type: Literal["organization"] - - -class PodScope(TypedDict): - type: Literal["pod"] - id: str - - -class InboxScope(TypedDict): - type: Literal["inbox"] - id: str - - -Scope = Union[OrganizationScope, PodScope, InboxScope] - - -def b64url_coordinate(value: int) -> str: - return base64.urlsafe_b64encode(value.to_bytes(32, "big")).rstrip(b"=").decode() - - -def public_jwk(private_key: ec.EllipticCurvePrivateKey) -> Dict[str, str]: - numbers = private_key.public_key().public_numbers() - return { - "kty": "EC", - "crv": "P-256", - "x": b64url_coordinate(numbers.x), - "y": b64url_coordinate(numbers.y), - } - - -def register_agentid_key( - client: AgentMail, - private_key: ec.EllipticCurvePrivateKey, - *, - scope: Optional[Scope] = None, - name: Optional[str] = None, - expires_at: Optional[datetime] = None, -): - request: Dict[str, Any] = {"public_key": public_jwk(private_key)} - if scope is not None: - request["scope"] = scope - if name is not None: - request["name"] = name - if expires_at is not None: - request["expires_at"] = expires_at - return client.api_keys.create_public_key(**request) - - -# Generate inside your keystore in production. This in-process object is only a -# minimal example; persist it before registration so a crash cannot orphan the kid. -private_key = ec.generate_private_key(ec.SECP256R1()) -client = AgentMail(api_key="YOUR_EXISTING_AGENTMAIL_API_KEY") -credential = register_agentid_key( - client, - private_key, - name="production signer", - scope={"type": "inbox", "id": "agent@example.com"}, -) - -# Store this mapping in trusted application state. -key_record = { - "keystore_handle": "opaque-keystore-handle", - "kid": str(credential.api_key_id), -} -``` - -```typescript title="TypeScript" -import { generateKeyPairSync, type KeyObject } from "node:crypto"; -import { AgentMailClient } from "agentmail"; - -type Scope = - | { type: "organization" } - | { type: "pod"; id: string } - | { type: "inbox"; id: string }; - -function publicJwk(publicKey: KeyObject) { - const jwk = publicKey.export({ format: "jwk" }); - if (jwk.kty !== "EC" || jwk.crv !== "P-256" || !jwk.x || !jwk.y) { - throw new Error("expected a P-256 public key"); - } - // Select the four public members explicitly. Do not spread an exported JWK. - return { kty: "EC" as const, crv: "P-256" as const, x: jwk.x, y: jwk.y }; -} - -async function registerAgentIdKey( - client: AgentMailClient, - publicKey: KeyObject, - options: { scope?: Scope; name?: string; expiresAt?: Date } = {}, -) { - return client.apiKeys.createPublicKey({ - publicKey: publicJwk(publicKey), - scope: options.scope, - name: options.name, - expiresAt: options.expiresAt, - }); -} - -// Generate inside your keystore in production. Persist the private-key handle -// before registration so a crash cannot orphan the returned kid. -const { privateKey, publicKey } = generateKeyPairSync("ec", { - namedCurve: "prime256v1", -}); -const client = new AgentMailClient({ apiKey: "YOUR_EXISTING_AGENTMAIL_API_KEY" }); -const credential = await registerAgentIdKey(client, publicKey, { - name: "production signer", - scope: { type: "inbox", id: "agent@example.com" }, -}); - -const keyRecord = { - keystoreHandle: "opaque-keystore-handle", - kid: credential.apiKeyId, -}; -``` - - - -The examples keep a process-local private key only to show the types. In a -production helper, make the signer accept an opaque keystore handle and return a -signature; do not make private key bytes an application-level return value. - -## Scope and expiry - -Omitting `scope` inherits the registering bearer key's exact live scope. An -explicit scope may be the caller's scope or a live descendant, never an ancestor -or sibling. - -```json title="Organization scope" -{ "type": "organization" } -``` - -```json title="Pod scope" -{ "type": "pod", "id": "33333333-3333-4333-8333-333333333333" } -``` - -```json title="Inbox scope" -{ "type": "inbox", "id": "agent@example.com" } -``` - -For `expires_at`, omission inherits the registering bearer credential's expiry. -If that bearer does not expire, the public-key credential does not expire. An -explicit expiry must be in the future and cannot be later than the creator's -expiry. Scope, key material, AgentID eligibility, and expiry are immutable after -registration; only `name` can be patched. - -## Sign and submit one approval - -The protected header and payload are intentionally smaller than a general JWT: - -```json title="Protected header" -{ - "alg": "ES256", - "typ": "agentid-approval+jwt", - "kid": "api_key_id returned by registration" -} -``` - -```json title="Signed payload" -{ "jti": "transaction challenge", "inbox_id": "agent@example.com" } -``` - -Do not add `aud`, `iat`, `exp`, `nonce`, `scope`, or any other claim. Do not add -`jwk`, `jku`, `x5u`, `x5c`, or `crit` to the protected header. The transaction's -server-side expiry is authoritative. The assertion must be a three-segment -compact JWS no larger than 2 KiB; `jti` is 1–128 characters and `inbox_id` is -1–254 characters and must identify an email inbox. - - - -```python title="Python" -import httpx -import jwt -from cryptography.hazmat.primitives.asymmetric import ec - -AGENTID_APPROVE_URL = "https://auth.agentid.com/authorize/approve" - - -def approve_agentid_transaction( - *, - private_key: ec.EllipticCurvePrivateKey, - api_key_id: str, - jti: str, - inbox_id: str, -) -> None: - assertion = jwt.encode( - {"jti": jti, "inbox_id": inbox_id}, - private_key, - algorithm="ES256", - headers={"alg": "ES256", "typ": "agentid-approval+jwt", "kid": api_key_id}, - ) - - response = httpx.post( - AGENTID_APPROVE_URL, - json={"assertion": assertion, "inbox_id": inbox_id}, - # Deliberately no Authorization header and no browser cookies. - headers={"Content-Type": "application/json"}, - timeout=10, - ) - response.raise_for_status() - if response.status_code != 204: - raise RuntimeError(f"unexpected approval status {response.status_code}") -``` - -```typescript title="TypeScript" -import type { KeyObject } from "node:crypto"; -import { CompactSign } from "jose"; - -const AGENTID_APPROVE_URL = "https://auth.agentid.com/authorize/approve"; - -async function approveAgentIdTransaction(input: { - privateKey: KeyObject; - apiKeyId: string; - jti: string; - inboxId: string; -}): Promise { - const payload = new TextEncoder().encode( - JSON.stringify({ jti: input.jti, inbox_id: input.inboxId }), - ); - const assertion = await new CompactSign(payload) - .setProtectedHeader({ - alg: "ES256", - typ: "agentid-approval+jwt", - kid: input.apiKeyId, - }) - .sign(input.privateKey); - - const response = await fetch(AGENTID_APPROVE_URL, { - method: "POST", - headers: { "content-type": "application/json" }, - // Deliberately no Authorization header, credentials option, or browser cookie. - body: JSON.stringify({ assertion, inbox_id: input.inboxId }), - }); - if (response.status !== 204) { - throw new Error(`AgentID approval failed with ${response.status}`); - } -} -``` - - - -The unsigned `inbox_id` in the JSON body is an ergonomic duplicate and must be -byte-for-byte equal to the signed claim. The server resolves `kid` only against a -stored public-key credential, verifies the signature, validates the transaction, -and rechecks the key, organization, scope, inbox, generation, and expiry before -committing one approval. Concurrent or repeated submissions have one winner. - -## List, rename, revoke, and rotate - -The generated clients for this contract expose dedicated lifecycle methods. -Legacy `api_keys.list`, `api_keys.create`, and `api_keys.delete` remain -bearer-only and have no public-key request member. - - - -```python title="Python" -# Public-key list results never include bearer credentials. -page = client.api_keys.list_public_keys(limit=20) - -# Name is the only mutable property. -renamed = client.api_keys.update_public_key_name( - credential.api_key_id, - name="production signer 2026-08", -) - -# Rotate by creating the replacement first, deploying its new kid, then deleting old. -replacement = register_agentid_key(client, replacement_private_key, name="replacement") -deploy_kid_and_keystore_handle(replacement.api_key_id, replacement_private_key_handle) -client.api_keys.revoke_public_key(credential.api_key_id) -``` - -```typescript title="TypeScript" -// Public-key list results never include bearer credentials. -const page = await client.apiKeys.listPublicKeys({ limit: 20 }); - -// Name is the only mutable property. -const renamed = await client.apiKeys.updatePublicKeyName(credential.apiKeyId, { - name: "production signer 2026-08", -}); - -// Rotate by creating the replacement first, deploying its new kid, then deleting old. -const replacement = await registerAgentIdKey(client, replacementPublicKey, { - name: "replacement", -}); -await deployKidAndKeystoreHandle(replacement.apiKeyId, replacementPrivateKeyHandle); -await client.apiKeys.revokePublicKey(credential.apiKeyId); -``` - - - -Registration never updates in place. Even registering identical JWK coordinates -again returns a new `api_key_id`; store and use that new value as `kid`. Rotation -is therefore create new, deploy new, then delete old. Never reuse an old `kid` -for new key material. - -For an emergency organization-wide fence, call -`POST /v0/api-keys/public-keys/agentid-sign-in/revoke-all` with an -organization-scoped bearer credential and a required UUID `Idempotency-Key`. -The caller normally needs `api_key_delete`. A verified self-serve agent -organization may instead use an unrestricted unmanaged bearer credential for -this emergency operation. The request has no body. Repeating the same UUID -returns the original `{previous_generation, current_generation, revoked_at}` -receipt and does not advance the generation twice. A new UUID advances it again. -Existing rows remain visible with `revoked_at` for audit; individually revoked -keys are deleted. - - - -```python title="Python" -import uuid - -receipt = client.api_keys.revoke_all_agent_id_sign_in_keys( - idempotency_key=str(uuid.uuid4()), -) -print(receipt.previous_generation, receipt.current_generation) -``` - -```typescript title="TypeScript" -import { randomUUID } from "node:crypto"; - -const receipt = await client.apiKeys.revokeAllAgentIdSignInKeys({ - idempotencyKey: randomUUID(), -}); -console.log(receipt.previousGeneration, receipt.currentGeneration); -``` - - - -## Intent and browser-session limitation - - - The approval assertion proves that the key holder approved the server-created - transaction identified by `jti` for one inbox. It does not prove that the key - holder initiated the transaction, controls the browser session, inspected the - relying party, or intended the relying party's action. - - -An attacker can start a valid authorization transaction in the attacker's own -browser, induce an agent to sign that transaction's `jti`, and then continue in -the same attacker browser session. AgentID's per-transaction cookie binding -prevents a different browser from continuing the flow, but it does not remove -this accepted transaction-intent/session-swap residual. - -If your product requires intent assurance, bind the displayed relying party and -transaction to an authenticated, trusted out-of-band instruction before calling -the signing helper. Do not claim that signature validity alone verifies user -intent. diff --git a/openapi/openapi.yml b/openapi/openapi.yml index 5c69764..428b9f8 100644 --- a/openapi/openapi.yml +++ b/openapi/openapi.yml @@ -8352,9 +8352,9 @@ components: title: PublicKeyCredential type: object description: |- - An AgentID sign-in credential. `type` and `api_key_id` are server-owned; - use `api_key_id` as the JWS `kid`. This response never contains a bearer - secret or private key. + A registered public-key credential for signed requests. `type` and + `api_key_id` are server-owned; `api_key_id` is the credential's JWS `kid`. + This response never contains a bearer secret or private key. properties: api_key_id: type: string @@ -8407,9 +8407,9 @@ components: title: CreatePublicKeyRequest type: object description: |- - Register only a public P-256 JWK. Credential type, `api_key_id`, sign-in - eligibility, permissions, and generation are server-owned and are not - request properties. + Register only a public P-256 JWK. Credential type, `api_key_id`, + permissions, and generation are server-owned and are not request + properties. properties: public_key: $ref: '#/components/schemas/PublicJwk' @@ -8440,8 +8440,8 @@ components: title: UpdatePublicKeyNameRequest type: object description: |- - Rename a public-key credential. Key material, ID, type, scope, sign-in - eligibility, permissions, generation, and expiry are immutable. + Rename a public-key credential. Key material, ID, type, scope, + permissions, generation, and expiry are immutable. properties: name: type: string @@ -8472,8 +8472,8 @@ components: title: RevokeAllAgentIdSignInKeysResponse type: object description: >- - Permanent idempotency receipt for an organization-wide AgentID sign-in - key revocation. + Permanent idempotency receipt for an organization-wide public-key + credential revocation. properties: previous_generation: type: integer