本服务唯一支持的用户登录流程是 OAuth 2.0 授权码(Authorization Code)+ PKCE,符合 OIDC Core 1.0 规范。本页给出协议细节、参数定义与响应字段。协议依据:RFC 6749(OAuth 2.0)、RFC 7636(PKCE)、OIDC Core 1.0。
流程选型#
| 流程 | 是否支持 | 说明 |
|---|---|---|
| 授权码 + PKCE | 支持 | 用户登录的标准流程。token 只在服务器之间交换,浏览器不接触 token |
| client_credentials | 支持 | 服务器对服务器凭据,无用户参与,仅用于应用成员组管理 API,不能登录用户 |
| 隐式流程(implicit) | 不支持 | 不安全,不提供 |
时序#
用户 应用 平台
│ │ │
│ 点击"用平台账号登录" │ │
│──────────────────▶│ │
│ │ 302 → /oauth/authorize │
│ │ (client_id, redirect_uri, │
│ │ scope, state, PKCE) │
│ │────────────────────────────▶│
│ │ 302 → 登录页(未登录时) │
│ ◀──────────────────────────────────────────────│
│ 登录 + 同意页 │
│──────────────────────────────────────────────▶│
│ │ 同意 → 302 回 redirect_uri │
│ │ ?code=…&state=… │
│ ◀──────────────────────────────────────────────│
│ │ 后端用 code 换 token (POST) │
│ │────────────────────────────▶│
│ │ ← access + refresh + id_token
│ │ 调 userinfo 拿身份与应用成员组 │
│ │────────────────────────────▶│
│ ◀── 按应用成员组渲染的内容 ────────────────── │
端点#
| 端点 | 方法 | 调用方 | 作用 |
|---|---|---|---|
/oauth/authorize | GET / POST | 浏览器 | 授权页与同意 / 拒绝 |
/oauth/token | POST | 应用后端 | 换 token、刷新、client_credentials |
/oauth/userinfo | GET | 应用后端 | 获取用户身份、群与应用成员组 |
/oauth/jwks | GET | 应用后端 | RS256 公钥,用于验 id_token |
/.well-known/openid-configuration | GET | 应用后端 | 发现端点,列出以上全部地址 |
协议端点前缀为 https://mooc.pneumas.org,不带 /api/v1。
发现端点#
GET /.well-known/openid-configuration 返回 OIDC 发现文档,标准客户端库可据此自动配置:
{
"issuer": "https://mooc.pneumas.org",
"authorization_endpoint": "https://mooc.pneumas.org/oauth/authorize",
"token_endpoint": "https://mooc.pneumas.org/oauth/token",
"userinfo_endpoint": "https://mooc.pneumas.org/oauth/userinfo",
"jwks_uri": "https://mooc.pneumas.org/oauth/jwks",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "client_credentials", "refresh_token"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["email", "group", "member_groups", "member_groups:write", "members:read", "openid", "profile"],
"claims_supported": ["sub", "name", "preferred_username", "email", "email_verified", "picture"],
"code_challenge_methods_supported": ["S256"]
}
authorize#
GET /oauth/authorize,参数:
| 参数 | 必填 | 说明 |
|---|---|---|
client_id | 是 | 应用创建时获得 |
redirect_uri | 是 | 必须与注册值 完全一致,平台逐字符比对 |
response_type | 是 | 固定 code |
scope | 否 | 空格分隔;留空默认 openid profile email。请求未获批的 scope 会被拒绝 |
state | 推荐 | 应用生成的随机串,回调时原样带回,用于防 CSRF |
nonce | 可选 | 回显到 id_token,用于防 id_token 重放 |
code_challenge | 是 | PKCE 挑战值 |
code_challenge_method | 固定 S256 | 仅支持 S256 |
PKCE 算法(RFC 7636):
code_verifier = 43–128 位随机串,仅含 A-Za-z0-9-._~
code_challenge = base64url( SHA256( code_verifier ) ) # 去掉末尾的 =
校验发生在 token 阶段:应用换 token 时必须携带 code_verifier,平台重新计算 challenge 并比对。即使授权码在传输中被截获,没有 verifier 也无法换 token。
未登录用户会被 302 跳到平台登录页,登录完成后自动回到授权流程,参数不丢失。非群成员不被拦截,正常展示同意页并签发 code,userinfo 返回 is_member=false。
同意页#
同意页向用户展示应用名(含 Logo)、要访问的群名,以及按申请 scope 翻译成中文的信息清单。用户选择"同意"或"拒绝"。拒绝时平台回跳:
redirect_uri?error=access_denied&state=STATE
每次发起授权都会展示同意页,即使用户此前已授权过同一应用。用户撤销授权后再次登录时同样会看到同意页。
回调与 code#
用户同意后:
redirect_uri?code=<32字节随机串>&state=STATE
code 的约束:
- 随机 32 字节,URL 安全编码后约 64 字符;
- 10 分钟 内有效;
- 只能使用一次,用后即作废;
- 必须与同一
client_id、同一redirect_uri配对; - 平台只存其 SHA-256 哈希,数据库泄露无法还原明文。
token#
POST /oauth/token,请求体为表单编码。客户端认证两种方式任选其一:
# 方式一:HTTP Basic
Authorization: Basic base64(client_id:client_secret)
# 方式二:表单字段
client_id=…&client_secret=…
授权码换 token 的参数:
| 参数 | 说明 |
|---|---|
grant_type | authorization_code |
code | 回调获得的 code |
redirect_uri | 与授权时一致 |
code_verifier | 发起授权时给了 challenge 就必须携带 |
成功响应字段:
| 字段 | 说明 |
|---|---|
access_token | 不透明串,1 小时有效。调用 userinfo 与管理 API 时携带 |
token_type | Bearer |
expires_in | 3600(秒) |
scope | 实际授予的 scope,等于"申请 ∩ 获批" |
refresh_token | 30 天有效,可轮换刷新;仅授权码流程签发,client_credentials 不签发 |
id_token | 请求含 openid 时签发;JWT,RS256 签名 |
id_token 只含标准身份 claims,不含群与应用成员组——群与应用成员组必须从 userinfo 获取。验签使用 GET /oauth/jwks 的公钥,按 kid 匹配。payload 示例:
{
"iss": "https://mooc.pneumas.org",
"sub": "10042",
"aud": "app_9f8e7d6c5b4a3f2e1d0c",
"iat": 1750000000,
"exp": 1750003600,
"nonce": "客户端在 authorize 传入的 nonce(未传则无此字段)",
"name": "用户 10042",
"preferred_username": "user_10042",
"email": "user10042@example.com",
"email_verified": true,
"picture": "https://cdn.example.com/avatar/10042.webp"
}
校验时需确认:iss 等于 https://mooc.pneumas.org、aud 等于你的 client_id、exp 未过期;若 authorize 时传了 nonce,需与本地记录一致。
userinfo#
GET /oauth/userinfo,Authorization: Bearer <access_token>。返回字段按 token 的 scope 过滤:
| scope | userinfo 字段 |
|---|---|
profile | name、preferred_username、picture |
email | email、email_verified |
group | group 对象:is_member、role(creator / admin / member / null)、群 id / slug / name |
member_groups | member_groups 数组:用户在该群的全部应用成员组 |
完整示例见接入指南。群与应用成员组只从 userinfo 获取,id_token 不含。
refresh#
POST /oauth/token,grant_type=refresh_token,携带旧 refresh_token。成功返回新的 access token 与轮换后的新 refresh_token,旧 refresh_token 即时作废。
每次刷新平台都会重新校验以下条件,任一不满足即返回 invalid_grant:
- 应用仍处于激活状态(被禁用则失败);
- 绑定的群仍然存在;
- 用户仍是该群的成员(被移出则失败)。
因此"移出群即失去访问"的最迟生效时间为下一次刷新或 access token 过期(1 小时)。
错误响应#
token 与 userinfo 的错误统一为 RFC 6749 形状:
{ "error": "invalid_grant", "error_description": "授权码无效、已使用或已过期" }
完整错误码表见API 参考 · 错误码。401 invalid_token 响应携带 WWW-Authenticate: Bearer 头,标准客户端库会自动处理。
