OAuth 与 OIDC:授权码、令牌和登录回调
一次 OIDC 登录的授权请求通常包含这些字段:
GET /oauth2/authorize
├── response_type=code 使用授权码
├── client_id=lab-client 标识已登记客户端
├── redirect_uri=.../login/oauth2/code/lab
│ 返回授权结果的地址
├── scope=openid profile bills:read 请求认证与访问范围
├── state=<本轮关联值> 关联客户端发起的授权事务
├── nonce=<本轮认证关联值> 关联后续 ID token
├── code_challenge=<挑战值> 约束授权码兑换者
└── code_challenge_method=S256 PKCE 挑战计算方式这些参数由客户端框架生成并保存关联状态。用户通过浏览器在身份服务登录后,浏览器把授权码带回客户端;客户端再通过后端连接兑换令牌。
协议中的角色与关联关系
OAuth 委托访问,OIDC 增加身份认证
OAuth 中,资源所有者允许客户端在一定范围内访问资源。客户端是请求访问的软件,不等同于浏览器:服务端 Web 应用、桌面程序、移动应用和后台任务都可以承担这个角色。
授权服务器验证用户或客户端,处理授权并签发令牌;资源服务器接收 access token,判断令牌是否适用于本资源以及是否具有所需权限。一个组织可以把授权服务和资源服务部署在同一进程,协议职责仍然分开。
OIDC 在 OAuth 上增加用户认证语义。请求包含 openid scope,客户端得到 ID token,并按 OIDC 规则确认身份。单纯拿到一个 access token,无法自动知道应该怎样把其内容当作登录结果。身份声明、ID token 与 UserInfo 的完整要求见 OpenID Connect Core。
| 材料 | 交给谁使用 | 主要作用 |
|---|---|---|
| authorization code | 产生该授权请求的客户端 | 短时、一次性兑换令牌 |
| access token | 指定资源服务 | 表达获准的资源访问能力 |
| ID token | OIDC 客户端 | 表达经验证的认证结果 |
| refresh token | 授权服务器 | 申请新的 access token |
scope 表达获准的操作范围,audience 表达令牌的预期接收方;两者需要同时匹配。用户的应用角色还可能来自本地数据库,不能把任意外部 group 字段直接提升为本地超级管理员权限。
公共客户端与机密客户端
机密客户端能在受控服务器中保存凭据,常见于后端 Web 应用。公共客户端运行在用户可检查的环境中,例如浏览器脚本或安装到用户设备的程序,不能把随包发布的 client secret 当作秘密。client_id 只是公开标识。
BFF 模式把令牌保存在后端,浏览器使用应用会话 Cookie;这样减少浏览器直接接触令牌,但要处理 CSRF、会话过期与后端令牌刷新。SPA 直接持有令牌时,需要更仔细地控制脚本来源、XSS、跨域请求和令牌存储。原生应用应使用系统浏览器等合适授权入口,避免把用户密码收集到应用自身的登录表单中。
常用授权方式各有用途。授权码配合 PKCE 用于用户参与的授权;client credentials 表达客户端自身身份,适合后台服务调用,不会凭空产生一个终端用户。设备授权适合输入受限或缺少合适浏览器的设备:设备显示验证地址和用户码,用户在另一设备确认,原设备按服务器规定的间隔轮询。设备授权的超时、slow_down 和拒绝响应见 RFC 8628。
不再为新系统选择密码授权模式;客户端直接掌握用户密码会破坏认证器升级和身份服务隔离。隐式流程在前通道直接返回访问令牌,现代接入通常选择授权码与 PKCE。RFC 9700说明了当前 OAuth 安全要求和旧流程风险。
授权码如何被绑定到一次请求
浏览器 客户端后端 授权服务器 资源服务器
│ 发起登录 │ │ │
│ ──────────────────> │ 保存 state/nonce/verifier │
│ <─ 重定向授权地址 ── │ │ │
│ ── 授权请求、登录和确认 ─────────────────────> │ │
│ <────────── 带 code 与 state 的回调地址 ───── │ │
│ ── 回调 ──────────> │ │ │
│ │ 校验事务关联 │ │
│ │ ── code + verifier ──> │ │
│ │ <─ access / ID token ─ │ │
│ │ 校验 ID token 并建立应用 Session │
│ │ ───────── access token ─────────────────> │
│ │ <────────── 业务结果 ───────────────────── │redirect URI 必须符合已登记地址及协议允许的匹配规则。授权服务器不能接受一个任意地址,再把 code 发过去;出现无效重定向地址时,应在受控页面报告错误,而不跳向那个地址。
PKCE 为每轮请求生成高熵 verifier,授权请求携带其 S256 challenge,兑换时再发送 verifier。授权服务器把 code 与 challenge 关联,错误 verifier 无法兑换。它保护的是授权码兑换过程,不负责给用户分配业务角色。算法与字段定义见 RFC 7636。
state 通常把回调与客户端保存的授权事务关联;nonce 把 ID token 与发起的认证请求关联。正确实施且满足协议前提的 PKCE 等机制也可承担特定 CSRF 防护,因此不能只按参数名字判断安全性。实际集成应保留框架的完整请求关联,并防止 challenge 降级、事务混用或用户可控的回调目标。
同时接入多个身份提供方时,客户端应把预期 issuer 绑定到本轮请求。发现文档、授权地址、token 地址和 JWKS 必须来自受信任的配置关系,不能根据回调里一个未经验证的 URL 临时选择签发方。
运行授权服务与 OIDC 客户端
实验中的两个进程
下载 oidc-lab.zip。工程使用 Spring Boot 4.1.1、Spring Security 7.1.1 和 Maven 3.9.12,编译目标 Java 17,可使用 Java 17 或 25 测试。授权服务配置采用 Spring Authorization Server 组件,客户端采用 oauth2Login。
server profile 在 18521 端口提供授权、令牌、JWKS、UserInfo,以及一个资源接口;client profile 在 18522 提供登录入口与回调。两者使用 SEC15IDP 和 SEC15CLIENT 两个 Cookie 名称。Cookie 不按端口隔离,使用不同名称避免本机实验互相覆盖。
本机采用 HTTP、内存客户端/授权记录和启动生成的 RSA 密钥,用户为 alice;客户端预先批准本实验 scope,不展示同意页。生产接入使用 HTTPS、受控私钥、持久化状态及适合业务的同意策略,不将这份本机服务公开部署。
在 Linux 宿主执行,需要 Docker、curl、jq、OpenSSL、unzip;当前账号具有 Docker daemon 访问权限:
unzip oidc-lab.zip
cd oidc-lab
mkdir -p .m2 secrets
umask 077
openssl rand -base64 24 > secrets/password
openssl rand -hex 24 > secrets/client-secret
LAB_UID=$(id -u)
LAB_GID=$(id -g)
docker run --rm --user "$LAB_UID:$LAB_GID" -e MAVEN_CONFIG=/tmp/.m2 \
-v "$PWD:/work" -v "$PWD/.m2:/m2" -w /work \
maven:3.9.12-eclipse-temurin-17 \
mvn -B -ntp -Dmaven.repo.local=/m2 -Duser.home=/tmp clean verify
docker run -d --name sec15-oidc --user "$LAB_UID:$LAB_GID" \
--read-only --tmpfs /tmp:rw,nosuid,nodev,size=192m \
-p 127.0.0.1:18521:18521 -p 127.0.0.1:18522:18522 \
-v "$PWD:/work:ro" -w /work \
eclipse-temurin:17.0.20_8-jdk bash run-local.sh
docker logs --tail 30 sec15-oidc
curl -q --noproxy '*' --fail-with-body http://127.0.0.1:18522/healthMaven 写入当前 UID/GID 拥有的缓存与 target。run-local.sh 先启动授权服务,最多等待其健康接口 60 轮,再启动需要发现元数据的客户端。两个应用都运行后,18522 的健康接口返回 ready。仍在启动时可先观察日志,完成监听后重试;失败则检查密码文件权限、镜像/依赖下载和端口占用。内网环境可以预装固定版本镜像和 Maven 缓存,不关闭 TLS 校验。
浏览器登录与 F12 观察
浏览器打开 http://127.0.0.1:18522/,点击“使用实验身份服务登录”。进入授权服务的登录页后,用户名填写 alice,密码使用本轮 secrets/password 的内容。不要改用 localhost:issuer 和回调配置固定为 127.0.0.1。

