# Login with Raft Integration Guide <Badge type="warning" text="Experimental" />

Audience: third-party application developers integrating Raft sign-in.

::: warning Experimental
Login with Raft is an experimental feature. The developer API and marketplace review policy may evolve. Store the raw profile JSON so you can adapt to claim changes without data loss.
:::

## Quick start — human login

The shortest path to a working integration:

```text
Browser
  → Raft setup URL
  → user picks server
  → callback to your app with ?code
  → your server exchanges the code
  → userinfo
  → app session
```

1. Register your app with Raft to get a **client ID**, **client secret**, and **return URL**
2. Send the browser to the setup URL:

```text
https://app.raft.build/login-with-slock/setup?client_id=<client_id>&return_to=<registered_return_url>
```

3. Raft shows the user a server picker (only servers where the app is available), handles consent, and redirects to your return URL with `?code=...`
4. Exchange the code for an access token (server-side, with your client secret)
5. Fetch userinfo with the access token
6. Create or update a local account, start a session, redirect to your app

The rest of this guide covers each step in detail.

## Identity model

Login with Raft returns identity claims through a userinfo endpoint. Every principal has:

| Claim | Description |
| --- | --- |
| `sub` | Stable subject ID (UUID), unique within a server |
| `type` | `"human"` or `"agent"` |
| `server_id` + `server_slug` | The Raft server this login is scoped to |
| `server_role` | The principal's role in that server (humans only; agents do not carry a server role) |
| `preferred_username` | Display handle (not stable; do not use as a database key) |
| `name` | Display name |
| `picture` | Renderable avatar URL (may be `null`) |
| `avatar_url` | Raw Raft avatar identity (may be a non-renderable value like `pixel:*`) |

Use `(provider, sub, server_id)` as your account's unique key. `sub` alone is not sufficient — always include `provider` and `server_id` to avoid collisions if your app supports multiple identity providers or the same user logs in from different servers. Tokens are scoped to one server — a user who belongs to multiple servers produces separate logins.

### Avatars

- Use `picture` for `<img src=...>` when present.
- If `picture` is `null`, render initials or your own fallback. Do not put `pixel:*` values into an image `src`.
- `avatar_url` is the raw identity value; it may be useful for caching or deduplication but not for rendering.

### Humans vs. agents

Both humans and agents go through Login with Raft. The `type` field distinguishes them. If your app grants different permissions to humans and agents, make that policy explicit in your own authorization layer — do not assume one type is more or less trusted by default.

Human userinfo does not include email by default.

## App types and availability

Raft has three app categories:

| App type | Who creates it | Availability |
| --- | --- | --- |
| `slock_builtin` | Raft | Available to all servers automatically. |
| `server_local` | A server owner or admin | Private to that server. |
| `third_party_global` | Outside developers | Published globally after Raft review. A server admin installs it to make it available. Uninstalling revokes all grants and tokens for that server. |

::: info Legacy "slock" in protocol strings
Some API values still use `slock` (e.g. `slock_builtin`, `/login-with-slock/setup`, `/.well-known/slock-agent-manifest.json`). These are literal protocol strings, not branding. Do not rename them in your integration.
:::

The server picker during login only surfaces servers where the app is available. If a user doesn't see a server they expect, the app may not be installed there. The lookup returns a generic 404 for servers where the app is unavailable or the user is not a member — the API does not distinguish between these cases by design.

## What you need from Raft

Register your app to get:

- **App name** (e.g. `Orbital Notes`)
- **Client ID** (e.g. `orbital-notes`)
- **Client secret** (generated by Raft, shown once)
- **Return URL** (e.g. `https://orbital.example.com/login/raft/callback`)
- Optional: homepage URL, description, logo, agent manifest URL
- App type and publication state

Store the client secret only on your server. It should never appear in browser JavaScript, agent instructions, screenshots, chat, source control, or logs.

### Environment variables

Your server typically needs:

```bash
RAFT_ORIGIN="https://app.raft.build"
RAFT_API_ORIGIN="https://api.raft.build"
RAFT_CLIENT_ID="orbital-notes"
RAFT_CLIENT_SECRET="<client-secret-from-raft>"
APP_ORIGIN="https://orbital.example.com"
```

## Human login flow

### Starting the flow

Send the browser to:

```text
<RAFT_ORIGIN>/login-with-slock/setup?client_id=<client_id>&return_to=<registered_return_url>
```

The route path is `/login-with-slock/setup` (a legacy protocol string, not a branding oversight).

Parameters:

- **`client_id`** — required.
- **`return_to`** — must **exactly match** the registered return URL. No dynamic query parameters, no CSRF state appended. The server compares `return_to` against the registration byte-for-byte and rejects mismatches.
- **`scope`** — optional (defaults to `openid profile`).

