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

# Vendor 客户端中的 OAuth

> C++ vendor 客户端如何处理 OAuth 2.0 授权、PKCE、webhook 与撤销。

## 概览

大多数健康数据源以 **OAuth 2.0** 授权。在 Mirobody 中，这在每个 C++ `vendor::Vendor` 内经三个契约操作处理 —— `authorize_url`、`handle_webhook`、`revoke` —— 另外，对于浏览器驱动的 SMART-on-FHIR EHR，还有一个独立的有状态连接服务。

<Info>
  代码库中存在两种 OAuth 角色，别把它们混淆：

  * **`src/oauth/`** —— Mirobody 作为一个 OAuth 2.0 **授权服务器**（它向 API 客户端签发 token；OIDC 发现 + PKCE）。
  * **Vendor 客户端** —— Mirobody 作为某个健康源（Fitbit、某个 EHR 等）*的* OAuth **客户端**。本页讲的是客户端这一侧。
</Info>

## OAuth 2.0 —— `authorize_url` 步骤

`authorize_url` 构建你重定向用户去的同意 URL。它是**纯字符串构建** —— 无网络调用。Fitbit（`device/fitbit.cpp`）是参考：保密型服务端客户端在 token 步骤用其 secret 认证，因此这里**不使用 PKCE**。

```cpp theme={null}
std::string authorize_url(const std::string& redirect_uri, const std::string& state,
                          const std::string& /*user_id*/, const std::string& /*provider*/) override {
    if (config().client_id.empty()) {
        throw VendorError(info_.id + ": authorize_url requires an OAuth client_id — set "
                          "MIROBODY_VENDOR_FITBIT_CLIENT_ID");
    }
    std::string url = "https://www.fitbit.com/oauth2/authorize"
                      "?response_type=code&client_id=" + url_encode(config().client_id) +
                      "&scope=" + url_encode("activity heartrate sleep weight profile");
    if (!redirect_uri.empty()) url += "&redirect_uri=" + url_encode(redirect_uri);
    if (!state.empty())        url += "&state=" + url_encode(state);
    return url;
}
```

* `state` 是你的关联 / CSRF 值，会在回调时原样回传。
* **token 交换**（在该 vendor 的 `/oauth2/token` 用 `code` 换 access token）在带外完成；所得的 access token 就是你存为 `MIROBODY_VENDOR_<ID>_API_KEY` 并在 `fetch` 时传入的那个。

## 撤销

`revoke` 撤销授权。Fitbit 把 token POST 到 `/oauth2/revoke`，以保密客户端身份认证（HTTP Basic `client_id:client_secret`）：

```cpp theme={null}
req.url     = base_url() + "/oauth2/revoke";
req.body    = "token=" + url_encode(config().api_key);
req.headers = { "Authorization: Basic " +
                storage::base64_encode(config().client_id + ":" + config().client_secret) };
```

Garmin 的 `revoke` 则调用其有文档的账号断连 hook（`DELETE /wellness-api/rest/user/registration`，Bearer）。Withings 没有公开的 token 撤销端点，所以它的 `revoke` 是一个诚实的桩。

## 验证 webhook

当某个源对其 webhook 签名时，在信任请求体之前先验证它。Fitbit 用 `X-Fitbit-Signature = base64(HMAC-SHA1(raw_body))`（以 `client_secret + "&"` 为密钥）签名；客户端重新计算并以恒定时间比较：

```cpp theme={null}
const std::string key = config().client_secret + "&";
std::array<unsigned char, 20> mac = storage::hmac_sha1(key, body);
if (!ct_equal(storage::base64_encode(mac.data(), mac.size()), sig)) {
    throw VendorError(info_.id + ": webhook signature verification failed (X-Fitbit-Signature)");
}
```

若签名方案**不公开**（如 Garmin 的未签名推送），`handle_webhook` 保留为桩 —— Mirobody 不会捏造一个验证方案。

## PKCE 与有状态流程（SMART on FHIR、Garmin）

某些流程需要 **PKCE**：在 authorize 时铸造的 `code_verifier` 必须被持久化，并在 token 交换时重放。裸 `authorize_url` 无处存放该 verifier，因此 PKCE 同意住在一个**有状态的连接层**里，而不在 vendor 的 `authorize_url` 中。这正是 Garmin 的 `authorize_url` 是桩的原因。

已发布的示例是 **SMART-on-FHIR EHR 连接服务**（`src/health/ehr_connect.*`），其中 Mirobody 是某医院 EHR 的 OAuth 客户端：

<Steps>
  <Step title="Authorize">
    `POST /health/ehr/authorize {fhir_base_url}` 运行 **SMART 发现**（`{base}/.well-known/smart-configuration`），生成 PKCE verifier + `state`，**缓存**它们，并返回供浏览器使用的该租户 `authorize_url`。
  </Step>

  <Step title="Callback">
    `GET /health/ehr/callback?code=&state=` —— 无 bearer；用户由一次性的 `state` 恢复。缓存的 verifier 被重放，用于在该租户的 token 端点交换 `code`，access token 按用户缓存。
  </Step>

  <Step title="Sync">
    `POST /health/ehr/sync` 使用缓存的 token，经 `ehr` 客户端获取 `Observation` 并持久化它们。
  </Step>
</Steps>

由 `SMART_FHIR_*` 系列键配置（client id / redirect uri / scope）。PKCE 生成本身有单元测试（`tests/oauth/pkce_test.cpp`）。

## 安全须知

<AccordionGroup>
  <Accordion title="Token 存储" icon="shield">
    access token 存于 `VendorConfig.api_key`，源自 `MIROBODY_VENDOR_<ID>_*`。名称中含 `_KEY` / `_SECRET` / `_TOKEN` 的配置值会在加载时自动加密（Fernet）。绝不记录 token。
  </Accordion>

  <Accordion title="State / CSRF" icon="fingerprint">
    向 `authorize_url` 传入一个随机 `state`，并在回调时验证它。EHR 连接服务使用一个一次性的、同时携带用户绑定的缓存 `state`。
  </Accordion>

  <Accordion title="恒定时间比较" icon="clock">
    用恒定时间比较（`ct_equal`）比对 webhook 签名，绝不用 `==`。
  </Accordion>

  <Accordion title="不要猜测契约" icon="triangle-exclamation">
    无公开签名方案 → webhook 为桩。无有文档的撤销 → revoke 为桩。诚实胜过捏造。
  </Accordion>
</AccordionGroup>

## 下一步

<CardGroup cols={2}>
  <Card title="OAuth 2.0 示例（Fitbit）" icon="key" href="/zh/examples/whoop-provider">
    一个完整的 OAuth 2.0 设备客户端
  </Card>

  <Card title="Garmin 示例" icon="watch" href="/zh/examples/garmin-provider">
    为何 PKCE / 推送保留为桩
  </Card>

  <Card title="Provider 集成" icon="code" href="/zh/development/provider-integration">
    构建一个新的 `vendor::Vendor`
  </Card>

  <Card title="使用 Provider" icon="link" href="/zh/providers/using-providers">
    端到端的 EHR 连接流程
  </Card>
</CardGroup>
