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),
不写「抛异常就算过」。

三处刻意保留的真实行为

  1. 换 token 时先消耗 code、再校验 PKCE(op.py 的 _exchange_code)。
    所以一次 PKCE 失败会把 code 烧掉,这是真实实现的默认行为,不是 bug。
  2. refresh token 轮换但不做 reuse detection(op.py 的 _refresh)。
    旧 token 仍在服务端映射表里,可以继续换——想收紧得自己开。
  3. 会话 cookie 带 Secure; SameSite=None,即使 issuer 是 http://localhost。
    脚本(不回传 Secure cookie)与浏览器(把 http://localhost 当可信源)在这一点上
    行为不同,正文 06 章把这条差异当成故障素材。

进入 keel 阅读