> ## Documentation Index
> Fetch the complete documentation index at: https://docs.flynet.org/llms.txt
> Use this file to discover all available pages before exploring further.

# OAuth

> OAuth 2.0 + PKCE for acting on behalf of Blackbird members. About 10 minutes.

Flynet's OAuth implementation follows the [Token-Mediating Backend](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps#name-token-mediating-backend)
variant of OAuth 2.0 + PKCE. Your backend holds the `client_secret`
and refresh token. The browser only sees a short-lived access token.

<Note>
  **Tasting note** - The published [`@flynetdev/core`](/concepts/typescript-client)
  SDK implements this flow for you: `FlynetOAuth` handles PKCE generation,
  the code exchange, and refresh-token rotation, and `FlynetMemberClient`
  carries the resulting access token on member routes. The walkthrough below
  shows the raw HTTP if you'd rather wire it by hand.
</Note>

## Prerequisites

After your app is approved, Blackbird sends you:

* `client_id` - embed in your frontend
* `client_secret` - backend-only
* A registered redirect URI - your callback URL
* Your allowed scopes - e.g. `read:profile`, `read:user_checkins`, `read:wallets`

<Warning>
  **Chef's warning** - This flow assumes you have a backend that can hold
  `client_secret` and store the refresh token. Pure single-page apps
  without a backend need a different OAuth variant. Contact support if
  that is your situation.
</Warning>

## Environment

Staging:

```bash theme={null}
AUTH_BASE_URL=https://api.staging.blackbird.xyz/oauth
API_BASE_URL=https://api.staging.blackbird.xyz/flynet/v1
```

