# auth.md

You are an agent. This is **carlosjofre.com**, the personal site of Carlos Jofré (Chilean solar engineer, founder and CEO of SolarPro Chile). Most of it is public and needs no credentials. The endpoints listed under "What needs a token" need an OAuth 2.1 access token, and this file tells you how to get one with **agentic registration** ([auth.md](https://github.com/workos/auth.md) v0.6): discover → pick a method → register → claim (if needed) → exchange for an access_token → call the API → handle revocation. Follow the steps in order; do not skip ahead.

This site has **no user accounts and no passwords**. The person you act for is represented by the contact details they **declare** (name and email). Nothing is verified automatically: Carlos confirms every request with that person, by email or WhatsApp, before acting on it.

> Last updated: September 2026 · Resumen en español al final.

## What is public (no token)

| Resource | URL |
|----------|-----|
| MCP server (profile and services tools) | `https://carlosjofre.com/api/mcp` (streamable HTTP, JSON-RPC over POST) · card: `https://carlosjofre.com/.well-known/mcp/server-card.json` |
| A2A endpoint + Agent Card | `https://carlosjofre.com/api/a2a` · `https://carlosjofre.com/.well-known/agent-card.json` |
| Profile API | `https://carlosjofre.com/api/perfil` |
| Services and fees API | `https://carlosjofre.com/api/servicios` (`?id=<service>&kwp=<size>` returns a quote) |
| Services catalog (JSON) | `https://carlosjofre.com/data/servicios.json` |
| OpenAPI | `https://carlosjofre.com/openapi.json` |
| API catalog (RFC 9727) | `https://carlosjofre.com/.well-known/api-catalog` |
| AI profile | `https://carlosjofre.com/llms.txt` · `https://carlosjofre.com/llms-full.txt` |

## What needs a token

| Endpoint | Scope | Notes |
|----------|-------|-------|
| `GET https://carlosjofre.com/api/yo` | any valid access token | Returns the claims of the token you present. Use it to test your auth. |
| `GET https://carlosjofre.com/api/miembros/radar` | `radar:read` | Radar Solar members API. `radar:read` is not granted yet (subscriptions are in pre-sale), so every token gets `403 insufficient_scope`. |

Endpoints that create quote requests or send orders require `cotizaciones:write` or `pedidos:write`; they are listed in the OpenAPI document as they go live.

### Scopes

| Scope | Grants | Who can get it |
|-------|--------|----------------|
| `perfil:read` | Read the public profile (career, sources, publications) | Every method, including unclaimed anonymous agents and `client_credentials` |
| `servicios:read` | Read the services catalog and fees | Every method |
| `cotizaciones:write` | Create quote and checkout requests | Every method (pre-claim OK) |
| `pedidos:write` | Send orders to Carlos on behalf of a person, with the contact details that person declared | Only with a person behind you: claimed agents (anonymous after the claim, or `service_auth`), or the authorization code flow when the person enters an email on the consent page |
| `radar:read` | Radar Solar bulletins and alerts (subscribers only) | Nobody yet: Radar Solar subscriptions are in pre-sale |

## Step 1 — Discover

Discovery is two hops. A protected endpoint called without a token answers `401` with the Protected Resource Metadata URL (and the scope it needs, when it needs a specific one):

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://carlosjofre.com/.well-known/oauth-protected-resource", scope="radar:read"
```

### 1a. Fetch the Protected Resource Metadata (RFC 9728)

```http
GET https://carlosjofre.com/.well-known/oauth-protected-resource
```

```json
{
  "resource": "https://carlosjofre.com",
  "resource_name": "carlosjofre.com: API de Carlos Jofré",
  "authorization_servers": ["https://carlosjofre.com"],
  "scopes_supported": ["perfil:read", "servicios:read", "cotizaciones:write", "pedidos:write", "radar:read"],
  "bearer_methods_supported": ["header"],
  "resource_documentation": "https://carlosjofre.com/agentes/",
  "resource_policy_uri": "https://carlosjofre.com/auth.md"
}
```

The path-suffixed form also works (RFC 9728 §3.1): `https://carlosjofre.com/.well-known/oauth-protected-resource/api/miembros/radar` returns the same document with `resource` set to `https://carlosjofre.com/api/miembros/radar`. The resource identifier of the root document is the origin, `https://carlosjofre.com` (RFC 9728 §3.3). Access tokens always carry `aud: "https://carlosjofre.com"`, which covers every path under `/api/`.

### 1b. Fetch the Authorization Server metadata (RFC 8414)

```http
GET https://carlosjofre.com/.well-known/oauth-authorization-server
```

```json
{
  "resource": "https://carlosjofre.com/api/",
  "authorization_servers": ["https://carlosjofre.com"],
  "scopes_supported": ["perfil:read", "servicios:read", "cotizaciones:write", "pedidos:write", "radar:read"],
  "bearer_methods_supported": ["header"],

  "issuer": "https://carlosjofre.com",
  "authorization_endpoint": "https://carlosjofre.com/oauth/authorize",
  "token_endpoint": "https://carlosjofre.com/oauth/token",
  "jwks_uri": "https://carlosjofre.com/.well-known/jwks.json",
  "registration_endpoint": "https://carlosjofre.com/oauth/register",
  "revocation_endpoint": "https://carlosjofre.com/oauth/revoke",
  "response_types_supported": ["code"],
  "response_modes_supported": ["query"],
  "grant_types_supported": [
    "authorization_code",
    "refresh_token",
    "client_credentials",
    "urn:ietf:params:oauth:grant-type:jwt-bearer",
    "urn:workos:agent-auth:grant-type:claim"
  ],
  "token_endpoint_auth_methods_supported": ["none", "client_secret_post", "client_secret_basic"],
  "revocation_endpoint_auth_methods_supported": ["none", "client_secret_post", "client_secret_basic"],
  "code_challenge_methods_supported": ["S256"],
  "authorization_response_iss_parameter_supported": true,
  "service_documentation": "https://carlosjofre.com/agentes/",
  "op_policy_uri": "https://carlosjofre.com/auth.md",
  "op_tos_uri": "https://carlosjofre.com/auth.md",
  "ui_locales_supported": ["es-CL", "en"],

  "agent_auth": {
    "skill": "https://carlosjofre.com/auth.md",
    "identity_endpoint": "https://carlosjofre.com/agent/identity",
    "claim_endpoint": "https://carlosjofre.com/agent/identity/claim",
    "register_uri": "https://carlosjofre.com/agent/identity",
    "claim_uri": "https://carlosjofre.com/agent/identity/claim",
    "identity_types_supported": ["anonymous", "service_auth"],
    "anonymous": {
      "credential_types_supported": ["identity_assertion", "access_token"],
      "pre_claim_scopes": ["perfil:read", "servicios:read", "cotizaciones:write"],
      "post_claim_scopes": ["perfil:read", "servicios:read", "cotizaciones:write", "pedidos:write"],
      "claim_uri": "https://carlosjofre.com/agent/identity/claim"
    },
    "service_auth": {
      "login_hint_types_supported": ["email"],
      "credential_types_supported": ["identity_assertion", "access_token"],
      "post_claim_scopes": ["perfil:read", "servicios:read", "cotizaciones:write", "pedidos:write"]
    },
    "identity_assertion": {
      "assertion_types_supported": []
    }
  }
}
```

- `issuer` equals `authorization_servers[0]` of the PRM: the authorization server is this same origin.
- `agent_auth.identity_endpoint` is where you register (Step 3); `agent_auth.claim_endpoint` starts the claim ceremony (Step 4). `register_uri` and `claim_uri` are the pre-v0.6 names of the same two URLs.
- `identity_assertion.assertion_types_supported` is empty: no ID-JAG provider is trusted yet. There is no `events_endpoint`, because this service has no Security Event Token receiver.
- `/.well-known/openid-configuration` does not exist: this is an OAuth authorization server, not an OpenID Connect provider.

## Step 2 — Pick a method

1. **You act for a specific person and know their email** → [service_auth](#service_auth). The person completes a claim ceremony before you get any token.
2. **You have no user identity** → [anonymous](#anonymous). You get a pre-claim token right away (`perfil:read servicios:read cotizaciones:write`); a person can claim you later to unlock `pedidos:write`.
3. **You are an OAuth client that can send the person to a browser** (an MCP client or an app) → [authorization code + PKCE](#oauth-clients-dcr--authorization-code--pkce).
4. **identity_assertion (ID-JAG)** is not accepted yet: it returns `identity_assertion_not_supported`.

Before a `service_auth` registration, tell the person which service you are registering with (`resource_name` from Step 1a) and the scopes you will act under, and get their agreement.

## Step 3 — Register

Registrations are limited to 20 per IP per hour (`429 rate_limited` with `Retry-After`).

### anonymous

```http
POST https://carlosjofre.com/agent/identity
Content-Type: application/json

{ "type": "anonymous" }
```

Response (200):

```json
{
  "registration_id": "reg_tl9TcOWA2Iuz5ttUVeKqBg",
  "registration_type": "anonymous",
  "identity_assertion": "eyJhbGciOiJFUzI1NiIsInR5cCI6Im9hdXRoLWlkLWphZytqd3Qi…",
  "assertion_expires": "2026-09-26T03:43:41.000Z",
  "pre_claim_scopes": ["perfil:read", "servicios:read", "cotizaciones:write"],
  "claim_url": "https://carlosjofre.com/agent/identity/claim",
  "claim_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImNsYWltK2p3dCI…",
  "claim_token_expires": "2026-09-26T03:43:41.000Z",
  "post_claim_scopes": ["perfil:read", "servicios:read", "cotizaciones:write", "pedidos:write"]
}
```

Exchange `identity_assertion` for an access token now ([Step 5](#step-5--exchange-the-assertion)). If a person wants to take ownership, go to [Step 4](#step-4--claim-ceremony) within 24 hours (`claim_token_expires`). `claim_token` is returned exactly once: keep it in memory for the ceremony and do not persist it after.

### service_auth

```http
POST https://carlosjofre.com/agent/identity
Content-Type: application/json

{ "type": "service_auth", "login_hint": "user@example.com" }
```

Response (200):

```json
{
  "registration_id": "reg_dccPMXMnFP12ktLlaSlwnQ",
  "registration_type": "service_auth",
  "claim_url": "https://carlosjofre.com/agent/identity/claim",
  "claim_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImNsYWltK2p3dCI…",
  "claim_token_expires": "2026-09-26T03:43:41.000Z",
  "post_claim_scopes": ["perfil:read", "servicios:read", "cotizaciones:write", "pedidos:write"],
  "claim": {
    "user_code": "957816",
    "expires_in": 600,
    "verification_uri": "https://carlosjofre.com/agentes/reclamar/?t=eyJ…",
    "interval": 5
  }
}
```

There is no `identity_assertion` yet: the ceremony materials are already in `claim`, so skip to [Step 4b](#4b-hand-off-to-the-person). A `login_hint` that is not an email returns `400 invalid_login_hint`.

### identity_assertion (not supported yet)

```json
{
  "error": "identity_assertion_not_supported",
  "error_description": "This service does not trust any ID-JAG provider yet, so identity_assertion registrations are not accepted: use service_auth (with the person's email) or anonymous"
}
```

## Step 4 — Claim ceremony

The goal: the person you act for types a 6-digit `user_code` **you give them** into a page this site owns, and confirms their email. That binds your registration to the contact details they declare there. There is no sign-in, and the email is not verified; Carlos confirms every request with the person before acting.

### 4a. Get the ceremony materials

For **service_auth** you already have them in `claim`. For **anonymous**, start a ceremony with the email of the person who will claim you:

```http
POST https://carlosjofre.com/agent/identity/claim
Content-Type: application/json

{ "claim_token": "eyJ…", "email": "user@example.com" }
```

Response (200):

```json
{
  "registration_id": "reg_tl9TcOWA2Iuz5ttUVeKqBg",
  "claim_attempt_id": "cla_Sye2Q4PiuWZ0vXZT",
  "status": "initiated",
  "expires_at": "2026-09-25T03:53:41.125Z",
  "claim_attempt": {
    "user_code": "068896",
    "expires_in": 600,
    "verification_uri": "https://carlosjofre.com/agentes/reclamar/?t=eyJ…",
    "interval": 5
  }
}
```

Calling this endpoint again mints a fresh `user_code` and `verification_uri` and invalidates the previous ones (use it when a code expires). For a `service_auth` registration the `email` must equal the `login_hint`. Errors: `401 invalid_claim_token`, `410 claim_expired` (the 24-hour registration window closed: restart at Step 3), `409 claimed_or_in_flight` (already claimed: poll the token endpoint to collect your tokens).

### 4b. Hand off to the person

Give the person `verification_uri` and `user_code` in a single message, in their language. The page is in Spanish. Suggested copy:

> To let me act for you on carlosjofre.com, open this link and enter the code **068896** (valid for 10 minutes):
> https://carlosjofre.com/agentes/reclamar/?t=…

On that page the person types the code, types the email you used (it must match), optionally their name, and ticks the consent box ("Autorizo a este agente a pedir cotizaciones y reservar servicios a mi nombre en carlosjofre.com"). Five wrong codes or emails lock the attempt: start a new one with 4a.

### 4c. Poll for completion

Poll the `token_endpoint` with the claim grant, honoring `interval`:

```http
POST https://carlosjofre.com/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:workos:agent-auth:grant-type:claim&claim_token=eyJ…
```

While waiting (400, RFC 8628 §3.5 vocabulary):

```json
{ "error": "authorization_pending", "error_description": "The person has not completed the claim yet" }
```

If you poll faster than `interval`, you get `slow_down` with the new minimum interval; keep it for every later poll:

```json
{ "error": "slow_down", "error_description": "Polling too fast: wait 10 seconds between requests", "interval": 10 }
```

`expired_token` means the `user_code` expired or was locked: call 4a again with the same `claim_token` and email. If that returns `claim_expired`, restart at Step 3.

On success (200), a standard token response plus a post-claim `identity_assertion`:

```json
{
  "access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCI…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "perfil:read servicios:read cotizaciones:write pedidos:write",
  "identity_assertion": "eyJhbGciOiJFUzI1NiIsInR5cCI6Im9hdXRoLWlkLWphZytqd3Qi…",
  "assertion_expires": "2026-10-25T03:45:02.000Z"
}
```

For **anonymous** registrations, completing the claim revokes the pre-claim access tokens and supersedes the pre-claim `identity_assertion`: drop them and use the ones from this response. The post-claim assertion carries `email`, `name` and `email_verified: false` and is valid for 30 days.

## Step 5 — Exchange the assertion

POST the `identity_assertion` to the token endpoint with the RFC 7523 JWT-bearer grant. `resource` is optional (if present it must be `https://carlosjofre.com` or a URL under `https://carlosjofre.com/api/`); `scope` is optional and can only narrow the set you are entitled to.

```http
POST https://carlosjofre.com/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=eyJ…&resource=https://carlosjofre.com
```

Response (200):

```json
{
  "access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCI…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "perfil:read servicios:read cotizaciones:write"
}
```

There is no refresh token in this flow: when the access token expires (1 hour), run this step again with the same `identity_assertion`. The pre-claim assertion lasts 24 hours and the post-claim one 30 days; after that, restart at Step 3. `invalid_grant` means the assertion expired, was revoked or was superseded by a claim.

## Step 6 — Use the access_token

Send it in the `Authorization` header (the only accepted method):

```http
GET https://carlosjofre.com/api/yo
Authorization: Bearer eyJ…
```

```json
{
  "sub": "reg_tl9TcOWA2Iuz5ttUVeKqBg",
  "client_id": "reg_tl9TcOWA2Iuz5ttUVeKqBg",
  "scope": "perfil:read servicios:read cotizaciones:write pedidos:write",
  "contacto": { "nombre": "Ana Pérez", "correo": "user@example.com", "verificado": false },
  "exp": 1790311421,
  "vence": "2026-09-25T04:43:41.000Z",
  "registration_id": "reg_tl9TcOWA2Iuz5ttUVeKqBg",
  "pre_claim": false
}
```

`contacto` is `null` when no person declared contact details. Access tokens are JWTs (RFC 9068: `typ: at+jwt`, ES256, verifiable with `jwks_uri`); you do not need to validate them. A `401` on a previously working token means it expired or was revoked: run Step 5 once; if that returns `invalid_grant`, restart at Step 1. A `403 insufficient_scope` names the missing scope in `WWW-Authenticate`.

## OAuth clients: DCR + authorization code + PKCE

For MCP clients and apps that can open a browser for the person. Standard OAuth 2.1: dynamic client registration (RFC 7591), authorization code with PKCE S256 (RFC 7636), `iss` in the authorization response (RFC 9207), resource indicators (RFC 8707) and revocation (RFC 7009).

1. **Register** (no state is kept on the server: the `client_id` itself is a signed token, so store it; if it stops working, register again):

   ```http
   POST https://carlosjofre.com/oauth/register
   Content-Type: application/json

   {
     "client_name": "My MCP client",
     "redirect_uris": ["http://127.0.0.1:33418/callback"],
     "grant_types": ["authorization_code", "refresh_token"],
     "token_endpoint_auth_method": "none"
   }
   ```

   Response (201): `client_id`, `client_id_issued_at` and the registered metadata (`client_name`, `redirect_uris`, `grant_types`, `response_types`, `token_endpoint_auth_method`, `scope` if sent), plus `client_secret` and `client_secret_expires_at: 0` for confidential clients. Redirect URIs must be `https://` URLs or `http://` loopback URLs (`127.0.0.1`, `localhost`, `[::1]`, any port), without fragment; custom schemes are rejected (`invalid_redirect_uri`). As RFC 7591 says, an omitted `token_endpoint_auth_method` means `client_secret_basic`: send `"none"` for a public client.

2. **Authorize**: send the person's browser to

   ```
   https://carlosjofre.com/oauth/authorize?response_type=code&client_id=…&redirect_uri=http%3A%2F%2F127.0.0.1%3A33418%2Fcallback&scope=perfil%3Aread%20pedidos%3Awrite&state=…&code_challenge=…&code_challenge_method=S256&resource=https%3A%2F%2Fcarlosjofre.com
   ```

   PKCE S256 is mandatory and `redirect_uri` must exactly match a registered one. The consent page (Spanish) shows your `client_name`, the redirect host and the scopes; the person may enter a name and email, declared and not verified, which travel inside the tokens as `contacto`. `pedidos:write` is granted only if they enter an email; `radar:read` is not granted yet (it is dropped from the request; asking for it alone is `invalid_scope`). Without `scope`, you get the scopes you registered, or all grantable ones. The browser comes back to `redirect_uri` with `code`, `state` and `iss=https://carlosjofre.com` (check it), or with `error=access_denied` if the person cancels. The code lasts 5 minutes and works once.

3. **Exchange the code**:

   ```http
   POST https://carlosjofre.com/oauth/token
   Content-Type: application/x-www-form-urlencoded

   grant_type=authorization_code&code=…&redirect_uri=http%3A%2F%2F127.0.0.1%3A33418%2Fcallback&code_verifier=…&client_id=…
   ```

   ```json
   {
     "access_token": "eyJ…",
     "token_type": "Bearer",
     "expires_in": 3600,
     "scope": "perfil:read pedidos:write",
     "refresh_token": "eyJ…"
   }
   ```

   `refresh_token` is issued only to clients registered with the `refresh_token` grant. Confidential clients authenticate with `client_secret_basic` or `client_secret_post`.

4. **Refresh**: `grant_type=refresh_token&refresh_token=…&client_id=…` (optional `scope` to narrow). Refresh tokens last 30 days and rotate on every use: store the new one. Reusing an old refresh token revokes the whole grant.

5. **Machine to machine**: confidential clients registered with `client_credentials` get `perfil:read servicios:read cotizaciones:write` (no person, so never `pedidos:write`) and no refresh token.

## Revocation

`POST https://carlosjofre.com/oauth/revoke` with `token=…` (form-encoded, RFC 7009; `token_type_hint` is optional). An access token revokes that token; a refresh token revokes its whole grant; an `identity_assertion` revokes the whole agent registration (its access tokens and any further exchange or claim). The answer is always `200`, also for unknown tokens.

## Errors

Errors at `/agent/identity` and `/agent/identity/claim` use the auth.md profile codes; errors at `/oauth/*` use the OAuth vocabulary (RFC 6749 §5.2). Bodies are `{ "error": "…", "error_description": "…" }`.

| Code | Where | What to do |
|------|-------|------------|
| `invalid_request` (400) | any | Malformed body, missing or repeated parameter. Fix the input. |
| `invalid_login_hint` (400) | `/agent/identity` | `login_hint` must be an email address. |
| `identity_assertion_not_supported` (400) | `/agent/identity` | No trusted ID-JAG providers yet. Use `service_auth` or `anonymous`. |
| `rate_limited` (429) | `/agent/identity`, `/agent/identity/claim` | Wait `Retry-After` seconds. |
| `invalid_claim_token` (401) | `/agent/identity/claim` | Restart at Step 3. |
| `claim_expired` (410) | `/agent/identity/claim` | The registration expired or was revoked. Restart at Step 3. |
| `claimed_or_in_flight` (409) | `/agent/identity/claim` | Already claimed. Poll `/oauth/token` with the claim grant to get your tokens. |
| `authorization_pending` (400) | `/oauth/token` (claim grant) | The person has not finished. Retry after `interval`. |
| `slow_down` (400) | `/oauth/token` (claim grant) | Use the returned `interval` from now on. |
| `expired_token` (400) | `/oauth/token` (claim grant) | Start a new attempt at `/agent/identity/claim`; if that returns `claim_expired`, restart at Step 3. |
| `invalid_grant` (400) | `/oauth/token` | Code, refresh token or assertion expired, revoked, reused, superseded or not issued by this server. |
| `invalid_client` (401) | `/oauth/token`, `/oauth/revoke` | Unknown `client_id` or wrong secret. Register again. |
| `unauthorized_client` (400) | `/oauth/token`, `/oauth/authorize` | The client is not registered for that grant. |
| `unsupported_grant_type` (400) | `/oauth/token` | Use one of `grant_types_supported`. |
| `invalid_scope` (400) | `/oauth/token`, `/oauth/authorize` | Unknown scope, or more than the grant allows. |
| `invalid_target` (400) | `/oauth/token`, `/oauth/authorize` | `resource` must be `https://carlosjofre.com` or a URL under `https://carlosjofre.com/api/`. |
| `invalid_redirect_uri`, `invalid_client_metadata` (400) | `/oauth/register` | Fix the client metadata. |
| `invalid_token` (401) | protected endpoints | Expired, revoked or superseded token. Run Step 5 again. |
| `insufficient_scope` (403) | protected endpoints | The token lacks the scope named in `WWW-Authenticate`. |
| `temporarily_unavailable` (503) | any | Retry later with backoff. |

Retry policy: 5xx → exponential backoff; 4xx → do not repeat the same request, act on the table above.

## Contact

- Agents and integrations: contacto@carlosjofre.com
- Services and fees: https://carlosjofre.com/servicios/
- Guide for AI agents: https://carlosjofre.com/agentes/

---

**Resumen en español:** el perfil, las fuentes y el catálogo de servicios con precios son públicos (MCP `/api/mcp`, A2A `/api/a2a`, `/api/perfil`, `/api/servicios`) y no requieren registro. Para lo demás, carlosjofre.com tiene su propio servidor de autorización OAuth 2.1 (issuer `https://carlosjofre.com`): un agente puede registrarse de forma anónima o con el correo de la persona a la que representa (`POST /agent/identity`), y la persona lo reclama escribiendo un código de 6 dígitos en `https://carlosjofre.com/agentes/reclamar/`. Las aplicaciones OAuth usan registro dinámico, código de autorización con PKCE y una pantalla de consentimiento. El sitio no tiene cuentas ni contraseñas: el contacto de la persona es declarado, no verificado, y Carlos confirma cada solicitud por correo o WhatsApp antes de actuar.
