2023年10月24日
OpenID Connect 登入流程拆解:Authorization Code + PKCE、id_token 與 access_token 的差別
OAuth 2.0 和 OpenID Connect 差在哪
這兩個名詞常被混用,但它們解決的是不同的問題:
- OAuth 2.0 是授權(Authorization)協定:讓一個應用程式取得「代表使用者存取某個資源」的權限,拿到的是 access_token。它本身沒有定義「這個使用者是誰」
- OpenID Connect(OIDC) 建立在 OAuth 2.0 之上,補上了身分驗證(Authentication):除了 access_token,還多給一張 id_token,裡面寫著「這次登入的使用者是誰」,並且定義了標準的使用者資訊欄位、探索端點(Discovery)、公鑰端點(JWKS)
簡單說:要知道「你是誰」用 OIDC,要取得「你能做什麼」用 OAuth 2.0。企業內部系統串接 SSO,需要的通常是兩者都有的 OIDC。
完整流程:Authorization Code + PKCE
瀏覽器上的單頁應用(SPA)目前建議使用的流程是 Authorization Code Flow 搭配 PKCE:
① 導向授權端點
使用者按下登入,前端把瀏覽器導向 OpenID Provider(公司的帳號中心)的 /authorize:
https://sso.example.com/authorize
?response_type=code
&client_id=nexus-gpt
&redirect_uri=https://gpt.example.com/callback
&scope=openid profile email
&state=8f2c... ← 防 CSRF:回來時要比對
&code_challenge=E9Mel... ← PKCE
&code_challenge_method=S256
scope 裡的 openid 是關鍵,代表這是一次 OIDC 登入,要求回傳 id_token。
② 使用者在 OpenID Provider 登入
帳號密碼、MFA 都在帳號中心的頁面上輸入,應用程式從頭到尾都碰不到使用者的密碼。這是 SSO 最大的價值:只有一個地方需要保護密碼。
③ 帶著授權碼導回
登入成功後,帳號中心把瀏覽器導回 redirect_uri,網址上帶著一次性的**授權碼(code)**和原本的 state。前端要先比對 state 和自己發出去的是否相同,不同就代表這不是自己發起的登入,直接拒絕。
④ 用授權碼換 Token
前端拿授權碼呼叫帳號中心的 /token 端點換取 Token。授權碼出現在網址上,可能被瀏覽器紀錄、外掛、或惡意的 redirect 攔截;PKCE 就是為了讓「攔截到授權碼的人也換不到 Token」:
- 在步驟 ① 之前,前端先產生一個隨機字串
code_verifier,只存在自己手上 - 步驟 ① 送出的是它的雜湊值
code_challenge = BASE64URL(SHA256(code_verifier)) - 步驟 ④ 換 Token 時,要附上原始的
code_verifier,帳號中心算一次雜湊,和 ① 收到的 challenge 比對
攔截者只拿得到授權碼和雜湊值,拿不到原始的 verifier,所以換不到 Token。
⑤ 拿到 id_token 與 access_token
兩者通常都是 JWT(JSON Web Token),由帳號中心用私鑰簽章。
⑥ 帶 access_token 呼叫 API
之後前端呼叫後端 API 時,在 Authorization: Bearer <access_token> 標頭帶上 access_token。
⑦ 後端驗證 Token
後端不需要每次都問帳號中心「這張 Token 是不是真的」。它在啟動時從帳號中心的 Discovery 端點(/.well-known/openid-configuration)找到 JWKS(公鑰),之後每個請求都在本機用公鑰驗證簽章即可。
id_token 和 access_token:用途不同,不要混用
| id_token | access_token | |
|---|---|---|
| 給誰看 | 前端應用程式 | 後端 API(資源伺服器) |
| 用途 | 證明「誰登入了」,前端拿來顯示使用者名稱、頭像 | 證明「有權限呼叫這個 API」 |
aud(對象) | 前端應用程式的 client_id | 後端 API 的識別名稱 |
| 該送到 API 嗎 | 不該 | 該 |
最常見的錯誤是把 id_token 送給後端 API 當作通行證。id_token 的 aud 是前端應用程式,後端如果有正確驗證 aud,應該要拒絕它;如果沒驗證,就等於任何應用程式拿到的 id_token 都能拿來呼叫你的 API。
後端該驗證什麼
ASP.NET Core 中,設定好 Authority 後,JWT Bearer 驗證會自動下載公鑰並檢查大部分項目:
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://sso.example.com"; // 由此找到 Discovery 與 JWKS
options.Audience = "nexus-gpt-api"; // 只接受發給這個 API 的 Token
options.MapInboundClaims = false; // 保留原始的 claim 名稱(sub、email…)
});
驗證項目包括:
- 簽章:確定 Token 是帳號中心簽發、內容沒有被竄改
iss(發行者):是我們信任的帳號中心aud(對象):這張 Token 是發給這個 API 的exp(到期時間):還沒過期
用 sub 當使用者 ID,不要用 email
Token 裡有好幾個看起來都能代表使用者的欄位,應該用哪一個存進資料庫,當作資料的擁有者?
sub(Subject):OIDC 規範保證它在同一個 OpenID Provider 內唯一且永久不變email、preferred_username:使用者改名、換 email、帳號被回收再分配時都可能改變
所以資料表上的「擁有者」欄位應該存 sub:
var userId = User.FindFirstValue("sub");
var rooms = await _db.ChatRooms.Where(r => r.OwnerId == userId).ToListAsync();
SignalR 連線怎麼帶 Token
WebSocket 在瀏覽器中無法自訂標頭,SignalR 的做法是把 Token 放在查詢字串 access_token 上。後端要額外告訴 JWT 驗證從哪裡讀:
options.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
var token = context.Request.Query["access_token"];
if (!string.IsNullOrEmpty(token) && context.HttpContext.Request.Path.StartsWithSegments("/hubs"))
{
context.Token = token;
}
return Task.CompletedTask;
}
};
驗證通過後,Hub 裡可以用 Context.UserIdentifier 取得目前的使用者(預設取自 NameIdentifier claim,關閉了 claim 對應時,可以實作 IUserIdProvider 改成讀 sub)。因為查詢字串可能出現在存取日誌裡,記得在反向代理或日誌設定中遮蔽它。
使用情境:內部 GPT 平台串接公司 SSO
公司內部的 GPT 平台一開始沒有登入,聊天紀錄只能跟著瀏覽器走。串上公司的 OpenID Connect 之後:
- 同仁直接用公司帳號登入,帳號停用後自然就無法使用,不需要另外維護帳號
- 聊天室以
sub作為擁有者存放,換電腦、換瀏覽器,登入後都是自己的聊天室與完整對話 - 所有查詢、修改、刪除都以 Token 中的
sub過濾,無法透過改網址看到別人的對話 - 每段對話的 Token 用量都能歸戶到使用者,掌握各部門的實際用量
一句話總結
OIDC 是在 OAuth 2.0 之上加了「你是誰」的 id_token;瀏覽器應用使用 Authorization Code 加 PKCE,讓密碼只在帳號中心輸入、授權碼被攔截也換不到 Token;id_token 給前端、access_token 給後端,後端用公鑰驗證簽章、iss、aud、exp,並以永久不變的 sub 作為使用者在系統中的身分。