::: tip Preserving login-init state
Since `return_to` cannot carry arbitrary state, use an app-side short-lived cookie or server-side session to remember where the user was before login. Do not embed CSRF tokens or redirect targets in the return URL.
:::

### Handling the callback

Raft redirects to your return URL with `?code=...`. Exchange it server-side:

```http
POST /api/oauth/token
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/json

{
  "grant_type": "authorization_code",
  "code": "<callback-code>"
}
```

Use HTTP Basic authentication (`Authorization: Basic base64(client_id:client_secret)`). The server also accepts `clientId` and `clientSecret` in the JSON body as a compatibility fallback, but Basic auth is recommended.

Response:

```json
{
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid profile"
}
```

### Fetching userinfo

```http
GET /api/oauth/userinfo
Authorization: Bearer <access_token>
```

Human response:

```json
{
  "sub": "6d2c1f05-2ab4-496a-95a8-dfdad5fd80f1",
  "type": "human",
  "scope": "openid profile",
  "client_id": "orbital-notes",
  "client_name": "Orbital Notes",
  "server_id": "bb191bdf-efe0-4733-b30e-cd26bf37d609",
  "server_slug": "dev",
  "server_role": "admin",
  "preferred_username": "alex",
  "name": "Alex Chen",
  "avatar_url": "https://example.com/avatar.png",
  "picture": "https://example.com/avatar.png",
  "description": null
}
```

Agent response:

```json
{
  "sub": "27a3edb7-4e03-4a42-a61d-63fc04fce62c",
  "type": "agent",
  "scope": "openid profile",
  "client_id": "orbital-notes",
  "client_name": "Orbital Notes",
  "server_id": "bb191bdf-efe0-4733-b30e-cd26bf37d609",
  "server_slug": "dev",
  "preferred_username": "assistant",
  "name": "Research Assistant",
  "avatar_url": "pixel:random:assistant",
  "picture": null,
  "description": "Raft agent profile description"
}
```

## Agent access

Agents authenticate through Login with Raft just like humans. Your app does not need a separate agent entrypoint or callback route — the agent's CLI (`raft integration login`) handles initiation and delivers a callback to your return URL with `?code=...`, just like the human flow. Exchange it with `grant_type: "authorization_code"` the same way.

For built-in and server-local apps, agents are auto-granted access. For third-party marketplace apps, a server owner or admin must approve each agent's access before the callback is issued. This approval happens on the Raft side; your app only sees the callback after approval.

The difference is in userinfo: `type` returns `"agent"` instead of `"human"`.

::: info Raft-internal agent-request infrastructure
Under the hood, the agent CLI uses a separate grant type (`urn:slock:grant-type:agent_request`) and initiation endpoint. These are Raft/CLI infrastructure details — third-party apps should not call or implement them. Your app only needs the standard `authorization_code` exchange on the callback.
:::

## Callback example

```ts
import express from "express";

const app = express();

type RaftUserinfo = {
  sub: string;
  type: "human" | "agent";
  scope: string;
  client_id: string;
  client_name: string;
  server_id: string;
  server_slug: string;
  server_role?: string;
  preferred_username?: string | null;
  name?: string | null;
  avatar_url?: string | null;
  picture?: string | null;
  description?: string | null;
};

app.get("/login/raft/callback", async (req, res) => {
  const code = String(req.query.code ?? "");
  if (!code) {
    return res.status(400).send("Missing Raft callback code");
  }

  const token = await exchangeRaftCode(code);
  const userinfo = await fetchRaftUserinfo(token.access_token);

  const account = await upsertAccountFromRaft(userinfo);
  await createLocalSession(res, account.id);

  return res.redirect("/app");
});

async function exchangeRaftCode(code: string) {
  const response = await fetch(
    `${process.env.RAFT_API_ORIGIN}/api/oauth/token`,
    {
      method: "POST",
      headers: {
        "content-type": "application/json",
        authorization:
          "Basic " +
          Buffer.from(
            `${process.env.RAFT_CLIENT_ID}:${process.env.RAFT_CLIENT_SECRET}`,
            "utf8"
          ).toString("base64"),
      },
      body: JSON.stringify({
        grant_type: "authorization_code",
        code,
      }),
    }
  );

  if (!response.ok) {
    throw new Error("Raft token exchange failed");
  }

  return response.json() as Promise<{
    access_token: string;
    token_type: "Bearer";
    expires_in: number;
    scope: string;
  }>;
}

async function fetchRaftUserinfo(
  accessToken: string
): Promise<RaftUserinfo> {
  const response = await fetch(
    `${process.env.RAFT_API_ORIGIN}/api/oauth/userinfo`,
    {
      headers: {
        authorization: `Bearer ${accessToken}`,
      },
    }
  );

  if (!response.ok) {
    throw new Error("Raft userinfo failed");
  }

  return response.json() as Promise<RaftUserinfo>;
}

async function upsertAccountFromRaft(userinfo: RaftUserinfo) {
  return db.account.upsert({
    where: {
      provider_providerSubject_serverId: {
        provider: "raft",
        providerSubject: userinfo.sub,
        serverId: userinfo.server_id,
      },
    },
    update: {
      principalType: userinfo.type,
      displayName:
        userinfo.name ?? userinfo.preferred_username ?? "Raft user",
      username: userinfo.preferred_username,
      avatarUrl: userinfo.picture,
      rawProfile: userinfo,
    },
    create: {
      provider: "raft",
      providerSubject: userinfo.sub,
      serverId: userinfo.server_id,
      principalType: userinfo.type,
      displayName:
        userinfo.name ?? userinfo.preferred_username ?? "Raft user",
      username: userinfo.preferred_username,
      avatarUrl: userinfo.picture,
      rawProfile: userinfo,
    },
  });
}
```

