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

# OAuth 集成

> 使用 OAuth 2.0 Authorization Code 流程集成袋袋用户身份和计费

袋袋实现了标准的 OAuth 2.0 Authorization Code 流程，允许你的 App 代表用户调用袋袋 API。通过 OAuth 授权，积分将从用户的账户中扣除。

## 流程概览

```
┌──────┐     ┌─────────┐     ┌──────────┐     ┌──────────┐
│ 用户 │────→│ 你的 App │────→│ 袋袋授权│────→│ 用户授权 │
└──────┘     └─────────┘     │   页面   │     │   同意   │
                             └──────────┘     └────┬─────┘
                                                   │
     ┌─────────┐     ┌──────────┐     ┌────────────┘
     │ 调用 API │←───│ 换取Token│←───│ 授权码回调
     └─────────┘     └──────────┘     └────────────
```

## 第一步：注册 App

在袋袋 Studio 中创建你的 App：

1. 进入 [app.profy.cn/studio](https://app.profy.cn/studio)
2. 创建一个新的 App
3. 在 App 设置中配置 OAuth 相关信息：
   * **Client ID**：自动生成的 App UUID
   * **Client Secret**：在设置中生成（SHA-256 哈希存储，仅展示一次）
   * **Redirect URIs**：注册你的回调地址（支持多个）

<Warning>
  Client Secret 仅在生成时展示一次，请妥善保存。Redirect URI 必须严格匹配注册列表中的地址，否则授权会被拒绝。
</Warning>

## 第二步：引导用户授权

将用户重定向到袋袋授权页面：

```
GET https://api.profy.cn/oauth/authorize
  ?client_id={your_app_uuid}
  &redirect_uri={your_redirect_uri}
  &response_type=code
  &scope=events:write
  &state={random_state}
```

| 参数              | 必填 | 说明              |
| --------------- | -- | --------------- |
| `client_id`     | ✅  | 你的 App UUID     |
| `redirect_uri`  | ✅  | 注册过的回调地址        |
| `response_type` | ✅  | 必须为 `code`      |
| `scope`         | 否  | 请求的权限范围（空格分隔）   |
| `state`         | 推荐 | 随机字符串，防 CSRF 攻击 |

用户会看到袋袋的授权页面，展示你的 App 名称和请求的权限。用户同意后，袋袋会将用户重定向回你的回调地址。

## 第三步：接收授权码

用户授权后，袋袋将用户重定向到你的 `redirect_uri`：

```
https://your-app.com/callback?code={authorization_code}&state={your_state}
```

<Warning>
  授权码（Authorization Code）有效期为 **5 分钟**，且只能使用一次。请在收到后立即换取 Token。
</Warning>

## 第四步：换取 Token

使用授权码换取 Access Token 和 Refresh Token：

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

  async def exchange_token(code: str, redirect_uri: str):
      async with httpx.AsyncClient() as client:
          resp = await client.post(
              "https://api.profy.cn/oauth/token",
              json={
                  "grant_type": "authorization_code",
                  "code": code,
                  "redirect_uri": redirect_uri,
                  "client_id": "your-app-uuid",
                  "client_secret": "your-client-secret",
              },
          )
          return resp.json()

  # 返回示例:
  # {
  #   "access_token": "eyJhbGciOiJSUzI1NiIs...",
  #   "token_type": "Bearer",
  #   "expires_in": 3600,
  #   "refresh_token": "a1b2c3d4e5f6...",
  #   "scope": "events:write"
  # }
  ```

  ```typescript TypeScript theme={null}
  async function exchangeToken(code: string, redirectUri: string) {
    const resp = await fetch("https://api.profy.cn/oauth/token", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        grant_type: "authorization_code",
        code,
        redirect_uri: redirectUri,
        client_id: "your-app-uuid",
        client_secret: "your-client-secret",
      }),
    });
    return resp.json();
  }
  ```

  ```bash curl theme={null}
  curl -X POST https://api.profy.cn/oauth/token \
    -H "Content-Type: application/json" \
    -d '{
      "grant_type": "authorization_code",
      "code": "your-auth-code",
      "redirect_uri": "https://your-app.com/callback",
      "client_id": "your-app-uuid",
      "client_secret": "your-client-secret"
    }'
  ```
</CodeGroup>

Token 端点也支持 `application/x-www-form-urlencoded` 格式。

## Token 生命周期

| Token 类型          | 有效期  | 用途           |
| ----------------- | ---- | ------------ |
| **Access Token**  | 1 小时 | API 调用认证     |
| **Refresh Token** | 90 天 | 换取新的 Token 对 |

### Access Token

* JWT 格式（RS256 签名）
* 包含 `sub`（用户 ID）、`aud`（App UUID）、`scope`（权限范围）
* 过期后使用 Refresh Token 获取新 Token

### Refresh Token

* 不透明字符串格式
* **旋转机制**：每次使用后旧 Token 失效，返回新的 Refresh Token
* 使用过的 Refresh Token 不能再次使用

## 第五步：刷新 Token

当 Access Token 过期时，使用 Refresh Token 获取新的 Token 对：

<CodeGroup>
  ```python Python theme={null}
  async def refresh_tokens(refresh_token: str):
      async with httpx.AsyncClient() as client:
          resp = await client.post(
              "https://api.profy.cn/oauth/token",
              json={
                  "grant_type": "refresh_token",
                  "refresh_token": refresh_token,
                  "client_id": "your-app-uuid",
                  "client_secret": "your-client-secret",
              },
          )
          data = resp.json()
          # 重要：保存新的 refresh_token，旧的已失效
          new_access_token = data["access_token"]
          new_refresh_token = data["refresh_token"]
          return new_access_token, new_refresh_token
  ```

  ```typescript TypeScript theme={null}
  async function refreshTokens(refreshToken: string) {
    const resp = await fetch("https://api.profy.cn/oauth/token", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        grant_type: "refresh_token",
        refresh_token: refreshToken,
        client_id: "your-app-uuid",
        client_secret: "your-client-secret",
      }),
    });
    const data = await resp.json();
    // 重要：保存新的 refresh_token，旧的已失效
    return { accessToken: data.access_token, refreshToken: data.refresh_token };
  }
  ```

  ```bash curl theme={null}
  curl -X POST https://api.profy.cn/oauth/token \
    -H "Content-Type: application/json" \
    -d '{
      "grant_type": "refresh_token",
      "refresh_token": "your-refresh-token",
      "client_id": "your-app-uuid",
      "client_secret": "your-client-secret"
    }'
  ```
</CodeGroup>

<Warning>
  Refresh Token 使用旋转机制。每次刷新后，你必须保存**新的** Refresh Token。旧的 Refresh Token 在使用后立即失效。
</Warning>

## 吊销 Token

当用户注销或你需要撤回访问时，调用吊销端点：

```bash theme={null}
curl -X POST https://api.profy.cn/oauth/revoke \
  -H "Content-Type: application/json" \
  -d '{
    "token": "your-refresh-token",
    "client_id": "your-app-uuid",
    "client_secret": "your-client-secret"
  }'
