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

# OAuth2

> Athlete-delegated access with PKCE authorization code flow

# OAuth2

OAuth2 lets athletes connect their existing Saturday accounts to your platform. Instead of creating new partner-scoped athletes, you can request access to an athlete's real Saturday profile — with their explicit consent.

## When to use OAuth2

| Scenario                                                    | Auth method                               |
| ----------------------------------------------------------- | ----------------------------------------- |
| You manage athlete profiles in your platform                | **API Key** — create athletes via the API |
| Athletes already have Saturday accounts and want to connect | **OAuth2** — athlete grants you access    |
| You want access to an athlete's full Saturday history       | **OAuth2** — requires athlete consent     |

Most partners start with API keys. Add OAuth2 when athletes tell you they already have Saturday accounts.

## The flow

```
Your app → Saturday authorize → Athlete logs in → Consent → Code → Token exchange → API access
```

<Steps>
  <Step title="Redirect to authorize">
    Your app redirects the athlete to Saturday's authorization page
  </Step>

  <Step title="Athlete authenticates">
    Athlete logs into their Saturday account (or creates one)
  </Step>

  <Step title="Athlete consents">
    Athlete sees what scopes you're requesting and clicks "Allow"
  </Step>

  <Step title="Code redirect">
    Saturday redirects back to your app with an authorization code
  </Step>

  <Step title="Token exchange">
    Your server exchanges the code for access and refresh tokens
  </Step>

  <Step title="API calls">
    Use the access token as a Bearer token for API requests
  </Step>
</Steps>

## Step 1: Generate PKCE challenge

Saturday requires PKCE (Proof Key for Code Exchange) for all OAuth2 flows. Generate a code verifier and challenge:

<CodeGroup>
  ```python Python theme={null}
  import hashlib
  import base64
  import secrets

  # Generate code verifier (43-128 characters)
  code_verifier = secrets.token_urlsafe(32)

  # Generate code challenge (SHA-256 hash of verifier)
  digest = hashlib.sha256(code_verifier.encode("utf-8")).digest()
  code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("utf-8")

  # Store code_verifier in your session — you need it for token exchange
  print(f"Verifier: {code_verifier}")
  print(f"Challenge: {code_challenge}")
  ```

  ```typescript TypeScript theme={null}
  import crypto from "crypto";

  // Generate code verifier (43-128 characters)
  const codeVerifier = crypto.randomBytes(32).toString("base64url");

  // Generate code challenge (SHA-256 hash of verifier)
  const codeChallenge = crypto
    .createHash("sha256")
    .update(codeVerifier)
    .digest("base64url");

  // Store codeVerifier in your session — you need it for token exchange
  console.log(`Verifier: ${codeVerifier}`);
  console.log(`Challenge: ${codeChallenge}`);
  ```
</CodeGroup>

## Step 2: Redirect to authorize

Build the authorization URL and redirect the athlete:

```
https://api.saturday.fit/v1/oauth/authorize?
  client_id=your_partner_id
  &redirect_uri=https://your-app.com/callback
  &response_type=code
  &scope=athlete:read activity:read nutrition:read
  &state=random_csrf_token
  &code_challenge=YOUR_CODE_CHALLENGE
  &code_challenge_method=S256
```

