# The shared services, as an API

`https://accounts.praecise.com/api`

Domain verification and mailbox provisioning are account-level facts. Every
product in the estate needs them, and none of them should learn to talk to the
mail service directly — that is how one estate ends up with four
domain-verification flows and a person proving the same domain four times.

There is no `/v1`. A version in a path is a promise to run two of everything.

Signing a person in to a product (the sign-in request, the ID token's claims,
silent sign-in, asking for a passkey, sign-out and logout tokens) is in
[sign-in.md](sign-in.md). This page is the shared services and the tokens for
an API.

## Authenticating

Two ways in, different in kind.

**The gateway session cookie** (`praecise_gateway`), for a browser that is
already signed in. Acts as that person.

**A service token**, for a product calling server to server:

```
Authorization: Bearer prc_…
```

A token acts as exactly one account — the account it was issued for. It is
stored only as a SHA-256, so the table is a list of account ids and dates
rather than a set of working credentials, and a leak costs one account rather
than the estate.

Everything answers JSON. `401` means neither credential was accepted.

## Getting a token

A person signs in to `accounts.praecise.com` and mints one:

```
POST /api/tokens          {"name": "rivier, production"}
 →  201 {"name": …, "token": "prc_…", "note": …}
GET  /api/tokens          →  200 {"tokens": [{id, name, created, lastUsed}]}
DELETE /api/tokens/:id    →  204   (idempotent)
```

The value is shown once. Minting and revoking accept the session cookie only:
a token that could mint its successors would make revoking the original
pointless, so presenting a bearer here is a `403`, not a `401`.

## The account

```
GET /api/me  →  {"account": {"id": …, "email": …}}
```

The cheapest way for a caller to check that a token still works.

### For a product's server: is this a Praecise account?

A product sometimes has to check an account it was given, by another product
or by a person typing an address (the Cloud console when Rivier acts for an
account; Inbox when a mailbox is shared with somebody), and must not take
their word for it. The server of any Praecise product may ask:

```
GET /api/accounts/lookup?email=person@example.com      (or ?id=<account id>)
Authorization: Basic base64(<client id>:<client secret>)

 →  200 {"account": {"id": "…", "email": "person@example.com", "confirmed": true}}
 →  404 {"error": "no Praecise account has that address"}
```

The credential is the product's own client id and secret, as at the token
endpoint. A person's service token or session is refused here (`401`), and an
outside application has no such credential at all. Give exactly one
of `email` and `id` (`400` otherwise), and prefer `id` (the `sub` of ID
tokens) whenever you hold it: it cannot be guessed and does not change when
an address does. `confirmed` says whether the person has proven the mailbox;
when it is false, do not act for the account on the strength of the address.
At most 120 calls a minute for each client (`429` with `Retry-After`), and
every call is logged with who asked and the outcome, never the address.

## Domains

```
GET  /api/domains
POST /api/domains                    {"domain": "example.com"}
POST /api/domains/:domain/verify
```

`records` is what still has to be published. An empty list on an unverified
domain means the records are there and DNS has not caught up.

If Praecise holds the registrar for the domain, `POST /api/domains` publishes
the ownership record and checks it in the same call — `published: true`, and
usually `verified: true` with it. Otherwise the records come back for somebody
to publish, which is the honest answer rather than a worse one.

## Mailboxes

```
GET  /api/mailboxes
POST /api/mailboxes   {"localpart": "alerts", "domain": "example.com",
                       "name": "Alerts", "kind": "machine"}
GET  /api/mailboxes/:email
```

`kind` is `human`, `agent` or `machine` — a server, a robot, an autonomous
machine. It is a Praecise fact and is kept here: the mail service's own
vocabulary is `human` and `send-only`, derived from whether a mailbox has an
inbox, and it discards anything else it is sent. That is reasonable of it —
delivery does not care who is at the other end — but it is what makes a record
readable afterwards. A thread between two addresses is two strangers; a thread
between a machine and an operator is an outage being reported.

`409` means the domain is on the account but not verified yet. `404` means it
is not on the account at all, and is also the answer for a mailbox that
belongs to somebody else — there is no way to learn from here whether an
address exists elsewhere.

## Mail hosts

