OAuth

OAuth

通过标准 OAuth 2.1,让用户用 Atli 账户登录你的客户端,并由客户端代表他们操作:支持发现、PKCE、轮换的刷新令牌和撤销。任何客户端都能使用:应用、机器人、网站、服务器或 AI 助手。

何时使用

  • AI 助手就是这样一种客户端。它们连接 Atli 的 MCP 服务器后,会自行以这种方式让用户登录,因此你无需为它们构建任何东西。所有客户端都使用同一个流程。

  • 任何代表用户操作的客户端:手机应用、网站、机器人或服务器,用用户自己的 Atli 账户向其展示 eSIM 或为其购买。注册一个你自己的客户端,它的令牌即可用于 Atli 的 API。

  • 通过你拥有的客户端,或在 MCP 服务器上你的合作伙伴地址登录的用户,可以作为你的推荐用户关联到你。此功能默认关闭,Atli 可以为你的账户开启。无论是否开启,你的客户端都可以自己把它带来的用户记在你名下:请参阅下文的“把你带来的用户记在你名下”。

  • 只有能在别处再次登录的账户才能连接:有电子邮箱的账户(包括通过 Google、Apple 或 Facebook),或使用 Telegram 或 WhatsApp 的账户。

工作原理

授权服务器是 Atli 的 API。登录和授权同意页面位于 heyatli.com 的 /connect,因此你的应用永远不会看到密码。请从元数据中读取每个端点,不要写死在代码里。本页的地址会随 沙盒 和 生产环境 切换而变化。

先注册一次客户端。然后对每个用户:带着 PKCE challenge 将其引导到授权端点,接收其返回时带回的授权码,用它换取令牌,再用访问令牌调用 API。令牌过期时刷新,用户退出登录时撤销。

curl https://api.heyatli.com/.well-known/oauth-authorization-server

{
  "issuer": "https://api.heyatli.com",
  "authorization_endpoint": "https://api.heyatli.com/oauth2/authorize",
  "token_endpoint": "https://api.heyatli.com/oauth2/token",
  "revocation_endpoint": "https://api.heyatli.com/oauth2/revoke",
  "registration_endpoint": "https://api.heyatli.com/oauth2/register",
  "jwks_uri": "https://api.heyatli.com/oauth2/jwks",
  "scopes_supported": ["api", "offline_access"],
  "code_challenge_methods_supported": ["S256"],
  "client_id_metadata_document_supported": true,
  …
}

令牌的用途

每个请求都通过 resource(RFC 8707)指明其令牌所针对的地址,令牌只在该地址有效。不在以下列表中的地址,或不存在的推荐码,都会以 invalid_target 被拒绝,因此两者都无法被猜出。

地址谁可以申请令牌的作用
Atli 的 API
https://api.heyatli.com
仅限你拥有的客户端

用于 API 的 Bearer 令牌,以该用户的身份操作,与 /login 返回的令牌相同。

Atli 的 MCP 服务器
https://mcp.heyatli.com
任何客户端

让助手通过 MCP 工具代表用户操作。

你的合作伙伴地址
https://mcp.heyatli.com/r/YOUR_CODE
任何客户端

同上,并且在你的账户开启此功能后,用户可以作为你的推荐用户关联到你。

注册客户端

