KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
配套项目 · 最小 OIDC 实现(纯标准库) — keel 龙骨
SSO 工程课:协议、令牌与会话 的配套项目:配套项目 · 最小 OIDC 实现(纯标准库)
这个项目不是要替代 Keycloak、Auth0 或任何生产级 IdP。它解决的是一件更小的事:
把「协议报文里到底交换了什么、谁记住了什么、哪一步之后什么就不能再用」变成
可读、可改、可注入故障的代码。
只用 Python 标准库(http.server / json / hmac / hashlib / base64 / urllib.parse),requirements.txt 里唯一的依赖是测试框架。
关于签名算法(必须如实说明):真实部署里 ID Token 用 RS256/ES256 非对称签名,
IdP 只公开公钥(JWKS),任何应用都能校验但无法伪造。本项目用 HS256 对称签名,
因为标准库没有现成的 RSA 签名。代价是签名密钥是双方共享的秘密——所以本项目的jwks_uri只发布kid元数据,RP 取密钥走的是进程内通道。这条差异是 07 章
「密钥轮换与 kid」那一节的讨论对象,不是本项目的能力。
分层
flowchart TD
subgraph BROWSER["Browser(浏览器)"]
B1["cookie jar<br/>OP_AUTH_REQ / OP_SESSION"]
B2["visit(rp):GET → 必要时填表 → 跟 302"]
end
subgraph RP["RP(应用侧,rp.py)"]
R1["begin_login():state / nonce / code_verifier"]
R2["handle_callback():校验 state 与 iss"]
R3["exchange_code():带上 code_verifier"]
R4["verify_id_token():签名 / iss / aud / exp / nonce / at_hash"]
end
subgraph OP["OP(IdP 侧,op.py)"]
O1["authorize():有会话 → 302 带 code;无会话 → 登录页"]
O2["authenticate():建会话,写 OP_SESSION"]
O3["token():先消耗 code,再校验 PKCE"]
O4["jwks() / userinfo() / end_session()"]
O5[("codes<br/>sessions<br/>refresh_tokens")]
end
J["jwt.py:三段结构、HS256、at_hash"]
I["ids.py:确定性 ID 与时钟"]
B2 --> R1
R1 -->|"授权请求 URL"| O1
O1 -->|"200 登录页"| B1
O1 -.->|"302 带 code(已有会话)"| R2
B1 -->|"POST 凭据"| O2
O2 --> O5
O2 -.->|"302 带 code"| R2
R2 --> R3
R3 -->|"code + code_verifier"| O3
O3 --> O5
O3 -.->|"invalid_grant"| R4
O3 -->|"三种 token"| R4
R4 --> R5["登录完成:sub / sid / claims"]
O4 --> O5
J --> R4
J --> O3
I --> R1
I --> O3
server.py 是同一份端点的另一张皮:把 MiniOP 挂到 http.server 上,
于是可以用 curl 手玩同一条流。两条路的分支逻辑完全一致,所以「浏览器与脚本行为不同」
这类现象可以直接归因到传输层(cookie 属性),而不是「两套实现不一样」。
文件
| 文件 | 作用 | 对应章节 |
|---|---|---|
src/minioidc/jwt.py |
JWT 三段结构、HS256 签名校验、at_hash 计算 |
03 |
src/minioidc/ids.py |
确定性 ID 工厂与假时钟(示例输出可复现的前提) | 全部 |
src/minioidc/op.py |
最小 OP:授权端点 / 令牌端点 / JWKS / 会话 / 登出 | 02、04、07 |
src/minioidc/rp.py |
最小 RP + 会管 cookie 的 Browser |
02、04 |
src/minioidc/lab.py |
固定配置的实验台:一个 IdP、两个应用、两个用户 | 全部 |
src/minioidc/server.py |
把 OP 挂到 http.server,供 curl 手动观察 |
02 |
examples/01_authorization_code_flow.py |
授权码流四步走通,打印每步要素 | 02 |
examples/02_tampered_verifier.py |
PKCE 失败,且失败那次也消耗了 code | 02、06 |
examples/03_reused_code.py |
同一个 code 再用一次 | 02、06 |
examples/04_bad_signature.py |
改 payload / 换 kid 两种改法都验不过 | 03、06 |
examples/05_sso_and_logout.py |
第二个应用第一步 GET 就 302;登出前后的 cookie | 04 |
examples/06_token_lifetime_and_rotation.py |
300 / 1800 两种寿命与刷新轮换 | 07 |
examples/07_secure_cookie_over_http.py |
Secure; SameSite=None 在 http 下脚本不回带、浏览器行为不同 |
06 |
examples/08_two_idps_and_account_linking.py |
两家 IdP 下 sub 不同、(issuer, sub) 关联、Mix-Up 被拦 |
05 |
tests/ |
43 条测试,覆盖正常路径、单点登录、令牌契约、联邦与六类故障注入 | 全部 |
跑起来
cd courses/identity/sso-engineering/course/project
# 1) 八个示例,按顺序看
python examples/01_authorization_code_flow.py
python examples/02_tampered_verifier.py
python examples/03_reused_code.py
python examples/04_bad_signature.py
python examples/05_sso_and_logout.py
python examples/06_token_lifetime_and_rotation.py
python examples/07_secure_cookie_over_http.py
python examples/08_two_idps_and_account_linking.py
# 2) 测试(标准库 unittest 或 pytest 都可)
python -m pytest -q tests # 43 passed
python -m unittest discover -s tests
# 3) 可选:用 curl 打真实 HTTP(另开一个终端)
PYTHONPATH=src python -m minioidc.server 8081
curl -s http://127.0.0.1:8081/realms/sso-lab/.well-known/openid-configuration
curl -s http://127.0.0.1:8081/realms/sso-lab/protocol/openid-connect/certs
示例 01–06 的输出逐字可复现:ID 来自顺序工厂,时间来自可显式推进的假时钟,
没有任何随机数或系统时间参与。
示例顺序与要看什么
| 顺序 | 示例 | 只看一件事 |
|---|---|---|
| 1 | 01 | 第一步 GET 给的是登录页(200),不是 code |
| 2 | 01 | 回跳 URL 上的四个参数:state / session_state / iss / code |
| 3 | 01 | ID Token 的 aud 是 client_id,access_token 的 aud 是 account |
| 4 | 02 | PKCE 失败后,正确 verifier 也换不到(code 已被消耗) |
| 5 | 03 | 同一个 code 第二次使用 → Code not valid |
| 6 | 04 | 改 payload = 签名不匹配;换 kid = 取不到密钥 |
| 7 | 05 | 第二个应用第一步 GET 直接 302,且全程没有登录页 |
| 8 | 05 | 登出后 OP_SESSION 消失,再访问又要重新登录 |
| 9 | 06 | 第一次刷新后 refresh_token 变了,但旧的仍能用 |
| 10 | 06 | 推进 1801 秒后刷新 → Refresh token expired |
| 11 | 07 | 同一个 Secure cookie:http 下脚本不回带、https 下回带 |
| 12 | 08 | 两家 IdP 给同一个人的 sub 不同;跨 issuer 的回跳被 invalid_issuer 拦下 |
测试覆盖
tests/ 下四组,共 34 条:
| 文件 | 覆盖 |
|---|---|
test_authorization_flow.py |
四步的正常路径、回跳参数集合、六项 ID Token 校验、aud 分工 |
test_sso_and_logout.py |
单点登录判据(第一个 GET 的状态码)、sub 相同 / aud 不同 / sid 相同、登出清会话 |
test_token_contracts.py |
300/1800 寿命、access_token 过期被拒、刷新轮换、旧 refresh 仍可用、client_secret 正误两路 |
test_failure_injection.py |
PKCE 失败 + code 被消耗、code 重放、redirect_uri 未登记、state 不符、iss 缺失、签名篡改、未知 kid、时钟容差、at_hash 不符、密钥轮换后老 token 仍可验 |
每条失败路径都断言具体的 error 串(例如 PKCE verification failed: Code mismatch),
不写「抛异常就算过」。
三处刻意保留的真实行为
- 换 token 时先消耗 code、再校验 PKCE(
op.py的_exchange_code)。
所以一次 PKCE 失败会把 code 烧掉,这是真实实现的默认行为,不是 bug。 - refresh token 轮换但不做 reuse detection(
op.py的_refresh)。
旧 token 仍在服务端映射表里,可以继续换——想收紧得自己开。 - 会话 cookie 带
Secure; SameSite=None,即使 issuer 是http://localhost。
脚本(不回传 Secure cookie)与浏览器(把http://localhost当可信源)在这一点上
行为不同,正文 06 章把这条差异当成故障素材。