OAuth 2.0 は、アプリケーションがリソースへのアクセスを認可される方法を定義します。OpenID Connect(OIDC)は、その上にユーザー認証結果の表現と検証を定義します。アプリケーションは API 認可のみを要求することも、OIDC ログインを行ってから独自のセッションを確立することもできます。JWT はトークンの表現形式の一つであり、OAuth の同義語でもなく、「一切の状態管理を不要にする」という約束でもありません。
ロール 例 主要な責任
Resource Owner ドキュメントを所有するユーザー アクセスを認可する;すべてのフローにユーザーが関与するわけではない
Client ドキュメントへのアクセス権限を要求するアプリケーション 認可の開始、トランザクションの保存、トークンの保持と使用
Authorization Server 組織のアイデンティティと認可サービス 認証、認可判断、トークンの発行と管理
Resource Server ドキュメント API トークンの検証、リソースレベルの権限の適用
認可サーバーとリソースサーバーは、同じ製品に属することもあれば、独立してデプロイされることもあります。ブラウザは主にユーザーインタラクションとリダイレクトを担当します。サーバーサイドWebモードでは、認可コードを交換し、トークンを保存するクライアントは通常、アプリケーションのバックエンドです。RFC 6749
1回のログインで Authorization Code + PKCE を理解する
認可コードログイン:フロントチャネルで code を受け取り、クライアントが交換してローカルセッションを構築
ブラウザ
アプリケーションクライアント
認可サーバー
① トランザクションを保存し、リダイレクト
② authorize:challenge、state、nonce
③ ログイン後のコールバック:code、state
④ ローカルトランザクションに関連付け
⑤ token:code、verifier、クライアント認証
⑥ ID Token を検証し、アプリケーションセッションを作成
図はサーバーサイドWebクライアントを使用しています。SPAやネイティブアプリでは、トークンの保存とクライアント認証の機能が異なります。
アプリケーションはログイン開始前に短期間のトランザクションを作成し、期待される issuer、コールバックアドレス、ランダムな state、nonce、および PKCE verifier を記録し、ログインを起動したブラウザセッションにバインドします。異なるタブで並列にログインできるため、「最後の state」を単一グローバル変数で保存することはできません。
ステップ 重要な内容 完了条件
認可の開始 response_type=code、client_id、redirect_uri、scopeパラメータが登録情報とローカルトランザクションと一致していること
PKCE バインディング code_challenge、code_challenge_method=S256verifier はクライアント側に残し、challenge は認可エンドポイントに送信
ユーザーインタラクション ログインおよび必要な同意 既存のセッションがある場合は再度資格情報を入力する必要がない場合があり、毎回ポップアップが表示されるわけではない
コールバックの処理 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 の 16 進文字列ではありません。
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 トークン内の認証結果が期待されるトランザクションにバインドされているか
PKCE 認可リクエストとトークン交換 交換者が、当時生成された verifier を保持しているか
これら3つの防護機能には重複がありますが、「そのいずれかが欠けていると、すべてのプロトコルモードで必ず違反する」と単純に言うことはできません。本文の例では、関連する関係を個別に検証しやすくするために、これら3つを明示的に使用しています。特に複数の issuer シナリオでは、A の応答を B のトークンエンドポイントに渡さないようにする必要があります。期待される issuer は、信頼できるトランザクションと設定から決定すべきであり、コールバックから与えられた任意のアドレスだけを信じてはいけません。
なぜ challenge は SHA256 の 16 進数ではないのか
これらの2つの出力は同一の 32 バイトのダイジェストから生成されますが、Base64url 出力のみがプロトコルで要求される challenge です。許可された文字の1つを変更すると、バインディング結果が変化します。verifier を 43 文字未満に短縮すると、構文要件に違反し、任意の文字列のハッシュを合法な PKCE として扱うことはできません。
図を準備しています 再読み込み
なぜ challenge は SHA256 の 16 進数ではないのか? ダイジェストは 32 バイトであり、hex と Base64url は異なるエンコーディングです。S256 ルールに従ってバインディング challenge を取得して初めて、この交換チェックを満たします。
{"id":"pkce-s256-bytes","title":"なぜ challenge は SHA256 の 16 進数ではないのか?","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 の 16 進数ではないのか?","intro":"公開テスト用の verifier を編集し、SHA256 とパディングなしの Base64url を実際に計算し、認可時のバインディング値と比較します。","reset":"リセット","result":"このバインディングチェック","scope":"RFC7636 の公開ベクトルであり、秘密ではありません。verifier の構文と S256 バインディングのみを検証し、code の有効期限、単一使用、クライアント認証、state または OIDC は実装しません。","scopeLabel":"モデルの範囲と規約","verifier":"公開 verifier"}}
ID Token、Access Token、Refresh Token は交換不可
3種類のトークンの受信者と用途
ID Token → クライアント
1回の認証結果を証明;aud には通常 client_id が含まれる
Access Token → リソースサーバー
API へのアクセス権限を付与;JWT である場合もあれば、不透明文字列である場合もある
Refresh Token → 認可サーバー
後続のトークンを取得;ビジネス API には送信されず、必ずしも発行されるとは限らない
形状が似ているからといって交換可能ではありません。署名が有効であっても、受信者、用途、時間、認可の制約を満たす必要があります。
ID Token は OIDC クライアント向けであり、ユーザーの認証結果を主張します。その有効期限は、アプリケーションセッションの強制有効期限とは等しくなく、毎回の API の権限判断を代替するものではありません。Access Token はリソースサーバー向けであり、ユーザー情報を含めることができる ほか、インタラクションのないクライアントを表すこともあります。ユーザークレームを含むかどうかは、トークンの形式と認可シナリオに依存し、名前だけで推測することはできません。
OIDC の scope=openid は、OIDC 認証を要求することを示します。profile、email などはクレームの要求であり、すべてのフィールドが表示されることを保証するものではありません。email は変更される可能性があり、常にグローバルに一意であるとは限りません。アプリケーションの外部アイデンティティキーは、信頼できる iss と sub の組み合わせを基盤として、ローカルアカウントのマッピングを構築すべきです。UserInfo を呼び出す際は、その sub がすでに検証された ID Token と一致していることを確認する必要があります。OIDC Core
offline_access を要求しても、サーバーが無条件で refresh token を返すわけではありません。発行はフロー、ユーザーの同意、プロバイダーのポリシーに依存します。1回のトークン応答に refresh token が含まれていないからといって、必ずしもプロトコルのエラーではありません。
JWT 検証:まず誰を信頼するかを決定し、次にキーを選択する
一般的な署名付き JWT は、JWS compact 形式(Base64url(header)、Base64url(payload)、signature)の3部構成を使用します。エンコーディングは暗号化ではなく、このようなトークンを取得した人は通常、クレームを読み取ることができます。暗号化された JWT は異なる構造を使用しており、「3つのドットセグメントを数える」ことですべての JWT を概括することはできません。
kid は、信頼できるキーセットから候補キーを検索するためのヒントであり、証明書でもなければ、任意のキー取得 URL でもありません。クライアントは、信頼できる issuer のディスカバリメタデータから jwks_uri を取得し、メタデータ内の issuer を照合する必要があります。JWKS のパスは /.well-known/jwks.json に固定されていません。検証されていないトークンに基づいて、任意の issuer や jku にアクセスしないでください。OIDC Discovery
検証レイヤー 判断内容
暗号とフォーマット 許可されたアルゴリズム、署名、キーの使用目的、解析および重複フィールドのポリシー
信頼の源泉 issuer は期待される設定と厳密に一致する必要があります
受信者 ID Token の aud には本クライアントが含まれる;Access Token は現在の API 向け
時間 exp、適用される nbf、許容される時計のずれ、および認証の新鮮さ
トランザクションバインディング nonce が要求された場合はその値を照合;azp などの関連ルールを処理
トークンの用途 ID Token と Access Token で異なる検証ルールを使用
ビジネス認可 scope、主体、テナント、リソースの帰属およびアクション
キーローテーション時には、未知の kid に対してキャッシュを限定的に更新できますが、各見知らぬ kid に対して無限の外部リクエストをトリガーしないようにする必要があります。古い公開鍵の保持期間は、まだ受け入れられるべき古いトークンをカバーする必要があります。検証キーの削除とトークンの自然な期限切れは、同じことではありません。JWT BCP
RFC 9068 に準拠した JWT Access Token は at+jwt タイプを使用し、関連するクレームと検証要件を定義しています。すべての履歴またはプライベートな access token が自動的にこのプロファイルに従うことを要求することはできず、「署名が正しい任意の JWT」を API 資格情報として受け入れることもできません。RFC 9068
以下は、信頼できるトークンが署名検証済みであることを前提としたビジネス認可 のみを示しており、JSON ディクショナリを完全なトークン検証器として意図的に使用していません。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 ( ' テナント間、期限切れ、および誤った受信者はすべて拒否' )
リフレッシュ、取り消し、ログアウトは別の3つの操作
Access Token が期限切れになると、クライアントは認可サーバーに対して refresh token を使用して新しいトークンを要求できます。ローテーションを採用している場合、各リフレッシュで新しい refresh token が返され、古い値は廃棄されます。クライアントは新しい値をアトミックに保存し、並列リフレッシュを調整する必要があります。2つのリクエストが同時に古いトークンを使用すると、そのうちの1つが再利用と見なされ、結果としてトークンファミリー全体が取り消される可能性があります。
再利用の検出には、「サーバーサイドでローテーションが完了したが、応答がネットワークで消失した」という曖昧さもあります。古い値を無限に再試行することはできません。プロバイダーの回復ウィンドウ、再利用ポリシー、およびクライアントの再認証パスは一致している必要があります。パブリッククライアントの refresh token は、送信者制約またはローテーションなどの規定されたリプレイ保護を採用すべきであり、単にトークンをクライアントストレージに配置するだけでは、このメカニズムではありません。RFC 9700 §4.14
操作 直接変更される状態 自動的に保証されないこと
アプリケーションセッションの削除 本アプリケーションのログイン状態 すべての API トークンが無効化されること
refresh token の取り消し 後続のリフレッシュ能力およびプロバイダー関連の認可状態 発行済みの JWT が各 API で即座に拒否されること
認可サーバーのログアウト アイデンティティプロバイダーのセッション 各アプリケーションのローカルセッションが同期して消失すること
access token の取り消し 認可サーバー内の有効性 オフライン署名検証のみの API がリアルタイムで通知されること
API が JWT の署名と期限切れ時間のみをチェックする場合、取り消し後でも期限切れまで受け入れる可能性があります。より迅速な収束が必要な場合は、イントロスペクション、取り消し状態の伝播、またはより短い有効期限などの明示的なソリューションを採用できます。イントロスペクションが返す active もそのコンテキストに依存しており、キャッシュには可視性の遅延が発生します。Token Introspection 、Token Revocation
Bearer token の保持者はそれを使用して操作できます。DPoP はトークンを証明キーにバインドし、リクエストに対して証明を生成することで、この使用条件を変更します。しかし、これは scope やリソース認可を代替するものではなく、完全に制御されたクライアントが自動的に信頼回復できるわけではありません。DPoP
アプリケーションの形態がトークンの保存場所を決定する
形態 アイデンティティと保存方法 処理が必要な問題
サーバーサイド Web / BFF バックエンドで交換・保存し、ブラウザはアプリケーションセッションクッキーを保持 クッキー属性、CSRF、バックエンドセッションのライフサイクル
ブラウザ SPA パブリッククライアント、秘密を保守的に内蔵できない XSS、リフレッシュの調整、ページリロード後の回復
ネイティブアプリ パブリッククライアント、システム認可インタラクションと PKCE を使用 リダイレクトの受信、システムストレージ、プロセスのライフサイクル
バックグラウンドサービス 適切な場合に client credentials を使用してサービスアイデンティティを表現 資格情報の保護、scope、ローテーション、およびワークロードの帰属
client credentials にはエンドユーザーのログイン手順がなく、ある従業員のアイデンティティを使ってその sub を解釈すべきではありません。基本仕様では、このフローで refresh token を発行すべきではありません。サービスは通常、自身の資格情報を使用して access token を再取得します。アプリケーション内蔵シークレット、OAuth 認可、およびユーザー認証の3つの問題は、それぞれ個別に設計する必要があります。RFC 6749 §4.4
統合時のトラブルシューティングでは、まずステージを特定します。認可リクエストが拒否された場合は登録とパラメータを確認;invalid_grant の場合は code が期限切れ、既に使用済み、またはバインディングが不一致かどうかを確認;署名検証に失敗した場合は信頼できる issuer とキーを確認;ログインは成功したが API が拒否された場合はトークンの種類、audience、scope、およびリソース権限を確認。「JWT をデコードできる」ことをログイン統合完了の基準として使用しないでください。