Clients connect to **`mail.praecise.com`**: IMAP on 993, submission on 465,
both with a certificate for that name. `_imaps._tcp` and `_submissions._tcp`
SRV records say the same, and `autoconfig.praecise.com` answers for clients
that look there.

Inbound delivery still has `mail.furcate.foundation` as the MX. That is not
drift: the MTA-STS policy at `mta-sts.praecise.com` is generated by the mail
server from its own `Hostname` and is served in `mode: enforce`, so an MX
naming anything the policy does not list would be skipped by every sender that
honours it. The MX moves when that `Hostname` does, which is a change on the
gateway host itself.

## Access tokens for a resource

Apart from the shared services above, this host is an OAuth authorization
server: it issues access tokens that another API accepts. What a client or a
resource server needs is in the metadata, at
`/.well-known/openid-configuration` and, with the same content,
`/.well-known/oauth-authorization-server`:

| field | what it is for |
|---|---|
| `issuer` | the `iss` of every token; a resource server compares it exactly |
| `jwks_uri` | the keys access tokens and ID tokens are signed with |
| `id_token_signing_alg_values_supported` | the signing algorithms; access tokens use the same key and algorithm as ID tokens (RS256), and each key in the JWKS names its `alg` |
| `dpop_signing_alg_values_supported` | `ES256`, `EdDSA` |
| `revocation_endpoint`, `introspection_endpoint` | see below |
| `client_id_metadata_document_supported` | `true` |
| `acr_values_supported` | `phr` |
| `authorization_response_iss_parameter_supported` | `true` (RFC 9207) |

A token is for exactly one resource. The client names it with `resource`
(RFC 8707) on the authorization request, and the token's `aud` is that value.

### The Skerry API

