---
title: Connect 流程
description: Connect 协议从 Establish、Authenticate、Redeem 到 Refresh 的完整流程，以及 Web 应用端到端示例。
editUrl: true
head: []
template: doc
sidebar:
  order: 1
  hidden: false
  attrs: {}
pagefind: true
draft: false
---

import { Tabs, TabItem } from "@astrojs/starlight/components";

本页讲解 **Connect 协议**：当应用希望直接控制浏览器登录往返时使用的 Sudomimus 流程。Connect 走 JSON over HTTPS，任何带 HTTP 客户端的后端语言都能使用；下面给出 curl、Node.js、Python、Go 四种示例。

如果你做的是原生客户端（桌面、游戏、CLI），见 [原生客户端](/zh-cn/native/overview/)。如果走 OIDC，见 [OIDC 接入方](/zh-cn/oidc/flow/)。

Tab 全页同步：选一次语言，下面所有代码块都会跟随切换。

## 协议一览

| 阶段 | 发起方 | 端点 | 结果 |
|---|---|---|---|
| **1. Establish** | 应用后端 | `connect-api POST /establish` | `{ exposureKey, hiddenKey }` |
| **2. Authenticate** | 浏览器 | `via.sudomimus.com` | 用户完成一项允许的认证挑战 |
| **3. Redeem** | 应用后端 | `connect-api POST /redeem` | `{ accessToken, refreshToken }` |
| **4. Refresh** | 应用后端 | `connect-api POST /refresh` | 新 access token 与轮换后的 refresh token |

三个参与方各自承担不同责任：

- **应用后端**签名 `/establish`、保存 `hiddenKey`、兑换已完成的 inquiry，并验证最终令牌。
- **浏览器**把 `exposureKey` 带到托管认证界面，但永远看不到 `hiddenKey`。
- **`via.sudomimus.com`**执行通行密钥、邮箱验证码、OAuth 或联合登录挑战，只有认证成功后才创建 `confirmationKey`。

这套生命周期只属于 Connect。OIDC 使用 authorization code + PKCE；原生 direct-issue 则在一次请求中交换 Steam ticket 或 AccessKey。

## 1. Establish —— 开启一次会话

后端请求 Connect 开启一次认证会话。返回里会同时给出 **exposure key**（要传给浏览器）和 **hidden key**（留在服务器）。

:::caution[`/establish` 必须带签好的 client-auth JWT]
每一次 `/establish` 请求都必须携带 `Authorization: SudomimusClientJWT <jwt>`，`<jwt>` 是用应用 client-auth 私钥签的 RS256 JWT。必备 claim：`iss = applicationAnchor`、`aud = "sudomimus-connect"`、`iat`、`exp`（距 `iat` 不超过 60s）、`jti`（UUID，防重放）、`body_sha256`（HTTP 原始 body 的 base64 SHA-256）。

这些步骤无法通过几行 curl 安全完成。下面的示例假设 `$SUDOMIMUS_CLIENT_AUTH_JWT` 由签名代码生成；实际集成建议使用 [`@sudomimus/connect`](/zh-cn/reference/sdks/)（TypeScript），由 SDK 在内部完成签名。未签名的请求会返回 HTTP 401。
:::