```

吊销端点遵循 RFC 7009 规范——即使 Token 已失效，也会返回 `200 OK`。

## 可用 Scopes

| Scope          | 说明                                                                     |
| -------------- | ---------------------------------------------------------------------- |
| `events:write` | 允许调用 `/v1/agents/run`、`/v1/chat/completions`、`/v1/events`、`/v1/models` |

## 安全最佳实践

<AccordionGroup>
  <Accordion title="使用 state 参数防止 CSRF">
    在发起授权请求时生成随机 state 值，回调时验证 state 是否匹配。
  </Accordion>

  <Accordion title="安全存储 Token">
    * 服务端：使用加密存储（数据库加密列、密钥管理服务）
    * 客户端：使用 httpOnly cookie 或安全存储
    * 不要在 URL、日志或前端 localStorage 中存放 Refresh Token
  </Accordion>

  <Accordion title="严格匹配 Redirect URI">
    袋袋会严格校验 redirect\_uri 是否在注册列表中。请确保注册的 URI 包含完整路径，避免开放重定向漏洞。
  </Accordion>

  <Accordion title="安全存储 Client Secret">
    Client Secret 应仅在服务端使用，不要暴露在前端代码中。使用环境变量或密钥管理服务存储。
  </Accordion>
</AccordionGroup>

## 错误处理

| 错误                          | HTTP 状态码 | 说明                                                    |
| --------------------------- | -------- | ----------------------------------------------------- |
| `invalid_client`            | 401      | Client ID 不存在或 Client Secret 错误                       |
| `invalid_grant`             | 400      | 授权码过期/无效，或 Refresh Token 已使用/过期                       |
| `invalid_request`           | 400      | 缺少必填参数或 redirect\_uri 未注册                             |
| `unsupported_grant_type`    | 400      | grant\_type 不是 `authorization_code` 或 `refresh_token` |
| `unsupported_response_type` | 400      | response\_type 不是 `code`                              |

## 下一步

<CardGroup cols={2}>
  <Card title="Events API" icon="bolt" href="/zh/developers/api/events">
    通过 OAuth Token 上报自定义计费事件
  </Card>

  <Card title="App 开发指南" icon="grid-2" href="/zh/developers/app-development">
    完整的 App 开发流程
  </Card>
</CardGroup>
