# Gaffer auth.md

Gaffer authenticates coding agents, MCP clients, and CI jobs with bearer credentials: either a key an agent already holds, or one it provisions itself through OAuth. If you are a human looking for the dashboard, go to https://app.gaffer.sh instead.

## Audience

This document is for autonomous or semi-autonomous callers: the `gaffer` CLI, the `@gaffer-sh/mcp` MCP server, CI pipelines uploading test results, and any script calling `https://app.gaffer.sh/api/v1/*` directly.

## Discovery

- **Protected-resource metadata** (RFC 9728): `GET https://app.gaffer.sh/.well-known/oauth-protected-resource`, mirrored at `https://gaffer.sh/.well-known/oauth-protected-resource` for agents that only crawl the marketing domain. Lists `authorization_servers`, `scopes_supported` (`read`, `upload`), and `bearer_methods_supported: ["header"]`.
- **Authorization-server metadata** (RFC 8414): `GET https://app.gaffer.sh/.well-known/oauth-authorization-server`. Lists `token_endpoint`, `device_authorization_endpoint`, and `grant_types_supported`. Not mirrored on gaffer.sh: `issuer` must equal the host that actually issues tokens, which is app.gaffer.sh only.
- **401 responses** from `/api/v1/*` carry `WWW-Authenticate: Bearer resource_metadata="https://app.gaffer.sh/.well-known/oauth-protected-resource"`, so a client that hits an unauthenticated request without reading this file first still finds its way to the metadata.

## Credentials

Two credential types exist, both sent as bearer tokens. Neither carries scopes beyond what its kind implies; each grants everything its holder can already see in the dashboard.

| Prefix | Kind | Scope |
|---|---|---|
| `gaf_...` | User API key | Read access across every organization the authenticated user belongs to. Most `/api/v1` reads made with a user key must also pass a `projectId`, since the key itself is not project-scoped. |
| `gfr_...` | Project token | Scoped to a single project: upload test reports and coverage, and read that project's data. This is the credential CI jobs should use. |

Send either as `Authorization: Bearer <token>` (preferred) or the legacy `X-API-Key: <token>` header, which is still accepted:

```
curl -H "Authorization: Bearer gfr_..." \
  -H "Content-Type: application/json" \
  -d @report.json \
  https://app.gaffer.sh/api/v1/ingest
```

## Flow 1: agent verified (CI, no secret to store)

A GitHub Actions workflow with `permissions: id-token: write` already holds a signed OIDC identity token proving which repository it is running in. Gaffer exchanges that token for a project-scoped upload token, RFC 8693-style, and provisions an organization for the repository on first use. Nobody signs up and nobody pastes a key into CI secrets.

**One-step CLI form** (what `gaffer upload` and `gaffer test` do automatically when no token is configured and the OIDC env vars are present):

```yaml
permissions:
  id-token: write   # required; grants the runner an OIDC token to exchange
jobs:
  test:
    steps:
      - run: gaffer test -- pnpm test
```

**Raw form**, for a non-Rust client implementing the exchange directly:

```
# 1. Fetch the runner's OIDC token (GitHub sets these two env vars when
#    id-token: write is granted)
curl -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
  "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://app.gaffer.sh"

# 2. Exchange it (RFC 8693) for a project-scoped upload token
curl -X POST https://app.gaffer.sh/oauth/token \
  -d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
  -d subject_token=<jwt from step 1> \
  -d subject_token_type=urn:ietf:params:oauth:token-type:jwt
```

Response:

```json
{
  "access_token": "gfr_...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "upload",
  "project_id": "...",
  "organization_id": "...",
  "claimed": false,
  "claim_url": "https://app.gaffer.sh/claim/<organization_id>"
}
```

`claim_url` is present only while the organization is unclaimed; a claimed organization omits it. The issued token is a normal `gfr_` project token with a one-hour lifetime, well past a single CI job's upload step.

Rate limit: 120 exchanges per repository per hour. Error codes on this endpoint: `invalid_request` (missing parameter), `invalid_grant` (subject token failed verification, e.g. expired or wrong audience), `invalid_target` (unsupported `scope` or `audience`), `unsupported_grant_type`, `slow_down` (429, rate limited).

## Flow 2: user claimed (device code)

For a human running the CLI locally, or any client with a browser available, Gaffer implements the RFC 8628 device authorization grant.

```bash
gaffer login                  # project-scoped gfr_ upload token (default)
gaffer login --scope read     # account-scoped gaf_ API key
```

`gaffer init` uses the same flow as part of project setup. Both print a short code and a URL, wait for a human to approve in the browser, then write the resulting credential to config.

**Raw form:**

```
# 1. Request a device code
curl -X POST https://app.gaffer.sh/oauth/device_authorization \
  -d client_id=gaffer-cli -d scope=upload

# -> { "device_code": "...", "user_code": "ABCD-1234",
#      "verification_uri": "https://app.gaffer.sh/cli/setup",
#      "verification_uri_complete": "https://app.gaffer.sh/cli/setup?code=ABCD-1234",
#      "expires_in": 300, "interval": 5 }

# 2. A human opens verification_uri_complete, signs in, and approves.

# 3. Poll for the credential
curl -X POST https://app.gaffer.sh/oauth/token \
  -d grant_type=urn:ietf:params:oauth:grant-type:device_code \
  -d device_code=<device_code from step 1> \
  -d client_id=gaffer-cli
```

