# auth.md

> How an autonomous agent discovers Publish Now, registers, obtains a credential, calls the API, and revokes it. Every step below is machine-actionable and needs no HTML parsing.

Publish Now is a unified social media API: one endpoint to publish, schedule, and manage media across X, Instagram, TikTok, YouTube, and LinkedIn. Site: https://publishnow.app — this document: https://publishnow.app/auth.md

## Who this is for

Agents acting **on behalf of a Publish Now user**. Every credential described here is bound to a human account and to exactly one organization inside it. There is no machine-only identity: an agent cannot create an account, cannot self-provision a credential, and cannot act without a user who approved it at least once.

If you are a scanner, everything you need is in this document and the two discovery documents in Step 1. Do not POST to the registration endpoint during a passive scan — it creates state.

## Step 1 — Discover

Two public documents, both fetchable with no credential:

| Document | URL |
| --- | --- |
| Protected resource metadata (RFC 9728) | `https://publishnow.app/.well-known/oauth-protected-resource` |
| Authorization server metadata (RFC 8414) | `https://publishnow.app/.well-known/oauth-authorization-server` |

The protected resource metadata names the resource and the authorization server that guards it:

```json
{
  "resource": "https://publishnow.app/mcp",
  "authorization_servers": ["https://publishnow.app/api/auth"],
  "bearer_methods_supported": ["header"],
  "scopes_supported": ["posts:write", "posts:read", "media:write"]
}
```

The authorization server metadata carries the standard RFC 8414 fields plus an `agent_auth` block. The standard fields that matter:

```json
{
  "issuer": "https://publishnow.app/api/auth",
  "authorization_endpoint": "https://publishnow.app/api/auth/oauth2/authorize",
  "token_endpoint": "https://publishnow.app/api/auth/oauth2/token",
  "registration_endpoint": "https://publishnow.app/api/auth/oauth2/register",
  "revocation_endpoint": "https://publishnow.app/api/auth/oauth2/revoke",
  "jwks_uri": "https://publishnow.app/api/auth/jwks",
  "scopes_supported": ["posts:write", "posts:read", "media:write"],
  "code_challenge_methods_supported": ["S256"],
  "response_types_supported": ["code"]
}
```

And the `agent_auth` block, verbatim — byte-identical to what that endpoint serves:

```json
{
  "skill": "https://publishnow.app/auth.md",
  "register_uri": "https://publishnow.app/api/auth/oauth2/register",
  "revocation_uri": "https://publishnow.app/api/auth/oauth2/revoke",
  "identity_types_supported": [
    "oauth_dynamic_client"
  ],
  "credential_types_supported": [
    "oauth_access_token",
    "api_key"
  ],
  "oauth_dynamic_client": {
    "registration_endpoint": "https://publishnow.app/api/auth/oauth2/register",
    "authorization_endpoint": "https://publishnow.app/api/auth/oauth2/authorize",
    "token_endpoint": "https://publishnow.app/api/auth/oauth2/token",
    "revocation_endpoint": "https://publishnow.app/api/auth/oauth2/revoke",
    "credential_types_supported": [
      "oauth_access_token"
    ],
    "grant_types_supported": [
      "authorization_code",
      "refresh_token"
    ],
    "code_challenge_methods_supported": [
      "S256"
    ],
    "requires_user_interaction": true
  }
}
```

Two notes that save a round trip. First, `issuer` is `https://publishnow.app/api/auth`, not `https://publishnow.app`; the authorization server metadata answers at all three spellings your library might build — `/.well-known/oauth-authorization-server`, `/.well-known/oauth-authorization-server/api/auth`, and `/api/auth/.well-known/oauth-authorization-server` — and returns the same document for each. Second, if you skip discovery entirely and call the API without a credential, the 401 tells you where to look:

```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://publishnow.app/.well-known/oauth-protected-resource"
```

## Step 2 — Pick a surface

| Surface | Endpoint | Credential | Who creates it |
| --- | --- | --- | --- |
| MCP | `https://publishnow.app/mcp` | OAuth 2.1 access token | the agent, via Steps 3-5 |
| REST | `https://publishnow.app/v1` | `pn_…` API key | a signed-in human, in the dashboard |

**The decision:** if you can put a browser in front of a human, register and run the OAuth flow against `/mcp`; if you cannot, ask your operator for a `pn_…` key and use `/v1`.