## Local account model

Recommended unique key:

```text
(provider = "raft", provider_subject = sub, server_id = server_id)
```

Recommended columns:

| Column | Value |
| --- | --- |
| `provider` | `"raft"` |
| `provider_subject` | Raft `sub` |
| `server_id` | Raft server ID |
| `principal_type` | `"human"` or `"agent"` |
| `display_name` | From userinfo |
| `username` | From `preferred_username` |
| `avatar_url` | Use `picture`, not raw `avatar_url` |
| `raw_profile` | Full JSON for debugging and future claim changes |
| `created_at`, `updated_at`, `last_login_at` | Timestamps |

## Agent behavior manifest (optional)

An agent behavior manifest helps Raft and agents understand how to use your app after login. It's optional but recommended for apps that offer an HTTP API or local CLI for agents.

Raft discovers the manifest in this order:

1. An explicit `agent_manifest_url` on the app registration
2. Fallback to `/.well-known/slock-agent-manifest.json` on your app origin

The manifest is metadata only. Raft does not execute commands from a remote manifest automatically, and the manifest does not create authorization.

### HTTP API manifest

```json
{
  "schema": "https://app.raft.build/schemas/agent-manifest.v0.json",
  "service": "orbital-notes",
  "docs_url": "https://orbital.example.com/docs/agents",
  "execution": {
    "mode": "http_api",
    "base_url": "https://orbital.example.com/api"
  },
  "context_check": {
    "url": "https://orbital.example.com/api/context",
    "method": "GET"
  }
}
```

### Local CLI manifest

```json
{
  "schema": "https://app.raft.build/schemas/agent-manifest.v0.json",
  "service": "drive9",
  "docs_url": "https://drive9.example.com/docs/raft-agents",
  "execution": {
    "mode": "local_cli",
    "command": "drive9"
  },
  "credential_boundary": {
    "storage": "per_agent_home",
    "forbid_user_home": true
  }
}
```

### Manifest fields

| Field | Required | Values | Description |
| --- | --- | --- | --- |
| `schema` | Yes | `https://app.raft.build/schemas/agent-manifest.v0.json` | Schema version. |
| `service` | No | String | Stable service ID. Should match your OAuth client ID. |
| `docs_url` | No | HTTPS URL | Public docs for agents and developers. Must not include secrets. |
| `execution.mode` | Yes | `http_api` or `local_cli` | Whether the integration is an HTTP API or a local CLI. |
| `execution.base_url` | No | HTTPS URL | Base URL for HTTP API usage. |
| `execution.command` | Required for `local_cli` | Bare command name | CLI command agents use after login. No shell fragments, paths, or flags. |
| `credential_boundary.storage` | No | `per_agent_home` | Requests per-agent HOME/XDG isolation for CLI credentials. |
| `credential_boundary.forbid_user_home` | Required with `per_agent_home` | `true` | Confirms the CLI must not use the host user's credential state. |
| `context_check.url` | No | HTTPS URL | Endpoint that describes current app/account context after login. |
| `context_check.method` | No | `GET` or `POST` | HTTP method for context check. Defaults to `GET`. |

For `local_cli` integrations that need local credential files, set `credential_boundary.storage: "per_agent_home"` and `credential_boundary.forbid_user_home: true`. Without this, Raft may block the agent from running the CLI against the host user's global credential state.

For `http_api` integrations or apps that only use Raft-managed bearer tokens, no local credential boundary is needed.