**Who is asked.** An outside application asking for a token for this API is
always shown a consent page listing what it may do. One of the family's own
products (Skerry's own application, Rivier) is not asked in between when there
is nothing for the person to decide, that is, when Skerry's hook answers with
claims (not with accounts to choose from) and allows everything that was
asked; Skerry is told which product is asking and decides. With a choice,
something withheld or a refusal, the page is shown. A silent request
(`prompt=none`) for the API is always answered `consent_required`.

**Staying connected.** Ask for `offline_access` together with `prompt=consent`
and the token answer carries a refresh token. Each use replaces it and asks
Skerry's hook again; an old one used a second time ends the grant. A refresh
token is good for 14 days from its own issue, and the grant behind them for
30 days from the consent, which use does not extend: after at most 30 days,
or 14 days without use, the application makes the authorization request
again (for Skerry's own application, with no page).

Resource `https://api.skerry.xyz` (`SKERRY_RESOURCE`). The access token is a
JWT with header `typ: at+jwt`:

| claim | value |
|---|---|
| `iss` | this issuer |
| `aud` | `https://api.skerry.xyz` |
| `sub` | the account id; for the client credentials grant, the client id |
| `client_id` | the client |
| `scope` | Skerry scope names, space separated; never `*` |
| `iat`, `exp` | at most 900 seconds apart |
| `jti` | unique per token |
| `skerry_partner` | the Skerry account the token acts in |
| `skerry_mode` | `sandbox` or `live` |
| `skerry_agent` | the registered agent, when the client acts as one |
| `cnf.jkt` | the key the token is bound to, when DPoP was used |

The three `skerry_` claims and the final scope come from Skerry's claims hook.
The scope names are listed in `src/resources.js`, from the Skerry API's
reference.

**A person, through an application** (authorization code with PKCE):

```
GET /auth?response_type=code
        &client_id=https://host.example/oauth/client.json
        &redirect_uri=http://127.0.0.1:3000/callback
        &resource=https://api.skerry.xyz
        &scope=payments:read customers:read offline_access
        &code_challenge=…&code_challenge_method=S256&state=…
```

`skerry_partner` and `skerry_mode` may be added to say which Skerry account
and environment. Without them, a person who can act for more than one is
asked. The consent screen is shown every time, and what it lists is what
Skerry allows that person, which may be less than was asked. `offline_access`
gets a refresh token; each use replaces it, and using an old one again
revokes the grant.

**An agent, with no person present** (client credentials):

```
POST /token
Authorization: Basic <client_id:client_secret>

grant_type=client_credentials
&resource=https://api.skerry.xyz
&scope=payments:read
&skerry_agent=agt_…
```

Only a client in `AGENT_CLIENTS` may use this grant, and the token is issued
only if the agent's record at Skerry lists that client. There is no refresh
token; the client asks again when the token expires.

**DPoP** (RFC 9449). Send a `DPoP` proof header with the token request and
the token comes back with `token_type: DPoP` and `cnf.jkt`. A client marked
`dpop_required` is refused without one.

**Revocation and introspection.** `POST /token/revocation` with a refresh
token ends it and the grant it belongs to. A JWT access token cannot be
revoked or introspected here (the endpoint answers `unsupported_token_type`);
a resource server verifies it itself and it expires within fifteen minutes.
`POST /token/introspection` answers for opaque tokens.

**When no token is issued.** At the token endpoint: `invalid_grant` (or
`unauthorized_client` for the client credentials grant) when the resource
refuses, with the resource's reason in `error_description`; `invalid_scope`
when none of the scopes may be granted; HTTP 503 `temporarily_unavailable`
when the resource's hook could not be asked. A refresh token is not used up
by a refusal or a 503.

## The claims hook

A resource that wants its own facts in its access tokens registers a hook: an
entry in `HOOKED_RESOURCES` (`src/resources.js`) with the URL, the name of the
environment variable holding a shared secret, the request parameters it takes
and the claims it may set. Nothing else here is specific to a resource.

The hook is asked when the consent screen is drawn, when Allow is pressed, and
every time a token for the resource is issued (code exchange, refresh, client
credentials). Answers are not kept from one request to the next.

**Request**

```
POST <hook url>
Content-Type: application/json
Praecise-Signature: t=1794571200000,v1=5f2c…

{
  "account": {"id": "…", "email": "person@example.com", "email_verified": true},
  "client_id": "https://host.example/oauth/client.json",
  "scopes": ["payments:read", "customers:read"],
  "resource": "https://api.skerry.xyz",
  "requested": {"skerry_partner": "…", "skerry_mode": "sandbox"}
}
```

`account` is `null` for the client credentials grant. `scopes` is what the
token would carry if nothing is narrowed. `requested` holds the resource's own
parameters: those sent on the authorization request, what the person chose, or
for client credentials those sent on the token request.

`v1` is the hex HMAC-SHA256, keyed with the shared secret, of the `t` value, a
dot, and the raw body. `t` is the time in milliseconds. The resource refuses a
request whose `t` is more than five minutes from its own clock. The check is
`verifySignature` in `src/claims-hook.js`.

**Answers**

| answer | body | effect |
|---|---|---|
| claims | `{"claims": {…}, "scopes": […], "label": "…", "requested": {…}}` | the claims go in the token; the token carries the listed scopes that were asked for |
| choices | `{"choices": [{"label": "…", "detail": "…", "requested": {…}}]}` | on the consent screen the person picks one, and the hook is asked again with that `requested`; at the token endpoint this is a refusal |
| refusal | `{"error": "access_denied", "error_description": "…"}`, at any status | no token |

`label` is what the consent screen calls the account. `requested`, in a claims
answer, is the parameters that select the same answer again; it is kept with
the grant so that a refresh asks about the account that was consented to.
Both are optional.

The answer can only narrow: a scope that was not asked for is dropped. A claim
must be one the resource registered, and never `iss`, `sub`, `aud`, `exp`,
`iat`, `nbf`, `jti`, `client_id`, `scope` or `cnf`. Any other answer is a
failure, and a failure means no token: a status outside 2xx without an
`error`, a body that is not JSON or is larger than 16 kB, a redirect, or no
answer within the timeout (3 seconds unless configured).

## Asking for a passkey

A client that needs a passkey assertion, not just a session, adds to its
authorization request:

```
&acr_values=phr&max_age=300
```

The person is asked for a passkey unless the session was signed in with one
within `max_age` seconds. The ID token then carries `acr: "phr"`, `amr`
(`hwk` or `swk`, `user`, `mfa`; a sign-in that began with the password lists
`pwd` as well) and `auth_time`; the client checks all three. A `nonce` sent
with the request comes back unchanged, so the token can be tied to one action.
`auth_time` is included when `max_age` or `prompt=login` is sent. With
`prompt=none` and no such session the answer is `login_required`. Cancel on
the screen returns `access_denied`. Scope `openid` is required, as for any
request that uses these parameters.