成功登录后浏览器回到客户端 /me,返回 subject=alice 和 issuer,页面不展示 access token 或 ID token。
在 F12 的 Network 中勾选 Preserve log,可以依次观察客户端登录入口、授权地址、授权服务表单提交和回调。授权请求包含 challenge,回调包含 code 与 state;客户端后端向 token endpoint 发起的兑换通常不会出现在浏览器 Network 中。
不要导出含完整 Cookie、code、token 或 verifier 的 HAR 作为公开材料。排查时记录路径、状态、参数名称和脱敏关联信息即可。确认两个会话时,检查 Cookie 名称与归属,而不比较某个固定会话值。
客户端配置与身份映射
ClientConfiguration 通过 issuer discovery 建立 ClientRegistration,指定 client_id、secret、回调地址和 scope。DefaultOAuth2AuthorizationRequestResolver 配合 withPkce() 为机密客户端也启用 PKCE;授权服务把 requireProofKey 设置为 true。对应扩展接口见 Spring Authorization Grant Support。
客户端需要验证 ID token 的签名、issuer、audience、时间声明及本轮 nonce 等规则,再建立本地会话。用户主键优先使用 (iss, sub),邮箱和展示名可能变化,也可能在不同身份提供方重复。读取 UserInfo 时还要校对其 sub 与 ID token 一致,不能把另一个主体的属性合并进当前用户。
本地角色映射应限制来源和允许的权限集合。新增身份提供方不应自动继承既有管理员权限;外部账号与本地账号合并也要有明确验证流程。oauth2Login 的 UserInfo 和权限转换扩展见 Spring OAuth2 Login 高级配置。
访问令牌、续期与撤销的实际行为
用客户端凭证调用资源接口
下面使用同一实验注册展示服务身份调用。生产中通常为交互式客户端和后台任务建立不同注册、凭据及权限集合,避免一份 secret 拥有两类用途。
AUTH=http://127.0.0.1:18521
CLIENT_SECRET=$(cat secrets/client-secret)
curl -q --noproxy '*' --fail-with-body -sS \
"$AUTH/.well-known/openid-configuration" > metadata.json
jq '{issuer, authorization_endpoint, token_endpoint, jwks_uri}' metadata.json
curl -q --noproxy '*' --fail-with-body -sS -u "lab-client:$CLIENT_SECRET" \
--data grant_type=client_credentials --data-urlencode scope=bills:read \
"$AUTH/oauth2/token" > token.json
TOKEN=$(jq -r .access_token token.json)
curl -q --noproxy '*' --fail-with-body \
-H "Authorization: Bearer $TOKEN" "$AUTH/resource/bills"资源接口返回 subject=lab-client,表示客户端自身;token 响应没有 ID token 或 refresh token。实验为 access token 设置 billing-api audience,资源服务同时要求正确 issuer、签名、时间与 bills:read scope。
本轮 client secret 使用十六进制字符,不含需要额外处理的 Basic 特殊字符。通用 client_secret_basic 按 OAuth 规则对客户端 ID 和 secret 进行表单编码后,再组成 Basic 值;接入任意凭据时使用框架客户端,避免直接复制一条未编码的 -u 命令。Basic 编码要求见 RFC 6749 客户端密码认证。
Bearer 令牌一般由持有者直接使用,应通过 HTTPS 的 Authorization Header 传输,不放在普通查询参数中,避免进入历史记录和代理日志。RFC 6750定义了 Bearer 请求与错误响应。
错误 client secret 的对照请求:
code=$(curl -q --noproxy '*' -sS -u lab-client:wrong-secret \
--data grant_type=client_credentials --data scope=bills:read \
-o bad-client.json -w '%{http_code}' "$AUTH/oauth2/token")
transport=$?
test "$transport" -eq 0 && test "$code" = 401 && \
test "$(jq -r .error bad-client.json)" = invalid_client || \
{ printf '客户端认证失败响应不符合预期\n' >&2; exit 1; }-q 必须是 curl 第一个选项;--noproxy '*' 固定回环直连。成功路径使用 --fail-with-body,负例同时核对传输、HTTP 状态和协议错误字段。参数说明见 curl 手册。
刷新与重放
授权码只使用一次,并与客户端、回调地址和 PKCE challenge 关联。网络超时后如果不知道兑换是否已经成功,盲目重复使用同一个 code 可能得到 invalid_grant;客户端需要重新发起适用的登录/授权流程,而不是放宽一次性校验。
Refresh token 轮换改变后续续期所需的材料。实验配置 reuseRefreshTokens(false),新值替代旧值;测试用旧值再次刷新时得到 invalid_grant。客户端应让同一授权记录的并发请求共享刷新结果,避免多个请求同时消费旧 refresh token。
轮换策略、令牌家族撤销、客户端认证与令牌寿命属于授权服务配置。Spring 的协议组件和端点范围见 Authorization Server 配置模型。
撤销端点与资源服务的接受窗口
保留上一步的 TOKEN,撤销后查询授权服务状态:
curl -q --noproxy '*' --fail-with-body -u "lab-client:$CLIENT_SECRET" \
--data-urlencode "token=$TOKEN" "$AUTH/oauth2/revoke"
curl -q --noproxy '*' --fail-with-body -sS -u "lab-client:$CLIENT_SECRET" \
--data-urlencode "token=$TOKEN" "$AUTH/oauth2/introspect" > token-state.json
test "$(jq -r .active token-state.json)" = false || \
{ printf '授权服务仍报告令牌有效\n' >&2; exit 1; }
curl -q --noproxy '*' --fail-with-body \
-H "Authorization: Bearer $TOKEN" "$AUTH/resource/bills"introspection 返回 active=false,但最后一个请求仍可能成功:实验资源服务本地验证 JWT,不查询授权存储,token 未到期且签名与声明仍符合要求。若操作过程中已经超过五分钟有效期,最后一个请求会因过期被拒绝;重新取得 token 后立即完成这组对照即可观察未过期窗口。
撤销定义见 RFC 7009,内省定义见 RFC 7662。需要较短撤销延迟时,资源服务应采用内省、账号版本或撤销集合等状态检查,并把缓存期限纳入要求。
本地应用注销、身份提供方会话注销、refresh token 撤销和 access token 失效各作用于不同材料。仅退出客户端 Session,用户仍可能在身份提供方保持登录,下次授权迅速回到客户端;单点登出的具体端点及前后通道能力需要按提供方支持配置。
发送者约束进一步限制令牌转用
mTLS 绑定令牌时,资源服务还要验证调用连接使用的客户端证书与令牌绑定相符;相关要求见 RFC 8705。DPoP 则使用请求级证明,把令牌与持有的密钥关联,并检查方法、目标、时间和重放信息,见 RFC 9449。
这类方案要求授权服务和资源服务共同支持。代理终止 TLS、请求目标改写和密钥持有方式都会影响绑定校验;仅启用普通服务端 TLS,不会自动把一个 Bearer token 变成证书绑定令牌。
按协议阶段定位登录失败
回调没有找到原来的授权事务
检查客户端发起授权时保存的 Session 是否在回调中恢复。常见原因包括 Cookie 域名不同、SameSite 与回调方式不符、HTTP/HTTPS 混用、负载均衡切到没有该会话的实例,或用户重复操作覆盖了旧事务。
不要把 state 校验关闭来消除错误。先比较发起请求与回调的关联值是否属于同一轮,再检查 Cookie 和会话存储。实验的 changedStateCannotEstablishAuthenticatedClientSession 会更改真实回调的 state,随后验证 /me 仍为 401;客户端可能保留匿名关联 Session,但没有建立已认证身份。
token endpoint 返回 invalid_grant
依次检查 code 是否已使用、是否过期、redirect URI 是否与授权请求一致、verifier 是否来自同一轮授权。client_id 和客户端认证失败常表现为 invalid_client,应与授权码错误区分。
实验 tokenEndpointRejectsWrongPkceAndRepeatedCode 对真实授权服务器分别提交错误 verifier 和重复 code,两种请求都被拒绝。排障日志记录错误类别,不输出 code、verifier 或 secret。跨代理环境检查外部回调 URL 的生成方式,只有受信任代理才可提供用于还原外部地址的 Forwarded 信息。
令牌已返回,客户端仍拒绝登录
继续检查 ID token 的 issuer、audience、签名密钥、时间和 nonce,以及 UserInfo 的主体是否一致。不要根据“已经拿到 token”跳过客户端验证。
实验 realOidcClientRejectsNonceNotBoundToItsRequest 修改真实授权请求的 nonce,授权服务签出相应 ID token 后,客户端因其不符合原请求而拒绝登录。这个分支能区分“授权服务成功签发”和“客户端接受此次认证”。
停止实验只回收本轮双进程容器:
docker stop sec15-oidc
docker rm sec15-oidc
unset CLIENT_SECRET TOKEN确认不再需要后删除 secrets、token.json 和相关 Cookie。测试工程的授权、刷新和密码记录都在内存中,容器重建后需要重新登录。
权威资料与规范地址
OpenID Connect Core:https://openid.net/specs/openid-connect-core-1_0.html
RFC 8628 设备授权:https://www.rfc-editor.org/rfc/rfc8628.html
RFC 9700 OAuth 安全最佳实践:https://www.rfc-editor.org/rfc/rfc9700.html
RFC 7636 PKCE:https://www.rfc-editor.org/rfc/rfc7636.html
Spring Authorization Server 入门:https://docs.spring.io/spring-security/reference/servlet/oauth2/authorization-server/getting-started.html
Spring Authorization Grant Support:https://docs.spring.io/spring-security/reference/servlet/oauth2/client/authorization-grants.html
Spring OAuth2 Login:https://docs.spring.io/spring-security/reference/servlet/oauth2/login/advanced.html
RFC 6749 客户端密码认证:https://www.rfc-editor.org/rfc/rfc6749.html#section-2.3.1
RFC 6750 Bearer Token:https://www.rfc-editor.org/rfc/rfc6750.html
curl 手册:https://curl.se/docs/manpage.html
Spring Authorization Server 配置模型:https://docs.spring.io/spring-security/reference/servlet/oauth2/authorization-server/configuration-model.html
RFC 7009 令牌撤销:https://www.rfc-editor.org/rfc/rfc7009.html
RFC 7662 令牌内省:https://www.rfc-editor.org/rfc/rfc7662.html
RFC 8705 mTLS 绑定:https://www.rfc-editor.org/rfc/rfc8705.html
RFC 9449 DPoP:https://www.rfc-editor.org/rfc/rfc9449.html
