# Authentication

The four credentials of the Filudi API, where each one is valid, and how it travels.

Four credentials open the API. Each one opens one group of routes and nothing else. The reference says, under
every route, which one it takes.

| Credential      | Looks like                    | Sent as                                   | Valid on                                                  |
| --------------- | ----------------------------- | ----------------------------------------- | --------------------------------------------------------- |
| Player token    | `fp1.<payload>.<hmac>`        | `Authorization: Bearer`                   | `/v1/sdk/*`, `/v1/account/*`, ratings, comments, feedback |
| Game token      | `fg1.<payload>.<hmac>`        | `Authorization: Bearer`                   | Five `/v1/sdk/*` routes, for its own game                 |
| Console session | cookie `filudi_console`       | the cookie, with `credentials: 'include'` | `/v1/console/*`, `/v1/admin/*`                            |
| Server key      | `fsk_<site slug>_<32 base62>` | `Authorization: Bearer`                   | `/v1/server/*`                                            |

## Player token

A player is a guest from the first page view. `POST /v1/sdk/players` creates one for a site and answers
`{ player, token }`; the magic link, its 6-digit code and the password sign-in answer the same shape for an
account. The token has no expiry. It opens the player's saves, scores, events, profile and the whole
`/v1/account/*` group, deletion included.

<CodePanel title="Create a guest and read it back">
  <Fragment slot="curl">

    ```sh
    curl -X POST 'https://api.filudi.com/v1/sdk/players' \
      -H 'content-type: application/json' \
      -d '{ "site": "filudi", "locale": "en" }'

    curl 'https://api.filudi.com/v1/sdk/players/me' \
      -H "authorization: Bearer $FILUDI_PLAYER_TOKEN"
    ```

  </Fragment>
  <Fragment slot="javascript">

    ```js
    const created = await fetch('https://api.filudi.com/v1/sdk/players', {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ site: 'filudi', locale: 'en' }),
    });
    const { token } = await created.json();
    localStorage.setItem('filudi:player:filudi', token);

    const me = await fetch('https://api.filudi.com/v1/sdk/players/me', {
      headers: { authorization: `Bearer ${token}` },
    });
    ```

  </Fragment>
  <Fragment slot="python">

    ```py
    import requests

    # From a server, every visitor shares your IP and the 21st guest of the hour is refused.
    # Create guests in the browser, or use a server key and POST /v1/server/players.
    created = requests.post(
        'https://api.filudi.com/v1/sdk/players',
        json={'site': 'filudi', 'locale': 'en'},
    )
    token = created.json()['token']
    me = requests.get(
        'https://api.filudi.com/v1/sdk/players/me',
        headers={'authorization': f'Bearer {token}'},
    )
    ```

  </Fragment>
</CodePanel>

Rules for the token:

- It lives in the browser, in `localStorage`. `PlayerClient` of `@filudi/hub-core` uses the key
  `filudi:player:<site>`. Never a cookie, never a URL, never a log.
- It never enters a game frame. A game gets a game token instead.
- A 401 means the API refused it: forget it and create a new guest. When the owner rotates the API's token
  secret, every player is signed out this way.
- A player belongs to one site. The same email on two sites makes two players.

## Game token

A game is untrusted. The page that frames it exchanges the player token with `POST /v1/sdk/game-tokens { game }`
and passes the result in the `filudi:init` message of the handshake. The token is `fg1`, valid 24 hours, and
opens these routes for the game it names:

| Route                                         | With a game token                                          |
| --------------------------------------------- | ---------------------------------------------------------- |
| `GET /v1/sdk/players/me`                      | Allowed; `emailMasked` is null and `hasPassword` false     |
| `GET`, `PUT`, `DELETE /v1/sdk/saves/{gameId}` | Its own game                                               |
| `POST /v1/sdk/sessions`                       | Its own game                                               |
| `/v1/sdk/leaderboards/{gameId}/*`             | Its own game                                               |
| `POST /v1/sdk/events`                         | An item that names another game refuses the batch with 403 |

Every other route answers 401 to a game token. The exchange is limited to 240 per player per hour. A hub sends
a fresh token to the frame every 12 hours as `filudi:token`; the SDK swaps it without a reload. A portal backend
gets the same token for one of its players with `POST /v1/server/game-tokens`.

