# Authentication

# Authentication

The API uses **OAuth 2.0 Client Credentials** (RFC 6749 §4.4) — the standard machine-to-machine grant every
HTTP library and OAuth SDK already supports. There is no user login, no redirect flow, no refresh token.

## The two credentials

| Credential | Looks like | Lifetime | Who has it |
|---|---|---|---|
| `client_id` | `scc_3f9a1c0d2b7e4a6f8c1d2e3f` | until the client is deleted | public — identifies the client, not a secret itself |
| `client_secret` | `scs_q0vX…` (43 chars) | until rotated | **secret** — shown once at creation/rotation, stored at our end only as a salted hash |
| access token | `sca_…` (43 chars) | **1 hour** | your server, in memory — never persisted, never logged |

Losing the `client_secret`? It cannot be retrieved — ask the club admin to **rotate** it (Settings → API
access → the client → Rotate secret). Rotation immediately invalidates the old secret and every access
token issued from it.

## A note on encoding

`client_id`/`client_secret` only ever use the base64url alphabet (letters, digits, `-`, `_`) plus their fixed
`scc_`/`scs_` prefix — they never contain a `:` or any character that needs percent-encoding. Every sample on
this site sends them as-is, `client_id:client_secret`, base64-encoded for the `Authorization: Basic` header;
you do not need to URL-encode either value first.

## Getting a token

`POST /api/v1/oauth/token` — see the full reference: [Get access token](/docs/api/token). Two equivalent
ways to send the credentials: HTTP Basic (recommended — never appears in a request body log), or as
`client_id`/`client_secret` form fields. Sending both at once is a `400 API_TOKEN_REQUEST_INVALID`.

```bash
curl -s -X POST https://sail-club-server.cloud.run/api/v1/oauth/token \
  -u "scc_3f9a…:scs_q0vX…" \
  -d grant_type=client_credentials
```

The response carries `expires_in` (always `3600`, but read the field — don't hard-code it) and `scope`
(the token's actual scopes, which may be a client asked for a narrower `scope=` than it holds).

## Refresh-before-expiry pattern

```text
if no cached token OR cached token expires within the next ~60s:
    fetch a new token, cache { access_token, expires_at = now + expires_in }
use the cached access_token on the call
if the call returns 401:
    fetch exactly one new token and retry the call once
    if it still 401s, stop and alert a human — the credential itself is the problem
```

Don't fetch a token per API call — the token endpoint has its own, tighter rate limit (20/min per client
and per caller IP — see [Rate limits](/docs/rate-limits)) and every extra round-trip is latency you don't
need, since one token covers an hour of calls.

## Secret handling

- Read `client_id`/`client_secret` from environment variables or a secrets manager — never commit them,
  never put them in a repository, never send them to a browser (this API has no CORS and rejects the
  Firebase user tokens the panel itself uses, so there is no browser-safe way to call it anyway).
- Never log the access token or the client secret, including in error messages or crash reports.
- One client per integration/environment (e.g. a separate client for staging vs. production) makes
  rotation and revocation blast-radius small.

## Rotation

The club admin can rotate a secret at any time from the panel. Effect: the old secret stops working for new
token requests immediately, and every access token already issued from it stops working **within 60
seconds** (client state is cached at our end for up to a minute). Build your token refresh path to treat a
sudden run of 401s as "credential changed, alert a human, don't just retry forever."

## What a Firebase (panel) token cannot do

The panel's own users authenticate with Firebase ID tokens. Those are a **completely separate scheme** from
this API: a Firebase token is rejected on every `/api/v1/*` route (`401 API_TOKEN_INVALID`), and an API
access token is rejected everywhere else. If you're building against this API, you should never see or
handle a Firebase token at all.