:::note[`applicationAnchor` 格式]
anchor 是你在 [`with.sudomimus.com`](https://with.sudomimus.com) 创建应用时定下的稳定标识符。它是小写 kebab-case（`[a-z][a-z0-9-]*`，3–64 字符，没有起始/结尾/连续连字符），全局唯一 —— 例如 `my-app` 或 `acme-checkout`。
:::

<Tabs syncKey="lang">
<TabItem label="curl">
```bash
curl -X POST https://connect-api.sudomimus.com/establish \
  -H "Content-Type: application/json" \
  -H "Authorization: SudomimusClientJWT $SUDOMIMUS_CLIENT_AUTH_JWT" \
  -d '{
    "applicationAnchor": "your-application",
    "returnMethods": [
      {
        "type": "CALLBACK",
        "payload": { "callbackUrl": "https://your-app.com/auth/callback" }
      }
    ]
  }'
```
</TabItem>
<TabItem label="Node.js">
```js
const res = await fetch("https://connect-api.sudomimus.com/establish", {
    method: "POST",
    headers: {
        "Content-Type": "application/json",
        "Authorization": `SudomimusClientJWT ${await signEstablishJwt(body)}`,
    },
    body: JSON.stringify({
        applicationAnchor: process.env.SUDOMIMUS_APPLICATION_ANCHOR,
        returnMethods: [
            {
                type: "CALLBACK",
                payload: { callbackUrl: "https://your-app.com/auth/callback" },
            },
        ],
    }),
});

const { exposureKey, hiddenKey } = await res.json();
```
</TabItem>
<TabItem label="Python">
```python
import os, requests

res = requests.post(
    "https://connect-api.sudomimus.com/establish",
    headers={
        "Authorization": f"SudomimusClientJWT {sign_establish_jwt(body)}",
    },
    json={
        "applicationAnchor": os.environ["SUDOMIMUS_APPLICATION_ANCHOR"],
        "returnMethods": [
            {
                "type": "CALLBACK",
                "payload": {"callbackUrl": "https://your-app.com/auth/callback"},
            },
        ],
    },
)

data = res.json()
exposure_key = data["exposureKey"]
hidden_key = data["hiddenKey"]
```
</TabItem>
<TabItem label="Go">
```go
body, _ := json.Marshal(map[string]any{
    "applicationAnchor": os.Getenv("SUDOMIMUS_APPLICATION_ANCHOR"),
    "returnMethods": []map[string]any{{
        "type": "CALLBACK",
        "payload": map[string]any{
            "callbackUrl": "https://your-app.com/auth/callback",
        },
    }},
})

req, _ := http.NewRequest(
    "POST",
    "https://connect-api.sudomimus.com/establish",
    bytes.NewReader(body),
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "SudomimusClientJWT "+signEstablishJwt(body))

res, err := http.DefaultClient.Do(req)

var data struct {
    ExposureKey string `json:"exposureKey"`
    HiddenKey   string `json:"hiddenKey"`
}
json.NewDecoder(res.Body).Decode(&data)
```
</TabItem>
</Tabs>

把 `hiddenKey` 与用户的 pending session 绑定存到服务端，然后用 URL 里带上 `exposureKey` 把用户跳转到 `via.sudomimus.com`。

## 2. Authenticate —— 交给 `via.sudomimus.com`

把用户浏览器跳转到 `via.sudomimus.com`，URL 里带上 exposure key。用户在那里完成通行密钥或邮箱验证码挑战。

<Tabs syncKey="lang">
<TabItem label="curl">
```text
# 不发 HTTP —— 你的应用做一次 302：
Location: https://via.sudomimus.com/?exposure-key=<exposureKey>
```
</TabItem>
<TabItem label="Node.js">
```js
const authUrl = new URL("https://via.sudomimus.com/");
authUrl.searchParams.set("exposure-key", exposureKey);

return Response.redirect(authUrl.toString(), 302);
```
</TabItem>
<TabItem label="Python">
```python
from urllib.parse import urlencode
from flask import redirect

return redirect(
    "https://via.sudomimus.com/?" + urlencode({"exposure-key": exposure_key}),
    code=302,
)
```
</TabItem>
<TabItem label="Go">
```go
http.Redirect(
    w, r,
    "https://via.sudomimus.com/?exposure-key="+url.QueryEscape(exposureKey),
    http.StatusFound,
)
```
</TabItem>
</Tabs>

用户完成挑战后，`via.sudomimus.com` 会将浏览器跳转回 `callbackUrl`，并在 URL 中附带 `exposure-key` 和 `confirmation-key` 两个查询参数。

:::note[URL 参数用 kebab-case]
浏览器 URL 中的查询参数使用 kebab-case（`exposure-key`、`confirmation-key`）。API 请求和响应体中的 JSON 字段使用 camelCase（`exposureKey`、`confirmationKey`）。
:::

## 3. Redeem —— 兑换令牌

在回调处理程序中，将三个密钥一并提交给 Connect，换取访问令牌和刷新令牌。

<Tabs syncKey="lang">
<TabItem label="curl">
```bash
curl -X POST https://connect-api.sudomimus.com/redeem \
  -H "Content-Type: application/json" \
  -d '{
    "exposureKey": "...",
    "hiddenKey": "...",
    "confirmationKey": "..."
  }'
```
</TabItem>
<TabItem label="Node.js">
```js
// 位于 GET /auth/callback?exposure-key=...&confirmation-key=...
const { exposureKey, hiddenKey } = await loadPendingSession(req);
const confirmationKey = req.query["confirmation-key"];

const res = await fetch("https://connect-api.sudomimus.com/redeem", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ exposureKey, hiddenKey, confirmationKey }),
});

const { accessToken, refreshToken } = await res.json();
```
</TabItem>
<TabItem label="Python">
```python
# 位于 GET /auth/callback?exposure-key=...&confirmation-key=...
exposure_key, hidden_key = load_pending_session(request)
confirmation_key = request.args["confirmation-key"]

res = requests.post(
    "https://connect-api.sudomimus.com/redeem",
    json={
        "exposureKey": exposure_key,
        "hiddenKey": hidden_key,
        "confirmationKey": confirmation_key,
    },
)

data = res.json()
access_token = data["accessToken"]
refresh_token = data["refreshToken"]
```
</TabItem>
<TabItem label="Go">
```go
// 位于 GET /auth/callback?exposure-key=...&confirmation-key=...
exposureKey, hiddenKey := loadPendingSession(r)
confirmationKey := r.URL.Query().Get("confirmation-key")

body, _ := json.Marshal(map[string]string{
    "exposureKey":     exposureKey,
    "hiddenKey":       hiddenKey,
    "confirmationKey": confirmationKey,
})

res, _ := http.Post(
    "https://connect-api.sudomimus.com/redeem",
    "application/json",
    bytes.NewReader(body),
)

var data struct {
    AccessToken  string `json:"accessToken"`
    RefreshToken string `json:"refreshToken"`
}
json.NewDecoder(res.Body).Decode(&data)
```
</TabItem>
</Tabs>

access token 是一个签名后的 JWT。验签时使用应用的 **token-signing 公钥**（从 `POST /info` 拉一次后缓存），即可信任其 claim。完整流程（包括 `kty: "Access"` header 检查）见 [令牌与验证](/zh-cn/concepts/tokens-and-verification/)。

## 4. Refresh —— 维持会话有效

在 access token 过期前，用 refresh token 换取一个新的 access token **和一个新的 refresh token**。`/refresh` **不**需要 client-auth JWT。refresh token 会被轮换 —— 你提交的令牌被消费，响应里返回它的替代者。请存下新的 `refreshToken` 并在下一次刷新时使用它；重复使用已经用过的令牌会导致整个会话被吊销。对同一个令牌近乎同时的并发刷新（例如多个标签页）会被容忍并收敛到同一个会话；只有在替代令牌签发**之后**再复用才会吊销它。

<Tabs syncKey="lang">
<TabItem label="curl">
```bash
curl -X POST https://connect-api.sudomimus.com/refresh \
  -H "Content-Type: application/json" \
  -d '{ "refreshToken": "..." }'
```
</TabItem>
<TabItem label="Node.js">
```js
const res = await fetch("https://connect-api.sudomimus.com/refresh", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ refreshToken }),
});

// refresh token 会被轮换 —— 捕获新的那个并持久化，替换掉你刚发出去的令牌。
const { accessToken, refreshToken: newRefreshToken } = await res.json();
await store.saveRefreshToken(newRefreshToken);
```
</TabItem>
<TabItem label="Python">
```python
res = requests.post(
    "https://connect-api.sudomimus.com/refresh",
    json={"refreshToken": refresh_token},
)

data = res.json()
access_token = data["accessToken"]
# refresh token 会被轮换 —— 持久化新的那个，替换掉旧的。
store.save_refresh_token(data["refreshToken"])
```
</TabItem>
<TabItem label="Go">
```go
body, _ := json.Marshal(map[string]string{"refreshToken": refreshToken})

res, _ := http.Post(
    "https://connect-api.sudomimus.com/refresh",
    "application/json",
    bytes.NewReader(body),
)

var data struct {
    AccessToken  string `json:"accessToken"`
    RefreshToken string `json:"refreshToken"`
}
json.NewDecoder(res.Body).Decode(&data)

// refresh token 会被轮换 —— 持久化 data.RefreshToken，替换掉旧的。
store.SaveRefreshToken(data.RefreshToken)
```
</TabItem>
</Tabs>

会话内省、登出、账户级吊销见 [管理会话](/zh-cn/guides/managing-sessions/)。

## 查询应用元数据

`POST /info` 根据 anchor 返回某个应用的公开资料（名称、公钥、本地化名称）。它**不**需要 client-auth JWT，可以从浏览器或其他不可信上下文中调用。

<Tabs syncKey="lang">
<TabItem label="curl">
```bash
curl -X POST https://connect-api.sudomimus.com/info \
  -H "Content-Type: application/json" \
  -d '{ "applicationAnchor": "your-application", "locale": "zh-CN" }'
```
</TabItem>
<TabItem label="Node.js">
```js
const res = await fetch("https://connect-api.sudomimus.com/info", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ applicationAnchor, locale: "zh-CN" }),
});

const { applicationAnchor: anchor, applicationName, applicationPublicKey } = await res.json();
```
</TabItem>
<TabItem label="Python">
```python
res = requests.post(
    "https://connect-api.sudomimus.com/info",
    json={"applicationAnchor": application_anchor, "locale": "zh-CN"},
)

info = res.json()
```
</TabItem>
<TabItem label="Go">
```go
body, _ := json.Marshal(map[string]string{
    "applicationAnchor": applicationAnchor,
    "locale":            "zh-CN",
})

res, _ := http.Post(
    "https://connect-api.sudomimus.com/info",
    "application/json",
    bytes.NewReader(body),
)
```
</TabItem>
</Tabs>

`/info` 返回的 `applicationPublicKey` 就是应用后端用于验证 access token 的密钥。请缓存它，仅在密钥轮换后重新拉取。