The two credentials are not interchangeable. See "REST API alternative" below.

## Step 3 — Register (MCP)

Dynamic client registration per RFC 7591. This endpoint is genuinely open: **it requires no credential**, no pre-registration, and no out-of-band approval. Registering creates an OAuth client — not an identity, not an account, and not anything that can publish on its own.

```http
POST https://publishnow.app/api/auth/oauth2/register
Content-Type: application/json
```

```json
{
  "client_name": "Example Agent",
  "redirect_uris": ["http://127.0.0.1:8976/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none",
  "scope": "posts:write posts:read media:write"
}
```

Response — `201 Created`, `Cache-Control: no-store`, echoing your metadata plus:

```json
{
  "client_id": "hZ3rQmT8vKpLxWdNbYcFgJsRtUvAeQiO",
  "client_id_issued_at": 1774483200
}
```

- `redirect_uris` is required whenever `grant_types` includes `authorization_code`. A loopback URI is the right choice for a local agent.
- `"token_endpoint_auth_method": "none"` registers a **public client**: no `client_secret` is issued and PKCE carries the security. Omit that field and the response includes a `client_secret` instead — shown once, never retrievable again, so store it immediately.
- Every call mints a **new** client. Register once, persist the `client_id`, reuse it. Do not register per session.

## Step 4 — Authorize

`authorization_code` with PKCE `S256`. `plain` is not accepted.

1. Generate a `code_verifier` (43-128 random characters) and derive `code_challenge = BASE64URL(SHA-256(code_verifier))`.
2. Send your user to the authorization endpoint:

```
https://publishnow.app/api/auth/oauth2/authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=http%3A%2F%2F127.0.0.1%3A8976%2Fcallback
  &scope=posts%3Awrite%20posts%3Aread%20media%3Awrite
  &state=RANDOM_OPAQUE_VALUE
  &code_challenge=YOUR_CODE_CHALLENGE
  &code_challenge_method=S256
```

3. A human signs in and approves the requested scopes in a browser. This is not skippable — it is why `agent_auth` sets `requires_user_interaction: true`. **Surface the URL to your operator; do not retry it headlessly.**
4. The browser is redirected to your `redirect_uri` with `?code=…&state=…`. Verify `state`, then continue to Step 5. Codes are single-use and short-lived.

Scopes: `posts:write` (create, schedule, update, delete posts), `posts:read` (read posts, their status, and connected profiles), `media:write` (upload media and attach it to posts). Ask for the minimum you need — a wider consent prompt is one a human is likelier to decline.

## Step 5 — Exchange

```http
POST https://publishnow.app/api/auth/oauth2/token
Content-Type: application/x-www-form-urlencoded
```

```
grant_type=authorization_code
&code=THE_CODE
&redirect_uri=http%3A%2F%2F127.0.0.1%3A8976%2Fcallback
&client_id=YOUR_CLIENT_ID
&code_verifier=YOUR_CODE_VERIFIER
```

Response — `Cache-Control: no-store`:

```json
{
  "access_token": "ACCESS_TOKEN",
  "token_type": "Bearer",
  "expires_in": 604800,
  "refresh_token": "REFRESH_TOKEN",
  "scope": "posts:write posts:read media:write"
}
```

**Lifetimes:** access token 604800 seconds (7 days), refresh token 2592000 seconds (30 days). Refresh before expiry with `grant_type=refresh_token&refresh_token=…&client_id=…`. If the refresh token has also expired, start again at Step 4 — a human has to approve again. Treat the access token as opaque: do not parse it, do not depend on its format.

## Step 6 — Call

```http
POST https://publishnow.app/mcp
Authorization: Bearer ACCESS_TOKEN
Content-Type: application/json
Accept: application/json, text/event-stream
```

- The MCP endpoint is **stateless and POST-only**. `GET` and `DELETE` return `405` with `Allow: POST` — expected behaviour, not an outage. Fall back to POST-only mode.
- Call `tools/list` to discover what you can do; the tool set is the authority and this document does not duplicate it.
- The token audience is `https://publishnow.app/mcp`. A token minted for any other audience is rejected.

Further descriptions: `https://publishnow.app/openapi.json` (OpenAPI, describes the REST API), `https://publishnow.app/.well-known/api-catalog` (RFC 9727 linkset), `https://publishnow.app/llms.txt`, and `https://publishnow.app/docs/api` for humans.

