openapi: 3.1.0
info:
  title: AuthHub API
  version: "1.0.0"
  description: |
    一站式 OAuth 登录中枢。下游站点通过**签名跳转** `/authorize` 发起登录,
    回调拿到 `code` 后由**服务端** `POST /token` 换取用户身份。

    ## 签名(前端信道,无密钥)
    `/authorize` 在浏览器发生,不带密钥。下游服务端用共享的 `SITE_KEY` 计算:

    ```
    sign = HMAC_SHA256( SITE_KEY , redirect_uri + "\n" + ts )   # 小写十六进制
    ```

    `ts` 为 unix 秒,有效期 10 分钟。密钥只在后端 `/token` 请求出现,绝不进 URL。

    ## 典型流程
    1. `GET /authorize?redirect_uri&ts&sign[&provider][&state]` → 用户去平台授权
    2. 回调到 `redirect_uri?code=...&state=...`
    3. 下游服务端 `POST /token { site_key, code }` → 得到 `{ user }`
  contact:
    name: AuthHub
servers:
  - url: https://auth.ikuai.cc
    description: 生产域名
  - url: https://authhub-6f4.pages.dev
    description: Pages 默认域名
tags:
  - name: OAuth
    description: 登录流程端点
  - name: Meta
    description: 元信息
paths:
  /api/providers:
    get:
      tags: [Meta]
      summary: 列出已启用的登录平台
      description: 返回当前通过环境变量启用的平台(登录页据此渲染按钮)。
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Provider"
              example:
                - { id: github, name: GitHub, brand: "#24292f" }
                - { id: apple, name: Apple, brand: "#000000" }
  /authorize:
    get:
      tags: [OAuth]
      summary: 发起登录(浏览器跳转,签名鉴权)
      description: |
        由下游站点把用户**浏览器**重定向到此端点(不带密钥,带签名)。
        带 `provider` 直达该平台;不带则显示托管登录选择页。
      parameters:
        - name: redirect_uri
          in: query
          required: true
          schema: { type: string, format: uri }
          description: 下游回调地址;登录完成后带 `code`、`state` 重定向回这里。
        - name: ts
          in: query
          required: true
          schema: { type: integer }
          description: unix 秒(10 分钟有效窗口)。
        - name: sign
          in: query
          required: true
          schema: { type: string }
          description: "HMAC_SHA256(SITE_KEY, redirect_uri + \"\\n\" + ts) 的小写十六进制。"
        - name: state
          in: query
          required: false
          schema: { type: string }
          description: 下游自有的防 CSRF 随机值,原样回传。
        - name: provider
          in: query
          required: false
          schema:
            type: string
            enum: [github, google, microsoft, discord, apple, qq]
          description: 指定则直达该平台;省略则显示托管选择页。
        - name: providers
          in: query
          required: false
          schema: { type: string }
          description: 托管选择页只显示这些平台(逗号分隔,按此顺序)。
      responses:
        "302":
          description: |
            重定向到上游平台或托管登录页。登录完成后最终重定向回
            `redirect_uri?code=<一次性签名code>&state=<原样>`。
        "400":
          description: 参数无效(返回 HTML 错误页)。
        "401":
          description: 签名无效或已过期(返回 HTML 错误页)。
  /token:
    post:
      tags: [OAuth]
      summary: 用 code 换取用户(服务端,带密钥)
      description: |
        下游**服务端**调用。`site_key` 为共享密钥,只在此后端请求出现。
        支持 `application/json` 或 `application/x-www-form-urlencoded`。
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/TokenRequest" }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/TokenRequest" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenResponse" }
        "400":
          description: code 无效或过期
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: invalid_or_expired_code }
        "401":
          description: site_key 无效
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
              example: { error: invalid_site_key }
  /callback/{provider}:
    get:
      tags: [OAuth]
      summary: 上游回调(内部)
      description: 由上游平台重定向调用(Apple 用 POST `form_post`)。一般无需自行调用。
      parameters:
        - name: provider
          in: path
          required: true
          schema: { type: string }
      responses:
        "302":
          description: 换取身份后重定向回下游 `redirect_uri?code=...`
        "400":
          description: state 无效或已过期
        "502":
          description: 与上游平台交换身份失败
components:
  schemas:
    Provider:
      type: object
      properties:
        id: { type: string, example: github }
        name: { type: string, example: GitHub }
        brand: { type: string, example: "#24292f" }
    TokenRequest:
      type: object
      required: [site_key, code]
      properties:
        site_key: { type: string, description: 共享站点密钥(与 Hub 同一个) }
        code: { type: string, description: /authorize 回调带回的一次性 code }
    User:
      type: object
      properties:
        sub: { type: string, example: "github:12345", description: "全局唯一稳定 ID,格式 provider:平台用户ID" }
        email: { type: string, example: "user@example.com" }
        name: { type: string, example: "Octocat" }
        picture: { type: string, format: uri }
        provider: { type: string, example: github }
    TokenResponse:
      type: object
      properties:
        id_token: { type: string, description: "HS256 JWT,可用 SITE_KEY 验签" }
        token_type: { type: string, example: Bearer }
        expires_in: { type: integer, example: 3600 }
        user: { $ref: "#/components/schemas/User" }
    Error:
      type: object
      properties:
        error: { type: string, example: invalid_site_key }