Production (separate credentials, issued when you're approved for live traffic):

```bash theme={null}
AUTH_BASE_URL=https://api.blackbird.xyz/oauth
API_BASE_URL=https://api.blackbird.xyz/flynet/v1
```

<Note>
  **Tasting note — local testing.** Your `redirect_uri` must be a public,
  allowlisted URL, and the allowlist is an exact match. `localhost` usually
  won't be registered, so expose your callback with a tunnel like
  [ngrok](https://ngrok.com) and register that exact URL:

  ```bash theme={null}
  ngrok http 3000   # → https://your-subdomain.ngrok-free.app
  REDIRECT_URI=https://your-subdomain.ngrok-free.app/callback
  ```

  Use a **reserved** ngrok domain (free tier includes one) so the URL is
  stable — because the allowlist is exact, a rotating hostname means
  re-registering with Blackbird every session.
</Note>

## The flow

```text theme={null}
1. Browser -> /oauth/authorize
2. Callback -> /api/oauth/exchange -> /oauth/token
3. API calls with Authorization: Bearer <access_token>
4. Refresh -> /api/oauth/refresh -> /oauth/token
```

Steps 2 and 4 hit Blackbird's `/oauth/token` from your backend.
Steps 1 and 3 happen in the browser.

## Step 1 - Initiate authorization

Generate PKCE parameters, store them in `sessionStorage`, and
redirect the browser to `/oauth/authorize`.

```js theme={null}
async function login() {
  const codeVerifier = base64UrlEncode(randomBytes(48));
  const codeChallenge = base64UrlEncode(await sha256(codeVerifier));
  const state = base64UrlEncode(randomBytes(24));

  sessionStorage.setItem('oauth_pending', JSON.stringify({
    state,
    code_verifier: codeVerifier,
    created_at: Date.now(),
  }));

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: 'read:profile read:user_checkins',
    state,
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
  });

  window.location.href = `${AUTH_BASE_URL}/authorize?${params}`;
}
```

<Note>
  **Tasting note** - Scope names are exact-match. `read:profiles`
  returns `invalid_request`. Use `read:profile`. Request only the
  scopes your app needs: a token is granted exactly the scopes you
  ask for, and member routes outside those scopes return
  `403 insufficient_scope`.
</Note>

<Note>
  **Tasting note** - `/oauth/authorize` requires PKCE: the
  `code_challenge` + `code_challenge_method=S256` parameters above are
  mandatory, and the matching `code_verifier` is required on token
  exchange. A bare authorize URL without them is rejected. The
  authorize request 302-redirects the browser to the consent host
  `passport.staging.flynet.org`: the auth tenant is a separate host
  from the API gateway, the same split you see in the token's `iss`
  claim.
</Note>

## Step 2 - Callback and token exchange

On the callback URL, your frontend reads the `code` and forwards
it to your backend with the `code_verifier`. Your backend calls
`/oauth/token` with `client_secret`, sets the refresh token in an
HttpOnly cookie, and returns the access token to the frontend.

```js theme={null}
async function handleCallback() {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const state = params.get('state');

  const pending = JSON.parse(sessionStorage.getItem('oauth_pending'));
  if (!pending || pending.state !== state) {
    throw new Error('Invalid state');
  }

  const MAX_FLOW_AGE_MS = 10 * 60 * 1000;
  if (Date.now() - pending.created_at > MAX_FLOW_AGE_MS) {
    sessionStorage.removeItem('oauth_pending');
    throw new Error('OAuth flow expired - restart login');
  }

  sessionStorage.removeItem('oauth_pending');

  const response = await fetch('/api/oauth/exchange', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'include',
    body: JSON.stringify({ code, code_verifier: pending.code_verifier }),
  });

  if (!response.ok) throw new Error('Exchange failed');

  const { access_token, expires_in } = await response.json();
  return { access_token, expires_in };
}
```

```js theme={null}
app.post('/api/oauth/exchange', async (req, res) => {
  const { code, code_verifier } = req.body;

  const response = await fetch(`${AUTH_BASE_URL}/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      code_verifier,
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
      redirect_uri: REDIRECT_URI,
    }),
  });

  if (!response.ok) {
    return res.status(response.status).json(await response.json());
  }

  const { access_token, refresh_token, expires_in, scope } =
    await response.json();

  res.cookie('bb_refresh', refresh_token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/api/oauth/refresh',
    maxAge: 30 * 24 * 60 * 60 * 1000,
  });

  res.json({ access_token, expires_in, scope });
});
```

<Warning>
  **Chef's warning** - Authorization codes are short-lived and single-use.
  If your callback handler consumes the code before your script reaches
  it, exchange returns `invalid_grant`. Use a callback URL that does
  nothing automatic, or exchange the code immediately.
</Warning>

<Note>
  **Tasting note** - On a member's first successful OAuth completion,
  two wallets are automatically minted for them: a MEMBERSHIP wallet
  and a SPENDING wallet.
</Note>

## Step 3 - Make API calls

The access token goes in `Authorization: Bearer ...`. Tokens last
60 minutes.

```js theme={null}
async function callApi(path, options = {}) {
  const response = await fetch(`${API_BASE_URL}${path}`, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${accessToken}`,
    },
  });

  if (response.status === 401) {
    await refreshAccessToken();
    return callApi(path, options);
  }

  return response;
}
```

<Note>
  **Tasting note** - Member routes resolve the subject from your token.
  A call to `/users/me/wallets` returns the authenticated member's
  wallets: there's no member UUID in the path, and you can't read
  another member's data with the token. Each member route is gated by
  scope (`read:wallets`, `read:user_checkins`, `read:profile`); a token
  missing the required scope returns `403 insufficient_scope`. Never
  expose the token client-side beyond the in-memory pattern in
  [Security notes](#security-notes).
</Note>

## Step 4 - Refresh

When the access token expires, your frontend asks the backend for
a new one. The backend reads the refresh token from the cookie,
exchanges it at `/oauth/token`, rotates the cookie, and returns a
fresh access token.

```js theme={null}
app.post('/api/oauth/refresh', async (req, res) => {
  const refreshToken = req.cookies.bb_refresh;
  if (!refreshToken) {
    return res.status(401).json({ error: 'no_refresh_token' });
  }

  const response = await fetch(`${AUTH_BASE_URL}/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
    }),
  });

  if (!response.ok) {
    return res.status(response.status).json(await response.json());
  }

  const { access_token, refresh_token, expires_in } =
    await response.json();

  res.cookie('bb_refresh', refresh_token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/api/oauth/refresh',
    maxAge: 30 * 24 * 60 * 60 * 1000,
  });

  res.json({ access_token, expires_in });
});
```

<Warning>
  **Chef's warning** - Refresh tokens are rotated on every use. Each
  successful refresh returns a new `refresh_token` that replaces the
  previous one. Reusing the old token returns `400 invalid_grant`.
</Warning>

## Identify the authenticated member

`/users/me/*` is the canonical way to read the authenticated
member: the subject is resolved server-side from the token, so you
rarely need the UUID at all. When you do need it (to key your own
state, for example), it lives in the access token's `sub` claim.
Decode it client-side or backend-side.

```ts theme={null}
// Node 18+
function getAuthenticatedUserId(accessToken: string): string {
  const payload = JSON.parse(
    Buffer.from(accessToken.split('.')[1], 'base64url').toString('utf-8'),
  );
  return payload.sub;
}
```

```ts theme={null}
// Browser
function getAuthenticatedUserId(accessToken: string): string {
  const part = accessToken.split('.')[1];
  const padded = part
    .replace(/-/g, '+')
    .replace(/_/g, '/')
    .padEnd(part.length + ((4 - (part.length % 4)) % 4), '=');
  return JSON.parse(atob(padded)).sub;
}
```

Every Flynet access token carries these claims: `sub`, `client_id`,
`scope`, `aud`, `iss`, `iat`, `exp`, `jti`. The `sub` is a Flynet
user UUID; it matches the `id` returned by `/users/me`.

<Tip>
  **From the kitchen** - Decode `sub` once when you receive a fresh
  access token, then cache it alongside the token in memory. Re-decode
  on every refresh: `sub` is stable per member, but explicit is better
  than assumed.
</Tip>

## Security notes

* Keep the access token in memory only. Do not put it in
  `localStorage` or `sessionStorage`.
* Store the refresh token in an HttpOnly cookie scoped to
  `/api/oauth/refresh`.
* Blackbird allowlists your registered redirect URI as an **exact
  match** — scheme, host, port, and path, character for character. A
  trailing slash or a different path is rejected. Register every callback
  URL you'll use (including a local-testing one; see Environment).
* **JWT issuer claim.** Tokens carry `iss: https://api-staging.blackbird.xyz`
  (staging). Note the dash between `api` and `staging`. The API host
  itself uses a dot: `api.staging.blackbird.xyz`. Both are valid; the
  hyphen form is the auth tenant, the dot form is the API gateway. If
  you verify JWT signatures or check `iss`, expect the hyphen form.

## Token TTLs

| Token         | Lifetime                           |
| ------------- | ---------------------------------- |
| Access token  | 60 minutes                         |
| Refresh token | Up to 30 days, rotated on each use |

## Token response shape

```json theme={null}
{
  "access_token": "<jwt>",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "<refresh>",
  "scope": "read:profile read:user_checkins"
}
```

## Common error responses

| Code              | When                                                                                                            |
| ----------------- | --------------------------------------------------------------------------------------------------------------- |
| `invalid_grant`   | Authorization code expired, was consumed, did not match `code_verifier`, or a rotated refresh token was reused. |
| `invalid_client`  | Wrong `client_id` or `client_secret`.                                                                           |
| `invalid_request` | Malformed request, missing parameter, or wrong scope name.                                                      |
