Skip to content

This document was written by AI and has been manually reviewed.

Prism 是一个符合标准的 OAuth 2.0 授权服务器和 OpenID Connect 提供商。任何支持 OAuth 2.0 授权码流程的应用都可以使用 Prism 作为其身份提供商。

Discovery

Prism 在这些 .well-known 位置发布元数据(RFC 8615):

text
https://your-prism-domain/.well-known/openid-configuration       # OpenID Connect Discovery 1.0
https://your-prism-domain/.well-known/oauth-authorization-server # RFC 8414
https://your-prism-domain/.well-known/oauth-protected-resource   # RFC 9728
https://your-prism-domain/.well-known/jwks.json                  # 签名密钥

前两份描述授权服务器(同一组端点),大多数 OAuth/OIDC 库可从任一自动配置。 oauth-protected-resource(RFC 9728)把 Prism 的 API 描述为受保护资源——由哪个授权 服务器签发其令牌、它识别的 scope、以及 DPoP 支持。受保护端点的 401 会通过 WWW-Authenticate: ... resource_metadata="…" 指向此处。

Issuer 发现(WebFinger,RFC 7033)

只有用户标识符的客户端可以借此发现 issuer:

text
GET /.well-known/webfinger?resource=acct:alice@your-prism-domain&rel=http://openid.net/specs/connect/1.0/issuer

返回一个 JRD(application/jrd+json),链接到 issuer:

json
{
  "subject": "acct:alice@your-prism-domain",
  "links": [
    {
      "rel": "http://openid.net/specs/connect/1.0/issuer",
      "href": "https://your-prism-domain"
    }
  ]
}

注册应用程序

  1. 登录 Prism,前往 Apps → New Application
  2. 填写名称、描述和重定向 URI
  3. 复制 Client IDClient Secret——密钥仅显示一次

如果你的应用完全运行在浏览器端(没有服务端来保密密钥),请启用公共客户端。公共客户端必须使用 PKCE,没有客户端密钥。

重定向 URI 匹配

每个注册的重定向 URI 都带有一个匹配方式

类型行为
等于URL 规范化后精确匹配(默认,最安全)。
通配符使用 * 代表任意长度字符的 glob,例如 https://example.com/*
正则对整个候选 URI 进行匹配的正则表达式,例如 https://example\.com/.*