## REST API alternative

The REST API at `https://publishnow.app/v1` takes an API key beginning with `pn_`. A signed-in human creates it at `https://publishnow.app/dashboard`; it is displayed once and stored only as a hash, so it cannot be recovered later.

```http
POST https://publishnow.app/v1/posts
Authorization: Bearer pn_YOUR_API_KEY
Content-Type: application/json
```

Three rules, stated plainly so you do not test them the hard way:

- **OAuth access tokens are not accepted at `/v1`.** Anything that does not begin with `pn_` is rejected with `401`.
- **`pn_…` keys are not accepted at `/mcp`.** That endpoint only validates OAuth access tokens.
- **Keys are organization-scoped.** One key sees exactly one organization's profiles, media, and posts. An operator working across several organizations needs one key per organization.

There is no way for an unauthenticated caller — agent or otherwise — to mint a `pn_…` key. If you need one, ask your operator.

## Revoke

- **OAuth tokens:** `POST https://publishnow.app/api/auth/oauth2/revoke` (RFC 7009), form-encoded `token=…&token_type_hint=access_token` (or `refresh_token`) plus your `client_id`. A public client needs no secret to revoke its own tokens. Revoke on shutdown, on uninstall, and whenever your user disconnects you — never leave a live 7-day token behind.
- **Liveness:** `POST https://publishnow.app/api/auth/oauth2/introspect` returns `{"active": true}` or `{"active": false}`, but requires a **confidential** client: both `client_id` and `client_secret`. A public client (`"token_endpoint_auth_method": "none"`) cannot use it and should detect revocation from the `401`. Either way, do not introspect on every call.
- **`pn_…` keys:** revoked by a human in the dashboard, effective immediately; the next request with that key returns `401`.

## Errors

| Status | Signal | Meaning and retry policy |
| --- | --- | --- |
| `401` | `WWW-Authenticate: Bearer error="invalid_token", resource_metadata="…"` | Missing, expired, malformed, or revoked credential. Refresh once, then re-authorize. Never replay the same token. |
| `403` | `WWW-Authenticate: Bearer error="insufficient_scope", scope="posts:write posts:read media:write"` | Valid token, missing scope. Do not retry — re-authorize with the scope you need. |
| `402` | at `/v1` | The organization has no active plan. Do not retry — a human must subscribe. |
| `400` | JSON body with `error` and `code` | The request is invalid. Do not retry — fix it. |
| `405` | `Allow: POST` | `GET`/`DELETE` on the MCP endpoint. Use `POST`. |

**Credit hard-freeze — read this one.** Usage is metered against an organization's credit balance. When that balance reaches zero the action is **rejected with a clear error; it is never silently degraded, truncated, or partially delivered.** Publishing stops and affected items are marked failed with `insufficient_credits`.

Do not treat a credit freeze as retryable. No amount of retrying succeeds until a human tops up the account — stop, report it to your operator, and wait. Successful REST responses carry an `X-Credits-Remaining` header and grow a `warning` object with `"code": "low_credits"` as the balance runs down; watch those and warn early rather than discovering the freeze mid-run.

## Not supported

Listed explicitly so you fall through instead of probing:

- **No ID-JAG.** `urn:ietf:params:oauth:token-type:id-jag` is neither issued nor accepted, and there is no token-exchange endpoint.
- **No `service_auth`.** There is no machine-only identity and no credential that exists without a user behind it.
- **No anonymous registration.** Registration mints an OAuth client, never an identity or an account. The identity arrives with the human at Step 4.
- **No claim ceremony.** There is no agent-identity endpoint, no claim URL, and no pre-claim scoped credential to upgrade later.
- **No revocation event webhook.** There is no `events_endpoint` (RFC 8417 / RFC 8935). Detect revocation from a `401`, or by introspecting if you are a confidential client.
- **`client_credentials` does not work here.** The authorization server advertises `client_credentials` in `grant_types_supported`, but tokens minted that way carry no user and are rejected at `https://publishnow.app/mcp`. Use `authorization_code` with PKCE. (This is why `client_credentials` is absent from `agent_auth.oauth_dynamic_client.grant_types_supported` above — that block is the accurate one.)
- **No password grant, no implicit flow, no `plain` PKCE.** `S256` only.
