Polhem.OAuth21.5.0:任何 OpenID Connect provider、Sign in with Apple、GitHub,以及有後端時一律由後端登入

Polhem.OAuth2 現在支援 OpenID Connect(建立在 OAuth2 之上的登入標準),1.4.0 與 1.5.0 新增了:
上一篇〈桌面、手機、網站都要接 OAuth2 登入,能用同一套嗎?〉介紹過這個函式庫,它讓桌面、.NET MAUI、ASP.NET Core 與舊的 Web Forms 網站用同一種寫法接第三方登入,當時支援 Google、Facebook、LINE、Microsoft Entra ID、Auth0 與 Okta。這篇說明新增的部分。
先看 1.5.0 為止支援哪些應用程式:
127.0.0.1 接收回呼,Windows、macOS、Linux 都能用。可以直連 provider,有後端時經由後端中轉。WebAuthenticator 登入,可以直連 provider,也可以經由自己的後端中轉。OAuth2Client 把登入拆成「產生授權網址」與「完成登入」兩步,不依賴任何 HTTP 框架。| 套件 | 目標框架 | 用途 |
|---|---|---|
| Polhem.OAuth2 | netstandard2.0、net8.0、net10.0 | 各 provider;桌面、主控台與 .NET MAUI 應用程式;其他伺服器端框架 |
| Polhem.OAuth2.AspNetCore | net10.0 | ASP.NET Core |
| Polhem.OAuth2.AspNet | net472 | System.Web 上的 Web Forms 與 MVC |
支援的帳號,以及每一家在各種應用程式的情形:
| 帳號 | 協定 | 桌面與主控台 | .NET MAUI | 網站 |
|---|---|---|---|---|
| OAuth2 + OIDC | 直連或經由後端中轉 | iOS 與 Mac Catalyst 直連;Android 經由後端中轉 | 可以 | |
| OAuth2 | 直連或經由後端中轉 | 直連或經由後端中轉 | 可以 | |
| LINE | OAuth2,email 取自 ID token | 直連或經由後端中轉 | iOS 與 Mac Catalyst 直連;Android 經由後端中轉 | 可以 |
| Microsoft Entra ID | OAuth2 + OIDC | 直連或經由後端中轉 | 直連或經由後端中轉 | 可以 |
| Auth0 | OAuth2 + OIDC | 直連或經由後端中轉 | 直連或經由後端中轉 | 可以 |
| Okta | OAuth2 + OIDC | 直連或經由後端中轉 | 直連或經由後端中轉 | 可以 |
| GitHub(1.4.0 新增) | OAuth2 | 直連或經由後端中轉 | 只能經由後端中轉 | 可以 |
| Apple(1.4.0 新增) | OAuth2 + OIDC | 只能經由後端中轉 | 只能經由後端中轉 | 可以 |
| 其他 OpenID Connect provider(1.4.0 新增),例如 Keycloak、GitLab | OAuth2 + OIDC | provider 允許不帶 client secret 登入時可直連,或經由後端中轉 | 直連或經由後端中轉 | 可以 |
「OAuth2 + OIDC」的 provider 會多回傳一份 ID token,這是簽章過的文件,寫著使用者是誰、發給哪個應用程式。函式庫會檢查它,或從裡面讀出使用者。「OAuth2」的 provider 則是從自己的 API 取得使用者資料。
不論哪一家,拿到的結果格式都一樣:使用者 ID、名稱、email,加上 access、refresh 與 ID token。
「經由後端中轉」是上一篇提過的做法:App 透過自己的 ASP.NET Core 後端登入,provider 發的 token 留在後端,App 只拿到一次性的代碼去換使用者資訊。1.5.0 起桌面程式也能這樣登入,細節在第 5 節。
表中 GitHub 與 Apple 在手機上只能經由後端,原因是這兩家每次換 token 都要附上 client secret,也就是 provider 發給應用程式的密碼。裝在手機上的 App 可以被拆開,放在裡面的 client secret 會被取出,所以它只能放在後端,由後端代為登入。Apple 還多一個限制:登入完只會導回有網域名稱的 https 網址,不會導回桌面程式在本機 127.0.0.1 監聽的位址,所以桌面程式也一樣要經由後端。
Keycloak、GitLab、AWS Cognito、Authentik 這些身分提供者都遵循 OpenID Connect,彼此的差別多半只在端點網址,一家一家寫並不划算。所以 1.4.0 加了一個通用的 OidcOAuth2Options,可以從 provider 的 discovery 文件(OpenID Connect 規定放在 /.well-known/openid-configuration 的設定說明)讀出端點:
var keycloak = await OidcOAuth2Options.FromDiscoveryAsync("https://sso.example.com/realms/example");
keycloak.ProviderName = "Keycloak";
keycloak.ClientId = "your-client-id";
keycloak.ClientSecret = "your-client-secret";
keycloak.RedirectUri = "https://localhost:7032/auth/callback";
builder.Services.AddOAuth2Client("Keycloak", keycloak);
拿到 options 後就和其他 provider 一樣用,上一篇的 controller 不用改。幾個設計上的考量:
ProviderName 沒有預設值。使用者是以「provider 名稱+使用者 ID」識別的,而兩家 provider 可能把同一個 ID 發給不同的人。如果有預設名稱,應用程式加了第二家又忘了命名,兩邊的使用者就會被當成同一個人。S256。Keycloak 26.4 與 GitLab.com 都以真實帳號登入過;Google 與 Okta 用通用 options 取得的使用者,也和用它們專屬 options 取得的相同。
網站同時接好幾家 provider 時,要留意 mix-up 攻擊:一家惡意或被入侵的 provider 引導使用者去另一家登入,再設法拿到那次登入的授權碼。擋下它的是另一家換 token 時要求的 client secret 與 PKCE,授權碼到了攻擊者手上也換不到 token。所以 provider 允許的話,每個網站 client 都設定 client secret,PKCE 維持預設開啟,也只接自己信任的 provider。
Apple 也是 OpenID Connect provider,但它的 client secret 不是固定字串,要用 Apple 給的私鑰檔(.p8)簽出 JWT。函式庫會代為簽署,到期前自動重簽,設定時把私鑰交給它即可:
builder.Services.AddOAuth2Client("Apple", new AppleOAuth2Options
{
ClientId = "com.example.web", // Services ID
TeamId = "your-team-id",
KeyId = "your-key-id",
PrivateKey = File.ReadAllText(keyPath), // .p8 檔的內容,不要放進版本控制
RedirectUri = "https://www.example.com/auth/callback"
});
使用上要注意幾點:
回呼端點的寫法、Apple 後台要登記什麼,見 repo 的 Sign in with Apple 文件。
GitHub 的使用者登入只有 OAuth2,沒有 OpenID Connect,所以另外有一組 GitHubOAuth2Options,GitHub App 與 OAuth App 都能用。
UserId 是數字帳號 ID。使用者改名時 login 會變,舊名稱還可能被別人註冊走,數字 ID 則不會變。GitHub 每次換 token 都要 client secret,所以桌面程式直接登入時,secret 得放在程式裡。有後端的話,改走後端中轉就能避免;.NET MAUI App 則只能走後端中轉。
沒有自家後端的應用程式直接登入,自己使用登入結果。有後端的應用程式不一樣,後端要知道是誰登入,才能對應到自己的帳號並發出 session。
直覺的做法是前端自己登入 provider,再把 access token 交給後端,由後端拿去呼叫 provider 的使用者資訊端點。這樣不夠安全:使用者登入過的其他應用程式,手上也有這位使用者的有效 token。只要那個應用程式把 token 送給你的後端,使用者資訊端點一樣會回傳這位使用者,它就能以這位使用者的身分登入你的後端。這種手法叫 token substitution。
1.4.0 曾經用 GetUserInfoAsync(accessToken) 處理這件事:先問 provider 這個 token 發給誰,確認是自己的 client ID 才回傳使用者。1.5.0 把它拿掉了,改成一條規則:有後端時一律由後端登入,不分 provider,OpenID Connect 的 provider 也一樣。
| 前端 | 登入方式 |
|---|---|
| 網頁 | 後端跑網頁登入,發 cookie session 給瀏覽器 |
| Android、iOS、Mac Catalyst 上的 .NET MAUI | 以 WebAuthenticator 經由後端中轉 |
| 桌面與主控台程式、Windows 上的 .NET MAUI | 以 1.5.0 新增的 LoopbackRelayClient 經由後端中轉 |
這樣一來,使用者是後端自己向 provider 確認的,不必相信前端交來的東西,provider 的 token 與 client secret 也都留在後端。
中轉的流程大致如下:
桌面程式的寫法:
var client = new LoopbackRelayClient("http://127.0.0.1:0/callback");
RelaySignInResult result = await client.SignInAsync((redirectUri, codeChallenge) => new Uri(
$"https://api.example.com/auth/app/GitHub?redirect_uri={Uri.EscapeDataString(redirectUri.AbsoluteUri)}&code_challenge={codeChallenge}"));
if (result.IsSuccess)
{
// 把 result.Code 與 result.CodeVerifier 送給後端兌換,後端發出自己的 session
}
後端登記 loopback 回呼網址時不帶 port,桌面程式每次就能挑一個空的 port 監聽:
builder.Services.AddOAuth2AppRelay(options =>
{
options.AppRedirectUris.Add("com.example.app:/relay"); // 手機 App
options.AppRedirectUris.Add("http://127.0.0.1/callback"); // 桌面程式,任意 port
});
127.0.0.1 與 [::1],不接受 localhost,因為 localhost 可能被解析到別的位址。LoopbackRelayClient 會在回呼網址加上 state,只接受帶著這個 state 的導回。GitHub 與 Facebook 都不發 ID token,以前有後端的應用程式只能讓前端送 access token 給後端。現在這兩家都已用真實帳號經由後端中轉登入過,主控台範例加上 --back-end 跑的就是這個流程。
1.4.0 與 1.5.0 共有三項破壞性變更。1.4.0 的兩項都是為了安全,影響使用 Google、Auth0、Okta 或 Microsoft Entra ID 的應用程式。
登入取得的 ID token 會被檢查。 ID token 必須是發給這個 client 的(audience 包含 client ID),subject 也要和使用者資訊相同;Google 與 Auth0 還會檢查 issuer。這是 OpenID Connect Core 規格要求的檢查,會被擋下的是發給別的 client、或使用者對不上的 token,以前這些都會放行。只要 ClientId 設定正確,應用程式不用改。加上這項檢查後,這四家都用真實帳號重新登入過,沒有被誤擋。
未驗證的 email 不再回傳。 provider 標示 email_verified 為 false 時,UserInfo.Email 會是 null。如果應用程式用 email 把登入對應到既有帳號,別人就可能用一個沒驗證過的 email 接管那個帳號,這項變更就是要擋掉這種情況。識別使用者請改用「provider 名稱+UserId」。
1.5.0 移除 access token 查證。 GetUserInfoAsync(accessToken) 與 AcceptedClientIds 拿掉了,用到它們的程式會編譯失敗。遷移方式是把登入移到後端,做法如第 5 節。
升級或安裝核心套件:
dotnet add package Polhem.OAuth2
README 的「支援的帳號」表格連到每家 provider 的文件,寫了要在 provider 後台登記什麼,以及實際測試的結果。
通用 OpenID Connect、Apple,以及有後端時由後端登入,這三項的取捨分別記在 repo 的 ADR-008、ADR-009 與 ADR-011。ADR 是 Architecture Decision Record,記錄設計決策與理由的文件。
函式庫的範圍和上一篇一樣:只負責登入、取得使用者資訊與 token,不管登入後的帳號與權限。如果你只有網站,ASP.NET Core 內建的驗證也夠用,不一定要換。它比較適合同時有桌面、手機與網站,又想用同一套寫法接上 Apple、GitHub 或自架 Keycloak 的情況。歡迎回饋、開 issue 或送 pull request。
📘 HackMD 原文筆記:
👉 https://hackmd.io/@jeff377/polhem-oauth2-oidc
📢 歡迎轉載,請註明出處
📬 歡迎追蹤我的技術筆記與實戰經驗分享
Facebook | HackMD | GitHub | NuGet