外观
授权界面接口
授权界面消费的端点。同源相对路径,前缀 /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 分支 |
fatal | true = 流程无法继续,前端切到终屏 ErrorScreen |
| 网络失败 | 归一成 status: 0 的 ApiError |
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 里取。
params在approve时逐字节原样回传,前端不重新组装。
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)或一张早先解题换来的 pass(captcha_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-context 的 captcha.kind 决定当前实例用哪种。生产是 cap (自托管实例 challenges.natlan.io)。
票据是一次性的,前端不做预校验(会烧掉 token),失败后自动 reset() 控件重新取票。 组件与 CSP 要求见 internals/captcha.md。