# SenderProbe auth.md

SenderProbe reads a domain the way a receiving mail server does — the records it publishes, the reputation of the address it sends from, and the message itself — and reports which one is the problem. This instance is https://senderprobe.com, running SenderProbe dev.

This document is for an agent, or for whoever is configuring one, that has arrived here with no credential. It is self-contained: every endpoint an agent needs is named below, every URL in it is served by this instance, and nothing in it has to be discovered anywhere else.

## Who this is for

- **AI agents** connecting to the Model Context Protocol endpoint at `https://senderprobe.com/mcp`, where each diagnostic is a tool. The server card is at `https://senderprobe.com/.well-known/mcp.json`.
- **Automated clients** calling the REST API at `https://senderprobe.com/api/v1`. Its generated OpenAPI 3.1 description is at `https://senderprobe.com/api/v1/openapi.json`.
- **Operators** provisioning either of the above for someone else to run.

An agent that only wants to read the reference material needs no credential at all. The tool pages, guides and glossary are public, and `https://senderprobe.com/llms.txt` maps them; `https://senderprobe.com/llms-full.txt` is the whole corpus in one document. A credential is needed the moment an agent wants SenderProbe to *run* something, because every run is charged to a workspace and recorded in its history.

Two endpoints take no credential but are not free: `POST https://senderprobe.com/api/v1/public/lookups` and `POST https://senderprobe.com/api/v1/contact` require a proof of work in the `X-PoW` header, minted at `GET https://senderprobe.com/api/v1/pow/challenge` and described in §12.2 of the REST contract. A request without one is answered `428` with a fresh challenge in `details`, so an agent that solves it and retries once is through; holding a workspace credential is still the easier path for anything that runs checks repeatedly.

## OAuth metadata

SenderProbe is an OAuth 2.0 protected resource and is its own authorization server. Two documents describe it, and each names endpoints that work:

- `https://senderprobe.com/.well-known/oauth-protected-resource` — RFC 9728 protected-resource metadata: this API's `resource` identifier, the single entry in `authorization_servers`, the `scopes_supported` it enforces, and `bearer_methods_supported`, which is `["header"]`.
- `https://senderprobe.com/.well-known/oauth-authorization-server` — RFC 8414 authorization-server metadata for that issuer. The `issuer` is `https://senderprobe.com`, which is the same string the document above advertises.

A `401` from any endpoint here carries `WWW-Authenticate: Bearer` with a `resource_metadata` parameter pointing at the first of those, so a client that arrives holding nothing can find the rest without being configured.

What this authorization server does **not** have, so that an agent stops rather than probing for it:

- No authorization endpoint, and therefore no authorization-code, implicit or device flow. `response_types_supported` is empty and says so.
- No dynamic client registration. Clients are not registered; the token endpoint is a public client with `token_endpoint_auth_methods_supported: ["none"]`.
- No identity assertion, in either direction: not `urn:ietf:params:oauth:token-type:id-jag`, not `verified_email`, not an anonymous credential. Registration is the self-service account below.
- No JWKS. Access tokens are symmetric and are validated by the only party that needs to — this server.

## Registration

`POST https://senderprobe.com/api/v1/auth/register` creates a user, their first workspace and that workspace's alerting defaults, and signs the new owner in. It is public: it requires no credential, which makes it the entry point for an agent that has none.

```http
POST /api/v1/auth/register HTTP/1.1
Content-Type: application/json

{
  "email": "agent@example.com",
  "password": "a password of at least 10 characters",
  "name": "Reporting agent",
  "workspaceName": "Example Ltd"
}
```

`201 Created` carries the user, the workspace and a signed-in session:

```json
{
  "accessToken": "eyJhbGciOi…",
  "refreshToken": "…",
  "expiresAt": "2026-01-01T00:00:00Z",
  "user": { "id": "…", "email": "…" },
  "workspace": { "id": "…", "name": "…" }
}
```

The password must be at least 10 characters and contain at least one letter and one digit; a shorter or weaker one comes back as `validation_failed` with the rule in the message. An address already registered comes back as `conflict`. There is no email-verification step to wait for — the account works immediately — and no invitation or allowlist to be admitted to.