Requesting `scope=upload` returns a `gfr_` project token for whichever project the human picks in the browser; any other scope (e.g. `read`) returns a `gaf_` user API key. `gaffer-cli` is the only `client_id` this authorization server issues codes for: a public client with no secret, since the CLI ships on developer machines.

Polling error codes (RFC 8628 §3.5, all returned as HTTP 400): `authorization_pending` (keep polling), `slow_down` (add 5s to the poll interval), `expired_token` (the 5-minute device code window passed; start over), `access_denied` (the human rejected it in the browser).

## Claiming an organization

The token-exchange flow above auto-provisions an organization keyed to the calling repository, with no human involved yet. To take ownership of it:

1. Open `https://app.gaffer.sh/claim/<organizationId>` (the `claim_url` from the exchange response, or from a quota email/notification).
2. Sign in with GitHub.
3. Gaffer checks that your GitHub account has admin access on the linked repository.

On success you become the organization's owner (its only member), unlocking billing, teammate invites, and the dashboard UI for a project that until then existed only through CI uploads. Claiming fails with 403 if you lack repository admin access or haven't linked GitHub, 404 if the organization doesn't exist, and 409 if it's already claimed.

## Quota

A free-tier storage limit hit during upload returns 402 with `error: "quota_exceeded"`. The body always carries `billing_url`, and carries `claim_url` too when the organization is still unclaimed, since claiming is the fastest path to upgrading a repo-keyed org nobody has signed into yet. (Landing alongside this document, GAF-244.)

## MCP

The MCP server reads its credential from the `GAFFER_API_KEY` environment variable (either key type works), or can be pointed at `gaffer login` output. Setup: https://gaffer.sh/docs/mcp/.

## Revoking a credential

A `gaf_` user API key is revoked from `https://app.gaffer.sh/account/api-keys`. A `gfr_` project token is revoked from its project's Settings > Project Tokens page, or by an authenticated `DELETE https://app.gaffer.sh/api/projects/<projectId>/project-tokens/<tokenId>`. An OIDC-exchanged token needs no manual revocation: it expires after one hour and is never reused.

## Rate limits and retention

Both are plan-dependent. See https://gaffer.sh/pricing/ rather than a number that will drift here.

## `agent_auth`

Both Gaffer's authorization-server metadata (above) and this file carry the same `agent_auth` block per the [auth.md convention](https://github.com/workos/auth.md). Gaffer's flows are simpler than that spec's full identity-assertion/claim-ceremony model: there is no separate registration step or revocation event feed, so this block adapts the shape rather than matching it field-for-field. See "Where this differs from the auth.md spec" below.

```json
{
  "skill": "https://gaffer.sh/auth.md",
  "register_uri": "https://app.gaffer.sh/oauth/device_authorization",
  "token_uri": "https://app.gaffer.sh/oauth/token",
  "identity_endpoint": "https://app.gaffer.sh/oauth/token",
  "identity_types_supported": ["identity_assertion", "device_code"],
  "identity_assertion": {
    "assertion_types_supported": ["urn:ietf:params:oauth:token-type:jwt"]
  },
  "credential_types_supported": ["gfr_project_token", "gaf_user_api_key"],
  "claim_uri": "https://app.gaffer.sh/claim/{organization_id}",
  "claim_endpoint": "https://app.gaffer.sh/claim/{organization_id}",
  "revocation_uri": "https://app.gaffer.sh/account/api-keys"
}
```

- `register_uri` is where a client with no credential yet starts: the device authorization endpoint (Flow 2). Flow 1 has no separate registration step; a valid OIDC token is itself sufficient, so it goes straight to `token_uri`.
- `identity_types_supported` names Gaffer's two concrete flows rather than the spec's abstract `anonymous`/`identity_assertion`/`service_auth` taxonomy, since Gaffer has exactly two and neither is anonymous.
- `credential_types_supported` lists the two token shapes a caller ends up holding, not OAuth scopes.
- `revocation_uri` points at the dashboard page for the credential type most callers hold (a user API key); project tokens are revoked per-project as described above.

### Where this differs from the auth.md spec

The published spec models a richer protocol: a resource-agnostic `identity_endpoint` that issues a registration plus a pre-claim identity assertion, a `claim_endpoint` that runs a separate claim ceremony independent of any one grant, and an `events_endpoint` for upstream revocation notifications (SETs). Gaffer implements neither the claim-ceremony indirection nor the events feed, so `events_endpoint`/`events_supported` are omitted outright, and `identity_endpoint`/`claim_endpoint` above are kept only as spec-named aliases of `token_uri`/`claim_uri`, not as separate endpoints with their own semantics. Gaffer's two grants (RFC 8693 token exchange, RFC 8628 device code) each mint a usable credential directly at `token_uri`, and claiming an auto-provisioned organization (`claim_uri` / `claim_endpoint`) is a separate, later action a human takes in the browser, not a step gating the initial exchange. Treat the JSON above as Gaffer's adaptation of the convention, not a byte-for-byte implementation of the WorkOS schema.