| Parameter               | Required | Description                                                                      |
| ----------------------- | -------- | -------------------------------------------------------------------------------- |
| `client_id`             | Yes      | Your partner ID, or your [Client ID Metadata Document URL](#client-registration) |
| `redirect_uri`          | Yes      | Must match a registered redirect URI                                             |
| `response_type`         | Yes      | Always `code`                                                                    |
| `scope`                 | Yes      | Space-separated list of requested scopes                                         |
| `state`                 | Yes      | Random string for CSRF protection                                                |
| `code_challenge`        | Yes      | PKCE challenge                                                                   |
| `code_challenge_method` | Yes      | Always `S256`                                                                    |

## Step 3: Handle the callback

After the athlete consents, Saturday redirects to your `redirect_uri` with a code:

```
https://your-app.com/callback?code=AUTH_CODE_HERE&state=random_csrf_token
```

**Always verify the `state` parameter matches what you sent.** This prevents CSRF attacks.

If the athlete denies access:

```
https://your-app.com/callback?error=access_denied&error_description=The+user+denied+access&state=random_csrf_token
```

## Step 4: Exchange code for tokens

<CodeGroup>
  ```python Python theme={null}
  import requests

  response = requests.post(
      "https://api.saturday.fit/v1/oauth/token",
      data={
          "grant_type": "authorization_code",
          "code": auth_code,
          "redirect_uri": "https://your-app.com/callback",
          "client_id": "your_partner_id",
          "client_secret": "your_client_secret",
          "code_verifier": code_verifier,
      },
  )

  tokens = response.json()
  access_token = tokens["access_token"]      # JWT, 1-hour expiry
  refresh_token = tokens["refresh_token"]    # Opaque, 90-day expiry
  expires_in = tokens["expires_in"]          # Seconds until access token expires
  ```

  ```typescript TypeScript theme={null}
  const response = await fetch("https://api.saturday.fit/v1/oauth/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: authCode,
      redirect_uri: "https://your-app.com/callback",
      client_id: "your_partner_id",
      client_secret: "your_client_secret",
      code_verifier: codeVerifier,
    }),
  });

  const tokens = await response.json();
  const accessToken = tokens.access_token;   // JWT, 1-hour expiry
  const refreshToken = tokens.refresh_token; // Opaque, 90-day expiry
  ```
</CodeGroup>

## Step 5: Use the access token

The access token is a JWT that contains the partner ID, athlete UID, and granted scopes. Use it as a Bearer token:

```bash theme={null}
curl -H "Authorization: Bearer ACCESS_TOKEN_JWT" \
  https://api.saturday.fit/v1/nutrition/calculate \
  -X POST -H "Content-Type: application/json" \
  -d '{"activity_type": "bike", "duration_min": 120, "intensity_level": 3}'
```

The API automatically scopes requests to the athlete who granted consent. You don't need to pass an `athlete_id` — it's in the JWT.

## Step 6: Refresh expired tokens

Access tokens expire after 1 hour. Use the refresh token to get a new pair:

<CodeGroup>
  ```python Python theme={null}
  response = requests.post(
      "https://api.saturday.fit/v1/oauth/token",
      data={
          "grant_type": "refresh_token",
          "refresh_token": refresh_token,
          "client_id": "your_partner_id",
      },
  )

  new_tokens = response.json()
  # Old refresh token is now invalid — save the new one
  access_token = new_tokens["access_token"]
  refresh_token = new_tokens["refresh_token"]
  ```

  ```typescript TypeScript theme={null}
  const response = await fetch("https://api.saturday.fit/v1/oauth/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: refreshToken,
      client_id: "your_partner_id",
    }),
  });

  const newTokens = await response.json();
  // Old refresh token is now invalid — save the new one
  ```
</CodeGroup>

<Warning>
  **Refresh tokens are single-use.** Each refresh rotates both the access and refresh token. The old refresh token is immediately invalidated. Always store the new refresh token.
</Warning>

## Available scopes

| Scope            | Access granted                      |
| ---------------- | ----------------------------------- |
| `athlete:read`   | View athlete profile and settings   |
| `athlete:write`  | Modify athlete profile and settings |
| `activity:read`  | View activities and prescriptions   |
| `activity:write` | Create, update, delete activities   |
| `nutrition:read` | Calculate nutrition prescriptions   |
| `ai:chat`        | AI Coach conversations              |

### Coach scopes

A coach (Business tier or higher) can additionally request coach scopes. These unlock the [Coach API](/guides/coach-api) and the coach tools in the [Claude connector](/guides/coach-connector). A coach's OAuth token also carries the athlete-self facet — the same token reads the coach's own athlete data on `/v1/athletes/*` and their roster on `/v1/coach/*`; the route decides which applies.

| Scope            | Access granted                                                           |
| ---------------- | ------------------------------------------------------------------------ |
| `coach:roster`   | View the coach's athlete roster and per-athlete needs-attention markers  |
| `coach:reports`  | Read roster athletes' fueling rollups, AI reports, and the roster digest |
| `coach:alerts`   | Create and manage the coach's **own** alert rules and report settings    |
| `coach:webhooks` | Register and manage the coach's **own** concern webhooks                 |

When coach scopes are requested, the consent screen discloses that the app will be able to read the coach's athletes' fueling data and manage the coach's alert settings.

Request only the scopes you need. Athletes are more likely to consent when the request is minimal.

## Client registration

Saturday supports two registration mechanisms:

| Mechanism                               | When to use                                                                              | How                                                                                   |
| --------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| **Client ID Metadata Documents (CIMD)** | MCP hosts and any client that controls an HTTPS origin — **no registration step at all** | Use the URL of your hosted metadata document as your `client_id`                      |
| **Pre-registered clients**              | Server-to-server partners with a standing relationship                                   | Contact [api-support@saturday.fit](mailto:api-support@saturday.fit) for a `client_id` |

### Client ID Metadata Documents

CIMD is the [MCP 2025-11-25 spec's preferred registration mechanism](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) ([draft-ietf-oauth-client-id-metadata-document](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/)). Instead of registering, host a JSON document describing your client at a stable HTTPS URL on a domain you control, and pass that URL as your `client_id`:

```json theme={null}
{
  "client_id": "https://your-app.com/oauth/client-metadata.json",
  "client_name": "Your App",
  "redirect_uris": ["https://your-app.com/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}
```

Saturday fetches, validates, and caches the document when it first sees your `client_id`. Requirements:

* The URL must be `https`, contain a path, and carry no fragment or userinfo.
* The document's `client_id` field must **exactly match** the document URL.
* `redirect_uris` must be `https` URLs or loopback (`http://localhost` / `http://127.0.0.1`) URIs. Loopback URIs match on any port (RFC 8252).
* CIMD clients are **public clients**: `token_endpoint_auth_method` must be `none` (or absent), and PKCE is mandatory. Confidential CIMD clients (`private_key_jwt`) are not supported.
* Documents are cached per their `Cache-Control` headers (clamped between 5 minutes and 24 hours), so metadata changes propagate within that window.

Saturday advertises support via `"client_id_metadata_document_supported": true` in its Authorization Server Metadata — spec-compliant MCP clients (including Claude) select CIMD automatically.

## Discovery (remote MCP connectors)

For the [Claude connector](/guides/coach-connector), clients auto-discover the authorization server — no manual configuration. Saturday serves the standard metadata documents (derived per-environment from the request host):

| Document                      | Path                                            | Spec     |
| ----------------------------- | ----------------------------------------------- | -------- |
| Protected Resource Metadata   | `/.well-known/oauth-protected-resource[/mcp]`   | RFC 9728 |
| Authorization Server Metadata | `/.well-known/oauth-authorization-server[/mcp]` | RFC 8414 |

An unauthenticated request to `/mcp` returns `401` with a `WWW-Authenticate: Bearer resource_metadata="…"` header pointing at the discovery document, which bootstraps the OAuth handshake. The connector is a **public client** (PKCE, no client secret) and may use **loopback redirects** (`http://127.0.0.1:<any-port>`, RFC 8252) for local CLIs such as Claude Code.

## Resource indicators (RFC 8707)

Saturday implements [RFC 8707 Resource Indicators](https://www.rfc-editor.org/rfc/rfc8707.html) — token audience binding, as required by the MCP authorization spec. A client names the resource it intends to use the token with via the `resource` parameter on the authorize **and** token requests, using the **canonical MCP server URI**:

```
resource=https://api.saturday.fit/mcp
```

When a `resource` is supplied, Saturday binds it into the access token's `aud` claim, and the MCP endpoint rejects any token whose audience was issued for a different resource. A token minted for Saturday can only be used against Saturday — it cannot be replayed elsewhere.

| Field      | Required    | Description                                                                                                                                      |
| ---------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `resource` | Recommended | Canonical URI of the MCP server (`https://api.saturday.fit/mcp`). The MCP 2025-11-25 spec has clients always send it; older clients may omit it. |

A `resource` that is malformed (not an absolute URI, or carries a fragment) or names a resource this server does not serve is rejected with `invalid_target`. On refresh, a `resource` may match the originally-granted resource but cannot retarget the token.

## Error scenarios

| Scenario                              | Response                               |
| ------------------------------------- | -------------------------------------- |
| Invalid `client_id`                   | 400 + "Unknown client\_id"             |
| Mismatched `redirect_uri`             | 400 + "redirect\_uri does not match"   |
| Missing PKCE                          | 400 + "PKCE is required"               |
| Expired auth code (>10 min)           | 400 + "Authorization code has expired" |
| Reused auth code                      | 400 + all tokens revoked               |
| Wrong `code_verifier`                 | 400 + "PKCE verification failed"       |
| Invalid / foreign `resource`          | 400 + `invalid_target`                 |
| Expired access token                  | 401 + "access token has expired"       |
| Token used against the wrong resource | 401 + "not issued for this resource"   |
| Revoked refresh token                 | 400 + "refresh token has been revoked" |

## Revoking tokens

Athletes can revoke access from their Saturday account settings. Partners can programmatically revoke:

```bash theme={null}
POST /v1/oauth/token/revoke
Content-Type: application/x-www-form-urlencoded

token=REFRESH_TOKEN&client_id=your_partner_id
```