有三种方式。如果是你自己的应用,请注册你自己的客户端:只有这种客户端可以申请 Atli 的 API。

  1. 你自己的客户端

    以你自己的身份登录后,调用 POST /user/oauth-clients。最多可以有 10 个。对于运行在他人设备上的程序(例如手机应用或用户浏览器中的网页),选择 public;对于能安全保管密钥的服务器,选择 confidential。

    机密客户端的 client_secret 只出现在这次响应中,别处都没有。如果丢失,请用 POST /user/oauth-clients/{id}/rotate-secret 替换。你的账户需要有电子邮箱,因为客户端需要一个推荐码;如果你还没有推荐码,系统会为你创建一个。

    curl https://api.heyatli.com/user/oauth-clients \
      -H 'Authorization: Bearer YOUR_TOKEN' \
      -H 'Content-Type: application/json' \
      -d '{ "name": "Travel Bot",
            "redirect_uris": ["https://bot.example/oauth/callback"],
            "client_type": "confidential" }'
    
    {
      "client_id": "atli_client_7f3a9c2e…",
      "client_secret": "…",
      "client_type": "confidential",
      …
    }
  2. 动态注册

    POST /oauth2/register(RFC 7591),无需登录。大多数助手都是这样注册的。同一地址每小时最多注册 10 次,从未有人连接过的客户端会在 30 天后被删除。

    curl https://api.heyatli.com/oauth2/register \
      -H 'Content-Type: application/json' \
      -d '{ "client_name": "Travel Bot",
            "redirect_uris": ["https://bot.example/oauth/callback"] }'
    
    {
      "client_id": "atli_client_…",
      "token_endpoint_auth_method": "none",
      …
    }
  3. 客户端 ID 元数据文档

    使用你托管的一个 https 地址作为 client_id,在该地址提供描述客户端的 JSON 文档。有人登录时 Atli 会读取它,因此无需注册。MCP 规范更推荐这种方式,而不是动态注册。

    // Served at https://bot.example/oauth/client.json
    {
      "client_id": "https://bot.example/oauth/client.json",
      "client_name": "Travel Bot",
      "redirect_uris": ["https://bot.example/oauth/callback"],
      "token_endpoint_auth_method": "none"
    }
  • 重定向 URI 需要完全匹配。每个都必须是 https,或者是 localhost、127.0.0.1 或 [::1] 上的 http(此时只有端口可以不同),且不能带片段。一个客户端最多可有 10 个。

让用户登录

带 PKCE(S256)的授权码流程,所有客户端都使用它,无论公开还是机密。

  1. 生成一对 PKCE 值

    保留 verifier 用于授权码交换,并在请求中发送其 SHA-256 challenge。每次登录都生成新的一对值和新的 state。

    const base64url = (bytes) =>
      btoa(String.fromCharCode(...bytes))
        .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
    
    const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
    const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
    const challenge = base64url(new Uint8Array(digest));
    const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
  2. 引导用户去登录

    在用户的浏览器中打开授权端点。scope 为 api,加上 offline_access 会额外返回刷新令牌。用户在 heyatli.com/connect 登录并同意,该页面会显示你的客户端名称、它会把用户送回哪里,以及用户是否会成为某人的推荐用户。请求的有效期为 10 分钟。

    const url = new URL('https://api.heyatli.com/oauth2/authorize');
    url.search = new URLSearchParams({
      response_type: 'code',
      client_id: CLIENT_ID,
      redirect_uri: 'https://bot.example/oauth/callback',
      scope: 'api offline_access',
      state,
      code_challenge: challenge,
      code_challenge_method: 'S256',
      // Atli's API, for a client you own; or the MCP server, https://mcp.heyatli.com
      resource: 'https://api.heyatli.com',
    }).toString();
    window.location.assign(url);
  3. 接收用户带回的授权码

    用户会带着 code、你的 state 和 iss 返回你的重定向 URI。请检查 state 是你发出的,并且 iss 是签发方。如果用户拒绝,返回的会是 error=access_denied。

    https://bot.example/oauth/callback?code=4bd81f…&state=…&iss=https%3A%2F%2Fapi.heyatli.com
    
    https://bot.example/oauth/callback?error=access_denied&state=…&iss=https%3A%2F%2Fapi.heyatli.com
  4. 用授权码换取令牌

    发送授权码,同时附上 verifier、相同的 redirect_uri 和 resource,以及客户端的凭据(如果有)。授权码有效期为 60 秒且只能使用一次:重复发送或使用错误的 verifier 发送,都会终止该用户的访问权限,因为授权码可能已落入他人之手。

    访问令牌的有效期为 expires_in 秒,即 15 分钟。刷新令牌总会一并返回。

    curl https://api.heyatli.com/oauth2/token \
      -u 'CLIENT_ID:CLIENT_SECRET' \
      -d grant_type=authorization_code \
      -d code=THE_CODE \
      -d code_verifier=THE_VERIFIER \
      -d redirect_uri=https://bot.example/oauth/callback \
      -d client_id=CLIENT_ID \
      -d resource=https://api.heyatli.com
    
    {
      "access_token": "…",
      "token_type": "Bearer",
      "expires_in": 900,
      "refresh_token": "…",
      "scope": "api offline_access"
    }

