OAuth 2.0 定义应用怎样取得访问资源的授权;OpenID Connect(OIDC)在其上定义用户认证结果的表达和验证。一个应用可以只申请 API 授权,也可以通过 OIDC 登录,再建立自己的 session。JWT 是一种 token 表示格式,既不是 OAuth 的同义词,也不是“无需维护任何状态”的承诺。
| 角色 | 例子 | 核心责任 |
| Resource Owner | 拥有文档的用户 | 授权访问;并非所有流程都有用户参与 |
| Client | 请求文档权限的应用 | 发起授权、保存事务、持有并使用 token |
| Authorization Server | 组织身份与授权服务 | 认证、授权决策、签发和管理 token |
| Resource Server | 文档 API | 验证 token,并落实资源级权限 |
授权服务器与资源服务器可以属于同一产品,也可以独立部署。浏览器主要承担用户交互和重定向;在服务端 Web 模式中,真正兑换授权码、保存 token 的客户端通常是应用后端。RFC 6749
用一次登录解释 Authorization Code + PKCE
应用开始登录前创建一笔短期事务,记录预期 issuer、回调地址、随机 state、nonce 与 PKCE verifier,并与发起登录的浏览器会话绑定。不同标签页可以并发登录,所以不能只用一个全局变量保存“最后一个 state”。
| 步骤 | 关键内容 | 完成条件 |
| 发起授权 | response_type=code、client_id、redirect_uri、scope | 参数与注册和本地事务一致 |
| PKCE 绑定 | code_challenge、code_challenge_method=S256 | verifier 留在客户端,challenge 发往授权端 |
| 用户交互 | 登录及适用的 consent | 已有会话可能省去再次输入凭据,并非每次弹窗 |
| 处理回调 | code 或 error,以及返回的 state | 找到并核对发起事务,拒绝过期或无关响应 |
| 兑换 | code、verifier、适用的 redirect_uri 和客户端认证 | 授权服务器验证绑定关系,授权码不可重复使用 |
| 建立应用会话 | 验证 OIDC ID Token、映射本地账户 | 再创建应用自己的会话与权限上下文 |
客户端需将错误回调也关联到原事务;不能因返回了 error=access_denied 就跳过关联检查。生产实现还要处理回调重复、换码响应丢失和事务过期,而非简单重放同一个授权码。
OAuth 安全最佳实践要求授权服务器支持 PKCE,公共客户端使用 PKCE;机密客户端也应使用。隐式流程并不是从所有系统中消失了,但直接在授权响应返回 access token 的方式不再是新实现的常规选择;密码授权则明确不应使用。机器身份、设备交互等仍有自己的授权方式,不能把 code flow 称为“一切场景唯一流程”。RFC 9700 §2
PKCE 的值怎样算出来
S256 的计算是 BASE64URL(SHA256(ASCII(code_verifier))),移除 Base64 尾部的 =。verifier 长度为 43–128 个允许字符,应由足够熵的随机数产生;challenge 不是 SHA-256 的十六进制字符串。
import base64
import hashlib
import secrets
def challenge(verifier):
return base64.urlsafe_b64encode(
hashlib.sha256(verifier.encode('ascii')).digest()
).rstrip(b'=').decode('ascii')
verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
expected = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'
assert challenge(verifier) == expected
assert challenge(verifier + 'x') != expected
fresh = secrets.token_urlsafe(32)
assert len(fresh) == 43 and len(challenge(fresh)) == 43
print('PKCE 公开向量及修改拒绝通过')
拦截到 code 的一方如果没有对应 verifier,便不能仅凭 code 完成这次兑换。PKCE 不是客户端长期身份认证:公开分发的 SPA 或原生程序不能靠内嵌 client_secret 保密;能够保护秘密的服务端客户端仍按注册方式认证自己。RFC 7636
state、nonce、PKCE 分别绑定什么
| 值 | 关联的位置 | 应核对的关系 |
| state | 授权请求与浏览器回调 | 此回调是否属于这个浏览器发起的事务 |
| nonce | OIDC 请求与 ID Token | token 中的认证结果是否绑定预期事务 |
| PKCE | 授权请求与 token 兑换 | 兑换者是否持有当时生成的 verifier |
三者的防护作用有交集,不能简单说“缺少其中任何一个,在所有协议模式中都必定违规”;本文示例显式使用三者,便于分别验证关联关系。尤其在多个 issuer 场景,还要防止把 A 的响应交给 B 的 token endpoint:预期 issuer 应从可信事务和配置确定,不能只相信回调给出的任意地址。
challenge为什么不是SHA256的十六进制?
这两个输出来自同一份32 B摘要,但只有Base64url输出是协议要求的challenge。改一个允许字符会改变绑定结果;把verifier缩短到43字符以下,则先违反语法要求,不能把任意字符串的hash当合法PKCE。
正在呈现知识画面
challenge为什么不是SHA256的十六进制?摘要是32个字节,hex与Base64url是不同编码。只有按S256规则得到绑定challenge,才满足这项兑换检查。
{"id":"pkce-s256-bytes","title":"challenge为什么不是SHA256的十六进制?","summary":"摘要是32个字节,hex与Base64url是不同编码。只有按S256规则得到绑定challenge,才满足这项兑换检查。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><label class=\"form-field\"><span data-i18n=\"verifier\"></span><input type=\"text\" id=\"verifier\" value=\"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk\" maxlength=\"128\" autocomplete=\"off\" spellcheck=\"false\"></label><section class=\"panel\"><h3 data-i18n=\"digest\"></h3><div id=\"digest\"></div></section><section class=\"panel\"><h3 data-i18n=\"challenge\"></h3><div id=\"challenge\"></div></section><div class=\"metrics\"><div><span data-i18n=\"result\"></span><strong id=\"result\"></strong></div></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n.panel{border:1px solid var(--rule);border-radius:9px;padding:12px;margin:14px 0;min-width:0}.panel h3{font-size:13px;margin:0 0 10px;color:var(--muted)}.source{font:13px/1.8 var(--viz-font-mono);white-space:pre-wrap;overflow-wrap:anywhere;margin:12px 0}.source mark{background:var(--accent-soft);color:var(--ink);border-bottom:2px solid var(--accent)}.source .fault{background:var(--second-soft);border-color:var(--second)}.diagram{display:block;width:100%;height:auto;max-height:320px;margin:14px 0;overflow:visible}.diagram text{fill:var(--ink);font:12px var(--viz-font-ui)}.diagram .edge{stroke:var(--muted);stroke-width:1.5;fill:none}.diagram .edge.on{stroke:var(--accent);stroke-width:3}.diagram .node{fill:var(--surface);stroke:var(--rule);stroke-width:1.5}.diagram .node.on{fill:var(--accent-soft);stroke:var(--accent)}.diagram .node.gold{fill:var(--second-soft);stroke:var(--second)}.log{font:12px/1.8 var(--viz-font-mono);white-space:pre-line;overflow-wrap:anywhere;min-height:7.2em}.table{display:grid;gap:5px;margin:14px 0}.table .tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.table .tr>*{padding:8px 5px;background:var(--surface);border-radius:4px;min-width:0;overflow-wrap:anywhere;font-size:12px}.table .tr .on{background:var(--accent-soft)}.table .tr .gold{background:var(--second-soft)}.choices button{min-height:44px}.metrics strong{min-height:1.6em}.code-line{display:block}.code-line.on{background:var(--accent-soft);border-left:2px solid var(--accent);padding-left:6px}.status-badge{font-size:12px;color:var(--accent)}.tile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.tile-grid .cell{min-height:40px}.actions button{min-height:48px}.form-field{display:grid;gap:8px;margin:14px 0;font-size:13px}.form-field input{width:100%;font-family:var(--viz-font-ui)}.metrics.three{grid-template-columns:repeat(3,minmax(0,1fr))}.formula{min-height:3.2em}.presets{flex-wrap:wrap}.presets button{flex-basis:25%;min-height:44px}\n\n.budget{display:flex;height:28px;margin:16px 0;background:var(--surface);border-radius:5px;overflow:hidden}.budget i{display:block;background:var(--accent);transition:width .2s}.budget i.gold{background:var(--second)}.intervals{margin:16px 0}.interval{position:relative;height:36px;background:var(--surface);margin:6px 0;border-radius:4px;overflow:hidden}.interval i{position:absolute;height:100%;background:var(--accent-soft);border-left:2px solid var(--accent)}.interval i.gold{background:var(--second-soft);border-color:var(--second)}.interval span{position:relative;z-index:1;font:12px/36px var(--viz-font-ui);padding-left:7px}.table .tr>*{min-height:4.8em}.formula{white-space:pre-line;min-height:7.2em}.actions:has(>button:only-child){grid-template-columns:1fr}.metrics strong{min-height:3.2em}.rangegrid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:3px;margin:14px 0}.rangegrid .cell{min-height:30px;padding:3px 1px;font-size:10px}.message{min-height:7.2em;white-space:pre-line;font:13px/1.8 var(--viz-font-ui);overflow-wrap:anywhere}\n/* Allow CJK hanging punctuation without overflowing a metric's border box. */\n.metrics>div{padding-right:4px}.metrics span{line-break:anywhere}\n\n#digest,#challenge{font:13px/1.8 var(--viz-font-mono);overflow-wrap:anywhere;min-height:5.4em}.metrics strong{font-size:18px}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).classList.add('table');$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}function band(id,parts,total){$('#'+id).replaceChildren(...parts.map((p,i)=>{const e=document.createElement('i');e.style.width=(100*p.value/total)+'%';e.className=i%2?'gold':'on';e.title=p.name+': '+p.value;return e}));}function intervals(id,items,total){$('#'+id).replaceChildren(...items.map(p=>{const row=document.createElement('div');row.className='interval';const bar=document.createElement('i');bar.style.left=(100*p.start/total)+'%';bar.style.width=(100*(p.end-p.start)/total)+'%';bar.className=p.cls||'';const label=document.createElement('span');label.textContent=p.label;row.append(bar,label);return row;}));}const enc=s=>new TextEncoder().encode(s),cat=(...xs)=>Uint8Array.from(xs.flatMap(x=>[...x])),hex=x=>[...x].map(n=>n.toString(16).padStart(2,'0')).join('');async function sha(x){return new Uint8Array(await crypto.subtle.digest('SHA-256',x))}async function hmac(k,x){const key=await crypto.subtle.importKey('raw',k,{name:'HMAC',hash:'SHA-256'},false,['sign']);return new Uint8Array(await crypto.subtle.sign('HMAC',key,x))}async function prf(secret,label,seed,n){const s=cat(enc(label),seed);let a=s,out=new Uint8Array;while(out.length<n){a=await hmac(secret,a);out=cat(out,await hmac(secret,cat(a,s)))}return out.slice(0,n)}async function expand(secret,info,n){let prev=new Uint8Array,out=new Uint8Array;for(let i=1;out.length<n;i++){prev=await hmac(secret,cat(prev,info,[i]));out=cat(out,prev)}return out.slice(0,n)}async function label(secret,name,ctx,n){const l=enc('tls13 '+name),info=cat([n>>8,n&255,l.length],l,[ctx.length],ctx);return expand(secret,info,n)}async function derive(secret,name,messages){return label(secret,name,await sha(messages),32)}let epoch=0;async function draw(){const e=++epoch,v=$('#verifier').value,valid=/^[A-Za-z0-9._~-]{43,128}$/.test(v);$('#result').dataset.ready='false';set('result',t('busy'));const hash=await sha(enc(v)),b64=btoa(String.fromCharCode(...hash)).replace(/\\+/g,'-').replace(/\\//g,'_').replace(/=+$/,'');if(e!==epoch)return;set('digest',hex(hash));set('challenge',b64);const match=valid&&b64==='E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';set('result',!valid?'INVALID VERIFIER':match?'MATCH':'MISMATCH');$('#result').dataset.ready='true';$('#result').dataset.match=String(match);}$('#verifier').oninput=draw;draw();","audio":false,"strings":{"busy":"计算中","challenge":"Base64url(43字符)","digest":"SHA256 hex(64字符)","heading":"challenge为什么不是SHA256的十六进制?","intro":"编辑公开测试verifier,实际计算SHA256和无填充Base64url,比较授权时绑定值。","reset":"重置","result":"本项绑定检查","scope":"RFC7636公开向量,不是秘密;只验证verifier语法与S256绑定,不实现code有效期、一次性消费、客户端认证、state或OIDC。","scopeLabel":"模型范围与约定","verifier":"公开 verifier"}}
ID Token、Access Token、Refresh Token 不能互换
ID Token 面向 OIDC 客户端,陈述用户认证结果;它的有效期不等于应用 session 的强制有效期,也不代替每次 API 的权限判断。Access Token 面向资源服务器,可以包含用户信息,也可能代表没有交互用户的客户端。是否包含用户声明取决于令牌格式和授权场景,不能从名称推断。
OIDC 的 scope=openid 表明请求 OIDC 认证;profile、email 等是对 claim 的请求,并不保证所有字段都出现。email 可能变化,也不天然全局唯一;应用的外部身份键应以可信 iss 与 sub 的组合为基础,再建立本地账户映射。调用 UserInfo 时,应核对其 sub 与已经验证的 ID Token 一致。OIDC Core
请求 offline_access 也不代表服务端无条件返回 refresh token;签发取决于流程、用户同意和提供方策略。一次 token 响应没有 refresh token,不一定是协议错误。
JWT 验证:先决定信任谁,再选择 key
常见签名 JWT 使用 JWS compact 三段形式:Base64url(header)、Base64url(payload)、signature。编码不是加密,拿到这种 token 的人通常能读取 claims;加密 JWT 使用不同结构,不能用“数三个点段”概括所有 JWT。
kid 是查找可信密钥集合中候选密钥的提示,不是证书、更不是任意取钥 URL。客户端应从可信 issuer 的 discovery 元数据取得 jwks_uri,核对元数据中的 issuer;JWKS 路径不固定为 /.well-known/jwks.json。不要根据未经验证的 token 任意访问一个 issuer 或 jku。OIDC Discovery
| 验证层 | 判断内容 |
| 密码与格式 | 允许的算法、签名、密钥用途、解析和重复字段策略 |
| 信任来源 | issuer 必须精确匹配预期配置 |
| 接收者 | ID Token 的 aud 包含本客户端;Access Token 面向当前 API |
| 时间 | exp、适用的 nbf、可接受时钟偏差及认证新鲜度 |
| 事务绑定 | 若请求 nonce,核对其值;处理 azp 等相应规则 |
| token 用途 | 为 ID Token 和 Access Token 使用不同验证规则 |
| 业务授权 | scope、主体、租户、资源归属与动作 |
密钥轮换时可对未知 kid 有限度刷新缓存,同时避免每个陌生 kid 都触发无限外部请求。旧公钥的保留期要覆盖仍应被接受的旧 token;删除验证密钥与让 token 自然过期不是同一件事。JWT BCP
符合 RFC 9068 的 JWT Access Token 使用 at+jwt 类型,并定义相应 claims 与验证要求;不能要求所有历史或私有 access token 都自动遵循该 profile,也不能把“签名正确的任意 JWT”都接纳成 API 凭据。RFC 9068
下面仅演示可信 token 已验签后的业务授权,故意不把 JSON 字典当作完整 token 验证器。即便 scope 允许读取,也必须检查文档是否属于当前租户。
def may_read(claims, document, now):
return (
claims['iss'] == 'https://identity.example'
and claims['aud'] == 'documents-api'
and now < claims['exp']
and 'documents:read' in claims['scope'].split()
and claims['tenant_id'] == document['tenant_id']
)
c = dict(iss='https://identity.example', aud='documents-api',
exp=2000, scope='documents:read', tenant_id='tenant-a')
assert may_read(c, {'tenant_id': 'tenant-a'}, 1999)
assert not may_read(c, {'tenant_id': 'tenant-b'}, 1999)
assert not may_read(c, {'tenant_id': 'tenant-a'}, 2000)
assert not may_read(dict(c, aud='web-client'), {'tenant_id': 'tenant-a'}, 1999)
print('跨租户、过期和错误接收者均拒绝')
刷新、撤销与退出登录是三件事
Access Token 到期后,客户端可向授权服务器使用 refresh token 申请新 token。若采用轮换,每次刷新返回新的 refresh token 并淘汰旧值;客户端需要原子保存新值并协调并发刷新。两个请求同时使用旧 token,可能导致其中一个被视为复用,继而撤销整个 token 家族。
复用检测也会遇到“服务端已轮换,响应在网络丢失”的歧义。不能无限重试旧值;提供方的恢复窗口、复用策略和客户端重新认证路径需一致。公共客户端的 refresh token 应采用发送者约束或轮换等规定的重放保护,单纯把 token 放进客户端存储不是该机制。RFC 9700 §4.14
| 操作 | 直接改变的状态 | 不自动保证 |
| 删除应用 session | 本应用登录状态 | 所有 API token 已失效 |
| 撤销 refresh token | 后续刷新能力及提供方相关授权状态 | 已发 JWT 在各 API 立即被拒绝 |
| 授权服务退出登录 | 身份提供方会话 | 每个应用本地会话同步消失 |
| 撤销 access token | 授权服务器中的有效性 | 仅离线验签的 API 已实时获知 |
若 API 只检查 JWT 签名和到期时间,撤销后仍可能接受它直到到期;需要更快收敛时,可采用 introspection、撤销状态传播或更短有效期等明确方案。introspection 返回的 active 也依赖其上下文,缓存会产生可见延迟。Token Introspection、Token Revocation
Bearer token 的持有者可以使用它;DPoP 将 token 绑定到证明密钥,并对请求生成证明,改变这一使用条件。但它不替代 scope 或资源授权,也不能让被完全控制的客户端自动恢复可信。DPoP
应用形态决定 token 放在哪里
| 形态 | 身份与保存方式 | 需要处理的问题 |
| 服务端 Web / BFF | 后端兑换并保存 token,浏览器持有应用 session cookie | Cookie 属性、CSRF、后端会话生命周期 |
| 浏览器 SPA | 公共客户端,不能保守内嵌秘密 | XSS、刷新协调、页面重载后的恢复 |
| 原生应用 | 公共客户端,使用系统授权交互与 PKCE | 重定向接收、系统存储、进程生命周期 |
| 后台服务 | 适用时用 client credentials 表达服务身份 | 凭据保护、scope、轮换与工作负载归属 |
client credentials 没有终端用户登录步骤,不应用某个员工的身份解释它的 sub。基础规范中此流程不应签发 refresh token;服务通常用自身凭据重新获取 access token。应用内嵌 secret、OAuth 授权和用户认证三个问题要分别设计。RFC 6749 §4.4
排查集成时先定位阶段:授权请求被拒绝看注册和参数;invalid_grant 看 code 是否过期、已用或绑定不匹配;验签失败看可信 issuer 和密钥;登录成功但 API 被拒绝看 token 类型、audience、scope 与资源权限。不要用“能解码 JWT”作为登录集成完成的判据。