An account that already exists signs in at `POST https://senderprobe.com/api/v1/auth/login` with `{"email", "password"}`. An account with a second factor answers that `200` with `{"twoFactorRequired": true, "challenge": "…"}` and no session; the sign-in is then completed at `POST https://senderprobe.com/api/v1/auth/login/2fa` with a TOTP code, which an unattended agent cannot produce. So give an agent an API key and leave two-factor to the humans.

## Methods supported

| Method | Credential | How it is obtained | Lifetime |
| --- | --- | --- | --- |
| `password` | Access token (JWT) | `POST https://senderprobe.com/oauth/token` with `grant_type=password`, or `POST /api/v1/auth/register` | Short; `expires_in` says how short. Rotate with `grant_type=refresh_token`. |
| `api_key` | Opaque key, `sk_live_…` | `POST /api/v1/api-keys`, from a session or key that already holds the `admin` scope | Until revoked at `DELETE /api/v1/api-keys/{id}`. |

There is no third method. An agent meant to run unattended should hold an API key: an access token expires, refreshing one means storing a token that rotates on every use, and an account with two-factor authentication cannot complete the password grant at all.

### agent_auth

The same thing, for a client that would rather parse than read:

```json
{
  "skill": "senderprobe",
  "register_uri": "https://senderprobe.com/api/v1/auth/register",
  "authorization_server": "https://senderprobe.com",
  "methods": [
    {
      "type": "password",
      "register_uri": "https://senderprobe.com/api/v1/auth/register",
      "register_body": { "email": "string", "password": "string", "name": "string", "workspaceName": "string" },
      "password_min_length": 10,
      "token_uri": "https://senderprobe.com/oauth/token",
      "grant_types": ["password", "refresh_token"],
      "revocation_uri": "https://senderprobe.com/oauth/revoke",
      "credential_type": "bearer_access_token",
      "credential_use": "Authorization: Bearer <access_token>"
    },
    {
      "type": "api_key",
      "issue_uri": "https://senderprobe.com/api/v1/api-keys",
      "issue_body": { "name": "string", "scopes": ["string"] },
      "requires": "a credential holding the admin scope",
      "scopes_supported": ["lookups", "tests", "monitors", "reports", "admin"],
      "credential_type": "opaque_key",
      "credential_use": "Authorization: Bearer sk_live_…"
    }
  ],
  "identity_types_supported": [],
  "documentation": "https://senderprobe.com/auth.md"
}
```

`identity_types_supported` is empty deliberately: no identity-assertion flow is implemented here, so an agent holding an ID-JAG or a verified-email assertion has nothing to present it to. Registration is the self-service account above.

## The token endpoint

`POST https://senderprobe.com/oauth/token` takes a form body, as RFC 6749 specifies, and answers with a bearer token. It is the same sign-in `POST /api/v1/auth/login` performs — the same account lockout, the same audit trail — in the envelope an OAuth client already knows how to read.

```http
POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded

grant_type=password&username=agent@example.com&password=…
```

```json
{
  "access_token": "eyJhbGciOi…",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "…"
}
```

`grant_type=refresh_token` with `refresh_token=…` rotates it; the token presented is spent, and replaying one revokes the whole family. `POST https://senderprobe.com/oauth/revoke` with `token=<refresh token>` ends the session, and answers `200` for a token it does not recognise — RFC 7009 requires that, so the endpoint cannot be used to test whether a stolen token is live.

A failure is the RFC 6749 §5.2 body — `{"error": "invalid_grant", "error_description": "…"}` — and not this API's error envelope, because that is what an OAuth client parses. Two refusals are worth knowing before they happen:

- **`invalid_scope`.** A `scope` parameter is refused rather than ignored. These are session tokens, and a session's authority is the caller's role in their workspace; echoing back a scope the token does not carry would be a lie an agent would act on. The scope-bounded credential here is an API key.
- **`invalid_grant` on a two-factor account.** No OAuth grant can carry a TOTP code, so the refusal names the REST endpoints that complete that sign-in. This is the reason to give an unattended agent a key rather than a password.

## Provisioning an agent's API key

`POST https://senderprobe.com/api/v1/api-keys` mints a key. It requires an existing credential — a session held by an admin or the owner, or a key carrying the `admin` scope — because a credential that can mint credentials is the one thing that must never be issued to an anonymous caller.