<CodePanel title="Exchange a player token for a game token">
  <Fragment slot="curl">

    ```sh
    curl -X POST 'https://api.filudi.com/v1/sdk/game-tokens' \
      -H "authorization: Bearer $FILUDI_PLAYER_TOKEN" \
      -H 'content-type: application/json' \
      -d '{ "game": "gloop" }'
    ```

  </Fragment>
  <Fragment slot="javascript">

    ```js
    const response = await fetch('https://api.filudi.com/v1/sdk/game-tokens', {
      method: 'POST',
      headers: { authorization: `Bearer ${playerToken}`, 'content-type': 'application/json' },
      body: JSON.stringify({ game: 'gloop' }),
    });
    const { token, expiresAt } = await response.json();
    // Post `token` to the frame in filudi:init; keep playerToken on your origin.
    ```

  </Fragment>
  <Fragment slot="python">

    ```py
    import requests

    # A backend uses its server key instead: POST /v1/server/game-tokens { playerId, game }.
    response = requests.post(
        'https://api.filudi.com/v1/server/game-tokens',
        headers={'authorization': f'Bearer {site_key}'},
        json={'playerId': player_id, 'game': 'gloop'},
    )
    game_token = response.json()['token']
    ```

  </Fragment>
</CodePanel>

## Console session

The developer console and the back office sign in with `POST /v1/console/sign-in` (email, password), then
`POST /v1/console/sign-in/totp` when the account has an authenticator. The answer sets the cookie
`filudi_console`: HttpOnly, Secure, SameSite Strict, host-only on the API, 14 days. The browser sends it on its
own; a script adds `credentials: 'include'` to each `fetch`.

Every non-GET request under `/v1/console/*` and `/v1/admin/*` must carry an `Origin` header listed in the
API's console origins (developers.filudi.com and admin.filudi.com). A browser sets it. A request without one,
or with another, answers 403 `forbidden`.

Staff without an authenticator gets 403 on every `/v1/admin/*` route except `GET /v1/admin/me`. A staff member
who opens an impersonation gets a second cookie, `filudi_impersonation`, valid one hour; a request with the
header `X-Filudi-Impersonation: 1` is read from that cookie instead.

<CodePanel title="Sign in and read the account">
  <Fragment slot="curl">

    ```sh
    curl -X POST 'https://api.filudi.com/v1/console/sign-in' \
      -H 'origin: https://developers.filudi.com' \
      -H 'content-type: application/json' \
      -c cookies.txt \
      -d '{ "email": "you@example.com", "password": "string" }'

    curl 'https://api.filudi.com/v1/console/me' -b cookies.txt
    ```

  </Fragment>
  <Fragment slot="javascript">

    ```js
    const signIn = await fetch('https://api.filudi.com/v1/console/sign-in', {
      method: 'POST',
      credentials: 'include',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ email, password }),
    });
    const { totpRequired, me } = await signIn.json();
    // totpRequired: finish with POST /v1/console/sign-in/totp { code }

    const account = await fetch('https://api.filudi.com/v1/console/me', { credentials: 'include' });
    ```

  </Fragment>
  <Fragment slot="python">

    ```py
    import requests

    # The console API is made for a browser. A script keeps the cookie in a session.
    session = requests.Session()
    session.headers['origin'] = 'https://developers.filudi.com'
    session.post(
        'https://api.filudi.com/v1/console/sign-in',
        json={'email': 'you@example.com', 'password': password},
    ).raise_for_status()
    me = session.get('https://api.filudi.com/v1/console/me').json()
    ```

  </Fragment>
</CodePanel>

## Server key

A portal backend calls `/v1/server/*` with a key created in the console, under the site, Keys. The key is
`fsk_<site slug>_<32 base62 characters>`, shown once; the API stores a hash and the console shows the first 12
characters. Each key carries scopes (`catalog`, `players`, `tokens`, `leaderboards`, `stats`) and each route says
which one it needs. 600 requests a minute per key; over that, 429 with `Retry-After`. Only an approved site has
keys, and a revoked key answers 403.

A server key never reaches a browser: `/v1/server/*` sends no CORS headers, so a page cannot call it. The key
sits in your backend's environment.

<CodePanel title="Read the catalog with a server key">
  <Fragment slot="curl">

    ```sh
    curl 'https://api.filudi.com/v1/server/catalog?locale=en' \
      -H "authorization: Bearer $FILUDI_SITE_KEY"
    ```

  </Fragment>
  <Fragment slot="javascript">

    ```js
    // Node, in your backend. siteKey comes from the environment.
    const response = await fetch('https://api.filudi.com/v1/server/catalog?locale=en', {
      headers: { authorization: `Bearer ${siteKey}` },
    });
    const catalog = await response.json();
    ```

  </Fragment>
  <Fragment slot="python">

    ```py
    import os
    import requests

    response = requests.get(
        'https://api.filudi.com/v1/server/catalog',
        params={'locale': 'en'},
        headers={'authorization': f"Bearer {os.environ['FILUDI_SITE_KEY']}"},
    )
    catalog = response.json()
    ```

  </Fragment>
</CodePanel>

## Public routes

`GET /v1/catalog/{site}`, `GET /v1/catalog/{site}/games/{slug}`, the reviews of a game, the bundle and media
files, `POST /v1/sdk/players`, `POST /v1/feedback`, `POST /v1/console/invitation-requests` and the health routes
take no credential. A few routes accept a player token and answer without one too; the reference marks them
"Credential, optional".
