[{"data":1,"prerenderedAt":836},["ShallowReactive",2],{"article-\u002Farticles\u002Fopenid-connect-authorization-code-pkce":3},{"id":4,"title":5,"body":6,"date":822,"description":102,"excerpt":823,"extension":824,"meta":825,"navigation":826,"path":827,"seo":828,"stem":829,"tags":830,"__hash__":835},"articles\u002Farticles\u002Fopenid-connect-authorization-code-pkce.md","OpenID Connect 登入流程拆解：Authorization Code + PKCE、id_token 與 access_token 的差別",{"type":7,"value":8,"toc":811},"minimark",[9,14,18,54,65,69,76,83,88,95,105,115,120,127,132,146,151,162,187,190,195,202,207,214,219,230,234,310,319,322,329,443,446,483,488,491,515,520,592,596,606,746,764,768,771,791,794,807],[10,11,13],"h2",{"id":12},"oauth-20-和-openid-connect-差在哪","OAuth 2.0 和 OpenID Connect 差在哪",[15,16,17],"p",{},"這兩個名詞常被混用，但它們解決的是不同的問題：",[19,20,21,40],"ul",{},[22,23,24,28,29],"li",{},[25,26,27],"strong",{},"OAuth 2.0"," 是",[25,30,31,32,39],{},"授權（Authorization）",[25,33,34,35,38],{},"協定：讓一個應用程式取得「代表使用者存取某個資源」的權限，拿到的是 ",[25,36,37],{},"access_token","。它本身","沒有定義「這個使用者是誰」",[22,41,42,45,46,49,50,53],{},[25,43,44],{},"OpenID Connect（OIDC）"," 建立在 OAuth 2.0 之上，補上了",[25,47,48],{},"身分驗證（Authentication）","：除了 access_token，還多給一張 ",[25,51,52],{},"id_token","，裡面寫著「這次登入的使用者是誰」，並且定義了標準的使用者資訊欄位、探索端點（Discovery）、公鑰端點（JWKS）",[15,55,56,57,60,61,64],{},"簡單說：要知道「",[25,58,59],{},"你是誰","」用 OIDC，要取得「",[25,62,63],{},"你能做什麼","」用 OAuth 2.0。企業內部系統串接 SSO，需要的通常是兩者都有的 OIDC。",[10,66,68],{"id":67},"完整流程authorization-code-pkce","完整流程：Authorization Code + PKCE",[15,70,71,72,75],{},"瀏覽器上的單頁應用（SPA）目前建議使用的流程是 ",[25,73,74],{},"Authorization Code Flow 搭配 PKCE","：",[15,77,78],{},[79,80],"img",{"alt":81,"src":82},"OpenID Connect Authorization Code 加 PKCE 流程：瀏覽器先產生隨機的 code_verifier 並算出 code_challenge；① 導向 OpenID Provider 的 authorize 端點並帶上 scope=openid、code_challenge 與 state；② 使用者輸入公司帳號密碼或 MFA；③ OpenID Provider 帶著授權碼導回 redirect_uri；④ 瀏覽器以授權碼加 code_verifier 呼叫 token 端點，OpenID Provider 驗證 SHA256(verifier) 等於 challenge；⑤ 回傳 id_token 與 access_token；⑥ 瀏覽器以 Bearer access_token 呼叫後端 API 或建立 SignalR 連線；⑦ 後端啟動時下載 JWKS 公鑰，在本機驗證簽章、iss、aud、exp，並取出 sub 作為使用者唯一 ID","\u002Fimages\u002Farticles\u002Foidc-auth-code-flow.svg",[15,84,85],{},[25,86,87],{},"① 導向授權端點",[15,89,90,91,75],{},"使用者按下登入，前端把瀏覽器導向 OpenID Provider（公司的帳號中心）的 ",[92,93,94],"code",{},"\u002Fauthorize",[96,97,103],"pre",{"className":98,"code":100,"language":101,"meta":102},[99],"language-text","https:\u002F\u002Fsso.example.com\u002Fauthorize\n  ?response_type=code\n  &client_id=nexus-gpt\n  &redirect_uri=https:\u002F\u002Fgpt.example.com\u002Fcallback\n  &scope=openid profile email\n  &state=8f2c...           ← 防 CSRF：回來時要比對\n  &code_challenge=E9Mel...  ← PKCE\n  &code_challenge_method=S256\n","text","",[92,104,100],{"__ignoreMap":102},[15,106,107,110,111,114],{},[92,108,109],{},"scope"," 裡的 ",[92,112,113],{},"openid"," 是關鍵，代表這是一次 OIDC 登入，要求回傳 id_token。",[15,116,117],{},[25,118,119],{},"② 使用者在 OpenID Provider 登入",[15,121,122,123,126],{},"帳號密碼、MFA 都在",[25,124,125],{},"帳號中心的頁面","上輸入，應用程式從頭到尾都碰不到使用者的密碼。這是 SSO 最大的價值：只有一個地方需要保護密碼。",[15,128,129],{},[25,130,131],{},"③ 帶著授權碼導回",[15,133,134,135,138,139,142,143,145],{},"登入成功後，帳號中心把瀏覽器導回 ",[92,136,137],{},"redirect_uri","，網址上帶著一次性的**授權碼（code）**和原本的 ",[92,140,141],{},"state","。前端要先比對 ",[92,144,141],{}," 和自己發出去的是否相同，不同就代表這不是自己發起的登入，直接拒絕。",[15,147,148],{},[25,149,150],{},"④ 用授權碼換 Token",[15,152,153,154,157,158,161],{},"前端拿授權碼呼叫帳號中心的 ",[92,155,156],{},"\u002Ftoken"," 端點換取 Token。授權碼出現在網址上，可能被瀏覽器紀錄、外掛、或惡意的 redirect 攔截；",[25,159,160],{},"PKCE"," 就是為了讓「攔截到授權碼的人也換不到 Token」：",[19,163,164,171,177],{},[22,165,166,167,170],{},"在步驟 ① 之前，前端先產生一個隨機字串 ",[92,168,169],{},"code_verifier","，只存在自己手上",[22,172,173,174],{},"步驟 ① 送出的是它的雜湊值 ",[92,175,176],{},"code_challenge = BASE64URL(SHA256(code_verifier))",[22,178,179,180,183,184,186],{},"步驟 ④ 換 Token 時，要附上",[25,181,182],{},"原始的"," ",[92,185,169],{},"，帳號中心算一次雜湊，和 ① 收到的 challenge 比對",[15,188,189],{},"攔截者只拿得到授權碼和雜湊值，拿不到原始的 verifier，所以換不到 Token。",[15,191,192],{},[25,193,194],{},"⑤ 拿到 id_token 與 access_token",[15,196,197,198,201],{},"兩者通常都是 ",[25,199,200],{},"JWT","（JSON Web Token），由帳號中心用私鑰簽章。",[15,203,204],{},[25,205,206],{},"⑥ 帶 access_token 呼叫 API",[15,208,209,210,213],{},"之後前端呼叫後端 API 時，在 ",[92,211,212],{},"Authorization: Bearer \u003Caccess_token>"," 標頭帶上 access_token。",[15,215,216],{},[25,217,218],{},"⑦ 後端驗證 Token",[15,220,221,222,225,226,229],{},"後端不需要每次都問帳號中心「這張 Token 是不是真的」。它在啟動時從帳號中心的 Discovery 端點（",[92,223,224],{},"\u002F.well-known\u002Fopenid-configuration","）找到 ",[25,227,228],{},"JWKS","（公鑰），之後每個請求都在本機用公鑰驗證簽章即可。",[10,231,233],{"id":232},"id_token-和-access_token用途不同不要混用","id_token 和 access_token：用途不同，不要混用",[235,236,237,250],"table",{},[238,239,240],"thead",{},[241,242,243,246,248],"tr",{},[244,245],"th",{},[244,247,52],{},[244,249,37],{},[251,252,253,269,280,297],"tbody",{},[241,254,255,259,264],{},[256,257,258],"td",{},"給誰看",[256,260,261],{},[25,262,263],{},"前端應用程式",[256,265,266],{},[25,267,268],{},"後端 API（資源伺服器）",[241,270,271,274,277],{},[256,272,273],{},"用途",[256,275,276],{},"證明「誰登入了」，前端拿來顯示使用者名稱、頭像",[256,278,279],{},"證明「有權限呼叫這個 API」",[241,281,282,288,294],{},[256,283,284,287],{},[92,285,286],{},"aud","（對象）",[256,289,290,291],{},"前端應用程式的 ",[92,292,293],{},"client_id",[256,295,296],{},"後端 API 的識別名稱",[241,298,299,302,307],{},[256,300,301],{},"該送到 API 嗎",[256,303,304],{},[25,305,306],{},"不該",[256,308,309],{},"該",[15,311,312,313,315,316,318],{},"最常見的錯誤是把 id_token 送給後端 API 當作通行證。id_token 的 ",[92,314,286],{}," 是前端應用程式，後端如果有正確驗證 ",[92,317,286],{},"，應該要拒絕它；如果沒驗證，就等於任何應用程式拿到的 id_token 都能拿來呼叫你的 API。",[10,320,321],{"id":321},"後端該驗證什麼",[15,323,324,325,328],{},"ASP.NET Core 中，設定好 ",[92,326,327],{},"Authority"," 後，JWT Bearer 驗證會自動下載公鑰並檢查大部分項目：",[96,330,334],{"className":331,"code":332,"language":333,"meta":102,"style":102},"language-csharp shiki shiki-themes github-dark","builder.Services\n    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)\n    .AddJwtBearer(options =>\n    {\n        options.Authority = \"https:\u002F\u002Fsso.example.com\";   \u002F\u002F 由此找到 Discovery 與 JWKS\n        options.Audience  = \"nexus-gpt-api\";               \u002F\u002F 只接受發給這個 API 的 Token\n        options.MapInboundClaims = false;                  \u002F\u002F 保留原始的 claim 名稱（sub、email…）\n    });\n","csharp",[92,335,336,345,358,376,382,402,419,437],{"__ignoreMap":102},[337,338,341],"span",{"class":339,"line":340},"line",1,[337,342,344],{"class":343},"s95oV","builder.Services\n",[337,346,348,351,355],{"class":339,"line":347},2,[337,349,350],{"class":343},"    .",[337,352,354],{"class":353},"svObZ","AddAuthentication",[337,356,357],{"class":343},"(JwtBearerDefaults.AuthenticationScheme)\n",[337,359,361,363,366,369,372],{"class":339,"line":360},3,[337,362,350],{"class":343},[337,364,365],{"class":353},"AddJwtBearer",[337,367,368],{"class":343},"(",[337,370,371],{"class":353},"options",[337,373,375],{"class":374},"snl16"," =>\n",[337,377,379],{"class":339,"line":378},4,[337,380,381],{"class":343},"    {\n",[337,383,385,388,391,395,398],{"class":339,"line":384},5,[337,386,387],{"class":343},"        options.Authority ",[337,389,390],{"class":374},"=",[337,392,394],{"class":393},"sU2Wk"," \"https:\u002F\u002Fsso.example.com\"",[337,396,397],{"class":343},";   ",[337,399,401],{"class":400},"sAwPA","\u002F\u002F 由此找到 Discovery 與 JWKS\n",[337,403,405,408,410,413,416],{"class":339,"line":404},6,[337,406,407],{"class":343},"        options.Audience  ",[337,409,390],{"class":374},[337,411,412],{"class":393}," \"nexus-gpt-api\"",[337,414,415],{"class":343},";               ",[337,417,418],{"class":400},"\u002F\u002F 只接受發給這個 API 的 Token\n",[337,420,422,425,427,431,434],{"class":339,"line":421},7,[337,423,424],{"class":343},"        options.MapInboundClaims ",[337,426,390],{"class":374},[337,428,430],{"class":429},"sDLfK"," false",[337,432,433],{"class":343},";                  ",[337,435,436],{"class":400},"\u002F\u002F 保留原始的 claim 名稱（sub、email…）\n",[337,438,440],{"class":339,"line":439},8,[337,441,442],{"class":343},"    });\n",[15,444,445],{},"驗證項目包括：",[19,447,448,454,463,474],{},[22,449,450,453],{},[25,451,452],{},"簽章","：確定 Token 是帳號中心簽發、內容沒有被竄改",[22,455,456,462],{},[25,457,458,461],{},[92,459,460],{},"iss","（發行者）","：是我們信任的帳號中心",[22,464,465,469,470,473],{},[25,466,467,287],{},[92,468,286],{},"：這張 Token 是發給",[25,471,472],{},"這個 API"," 的",[22,475,476,482],{},[25,477,478,481],{},[92,479,480],{},"exp","（到期時間）","：還沒過期",[484,485,487],"h3",{"id":486},"用-sub-當使用者-id不要用-email","用 sub 當使用者 ID，不要用 email",[15,489,490],{},"Token 裡有好幾個看起來都能代表使用者的欄位，應該用哪一個存進資料庫，當作資料的擁有者？",[19,492,493,505],{},[22,494,495,501,502],{},[25,496,497,500],{},[92,498,499],{},"sub","（Subject）","：OIDC 規範保證它在同一個 OpenID Provider 內",[25,503,504],{},"唯一且永久不變",[22,506,507,510,511,514],{},[92,508,509],{},"email","、",[92,512,513],{},"preferred_username","：使用者改名、換 email、帳號被回收再分配時都可能改變",[15,516,517,518,75],{},"所以資料表上的「擁有者」欄位應該存 ",[92,519,499],{},[96,521,523],{"className":331,"code":522,"language":333,"meta":102,"style":102},"var userId = User.FindFirstValue(\"sub\");\nvar rooms  = await _db.ChatRooms.Where(r => r.OwnerId == userId).ToListAsync();\n",[92,524,525,550],{"__ignoreMap":102},[337,526,527,530,533,536,539,542,544,547],{"class":339,"line":340},[337,528,529],{"class":374},"var",[337,531,532],{"class":353}," userId",[337,534,535],{"class":374}," =",[337,537,538],{"class":343}," User.",[337,540,541],{"class":353},"FindFirstValue",[337,543,368],{"class":343},[337,545,546],{"class":393},"\"sub\"",[337,548,549],{"class":343},");\n",[337,551,552,554,557,560,563,566,569,571,574,577,580,583,586,589],{"class":339,"line":347},[337,553,529],{"class":374},[337,555,556],{"class":353}," rooms",[337,558,559],{"class":374},"  =",[337,561,562],{"class":374}," await",[337,564,565],{"class":343}," _db.ChatRooms.",[337,567,568],{"class":353},"Where",[337,570,368],{"class":343},[337,572,573],{"class":353},"r",[337,575,576],{"class":374}," =>",[337,578,579],{"class":343}," r.OwnerId ",[337,581,582],{"class":374},"==",[337,584,585],{"class":343}," userId).",[337,587,588],{"class":353},"ToListAsync",[337,590,591],{"class":343},"();\n",[484,593,595],{"id":594},"signalr-連線怎麼帶-token","SignalR 連線怎麼帶 Token",[15,597,598,599,602,603,605],{},"WebSocket 在瀏覽器中",[25,600,601],{},"無法自訂標頭","，SignalR 的做法是把 Token 放在查詢字串 ",[92,604,37],{}," 上。後端要額外告訴 JWT 驗證從哪裡讀：",[96,607,609],{"className":331,"code":608,"language":333,"meta":102,"style":102},"options.Events = new JwtBearerEvents\n{\n    OnMessageReceived = context =>\n    {\n        var token = context.Request.Query[\"access_token\"];\n        if (!string.IsNullOrEmpty(token) && context.HttpContext.Request.Path.StartsWithSegments(\"\u002Fhubs\"))\n        {\n            context.Token = token;\n        }\n        return Task.CompletedTask;\n    }\n};\n",[92,610,611,624,629,641,645,664,704,709,719,725,734,740],{"__ignoreMap":102},[337,612,613,616,618,621],{"class":339,"line":340},[337,614,615],{"class":343},"options.Events ",[337,617,390],{"class":374},[337,619,620],{"class":374}," new",[337,622,623],{"class":353}," JwtBearerEvents\n",[337,625,626],{"class":339,"line":347},[337,627,628],{"class":343},"{\n",[337,630,631,634,636,639],{"class":339,"line":360},[337,632,633],{"class":343},"    OnMessageReceived ",[337,635,390],{"class":374},[337,637,638],{"class":353}," context",[337,640,375],{"class":374},[337,642,643],{"class":339,"line":378},[337,644,381],{"class":343},[337,646,647,650,653,655,658,661],{"class":339,"line":384},[337,648,649],{"class":374},"        var",[337,651,652],{"class":353}," token",[337,654,535],{"class":374},[337,656,657],{"class":343}," context.Request.Query[",[337,659,660],{"class":393},"\"access_token\"",[337,662,663],{"class":343},"];\n",[337,665,666,669,672,675,678,681,684,687,690,693,696,698,701],{"class":339,"line":404},[337,667,668],{"class":374},"        if",[337,670,671],{"class":343}," (",[337,673,674],{"class":374},"!",[337,676,677],{"class":374},"string",[337,679,680],{"class":343},".",[337,682,683],{"class":353},"IsNullOrEmpty",[337,685,686],{"class":343},"(token) ",[337,688,689],{"class":374},"&&",[337,691,692],{"class":343}," context.HttpContext.Request.Path.",[337,694,695],{"class":353},"StartsWithSegments",[337,697,368],{"class":343},[337,699,700],{"class":393},"\"\u002Fhubs\"",[337,702,703],{"class":343},"))\n",[337,705,706],{"class":339,"line":421},[337,707,708],{"class":343},"        {\n",[337,710,711,714,716],{"class":339,"line":439},[337,712,713],{"class":343},"            context.Token ",[337,715,390],{"class":374},[337,717,718],{"class":343}," token;\n",[337,720,722],{"class":339,"line":721},9,[337,723,724],{"class":343},"        }\n",[337,726,728,731],{"class":339,"line":727},10,[337,729,730],{"class":374},"        return",[337,732,733],{"class":343}," Task.CompletedTask;\n",[337,735,737],{"class":339,"line":736},11,[337,738,739],{"class":343},"    }\n",[337,741,743],{"class":339,"line":742},12,[337,744,745],{"class":343},"};\n",[15,747,748,749,752,753,756,757,760,761,763],{},"驗證通過後，Hub 裡可以用 ",[92,750,751],{},"Context.UserIdentifier"," 取得目前的使用者（預設取自 ",[92,754,755],{},"NameIdentifier"," claim，關閉了 claim 對應時，可以實作 ",[92,758,759],{},"IUserIdProvider"," 改成讀 ",[92,762,499],{},"）。因為查詢字串可能出現在存取日誌裡，記得在反向代理或日誌設定中遮蔽它。",[10,765,767],{"id":766},"使用情境內部-gpt-平台串接公司-sso","使用情境：內部 GPT 平台串接公司 SSO",[15,769,770],{},"公司內部的 GPT 平台一開始沒有登入，聊天紀錄只能跟著瀏覽器走。串上公司的 OpenID Connect 之後：",[19,772,773,776,782,788],{},[22,774,775],{},"同仁直接用公司帳號登入，帳號停用後自然就無法使用，不需要另外維護帳號",[22,777,778,779,781],{},"聊天室以 ",[92,780,499],{}," 作為擁有者存放，換電腦、換瀏覽器，登入後都是自己的聊天室與完整對話",[22,783,784,785,787],{},"所有查詢、修改、刪除都以 Token 中的 ",[92,786,499],{}," 過濾，無法透過改網址看到別人的對話",[22,789,790],{},"每段對話的 Token 用量都能歸戶到使用者，掌握各部門的實際用量",[10,792,793],{"id":793},"一句話總結",[15,795,796,797,510,799,510,801,803,804,806],{},"OIDC 是在 OAuth 2.0 之上加了「你是誰」的 id_token；瀏覽器應用使用 Authorization Code 加 PKCE，讓密碼只在帳號中心輸入、授權碼被攔截也換不到 Token；id_token 給前端、access_token 給後端，後端用公鑰驗證簽章、",[92,798,460],{},[92,800,286],{},[92,802,480],{},"，並以永久不變的 ",[92,805,499],{}," 作為使用者在系統中的身分。",[808,809,810],"style",{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":102,"searchDepth":347,"depth":347,"links":812},[813,814,815,816,820,821],{"id":12,"depth":347,"text":13},{"id":67,"depth":347,"text":68},{"id":232,"depth":347,"text":233},{"id":321,"depth":347,"text":321,"children":817},[818,819],{"id":486,"depth":360,"text":487},{"id":594,"depth":360,"text":595},{"id":766,"depth":347,"text":767},{"id":793,"depth":347,"text":793},"2023-10-24",null,"md",{},true,"\u002Farticles\u002Fopenid-connect-authorization-code-pkce",{"title":5,"description":102},"articles\u002Fopenid-connect-authorization-code-pkce",[831,27,200,832,833,834],"OpenID Connect","身分驗證","ASP.NET Core","資訊安全","uD3TK51Ej45O4UNO5VqPKswLn0QpSHxwL8Xa68i4g8E",1792959150497]