```http
POST /api/v1/api-keys HTTP/1.1
Authorization: Bearer <access token from registration>
Content-Type: application/json

{
  "name": "Reporting agent",
  "scopes": ["lookups"]
}
```

`201 Created` returns the key in the `key` field. **That is the only time the plaintext exists.** SenderProbe stores a SHA-256 hash and the first 12 characters for display; a key that has been lost is revoked and replaced, never recovered.

A person can do the same thing in the product at **Settings → API keys**, which is the usual way an operator provisions an agent they are about to hand a config file to.

## Using the credential

One header, one scheme, both credential types:

```http
Authorization: Bearer sk_live_8f4a1b2c3d4e5f60718293a4b5c6d7e8
```

It works the same on every authenticated REST endpoint under `https://senderprobe.com/api/v1` and on the MCP endpoint at `https://senderprobe.com/mcp`, which authenticates exactly as `POST /api/v1/lookups` does and answers a rejected credential with a JSON-RPC error rather than the REST envelope.

The header is the only place a credential is read from. Nothing authenticates from a query string or a request body — a credential in a URL ends up in logs and referrers — and the one cookie SenderProbe reads, `sp_refresh`, carries a *refresh* token to `POST /api/v1/auth/refresh` and authorises nothing else. An agent holding an API key needs no cookie jar at all.

A request with no credential, or one that has expired or been revoked, gets `401` with a `Bearer realm="senderprobe", resource_metadata="https://senderprobe.com/.well-known/oauth-protected-resource"` challenge — follow that parameter and you are back at the metadata above. A credential that is valid but not permitted to do this gets `403`. Both carry the standard error envelope:

```json
{ "error": { "code": "unauthorized", "message": "…" } }
```

## Scopes

A key carries one or more of these. Ask for the narrowest set that does the job — an agent running lookups needs one scope, not five.

| Scope | What it allows |
| --- | --- |
| `lookups` | Run any of the diagnostics — SPF, DKIM, DMARC, MX, blacklist, SMTP, WHOIS and the rest — and read their results. This is the scope an MCP agent needs. |
| `tests` | Issue deliverability test addresses, read the messages they receive and analyse pasted headers. |
| `monitors` | Create and edit recurring checks, and read the incidents they open. |
| `reports` | Read DMARC aggregate reporting and check history, and publish share links. |
| `admin` | Everything above, plus workspace, team and API-key management. Grant it only to a credential that needs to mint or revoke other credentials. |

Scopes bound an API key only. A session's authority comes from the caller's role in the workspace — owner, admin, analyst or viewer — and the two are checked independently: a key with the right scope held by a viewer still cannot write.

## What to expect once authenticated

- Every credential belongs to exactly one workspace, and every request is answered inside that workspace. A domain, check or report belonging to another one is a `404`, not a `403`; there is no cross-workspace read.
- Diagnostics are rate limited per workspace. Over budget is `429` with `{"error": {"code": "rate_limited"}}` and a message saying when it clears — back off rather than retrying immediately.
- Every run is recorded in the workspace's history with `source: "api"`, whether it arrived over REST or as an MCP tool call. An agent's work is visible to the humans who own the workspace, by design.
- Checks are live. SenderProbe queries DNS, SMTP and WHOIS at the moment of the call and reports what a receiver would see, so a tool that runs all eleven diagnostics to answer one question spends a real budget on ten answers nobody asked for.

## Related documents

- `https://senderprobe.com/.well-known/oauth-protected-resource` — RFC 9728 protected-resource metadata for this API.
- `https://senderprobe.com/.well-known/oauth-authorization-server` — RFC 8414 metadata for the authorization server it names.
- `https://senderprobe.com/.well-known/mcp.json` — the MCP server card: the endpoint, the protocol revision it speaks, and the tools it offers.
- `https://senderprobe.com/.well-known/api-catalog` — the RFC 9727 catalog of every API this instance publishes.
- `https://senderprobe.com/api/v1/openapi.json` — the generated OpenAPI 3.1 description of the REST API, including the security requirement on every endpoint.
- `https://senderprobe.com/llms.txt` — a map of the public reference material, for an agent that needs the explanation rather than a check.
- `https://senderprobe.com/api/v1/status` — live health of the API, database, resolvers and listeners.