保持登录状态

访问令牌过期后,用刷新令牌申请新的。你可以申请更窄的 scope,但不能申请更宽的。

刷新令牌每次使用后都会变化,所以每次都要保存新的令牌。如果某次响应没有送达,在短时间内再次发送旧令牌会得到相同的响应。超过这段时间后,旧令牌就意味着它被复制过,该用户的访问权限将被终止。

curl https://api.heyatli.com/oauth2/token \
  -u 'CLIENT_ID:CLIENT_SECRET' \
  -d grant_type=refresh_token \
  -d refresh_token=THE_REFRESH_TOKEN \
  -d client_id=CLIENT_ID \
  -d resource=https://api.heyatli.com

使用令牌

以 Authorization: Bearer ACCESS_TOKEN 的形式发送,只发送到该令牌签发时针对的地址,不要发往别处。

用于 Atli API 的令牌在所有 API 路由上都以该用户的身份操作,与 /login 返回的令牌一样。请将其视为不透明的值:其内容可能会变化。

用于 MCP 服务器的令牌是使用 EdDSA 签名的 JWT。服务器根据其 kid,用 /oauth2/jwks 中的密钥进行验证,并检查其 aud 是否为自己的地址。令牌中不含电子邮箱或其他个人信息:只有 sub(用户的 ID)、scope、grant_id 和各项时间。

curl https://api.heyatli.com/esims \
  -H 'Authorization: Bearer ACCESS_TOKEN'

把你带来的用户记在你名下

将通过你的客户端或在你的合作伙伴地址登录的用户关联到你,默认是关闭的,Atli 可以为你的账户开启。无论是否开启,你的客户端都可以自己用已登录用户的访问令牌,把该用户关联到你的推荐码:调用 POST /user/referral/redeem 并传入你的 code。

result 说明结果:ok 表示已关联,already 表示对方已有推荐人,notnew 表示对方以前购买过,own 表示这是你自己的推荐码,notfound 表示推荐码不存在。只有没有推荐人且尚未购买的用户才能被关联,并且只能关联一次。

curl https://api.heyatli.com/user/referral/redeem \
  -H 'Authorization: Bearer ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{ "code": "YOUR_CODE" }'

{
  "success": true,
  "result": "ok",
  "message": "…",
  "discount_percent": 10
}

退出登录并撤销访问

当用户退出你的应用时,撤销其刷新令牌。这会终止该用户在你客户端中的访问权限,连同其下的所有令牌。即使是它不认识的令牌,也会返回 200,因此无法借此探测哪些令牌存在。

用户也可以自行查看已连接的应用并撤销其访问权限(GET /user/oauth-grants、DELETE /user/oauth-grants/{id})。此后你的客户端将无法获取新令牌,因此请将刷新被拒视为已退出登录。

curl https://api.heyatli.com/oauth2/revoke \
  -u 'CLIENT_ID:CLIENT_SECRET' \
  -d token=THE_REFRESH_TOKEN \
  -d client_id=CLIENT_ID

错误

令牌、撤销和注册端点使用 OAuth 自己的错误格式 {"error": "invalid_grant", "error_description": "…"} 进行响应,而不是 API 通常的格式。请根据 error 进行分支判断,不要依据描述文字。令牌端点的每个响应都带有 Cache-Control: no-store。

  • invalid_grant:授权码已被使用或已过期,verifier 不正确,或刷新令牌在宽限期过后被再次使用。请让用户重新登录。

  • invalid_target:resource 不是令牌可以针对的地址,或指向不存在的推荐码。

  • invalid_client:客户端凭据错误或缺失。所有类型的客户端都以同样的方式失败。

  • invalid_scope:刷新请求申请的范围超出了用户允许的范围。