无论使用哪种匹配方式,每个候选 URI 都会先经过安全校验:scheme 必须为 https:(loopback 主机可用 http:),且不得包含 userinfo(user:pass@…)或 fragment(#…)。

空列表(学习首次使用)。 如果重定向 URI 列表留空,应用会「学习」第一个成功使用的重定向 URI,将其固定为 等于 条目,此后锁定为该值。

WARNING

值为 .*正则 会允许任意重定向 URI,包括攻击者控制的地址。请仅在完全理解安全风险的情况下使用。

授权码流程(含 PKCE)

第一步 — 重定向用户

text
GET https://your-prism-domain/api/oauth/authorize
  ?response_type=code
  &client_id=<CLIENT_ID>
  &redirect_uri=https://yourapp.com/callback
  &scope=openid profile email
  &state=<RANDOM_STATE>
  &code_challenge=<CODE_CHALLENGE>
  &code_challenge_method=S256

PKCE — 生成一个 code_verifier(43–128 个随机 URL 安全字符),然后:

text
code_challenge = BASE64URL(SHA-256(ASCII(code_verifier)))

权限范围

范围包含的声明 / 授权的访问
openidsubissaudiatexp(OIDC 必须)
profilenamepreferred_usernamepicture
profile:write更新用户的个人资料(名称、头像)
emailemailemail_verified
apps:read用户拥有的应用列表
apps:write创建、更新和删除用户的应用
teams:read列出用户的团队
teams:write更新团队设置和管理成员
teams:create创建新团队
teams:delete删除团队
domains:read列出用户的自定义域名
domains:write添加和删除自定义域名
gpg:read列出用户已注册的 GPG 公钥
gpg:write添加或删除用户的 GPG 公钥
social:read列出用户已关联的社交提供商账号
social:write断开社交提供商账号关联
admin:users:read读取所有用户账号(仅限管理员)
admin:users:write修改用户账号(仅限管理员)
admin:users:delete删除用户账号(仅限管理员)
admin:config:read读取实例配置(仅限管理员)
admin:config:write更新实例配置(仅限管理员)
admin:invites:read列出邀请(仅限管理员)
admin:invites:create创建邀请(仅限管理员)
admin:invites:delete删除邀请(仅限管理员)
offline_access启用刷新令牌颁发

团队相关 scope —— 三个层级

涉及团队的 scope 分三类,作用范围差异巨大。请按需选最窄的那一类。

层级示例访问范围授予方式
聚合(复数)teams:read用户加入的所有团队普通用户同意
单团队(单数)team:read同意时选定的某一个团队普通用户同意 + 团队选择器(用户须是该团队 admin 及以上)
跨实例site:team:read实例上所有团队仅管理员,且需通过 2FA + 输入确认短语
聚合 teams:*
teams:read   teams:write   teams:create   teams:delete

作用于用户的全部团队图谱。一次同意覆盖所有团队。适合需要反映或同步用户成员关系的场景 — 例如把 teams claim 注入到 ID Token 给 Cloudflare Access 用,或者展示一个「我所在的团队」切换器。

端点位于 /api/oauth/me/teams[/...]

单团队 team:*
team:read                       team:member:read
team:write                      team:member:write
team:delete                     team:member:profile:read

应用请求时使用上面这些字符串。同意时用户挑出一个具体团队,Prism 通过 bindTeamScopes() 把它们就地改写成 team:<team-id>:read 等 — 颁发的 token 里只剩绑定后的形式,从而只能作用于那一个团队。

同意时还有两条额外限制(worker/routes/oauth.ts:830-859):

  • 用户必须是所选团队的 ownerco-owneradmin有效角色同样适用 —— 通过上级团队继承得到的 admin 也可以在子团队上授予单团队 scope(与会话 API 一致)。同意页的团队选择器会列出该用户能管理的所有团队,无论是直接还是继承。
  • team:delete 还要求 ownerco-owner(admin 能授予读写,但只有真正能解散团队的人才能授予删除权)。继承的 owner 同样可以授予 team:delete,递归的 dissolveTeam 级联 会完整执行。

team:member:write 同样不能越权:admin 用户授权后,应用提升成员的角色受到与该 admin 相同的上限保护,不会因 token 而获得超越授权人本身的能力 — 这条上限会在每次成员变更时校验。

每次授予都会在 team_scope_grants 表中独立审计(含团队 ID 与权限列表),与 OAuth 同意记录分开。

端点位于 /api/oauth/me/team/:teamId/...,通过 resolveTeamToken(c, teamId, "read"|"write"|"member:read"|...) 校验绑定关系;用绑定到 A 团队的 token 去访问 B 团队的端点会得到 403 insufficient_scope

跨实例 site:team:*
site:team:read   site:team:write   site:team:delete

无需逐团队同意的跨团队管理员权限。授予时同意者必须是站点管理员,并通过 站点 scope 确认流程 — 2FA + 完整输入 grant site access 这一确认短语。仅适合真的需要看 / 改全部团队的站点管理工具。

选哪一种 — 速记
  • 「这个用户在哪些团队?」用 teams:*。绑定到单个团队的集成(如某 workspace 的部署机器人)用 team:*
  • 不要同时请求 teams:*team:* — 你会拿到两者的并集,但同意页里同时出现「团队选择器」和「全部团队提示」会让用户困惑。
  • site:team:* 是给站点管理工具用的,不要给产品集成用。这层的授予会绕过团队所有者的同意。

站点 scope(仅管理员)

完整的跨实例 scope 列表;与 site:team:* 共用 admin-only / 2FA / 确认短语 的同一道关卡:

Scope权限
site:user:read读取任意用户
site:user:write修改任意用户
site:user:delete删除任意用户
site:team:read读取任意团队
site:team:write修改任意团队
site:team:delete解散任意团队
site:config:read读取站点配置
site:config:write修改站点配置
site:token:revoke撤销任意用户的 OAuth token

第二步 — 用户授权

Prism 显示授权页面,列出你的应用名称和请求的权限范围。如果用户已经对相同的权限范围授权过,则自动跳过授权页面。

第三步 — 接收授权码

Prism 重定向到你的 redirect_uri

text
https://yourapp.com/callback?code=<AUTH_CODE>&state=<STATE>

请务必验证 state 与你发送的值一致。

第四步 — 换取令牌

http
POST /api/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<AUTH_CODE>
&redirect_uri=https://yourapp.com/callback
&client_id=<CLIENT_ID>
&client_secret=<CLIENT_SECRET>
&code_verifier=<CODE_VERIFIER>

公共客户端省略 client_secret,必须包含 code_verifier

响应

json
{
  "access_token": "...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "...",
  "id_token": "...",
  "scope": "openid profile email"
}

第五步 — 调用 UserInfo

http
GET /api/oauth/userinfo
Authorization: Bearer <ACCESS_TOKEN>

该端点同时支持 GETPOST(OpenID Connect Core §5.3.1)。访问令牌必须携带 openid 作用域;缺少该作用域的令牌会返回 403 insufficient_scope。被拒绝的请求 会按 RFC 6750 返回 WWW-Authenticate: Bearer 质询头。

UserInfo 响应

json
{
  "sub": "user-id",
  "name": "Alice",
  "preferred_username": "alice",
  "email": "alice@example.com",
  "email_verified": true,
  "picture": "https://your-prism-domain/api/assets/avatars/..."
}

刷新令牌

http
POST /api/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=<REFRESH_TOKEN>
&client_id=<CLIENT_ID>
&client_secret=<CLIENT_SECRET>

响应中会返回新的 refresh_token,请用它替换原有的令牌。刷新令牌每次使用后 都会轮换,出示已被替换的旧令牌会导致整个授权被撤销——无论是客户端保留了旧值, 还是令牌遭到窃取,处理方式都一样。新令牌沿用原有的过期时间,轮换不会延长授权 有效期。

令牌内省(RFC 7662)

用于服务端间验证,无需解析 JWT:

http
POST /api/oauth/introspect
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <base64(client_id:client_secret)>

token=<ACCESS_TOKEN>

必须提供客户端凭据,且客户端只能内省签发给自己的令牌,其他令牌一律返回 {"active": false}

响应(有效令牌)

json
{
  "active": true,
  "sub": "user-id",
  "scope": "openid profile",
  "client_id": "...",
  "token_type": "Bearer",
  "exp": 1234567890,
  "iat": 1234564290,
  "aud": "...",
  "iss": "https://your-prism-domain"
}

令牌撤销(RFC 7009)

http
POST /api/oauth/revoke
Content-Type: application/x-www-form-urlencoded

token=<ACCESS_OR_REFRESH_TOKEN>
&client_id=<CLIENT_ID>
&client_secret=<CLIENT_SECRET>

必须提供客户端凭据,且只会撤销该客户端自己的令牌。出示已被替换的刷新令牌会撤销 它所属的整个授权。

设备授权(RFC 8628)

适用于无法承载浏览器的输入受限设备(CLI、电视、IoT)。

http
POST /api/oauth/device_authorization
Content-Type: application/x-www-form-urlencoded

client_id=<CLIENT_ID>
&scope=openid profile

响应:

json
{
  "device_code": "…",
  "user_code": "WDJB-MJHT",
  "verification_uri": "https://your-prism-domain/device",
  "verification_uri_complete": "https://your-prism-domain/device?user_code=WDJB-MJHT",
  "expires_in": 600,
  "interval": 5
}

向用户展示 verification_uriuser_code(或便于生成二维码的 verification_uri_complete)。同时轮询令牌端点:

http
POST /api/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:device_code
&device_code=<DEVICE_CODE>
&client_id=<CLIENT_ID>

在用户操作前,端点返回 authorization_pending(轮询过快则返回 slow_down); 轮询间隔不得小于 interval 秒。批准后返回常规令牌响应(请求了 openid 时含 id_token,请求了 offline_access 时含 refresh_token)。access_deniedexpired_token 为终止状态。PKCE 可选:在设备授权请求中带上 code_challenge, 轮询时带上对应的 code_verifier。设备流不能授予站点级与团队级 scope。

动态客户端注册(RFC 7591 / 7592)

以编程方式注册客户端。请求需携带初始访问令牌——已登录用户的会话令牌,或带 apps:write 的个人访问令牌:

http
POST /api/oauth/register
Authorization: Bearer <SESSION_OR_PAT>
Content-Type: application/json

{
  "client_name": "My CLI",
  "redirect_uris": ["https://app.example.com/callback"],
  "scope": "openid profile email",
  "token_endpoint_auth_method": "client_secret_basic"
}

201 响应即客户端信息文档:client_idclient_secret(机密客户端)、一个 registration_access_tokenregistration_client_uri。之后在该 URI 管理客户端 (RFC 7592):GET 读取、PUT 更新、DELETE 注销——均以 Authorization: Bearer <registration_access_token> 认证。注册 private_key_jwt 客户端时,将 token_endpoint_auth_method 设为 private_key_jwt,并提供 jwks (内联 JWK Set)或 jwks_uri

private_key_jwt 客户端认证(RFC 7523)

机密客户端可用签名断言代替共享密钥认证。先注册客户端公钥并选择认证方式——可在 仪表盘中设置(应用详情 → 设置:将 令牌端点认证方式 设为 private_key_jwt, 并填入内联 jwksjwks_uri)、通过 DCR 元数据,或通过应用 API。随后在 令牌 / PAR / 内省 / 撤销端点发送:

text
client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<JWT>

该断言是一个 JWT,其 iss = sub = 你的 client_idaud = issuer 或令牌端点 URL,exp 较短,jti 唯一(一次性使用)。支持的签名算法:RS256、ES256、EdDSA。

DPoP — 发送方约束的令牌(RFC 9449)

把令牌绑定到客户端持有的密钥上,这样即便令牌值被窃取,没有密钥也无法使用。在令牌 请求上发送 DPoP 头——一个由客户端密钥签名、头部携带公钥并绑定到本次请求的 JWT:

http
POST /api/oauth/token
DPoP: <proof-jwt>   # htm=POST, htu=<令牌端点>, iat, jti
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=...&client_id=...&code_verifier=...

响应返回 "token_type": "DPoP",且访问令牌绑定到密钥指纹(cnf.jkt)。在资源端, 用 DPoP scheme 携带令牌,并附上同时对令牌做哈希(ath)的新证明:

http
GET /api/oauth/userinfo
Authorization: DPoP <ACCESS_TOKEN>
DPoP: <proof-jwt>   # htm=GET, htu=<资源 url>, ath=base64url(sha256(token))

以普通 Bearer 出示 DPoP 绑定令牌、或缺少匹配证明,都会被拒绝。刷新请求必须重复来自 同一密钥的证明。支持的证明算法:RS256、ES256、EdDSA。

令牌交换(RFC 8693)

用一个访问令牌换取另一个——用于应用间的委托:

http
POST /api/oauth/token
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/x-www-form-urlencoded

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
&subject_token=<ACCESS_TOKEN>
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
&scope=openid profile
&resource=https://api.example.com

请求方客户端只能交换签发给它自己的令牌,或携带指向它的跨应用 scope (app:<client_id>:*)的令牌。新令牌的 scope 是原 subject 令牌的子集,其受众受 resource / audience 约束。响应含 issued_token_type: urn:ietf:params:oauth:token-type:access_token。交换得到的令牌 不可刷新。

重新认证与上下文(promptmax_ageacr

授权请求遵循以下 OpenID Connect 参数:

  • prompt=none——无界面;若用户未登录(或需重新认证)则向客户端返回 login_required,若缺少同意则返回 consent_required
  • prompt=login——即使已有会话也强制重新登录。
  • prompt=consent——始终显示同意页。
  • max_age=<秒>——要求登录时间不早于此,否则重新认证。

ID 令牌随后携带 auth_time(用户登录时间)、amr(认证方式,如 ["pwd","otp","mfa"]["webauthn"]["ext"]),以及派生的 acr(使用了第二 因子时为 mfa,否则为 pwd)。

提升认证(RFC 9470)

在授权请求上用 acr_values 请求特定上下文(例如 acr_values=mfa);若当前会话不满足, Prism 会重新认证,让更强的因子提升它。访问令牌(以及内省响应)都携带 acr / auth_time / amr,因此资源服务器可以要求更强的认证,并对不满足的请求返回:

http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="insufficient_user_authentication", acr_values="mfa"

客户端随后带 acr_values=mfa 重新发起授权。

推送式授权请求(RFC 9126)

先把授权参数推送到服务器,换取一次性的 request_uri 用于授权端点——请求无法在 浏览器中被篡改,密钥也不会出现在前端信道。

http
POST /api/oauth/par
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <base64(client_id:client_secret)>

response_type=code
&redirect_uri=<REDIRECT_URI>
&scope=openid profile
&code_challenge=<CHALLENGE>&code_challenge_method=S256
&state=<STATE>

响应(201 Created):

json
{ "request_uri": "urn:ietf:params:oauth:request_uri:…", "expires_in": 90 }

随后仅带客户端与 request URI 将用户导向授权端点:

text
https://your-prism-domain/api/oauth/authorize?client_id=<CLIENT_ID>&request_uri=<REQUEST_URI>

request_uri 一次性使用且很快过期。

授权响应 iss(RFC 9207)

每个授权响应(成功与错误)都带有 iss 参数,值为你的 Prism 实例 URL。校验它的 客户端可抵御混淆(mix-up)攻击。Discovery 会通告 authorization_response_iss_parameter_supported: true

资源指示符(RFC 8707)

/par、授权或 /device_authorization 请求中加入一个或多个 resource 参数 (绝对 URI,不含 fragment),用于指明访问令牌面向的资源服务器。每个被接受的值都会 加入令牌的 aud,并在刷新时保留。

RP 发起的登出(OpenID Connect)

text
GET /api/oauth/end_session?id_token_hint=<ID_TOKEN>&post_logout_redirect_uri=<URI>&state=<STATE>

结束用户的 Prism 会话并清除会话 Cookie。当 post_logout_redirect_uri 与客户端 注册的某一项(应用的 post_logout_redirect_uris)完全匹配时,浏览器会带 state 跳转到该地址;否则落到 Prism 内置的登出页。id_token_hint 用于标识客户端(即使已 过期也会被接受),建议提供。

后端通道登出(OpenID Connect)

为客户端注册 backchannel_logout_uri(应用详情 → 设置、DCR 元数据或应用 API)。当 用户从 Prism 登出——通过 end_session 或仪表盘——Prism 会向该 URI POST 一个已签名的 logout_token

http
POST <backchannel_logout_uri>
Content-Type: application/x-www-form-urlencoded

logout_token=<JWT>

logout_token 是一个 RS256 JWT(typ: logout+jwt),含 issaud(你的 client_id)、subiatjtisid(结束的会话,也作为 sid 出现在 ID 令牌中) 以及后端通道登出的 events 声明。请对照 JWKS 验证并终止用户会话。Discovery 会通告 backchannel_logout_supportedbackchannel_logout_session_supported

ID 令牌

ID 令牌是一个签名的 JWT。默认算法为 ML-DSA-65(后量子,FIPS 204);/.well-known/jwks.json 同时发布 RS256 公钥以兼容旧客户端。可通过 JWKS 端点发布的公钥进行验证,或使用内省端点进行服务端验证。

标准声明(请求 openid 权限范围时始终包含):

声明
iss你的 Prism 实例 URL
sub稳定的用户 ID
aud你的 client_id
iat颁发时间戳
exp过期时间戳
role用户角色(useradmin
nonce从授权请求中原样返回

范围关联声明 — profileemail 声明在授予对应权限范围时自动包含。下表中其余声明还需要应用在 oidc_fields 配置中声明对应的字段名:

权限范围字段名添加到 ID 令牌的声明
profile(始终包含)namepreferred_usernamepicture
email(始终包含)emailemail_verified
teams:readteamsteams{ id, name, role, groups } 对象数组,表示用户的团队成员身份
apps:readappsapps{ id, name, client_id, is_verified } 对象数组,表示用户拥有的应用
domains:readdomainsdomains{ id, domain, verified } 对象数组
gpg:readgpg_keysgpg_keys{ id, fingerprint, key_id, name } 对象数组
social:readsocial_accountssocial_accounts{ id, provider, provider_user_id } 对象数组

通过 API 创建或更新应用时,在 oidc_fields 数组中声明所需字段,即可为该应用启用相应的自定义声明:

json
{ "oidc_fields": ["teams", "domains"] }

按团队的扁平 claim

oidc_fields 无关:只要授予了 teams:read,或任何绑定形式的 team:<id>:* scope,就一定会产出按团队的扁平标记 —— 因为 Cloudflare Access 这类策略引擎只能匹配扁平的 claim 名。

Claim何时产出
in_team_<team-id>true用户是该团队成员
role_in_team_<team-id>admin始终与 in_team_<team-id> 一同产出
groups_in_team_<team-id>slug 数组该团队启用了身份组且用户至少持有一个

groups_in_team_<id> 在为空时直接省略而不是下发空数组 —— 缺失本身已经表示「在该团队不持有任何身份组」,省略也避免了每个团队都往令牌里塞一个 claim。团队关闭身份组功能时同样不产出,无论库里存了什么。

值是身份组的 slug 而非展示名:slug 不可变,因此基于它编写的策略在团队改名后依然有效。

加强 2FA(敏感操作再确认)

应用可以请求 Prism 让用户在执行敏感操作前用 TOTP 或通行密钥再确认一次——例如转账、删除资源、授予提权访问权限等。

流程是服务端发起的:你的服务器先通过 HTTPS 把这次操作注册到 Prism,然后才重定向用户。操作描述和回调 URI 都在服务端到服务端这一步固定下来——只控制 URL 的攻击者无法伪造一个写有任意内容的确认页。

用户必须已登录 Prism(未登录会被引导登录),并已启用 TOTP 认证器或通行密钥。这一过程不会授予任何新的账户权限——返回结果只是用户重新确认了一次的一次性凭证。

第一步 — 创建挑战(服务端到服务端)

http
POST /api/oauth/2fa/challenges
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/json

{
  "redirect_uri": "https://app.example.com/2fa-callback",
  "action": "确认转账 $1,000",
  "nonce": "order_abc123",
  "code_challenge": "PKCE_CHALLENGE",
  "code_challenge_method": "S256"
}
字段是否必填说明
client_id必填(Basic 或请求体)OAuth 应用的 client ID
client_secret机密客户端必填通过 Basic 或请求体提供
redirect_uri必填必须已在应用上注册
action推荐用户要确认的操作的人类可读描述(≤ 200 字符)。在 Prism 页面原样展示,并在 verify 响应中回传
nonce可选应用自定义的不透明值(≤ 256 字符),原样回传。建议绑定到具体操作(如订单 ID)
code_challenge, code_challenge_method公开客户端必填PKCE — 见授权码流程

响应

json
{
  "challenge_id": "f3a…opaque…",
  "expires_at": 1761500900,
  "url": "https://prism.example.com/oauth/2fa?challenge_id=f3a…"
}

公开客户端(无 client_secret)依靠 PKCE 进行身份认证:在这里传 code_challenge,在校验时传 code_verifier。服务器对每个客户端限速创建挑战(每分钟 60 次),即便密钥泄露也无法用于骚扰用户。

第二步 — 重定向用户

text
https://prism.example.com/oauth/2fa?challenge_id=f3a…&state=RANDOM

URL 里只有不透明的 challenge_id 和你设的 CSRF state。攻击者没有任何可篡改的内容。

第三步 — 用户确认

Prism 会展示应用图标、(如适用的)已验证域名徽章、来自挑战的 action 文案,并提示用户输入 TOTP 或使用通行密钥。用户还必须勾选一个回显操作内容的复选框("我已阅读并理解:…"),「确认」按钮才会启用。

用户点击 确认拒绝

第四步 — 接收 code

Prism 将用户重定向回挑战中固定的 redirect_uri

text
https://app.example.com/2fa-callback?code=…&state=…

或在拒绝/出错时:

text
https://app.example.com/2fa-callback?error=access_denied&state=…

第五步 — 校验(服务端)

http
POST /api/oauth/2fa/verify
Content-Type: application/x-www-form-urlencoded

code=THE_CODE&client_id=YOUR_CLIENT_ID&redirect_uri=…&code_verifier=PKCE_VERIFIER

机密客户端可用 client_secret(请求体)或 HTTP Basic 认证。公开客户端仅依靠 PKCE。

响应

json
{
  "user_id": "u_abc",
  "client_id": "YOUR_CLIENT_ID",
  "verified_at": 1761500000,
  "action": "确认转账 $1,000",
  "nonce": "order_abc123",
  "method": "totp"
}

code 是单次使用的,签发后 5 分钟过期。校验成功后:

  • verified_at 是用户完成 2FA 的 Unix 时间戳——超过你认可的窗口期就视为过期。
  • nonceaction 与你应用最初构造 URL 时存储的值比对——不一致就拒绝结果。
  • method"totp""passkey""backup"

验证码门槛

站点可以要求用户在批准 2FA 加强前先通过验证码。触发该门槛有两种方式:

  • 站点默认 — 管理员开启 require_captcha_for_2fa,所有 2FA 加强都需要通过验证码。
  • 应用按挑战开启 — 应用在调用 POST /api/oauth/2fa/challenges 时传入 require_captcha: true。适合在站点默认关闭时,某些应用仍希望对自己的高风险操作增加阻力。(应用无法关闭站点已强制启用的门槛。)

使用站点已配置好的验证码提供商(Turnstile、hCaptcha、reCAPTCHA 或 PoW)。如果 captcha_provider"none",即便上述触发条件命中也不会有任何效果。

/api/oauth/2fa/info 响应中暴露了 captcha_requiredcaptcha_providercaptcha_site_key,以便前端渲染对应的小部件。用户解决挑战后,把 captcha_token(或 pow_challenge + pow_nonce)连同 TOTP/通行密钥一并提交到 /api/oauth/2fa/authorize

走 sudo 旁路时不会触发验证码:sudo 不检查任何因子,没有自动化攻击的面,强制挑战反而会让 sudo 宽限期失去意义。

Sudo 模式(宽限窗口)

用户在一次成功的 TOTP/通行密钥确认之后,可选择启用 sudo 宽限窗口:在此窗口内,同一会话同一应用的后续挑战会跳过 2FA 提示。但操作描述的确认复选框仍然必须勾选——用户始终能看到并确认自己批准的内容,只跳过 TOTP/通行密钥的重新输入。

TTL 由管理员通过 sudo_mode_ttl_minutes 站点设置控制。设为 0 即可完全禁用 sudo 模式。

授权绑定到 (user_id, session_id, client_id) 三元组——不会跨应用、跨会话、跨用户泄露。登出 Prism 会更换 session ID,该会话内所有 sudo 授权随即不可达。

用户启用 sudo 后,Prism 返回的重定向 code 的 method 字段为 "sudo"进行极高风险操作(销户、大额转账)的应用应要求 method !== "sudo",使这些操作始终触发一次全新的 2FA 提示。

提前撤销 sudo 窗口

用户可在 TTL 到期前主动撤销:

http
POST /api/oauth/2fa/sudo/revoke
Authorization: Bearer <user-session-jwt>
Content-Type: application/json

{ "client_id": "YOUR_CLIENT_ID" }

威胁模型

可防御的攻击:

  • 仅靠 URL 的钓鱼。 仅能构造 URL 的攻击者(如钓鱼邮件中的链接)无法注入任意 action 文案或挑选任意 redirect URI——这两者都在第 1 步在服务端固定,攻击者拿不到应用的 client_secret(或公开客户端的应用本身)就够不到这一步。
  • code 拦截。 PKCE 将 code 与 verifier 绑定;code 还与 (client_id, redirect_uri) 绑定。即使 code 泄露(如通过 referrer),也无法被其他应用兑换或发送到其他 URI。
  • TOTP 暴力破解。 每用户 5 分钟限速 8 次。一次失败也会消耗当前挑战——攻击者必须重新走一次服务端发起的 POST 才能重试。
  • 重放 / 重复兑换。 挑战和生成的 code 都通过原子操作(UPDATE … WHERE consumed_at IS NULL)单次消费。
  • 盲目点击。 用户必须显式勾选回显 action 文案的复选框,「确认」按钮才会启用。
  • UI 欺骗。 actionnoncestate 都有长度上限,避免恶意应用通过 UI 投放超长内容欺骗用户。

无法防御:

  • 设备完全失陷(恶意软件可读取屏幕上的 TOTP 验证码并窃取会话 Cookie——任何认证流程都救不了你)。
  • 同时拥有应用 client_secret 且被授权代表该应用行事的攻击者,他们可以发起合法挑战。如怀疑泄露请立即轮换密钥。

集成

Cloudflare Access

你可以将 Prism 作为 Cloudflare Access 的通用 OIDC 身份提供商,让用户使用 Prism 账号登录受 Cloudflare 保护的资源。

第一步 — 在 Prism 中创建 OAuth 应用

  1. 登录 Prism,前往 Apps → New Application

  2. 将重定向 URI 设置为:

    text
    https://<your-team-name>.cloudflareaccess.com/cdn-cgi/access/callback
  3. Allowed scopes 至少包含 openidemail,如需在 Access 策略中使用其他声明,可添加 profileteams:read 等。

  4. OIDC fields 设置为需要嵌入 ID 令牌的自定义声明字段名,例如 ["role", "teams"]

  5. 复制 Client IDClient Secret

第二步 — 在 Cloudflare 中添加 Prism 为身份提供商

Cloudflare Zero Trust 中,前往 Integrations → Identity providers → Add new → OpenID Connect,填写以下内容:

字段
NamePrism(或任意名称)
App ID你的 Prism Client ID
Client secret你的 Prism Client Secret
Auth URLhttps://your-prism-domain/api/oauth/authorize
Token URLhttps://your-prism-domain/api/oauth/token
Certificate URLhttps://your-prism-domain/.well-known/jwks.json
PKCE启用(推荐)
Scopesopenid email(按需添加 profile teams:read 等)
OIDC Claims每行一个 — 需要在策略中使用的声明名

OIDC Claims 中填入 Prism 返回的自定义声明名,例如:

text
role
in_team_<team-id>
role_in_team_<team-id>
groups_in_team_<team-id>

保存后点击 Test 验证连接。成功后可在 oidc_fields 中看到声明:

json
{
  "email": "alice@example.com",
  "oidc_fields": {
    "role": "admin",
    "in_team_abc123": true,
    "role_in_team_abc123": "owner",
    "groups_in_team_abc123": ["backend", "oncall"]
  }
}

第三步 — 使用 Prism 声明构建 Access 策略

在 Access 应用策略中使用 OIDC Claim 选择器:

选择器Claim nameClaim value效果
OIDC Claimroleadmin仅限 Prism 管理员
OIDC Claimin_team_<team-id>true指定团队成员
OIDC Claimrole_in_team_<team-id>owner仅限团队所有者
OIDC Claimgroups_in_team_<team-id>oncall持有该身份组的成员

注意: Cloudflare Access 从 ID 令牌(RS256 签名的 JWT)中读取自定义声明。Dashboard 中填写的声明名必须与 Prism 实际嵌入令牌的字段名完全一致,后者由应用的 oidc_fields 配置决定。

数组型 claim 的匹配: groups_in_team_<team-id> 是一个数组(如 ["backend", "oncall"])。Access 通过其 Multi-record OIDC claims(多记录 OIDC 声明) 支持来匹配 —— 数组会被拆成一条条记录单独引用,因此策略值填 oncall 即可匹配数组中含 oncall 的所有用户。匹配是整条记录级别的:Access 不支持部分 / 子串值引用,这也正是该 claim 携带不可变的身份组 slug 而非展示名的原因。

错误响应

授权错误会重定向到你的 redirect_uri,附带:

text
?error=access_denied&error_description=User+denied+access

令牌端点错误返回 HTTP 400:

json
{ "error": "invalid_grant", "error_description": "Code expired or invalid" }

常见错误码:invalid_requestinvalid_clientinvalid_grantunauthorized_clientunsupported_grant_typeaccess_denied

在 GPL-3.0 许可协议下发布.