# Partner API authentication

The partner APIs (KPIs, studio analytics) accept two schemes. Both resolve to your party, so you only ever see data for the players linked to you. The decision is recorded in [ADR 0033](../decisions/0033-partner-kpi-platform.md).

| Scheme | Use when | Header |
|---|---|---|
| OAuth 2.0 client credentials (recommended) | You can run a token flow | `Authorization: Bearer <access token>` |
| PP-HMAC-SHA256 request signing | You can't run OAuth, or want per-request integrity | `Authorization: PP-HMAC-SHA256 keyId=…, signature=…` |

A party ID sent in a query string or an `x-party-id` header is ignored. Your scope always comes from your credentials.

## Scopes

| Scope | Grants |
|---|---|
| `kpis.read` | `/v1/kpis/*` |
| `studio.read` | `/v1/analytics/studio*` (providers only) |

## OAuth client credentials

PlayerPal registers a service account for you and maps its client ID to your party. Request a token from the issuer with the client-credentials grant, using the PlayerPal partner API project as the audience. Then send it as `Authorization: Bearer <token>`. Tokens are short-lived (5–15 minutes), so cache one and refresh it before it expires.

## PP-HMAC-SHA256

PlayerPal issues you a key ID (`pp_kid_…`) and a secret (`pp_sk_…`). The secret is shown **once**; store it in your secret manager. You can have two active keys at a time. After a rotation the old key keeps working for 24 hours.

### Headers

```
Authorization: PP-HMAC-SHA256 keyId=pp_kid_XXXX, signature=<base64url>
X-PP-Timestamp: <unix seconds>
X-PP-Nonce: <16–128 chars of A-Z a-z 0-9 _ ->
```

### Canonical string

Join these six lines with `\n` (no trailing newline):

1. HTTP method, uppercase
2. Path, exactly as sent, without the query string
3. Query string: pairs sorted by key and then by value, each key and value RFC 3986 percent-encoded, joined with `&`. Empty if there's no query string.
4. Lowercase hex SHA-256 of the exact request body bytes (the hash of the empty string for GET)
5. The `X-PP-Timestamp` value
6. The `X-PP-Nonce` value

`signature = base64url_nopad(HMAC-SHA256(secret, canonical))`

### Rules

- The timestamp must be within **±300 seconds** of PlayerPal's clock.
- A nonce can be used only once per key within the window. A replay is rejected with `replayed_nonce`.
- Sign the body bytes you actually send. Re-serialising JSON after signing breaks the signature.

Errors are returned as `401` with `message` set to one of: `invalid_signature`, `stale_timestamp`, `invalid_nonce` or `replayed_nonce`. A missing scope is returned as `403 partner_scope_missing`.

### Test vectors

These all use secret `pp_sk_test-vector-secret`, timestamp `1760000000` and nonce `nonce_0123456789abcdef`.

| # | Request | Canonical query | Body SHA-256 | Signature |
|---|---|---|---|---|
| 1 | `GET /v1/kpis/own?to=2026-09-30&kpi=own.rounds&from=2026-09-01` | `from=2026-09-01&kpi=own.rounds&to=2026-09-30` | `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` | `NhQFk3mISFyIhDIMHBwZ-pWBoO5pKuFk_yWDVycaOyY` |
| 2 | `POST /v1/kpis/exports` with body `{"kpi":"own.rounds"}` | (empty) | `4dd0775e21e5aa993d44025a43efbd7aa90f24f91b463afdb3e80787704582df` | `7Jd4HZfVqJj3aJTY7K09ipVJHIDQ_6qfICxscuh1-kw` |

The API's test suite checks against these vectors (`services/api/src/auth/partner/hmac-signature.spec.ts`).

### Reference signer (TypeScript, Node 20+)

```ts

const enc = (s: string) =>
  encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`)

export function signRequest(keyId: string, secret: string, method: string, url: URL, body = '') {
  const query = [...url.searchParams.entries()]
    .sort(([ak, av], [bk, bv]) => (ak === bk ? (av < bv ? -1 : 1) : ak < bk ? -1 : 1))
    .map(([k, v]) => `${enc(k)}=${enc(v)}`)
    .join('&')
  const timestamp = String(Math.floor(Date.now() / 1000))
  const nonce = randomBytes(16).toString('hex')
  const canonical = [
    method.toUpperCase(),
    url.pathname,
    query,
    createHash('sha256').update(body).digest('hex'),
    timestamp,
    nonce,
  ].join('\n')
  const signature = createHmac('sha256', secret).update(canonical).digest('base64url')
  return {
    authorization: `PP-HMAC-SHA256 keyId=${keyId}, signature=${signature}`,
    'x-pp-timestamp': timestamp,
    'x-pp-nonce': nonce,
  }
}
```

### Reference signer (bash + openssl)

```bash
KEY_ID=pp_kid_XXXX; SECRET=pp_sk_XXXX
METHOD=GET; PATH_=/v1/kpis/catalogue; QUERY=""; BODY=""
TS=$(date +%s); NONCE=$(openssl rand -hex 16)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')
SIG=$(printf '%s\n%s\n%s\n%s\n%s\n%s' "$METHOD" "$PATH_" "$QUERY" "$BODY_HASH" "$TS" "$NONCE" \
  | openssl dgst -sha256 -hmac "$SECRET" -binary | openssl base64 -A | tr '+/' '-_' | tr -d '=')
curl -s "https://api.playerpal.io$PATH_" \
  -H "Authorization: PP-HMAC-SHA256 keyId=$KEY_ID, signature=$SIG" \
  -H "X-PP-Timestamp: $TS" -H "X-PP-Nonce: $NONCE"
```

## Credential administration (PlayerPal staff)

| Endpoint | Purpose |
|---|---|
| `GET /v1/staff/partners/:partyPublicId/credentials` | List keys (never returns secrets) |
| `POST /v1/staff/partners/:partyPublicId/credentials` | Issue a key: `{ "scopes": ["kpis.read"], "expires_at": "…" }` (both optional). The response contains the secret, once. |
| `POST /v1/staff/partners/:partyPublicId/credentials/:keyId/rotate` | Issue a replacement; the old key expires in 24 hours |
| `DELETE /v1/staff/partners/:partyPublicId/credentials/:keyId` | Revoke immediately |
| `PUT /v1/staff/partners/:partyPublicId/oauth-clients/:clientId` | Map an OAuth client ID to the party: `{ "scopes": [...] }` |

Every change is recorded as a `partner.credential.*` domain event. These endpoints use the machine key until workforce SSO is in place.
