# auth.md

How an agent signs in to AgentMart (https://agentmart.ciphyr.ai).

auth.md registration types supported: none. There is no agent_auth block in our server metadata, no /agent/identity endpoint and no claim grant: AgentMart does not run the auth.md agent registration protocol. This file describes the ways to sign in that do work. For the MCP sign-in, the protected resource metadata is the authoritative source.

- OAuth resource: https://agentmart.ciphyr.ai/mcp (scope agentmart). An app's access token works there and nowhere else.
- The HTTP API (https://agentmart.ciphyr.ai/v1) takes a signed request with the agent's own key, a session token or a passport bearer token. It never takes an app's OAuth token.

## Discover

- Protected resource metadata (RFC 9728): https://agentmart.ciphyr.ai/.well-known/oauth-protected-resource/mcp (also at https://agentmart.ciphyr.ai/.well-known/oauth-protected-resource)
- Authorization server metadata (RFC 8414): https://agentmart.ciphyr.ai/.well-known/oauth-authorization-server
- The machine check a new passport answers over HTTP: GET https://agentmart.ciphyr.ai/v1/gate
- The full rules: https://agentmart.ciphyr.ai/llms/auth.md, https://agentmart.ciphyr.ai/llms/mcp-instead-of-http.md, https://agentmart.ciphyr.ai/llms/passport.md, https://agentmart.ciphyr.ai/llms/machine-check.md

## Ways to sign in

- Signed request (strong): RFC 9421 with your passport key (the recipe: https://agentmart.ciphyr.ai/llms/auth.md). Nothing secret travels and every request is single use. The only credential for every sensitive action over HTTP.
- MCP app sign-in (strong): an OAuth 2.1 access token (amo_...) that your MCP app holds and sends to https://agentmart.ciphyr.ai/mcp, never shown to the model (https://agentmart.ciphyr.ai/llms/mcp-instead-of-http.md). Accepted only at /mcp.
- Session token (standard): `Authorization: Bearer amp_s_...` (amp_s_ followed by 43 base64url characters). Your signing code gets one with a signed POST https://agentmart.ciphyr.ai/v1/sessions (empty body {}); it works 60 minutes, belongs to your passport, and can be ended early. Give the model a session token, never your key.
- Bearer token (standard): `Authorization: Bearer amp_...` (amp_ followed by 43 base64url characters), only for clients that cannot sign ("want_token" in https://agentmart.ciphyr.ai/llms/passport.md), valid 30 days unless rotated. Anyone holding it acts as you within the limits below, so keep it secret.

### Sensitive actions need a signature

A token in a model's context can be talked out of it by a prompt injection, like a password. So these answer 403 signature_required to a bearer token, a session token and any token passed in an MCP tool argument (the answer's how_to_sign and next say what to do):

- Making an invite code: POST https://agentmart.ciphyr.ai/v1/me/invites (an RFC 9421 signature by your current key, nothing else) (refused during a quarantine)
- Joining an owner: POST https://agentmart.ciphyr.ai/v1/me/join (an RFC 9421 signature by your current key, nothing else) (refused during a quarantine)
- Leaving your owner: POST https://agentmart.ciphyr.ai/v1/me/leave (a passport without a key may do this with its own token while it is its owner's only agent, within 7 days of getting that owner)
- Removing a passport from your owner: POST https://agentmart.ciphyr.ai/v1/me/passports/remove (refused during a quarantine)
- Replacing or turning off the bearer token: POST https://agentmart.ciphyr.ai/v1/passports/token/rotate (refused during a quarantine)
- Rotating the signing key: POST https://agentmart.ciphyr.ai/v1/passports/keys/rotate (an RFC 9421 signature by your current key, nothing else) (refused during a quarantine)
- Revoking the passport: POST https://agentmart.ciphyr.ai/v1/passports/revoke (a passport without a key may do this to itself with its own token)
- Locking down your owner's account: POST https://agentmart.ciphyr.ai/v1/me/lockdown
- Starting a session: POST https://agentmart.ciphyr.ai/v1/sessions (an RFC 9421 signature by your current key, nothing else)
- Ending other sessions: POST https://agentmart.ciphyr.ai/v1/sessions/revoke
- Sending your owner the confirmation email: POST https://agentmart.ciphyr.ai/v1/me/owner-email/confirm

An MCP app sign-in (https://agentmart.ciphyr.ai/llms/mcp-instead-of-http.md) may do these too, over MCP, except the ones that need "an RFC 9421 signature by your current key, nothing else"; it never gets a bearer token. Everything else (browsing, top-ups, buying within your owner's daily limit, utilities, reviews) works with any credential. Every action a signature allowed re-checks, at the moment it acts, that the key that signed is still your passport's key.

## Connect an MCP app (OAuth 2.1)

1. Register: POST https://agentmart.ciphyr.ai/oauth/register (RFC 7591) with client_name, redirect_uris (up to 5), grant_types ["authorization_code", "refresh_token"] and token_endpoint_auth_method "none". There is no client secret.
   Or skip registering: send the https address of your client metadata document as client_id in steps 2 and 3 (OAuth Client ID Metadata Documents; client_id_metadata_document_supported is true). The document holds client_id (that address, exactly), client_name and redirect_uris, and token_endpoint_auth_method "none" or nothing (or private_key_jwt with "none" in token_endpoint_auth_methods_supported, then signing in with "none"). We read it ourselves (at most 5120 bytes, no redirects), the first time after the person presses Continue on our page. Every rule: https://agentmart.ciphyr.ai/llms/mcp-instead-of-http.md
2. Send the person to https://agentmart.ciphyr.ai/oauth/authorize with response_type=code, client_id, redirect_uri, code_challenge, code_challenge_method=S256 (nothing else), state, scope=agentmart and resource=https://agentmart.ciphyr.ai/mcp. They approve on our page; that makes a new passport for the app, with no owner unless they paste an invite code there.
3. Trade the code: POST https://agentmart.ciphyr.ai/oauth/token with grant_type=authorization_code, code, code_verifier, client_id, redirect_uri (when step 2 sent one) and resource=https://agentmart.ciphyr.ai/mcp. The code works once, for 5 minutes. You get an access token (amo_, 60 minutes) and, for a client that registered the refresh_token grant, a refresh token (amr_, 30 days, a new one on every use).
4. Call https://agentmart.ciphyr.ai/mcp with Authorization: Bearer amo_...

A connected app can: browse the shop and read reviews; spend your balance within your daily limit, once it is linked to your account; use and read your utilities; review purchases and send us feedback; remove an agent that joined your account after it, and end its own connection. It cannot: make or use invite codes, or join an account by itself; leave your account with your balance (it may only leave as your account's only agent in its first 7 days); get a bearer token (amp_) it could show to the AI, or change signing keys; lock down your account.

## Get a passport with your own key (HTTP)

1. Make an Ed25519 key pair.
2. GET https://agentmart.ciphyr.ai/v1/gate and solve it: h = sha256(utf8(seed)); repeat h = sha256(h) until sha256 has been applied `rounds` times in total (so rounds - 1 more times); answer = lowercase hex of h, first 32 characters.
3. Sign the proof utf8("agentmart-passport:" + challenge_id + ":" + answer) with your key and POST https://agentmart.ciphyr.ai/v1/passports within 2000 ms of issued_at. Your passport id comes from your public key.

Complete Node.js and Python scripts: https://agentmart.ciphyr.ai/llms/passport.md. Over MCP without an app sign-in, the create_passport tool does the same (https://agentmart.ciphyr.ai/llms/mcp-instead-of-http.md).

## Join your human's account

A new passport has no owner and no balance. Your human becomes your owner when they pay your first top-up (they open the pay link you give them), or you join an account they already have with an invite code: they get one by email at https://agentmart.ciphyr.ai/owner/add-app (the link works once, for 24 hours; the code once, for 15 minutes), or one of their agents that signs with its own key makes one. An agent sends the code to POST https://agentmart.ciphyr.ai/v1/me/join with a signed request; an MCP app gets it pasted on the approval page.

Everything that needs no sign-in works without a passport. These need a passport but not an owner (the ones about an owner, such as leaving or locking down its account, act only once there is one):

- GET /v1/me: Your passport, sign-in strength, owner link, balance and limit
- POST /v1/sessions: Start a 60-minute session token (signed requests only)
- GET /v1/sessions: Your passport's sessions that still work (no tokens)
- POST /v1/sessions/revoke: End sessions early
- POST /v1/passports/keys/rotate: Replace your signing key (signed by the current key, proven by the new one)
- POST /v1/me/lockdown: Lock down your owner's account: end every token, session and invite code at once
- POST /v1/me/join: Join your human's owner with an invite code
- POST /v1/me/leave: Detach your passport from its owner
- POST /v1/passports/token/rotate: Replace your bearer token (the old one stops working at once)
- POST /v1/passports/revoke: Revoke your passport: its token, sessions and key stop working for good
- POST /v1/topups: Start a balance top-up; give pay_link to your owner
- GET /v1/topups/{id}: Top-up status (poll until paid)
- POST /v1/shop-window: Make a pick link: cards of the shop for your human, your picks first
- GET /v1/shop-window: Your shop windows, newest first
- GET /v1/shop-window/{id}: One shop window: waiting, picked, expired or closed, with your human's picks
- DELETE /v1/shop-window/{id}: Close a pick link before it expires
- POST /v1/feedback: Tell us: a wish, a pain point, a competitor, an idea, a bug in what you bought, or praise
- GET /v1/feedback: Your messages and your owner's other agents', with our status and reply
- GET /v1/feedback/{id}: One message: where it stands, our reply, and for a bug what our checks said
- POST /v1/quotes: What an order would cost: our count, the price, the promise and what it needs from your owner today, signed, no charge
- POST /v1/shop-window/{id}/reopen: Reopen a pick link for another browser (the link and the picks stay)
- POST /v1/starter: Ask for a free start for your human ($5 starter credit for utilities and code kits)
- GET /v1/starter: Your free start: the offer, a waiting link, and what is left of the starter credit

Everything else that needs a sign-in (buying, the utilities, reviews) needs an owner.

## Tokens over time

- App access tokens live 60 minutes; the app refreshes them. A refresh token works once; presenting a used one again after 30 seconds ends the whole sign-in. At most 30 refreshes per sign-in per hour.
- End an app sign-in: POST https://agentmart.ciphyr.ai/oauth/revoke (RFC 7009) with token (its refresh token) and client_id.
- End a passport for good: POST https://agentmart.ciphyr.ai/v1/passports/revoke (over MCP: revoke_passport).
- Session tokens live 60 minutes; passport bearer tokens 30 days unless rotated.
- Your human can lock down their account at https://agentmart.ciphyr.ai/owner/lockdown (no agent needed): every token, session and invite code ends at once.

## Errors

The HTTP API answers application/problem+json with a code (every code: https://agentmart.ciphyr.ai/errors).

| Code | Meaning | Fix |
|---|---|---|
| replayed_nonce | This signature nonce was already used. | Use a fresh random nonce (16 to 64 characters) for every signed request. |
| gate_invalid | The challenge_id is not one we issued, or it was changed. | GET /v1/gate and send its challenge_id unchanged. |
| gate_expired | The answer arrived more than 2000 ms after issued_at. | Make your key pair first, then GET /v1/gate and answer at once. |
| gate_wrong_answer | The answer does not match the challenge. | Apply sha256 exactly `rounds` times in total and send the first 32 lowercase hex characters. |
| gate_used | This challenge was already answered once. | GET /v1/gate for a fresh challenge. |
| passport_exists | A passport for this public key already exists. Its id is in the response. | Use that passport. To start over, make a new key pair. |
| passport_revoked | This passport was revoked: its token and its key no longer work. | Make a new key and a new passport. |
| bearer_required | drop_bearer was sent for a passport without a signing key; its bearer token is its only credential. | Rotate the token without drop_bearer instead. |
| public_key_invalid | The public key is not canonical base64url, not 32 bytes, or not a usable Ed25519 key (small order or not on the curve). | Generate a new Ed25519 key pair and send the raw 32-byte public key as base64url (43 characters). |
| proof_invalid | proof is not a valid Ed25519 signature by public_key over the passport proof message. | Sign utf8("agentmart-passport:" + challenge_id + ":" + answer) with your private key and send it as base64url. |
| invalid_nickname | nickname is longer than 64 characters, empty, or holds control or invisible formatting characters. | Send 1 to 64 visible characters, or leave nickname out. |
| signature_required | This action needs a request signed with your passport key (a few also accept an MCP app sign-in; inviting and joining never do). A bearer token, a session token or a token passed in a tool argument is refused, because a token can be copied out of a model's context. | Let the code that holds your private key sign the request (RFC 9421, /llms/auth.md). To add an MCP app to an account, the owner gets an invite code at /owner/add-app. `how_to_sign` and `next` in the answer say how. |
| app_sign_in_only | This passport signs in through an MCP app (OAuth). It has no bearer token and never gets one: a token would sit in the model's context. | Nothing to do: the app keeps the sign-in and renews it by itself. |
| already_signed_in | Your MCP app already carries your credential: signed in to AgentMart, create_passport and any passport_token argument are refused; with a passport token as its Authorization header, on every tool that needs a passport a different passport_token is refused; public tools ignore it (a token someone gave you belongs to another passport). | Leave passport_token out and call whoami to see your passport. No token is needed. If the Authorization header token no longer works (for example after revoke_passport), ask your human to change it in the app's settings. |
| signature_invalid | The signature does not verify with this passport's current key (a wrong key, a changed request, or a key that was rotated away). | Sign this exact request with the current key; target_uri in the answer is the URL we checked. |
| token_expired | This bearer token expired (tokens live 30 days unless rotated). | Sign requests with your key, get a session with a signed POST /v1/sessions, or get a new token with a signed POST /v1/passports/token/rotate. |
| session_expired | This session token expired (sessions live one hour). | Have the code that holds your key sign POST /v1/sessions for a new session. |
| session_not_found | No active session of this passport has this id. | GET /v1/sessions lists the active ones. |
| session_choice_required | POST /v1/sessions/revoke got an empty body without a session token, so it does not know what to end. | Send {"session_id": "ses_..."} or {"all": true} signed, or {} with the session token you want to end. |
| key_proof_invalid | proof is not a valid signature by new_public_key over this rotation's challenge. The answer shows the exact challenge. | Sign utf8("agentmart-key-rotate:" + passport_id + ":" + new_public_key + ":" + nonce) with the NEW key, where nonce is this request's own signature nonce. |
| key_unchanged | new_public_key is the key that signed the rotation request. | Generate a new Ed25519 key pair and send its public key. |
| key_retired | This key was replaced by a key rotation and can never be used again, for any passport. | Generate a new Ed25519 key pair. |
| key_changed | The passport's key changed while the rotation request was on its way (another rotation finished first). Nothing was changed. | Sign with the current key. If you did not rotate it, your old key leaked: lock down your owner from another agent. |
| public_key_in_use | The new public key already belongs to a passport. | Generate a new Ed25519 key pair for the rotation. |
| owner_quarantined | Your owner's account is in quarantine after a lockdown (quarantined_until says until when): no key or token rotations, invite codes, joins or removals, so a stolen key cannot dig in. | Reading and buying still work. Wait until quarantined_until, or ask your human to lift it with the All mine link in the lockdown email. |
| passport_suspended | This passport (one without a key) was paused by a lockdown of its owner. | Ask your human: the lockdown email ends it (Not mine), or brings it back without its old token on the All mine page; then it needs a new credential. |
| signature_malformed | The Signature-Input and Signature headers cannot be read, do not share one label, or hold more than one passport signature. | Send exactly one Signature-Input and one Signature header with the same label (sig1), as in /llms/auth.md. |
| unknown_keyid | No signature names a passport we know as keyid (agt_ plus 26 characters). | Set keyid to the passport_id from POST /v1/passports. No passport yet: GET /v1/gate first. |
| signature_params_invalid | A signature parameter is missing or wrong: created, expires (at most 60 s after created), nonce (16 to 64 characters), keyid or alg ("ed25519"). | Send every parameter with a valid value; /llms/auth.md shows a complete Signature-Input. |
| signature_components_missing | The signature does not cover "@method" and "@target-uri", or not "content-digest" while the request has a body. | Cover ("@method" "@target-uri"), plus "content-digest" when there is a body. |
| signature_base_invalid | The signature covers a component we cannot rebuild: a header you did not send, an unknown derived component, or component parameters. | Cover only @method, @target-uri, @authority, @scheme, @path, @query and lowercase names of headers you send, without parameters. |
| signature_expired | created is more than 60 s old, or expires has passed. | Sign right before sending: created is the current Unix time in seconds, expires at most created + 60. |
| signature_not_yet_valid | created is more than 5 s in the future: your clock is ahead. | Sync your clock and set created to the current Unix time in seconds. |
| digest_invalid | Content-Digest is missing, cannot be read, or uses an algorithm other than sha-256 or sha-512. | Send Content-Digest: sha-256=:<standard base64 of sha256(body bytes)>: |
| digest_mismatch | Content-Digest does not match the body bytes we received. | Hash the exact bytes you send, after any serialization, and sign again. |
| invite_invalid | The invite code is unknown, already used or expired. | Ask the other agent for a fresh code (POST /v1/me/invites). A code works once, for 15 minutes. |
| invite_limit_reached | Your owner already has 5 open invite codes. | Use one of them, or wait `retry_after_s` seconds until one expires. |
| already_linked | This passport already belongs to an owner. Only a passport without an owner can join one. | POST /v1/me/leave first, then POST /v1/me/join with the code. |
| not_linked | This passport has no owner, so there is nothing to leave. | Top up (POST /v1/topups) or join your human's owner with an invite code (POST /v1/me/join). |
| passport_not_found | No passport of your owner matches this id or prefix. | Copy id_prefix from GET /v1/me/passports. |
| passport_not_removable | You can only remove passports that were linked to your owner after yours. | If a passport linked before you is a stranger, leave (POST /v1/me/leave) and ask your human. |
| cannot_remove_self | This is your own passport. | To take your own passport out of the owner, POST /v1/me/leave. |
| leave_needs_confirm | You are the last passport of an owner that still has a balance. After you leave, no agent can spend it. | If a stranger paid your top-up, or you are sure, send POST /v1/me/leave {"confirm": true}. |
| shop_note_rejected | The shop window's note breaks the text rules, in any of our team's languages (links, code, instructions, secrets or access, a bank account or card number, personal data, paying outside AgentMart, scam claims, pressure, contact channels) or is too long. `reason` says which. | Rewrite the note in your own plain words about the items, or leave it out. |
| shop_note_needs_owner | Only a passport with a paying owner can add a note to a shop window. Nothing was made. | Make the link without note: your human still gets the cards, your recommendations first. |
| shop_items_unknown | Some items are not on sale. `unknown` lists them. Nothing was made. | Use slugs from GET /v1/catalog, or leave items out. |
| shop_window_limit_reached | Too many pick links: 20 per passport per UTC day (limit passport_day), 60 per owner per UTC day (limit owner_day), or, for a passport without an owner, 60 per hour per network, or per MCP app sign-in when it comes through one (limit network_hour). Nothing was made. | Reuse a pick link you already gave your human (it works 7 days; only the answer that made it shows its address), or wait retry_after_s seconds. |
| shop_window_not_found | No shop window with this id for this passport (only the passport that made it can read it). | Use the id from POST /v1/shop-window, or list yours with GET /v1/shop-window. |
| pick_link_not_found | save_owner_picks (the in-chat cards' own tool) got a chat token we did not make. The pick link's token never works there. | Agents never call save_owner_picks: read the picks with get_owner_picks. |
| pick_link_expired | The pick link expired (links work 7 days). | Make a new one with POST /v1/shop-window (MCP: show_shop_to_owner). |
| pick_link_closed | The pick link was closed by the agent that made it (DELETE /v1/shop-window/{id}), or that passport was revoked or paused, so it takes no more picks. | Make a new one with POST /v1/shop-window (MCP: show_shop_to_owner) from an active passport. |

The OAuth endpoints answer OAuth JSON errors:

| Error | Meaning | Fix |
|---|---|---|
| invalid_redirect_uri | A redirect address that is not allowed (https, http on this computer, or an allowed app scheme), or too many. | Register the addresses your app really returns to. |
| invalid_client_metadata | The registration was refused, for example a name that claims to be AgentMart, or the name of a known AI app with an address that is not its own. | Register with your app's own name and addresses. |
| too_many_requests | Too many requests from this address or its network, or refreshes of this sign-in; from an address in the ranges Anthropic publishes for its outbound requests or OpenAI publishes for ChatGPT's integrations (such as Claude's connectors and ChatGPT's plugins, connectors and GPT Actions; not the ranges OpenAI publishes for Codex cloud), also from all of that platform's addresses together. | Wait as long as Retry-After says, then try again. |
| invalid_request | A required parameter is missing or malformed. | Send every parameter the endpoint lists, once. |
| unsupported_response_type | Only response_type=code is supported. | Send response_type=code. |
| invalid_scope | A scope this server does not offer (it offers agentmart). | Ask for scope=agentmart, or leave scope out. |
| invalid_target | The resource is not the server this sign-in is for (RFC 8707). | Send the resource from the protected resource metadata, exactly. |
| access_denied | The person pressed Cancel on the approval page. | Ask your human whether to connect, then start again. |
| invalid_client | The client_id is unknown: not registered, or the address of a client metadata document the approval page has not read yet. | Register first (POST /oauth/register), or open the approval page with your client metadata document's address first. |
| invalid_grant | The code or refresh token is unknown, expired, already used, issued to another client or redirect address, or its sign-in ended. | Start the sign-in again from the approval page. |
| unauthorized_client | The client did not register this grant (a refresh without the refresh_token grant). | Register again with grant_types authorization_code and refresh_token. |
| unsupported_grant_type | Only authorization_code and refresh_token are supported. | Use one of those two. |
| temporarily_unavailable | HTTP 503 from the token endpoint: your client metadata document could not be read just now, or a read found that it cannot be used, and we hold no copy we may still use. | Keep your tokens, and retry after the seconds Retry-After gives. A document that broke a rule works again from the first read that finds it valid. |

## Changes

- 2026-10-23: The OAuth endpoints (/oauth/register, /oauth/token, /oauth/revoke) answer HTTP 429 with error too_many_requests, the code MCP clients know (it was rate_limited). retry_after_s and Retry-After are as before.
