Skip to content

授权界面接口

授权界面消费的端点。同源相对路径,前缀 /api/v1——它与认证服务在 auth.natlan.io 同一个来源下,因此没有基址也没有 CORS。

VITE_AUTH_API 可覆盖基址以指向另一套部署;生产为空串。

客户端实现 apps/auth/src/lib/api.ts,类型 apps/auth/src/lib/types.ts

认证服务同时挂在 api.natlan.io/auth/v1,那是服务端到服务端的入口, 授权界面不使用它。理由见 internals/auth-hosting.md

1. 通用约定

约定
鉴权需要身份的端点带 Authorization: Bearer <会话 JWT>
错误体{"detail": "<稳定英文串>", "error": "<OAuth 风格 code>", "fatal": bool}
detail 为数组FastAPI 422 校验错误。前端归一成 '',按状态码 422 分支
fataltrue = 流程无法继续,前端切到终屏 ErrorScreen
网络失败归一成 status: 0ApiError

2. 授权流

GET /api/v1/oauth/authorize-context?<原始 query>

/authorize 的 query 原样转发location.search)。返回两种之一:

jsonc
// ① 正常:可以开始验证
{ "client": { "name": "NATLAN Peering Portal" },
  "scopes": [ { "id": "dn42:asn", "description": "…" } ],
  "params": {  },                                  // 服务端已校验并归一化
  "captcha": { "kind": "cap" | "turnstile" | "none", "endpoint": "…" } }

// ② 请求本身有问题:直接把用户送回 RP
{ "redirect_to": "…", "error": "invalid_request", "detail": "…" }

两条红线

  • 重定向目标只来自后端响应,绝不从原始 query 里取。
  • paramsapprove逐字节原样回传,前端不重新组装。

POST /api/v1/verification-options {asn}

查 registry 得到该 ASN 的 mntner 与可用验证方式。不消耗验证码票——返回的全是 公开的 registry 数据。

jsonc
{ "asn": 4242420028, "as_name": "…", "display_name": "…",
  "mntners": ["…"],
  "options": [ { "id": "…", "kind": "email" | "pgp" | "ssh",
                 "method": "…", "value": "…", "comment": "…", "mntner": "…",
                 "captcha_required": true } ] }

captcha_required 是服务端下发的策略,不要从 kind 反推。 策略住在服务端, 这样调整它不需要发一版前端。

POST /api/v1/challenge {asn, option_id, captcha_token, captcha_pass}

只有 captcha_required 为真的选项才需要验证码材料:给一张新鲜的一次性票captcha_token)或一张早先解题换来的 passcaptcha_pass)。其余情况服务端 两者都忽略。

jsonc
{ "challenge_id": "…", "method": "email" | "pgp" | "ssh",
  "nonce": "…", "instructions": "…", "sent_to": "…",
  "expires_at": "…", "reused": false, "resend_available_in": 60,
  "captcha_pass": "…", "captcha_pass_expires_in": 1800 }

captcha_pass 只在刚解过题时出现:它绑定该 ASN、约 30 分钟有效,让重发挑战 完全跳过验证码。

POST /api/v1/challenge/submit {challenge_id, answer, public_key?}

这里没有验证码——挑战本身就是凭据。

jsonc
{ "token": "<会话 JWT>", "asn": , "mntner": "…", "method": "email",
  "expires_at": "…",
  "passkey_enrollment_available": true }   // 该 mntner 尚无 passkey ⇒ 成功屏提供注册

passkey_enrollment_available 只在此端点出现;passkey 登录的返回里没有这个字段, 按 false 处理。

GET /api/v1/session

用已存令牌恢复身份 → {asn, mntner, method, sub, expires_at}

POST /api/v1/oauth/approve

jsonc
// 请求:authorize-context 的 params 原样回传 + deny 标志
{ ...params, "deny": false }
// 200
{ "redirect_to": "…" }

同意与拒绝走同一个端点,靠 deny 区分。

3. Passkey(WebAuthn)

注册与管理骑在会话 JWT 上;登录的两步不需要鉴权——passkey 本身就是凭据,也不需要 验证码票(服务端另有按 IP 的限流)。

端点鉴权说明
POST /api/v1/passkeys/register/begin需要返回 ceremony,options.excludeCredentials 已预填
POST /api/v1/passkeys/register/finish需要{ceremony_id, credential, label?}
GET /api/v1/passkeys需要已注册凭据列表
DELETE /api/v1/passkeys/{credentialId}需要404 也覆盖「不是你的」(统一当作不存在)
POST /api/v1/passkeys/login/begin {asn}404 = 该 ASN 无 passkey,静默回落到挑战流程
POST /api/v1/passkeys/login/finish{ceremony_id, credential} → 与 challenge/submit 同形状的 grant(method="passkey"

credential 必须是未裁剪的 credentialToJSON 产物——特别是保留 transports, 服务端存下来用于下次登录时给认证器提示。

RP ID 取自 token_issuer,即 auth.natlan.io。动它会让所有已注册凭据作废, 这也是那个主机名不能改的原因之一。

4. 人机验证

authorize-contextcaptcha.kind 决定当前实例用哪种。生产是 cap (自托管实例 challenges.natlan.io)。

票据是一次性的,前端不做预校验(会烧掉 token),失败后自动 reset() 控件重新取票。 组件与 CSP 要求见 internals/captcha.md