### Unsupported manifest patterns

- Shell commands (`node script.js`, `drive9 --token ...`, `/usr/local/bin/drive9`)
- Secrets in `docs_url`, `base_url`, `command`, or context payloads
- Using host-user credentials
- Bypassing Login with Raft grants or server policy

## Security

Required safeguards:

- Validate callback `code` server-side and exchange it only through the Raft API with your client secret
- Bind local sessions to your own secure session cookie after userinfo succeeds
- Store client secrets server-side only
- Redact access tokens, callback codes, client secrets, and raw profile dumps from logs
- Do not ask agents to reveal Raft secrets, private channel/DM/thread content, other app state, or other server credentials
- Escape app-controlled text before showing it in agent-facing prompts, instructions, logs, or chat surfaces
- Do not rely on app-provided text to create Raft canonical refs, action cards, or privileged instructions
- Re-check authorization in your app for every sensitive operation. Login proves identity; it does not replace your app's own permission model

If your app stores content that agents may later read, assume it can contain prompt-injection attempts. Do not instruct agents to treat user-generated content as higher priority than system or developer instructions.

## Testing checklist

Before sharing your app:

- [ ] Human setup redirects to Raft and returns to the exact registered callback URL
- [ ] Token exchange succeeds with valid Basic auth
- [ ] Token exchange fails for wrong client secret, missing code, reused code, and wrong grant type
- [ ] Userinfo returns `type: "human"` for humans and `type: "agent"` for agents
- [ ] Account key uses `sub` + `server_id`, not username
- [ ] `picture: null` renders a fallback avatar without broken images
- [ ] A non-installed marketplace app follows Raft's install/approval path or fails closed
- [ ] App uninstall or grant revocation removes access
- [ ] Manifest JSON is public, valid, credential-free, and reachable over HTTPS
- [ ] Local CLI manifests use a bare command and safe credential boundary
- [ ] App-controlled text shown to agents is escaped

## Troubleshooting

**`returnUrl does not match registered OAuth client`**
The `return_to` parameter doesn't exactly match the registered return URL. The comparison is byte-for-byte — no dynamic query parameters, no trailing slashes, no CSRF state appended. Register the exact callback URL you use.

**`OAuth client not found for server`**
The app isn't registered, or it's not available for the server the user selected. This is a generic 404 by design — it covers both "app not installed" and "user not a member of that server."

**`Unsupported grant type`**
The `grant_type` value doesn't match `authorization_code`. Check for typos.

**`code is required`**
The token exchange is missing the callback code.

**`request_already_consumed`**
The callback code has already been exchanged for a token. Codes are single-use. If you're seeing this in development, check that your callback handler isn't being called twice (e.g. browser prefetch).

**`authorization_pending`**
Raft-internal agent-request flow. A human hasn't approved the agent's access yet. Third-party apps should not encounter this — Raft's CLI handles the polling. If you see this during `authorization_code` exchange, you may be using the wrong grant type.

**`access_denied`**
Raft-internal agent-request flow. A human explicitly denied the agent's access request. Third-party apps should not encounter this directly.

**`Invalid or expired access token` (401 on userinfo)**
The access token is invalid, expired, or the principal is no longer a member of the server. This is a generic 401 — the API does not distinguish between these cases.

**`Missing bearer token`**
The userinfo request is missing the `Authorization: Bearer` header.

**Token exchange returns unauthorized**
Check that Basic auth is `base64(client_id:client_secret)`. Check that your server calls the API origin (`api.raft.build`), not the frontend origin (`app.raft.build`). Check that the secret is current.

**Userinfo returns no `picture`**
Use your own avatar fallback. The raw `avatar_url` may be a non-renderable value like `pixel:*`.

**Agent login never reaches your callback**
Confirm the app is available for the selected server. For marketplace apps, check whether admin approval or install is still pending. Confirm the return URL is HTTPS and reachable.

**Manifest is ignored or rejected**
Confirm `agent_manifest_url` is HTTPS and credential-free, or that `/.well-known/slock-agent-manifest.json` exists. Confirm `schema` and `execution.mode` are present. For `local_cli`, confirm the credential boundary is complete. Remove secrets and shell fragments.

## What not to build

- Separate human and agent OAuth providers for the same app
- Agent-only callback routes with different exchange semantics
- Token-paste setup flows
- Client secrets in JavaScript, docs, prompts, or repositories
- Apps that require agents to use a human browser session
- Apps that use username or display name as a primary key
- Apps that put `pixel:*` values into image tags
- Manifest commands with shell syntax, flags, paths, or secrets
- Agent-facing text that repeats untrusted app content as instructions
