iT邦幫忙

0

OAuth2 + OIDC 跨平台登入:一套 .NET 函式庫接上各家帳號

  • 分享至 

  • xImage
  •  

OAuth2 + OIDC 跨平台登入:一套 .NET 函式庫接上各家帳號

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

polhem-oauth2-oidc

Polhem.OAuth2 現在支援 OpenID Connect(建立在 OAuth2 之上的登入標準),1.4.0 與 1.5.0 新增了:

  • Apple 帳號支援
  • GitHub 帳號支援
  • 自訂的 OpenID Connect provider 支援,例如 Keycloak、GitLab、AWS Cognito
  • 桌面程式也能經由後端登入
  • 有前後端時,一律由後端登入

上一篇〈桌面、手機、網站都要接 OAuth2 登入,能用同一套嗎?〉介紹過這個函式庫,它讓桌面、.NET MAUI、ASP.NET Core 與舊的 Web Forms 網站用同一種寫法接第三方登入,當時支援 Google、Facebook、LINE、Microsoft Entra ID、Auth0 與 Okta。這篇說明新增的部分。


1️⃣ 現在支援哪些平台與帳號

先看 1.5.0 為止支援哪些應用程式:

  • 桌面與主控台應用程式:開啟系統瀏覽器,在本機 127.0.0.1 接收回呼,Windows、macOS、Linux 都能用。可以直連 provider,有後端時經由後端中轉。
  • .NET MAUI 應用程式:Android、iOS 與 Mac Catalyst,透過 WebAuthenticator 登入,可以直連 provider,也可以經由自己的後端中轉。
  • ASP.NET Core 網站。
  • ASP.NET(System.Web)的 Web Forms 與 MVC 網站。
  • 其他伺服器端框架:核心的 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 網站
Google OAuth2 + OIDC 直連或經由後端中轉 iOS 與 Mac Catalyst 直連;Android 經由後端中轉 可以
Facebook 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 監聽的位址,所以桌面程式也一樣要經由後端。


2️⃣ 任何 OpenID Connect provider

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 不用改。幾個設計上的考量:

  • discovery 只在啟動時讀一次。讀文件是非同步的,所以做成獨立的工廠方法,options 本身維持同步。這樣建立 client 時就能檢查設定,不必等到使用者第一次按下登入才發現有錯。
  • ProviderName 沒有預設值。使用者是以「provider 名稱+使用者 ID」識別的,而兩家 provider 可能把同一個 ID 發給不同的人。如果有預設名稱,應用程式加了第二家又忘了命名,兩邊的使用者就會被當成同一個人。
  • discovery 文件讀進來會先檢查:issuer 要等於傳入的網址,端點要是 https;文件若列出支援的 PKCE 方法,裡面要有 S256。
  • provider 沒有使用者資訊端點也能用,這時改從 ID token 讀使用者。

Keycloak 26.4 與 GitLab.com 都以真實帳號登入過;Google 與 Okta 用通用 options 取得的使用者,也和用它們專屬 options 取得的相同。

網站同時接好幾家 provider 時,要留意 mix-up 攻擊:一家惡意或被入侵的 provider 引導使用者去另一家登入,再設法拿到那次登入的授權碼。擋下它的是另一家換 token 時要求的 client secret 與 PKCE,授權碼到了攻擊者手上也換不到 token。所以 provider 允許的話,每個網站 client 都設定 client secret,PKCE 維持預設開啟,也只接自己信任的 provider。


3️⃣ Sign in with Apple

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"
});

使用上要注意幾點:

  • 回呼網址必須是有網域名稱的 https,開發時要用 tunnel 之類的公開網址。
  • Apple 以 form POST 回到回呼網址,所以回呼端點要同時接受 GET 與 POST。
  • 姓名只在使用者第一次授權時給一次,要在那時自己存起來。
  • 桌面程式與手機 App 要經由後端中轉登入。

回呼端點的寫法、Apple 後台要登記什麼,見 repo 的 Sign in with Apple 文件。


4️⃣ GitHub

GitHub 的使用者登入只有 OAuth2,沒有 OpenID Connect,所以另外有一組 GitHubOAuth2Options,GitHub App 與 OAuth App 都能用。

  • UserId 是數字帳號 ID。使用者改名時 login 會變,舊名稱還可能被別人註冊走,數字 ID 則不會變。
  • 使用者沒有公開 email 時,函式庫會另外查他主要且已驗證的 email;查不到就是 null,登入照常成功。
  • GitHub 換 token 時,有些錯誤是以 HTTP 200 回傳的。函式庫會把它判為失敗,並帶出 GitHub 的錯誤代碼,不會只回一句「沒有 access token」。

GitHub 每次換 token 都要 client secret,所以桌面程式直接登入時,secret 得放在程式裡。有後端的話,改走後端中轉就能避免;.NET MAUI App 則只能走後端中轉。


5️⃣ 有後端時,由後端登入

沒有自家後端的應用程式直接登入,自己使用登入結果。有後端的應用程式不一樣,後端要知道是誰登入,才能對應到自己的帳號並發出 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 也都留在後端。

中轉的流程大致如下:

  1. 應用程式產生一組 PKCE code verifier,帶著自己的回呼網址與 code challenge,在系統瀏覽器開啟後端的登入網址。
  2. 後端把瀏覽器轉到 provider 的授權網址,用的是後端自己的網頁 client,provider 後台不必為應用程式多登記回呼網址。
  3. 使用者登入後,provider 導回後端,後端換到 token 與使用者資訊。
  4. 後端把使用者資訊存在一個一次性的代碼下,再導回應用程式的回呼網址。
  5. 應用程式把代碼和 verifier 送給後端兌換,拿回使用者。代碼就算被別的程式攔到也沒用,對方沒有 verifier。

桌面程式的寫法:

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 可能被解析到別的位址。
  • 瀏覽器裡任何網頁都能對 loopback 位址送請求,所以 LoopbackRelayClient 會在回呼網址加上 state,只接受帶著這個 state 的導回。
  • 電腦上其他程式也能監聽 loopback port;應用程式若能固定用同一個 port,就帶 port 登記。
  • 開始登入的網址與兌換代碼的端點,是應用程式和自家後端之間的約定,函式庫不規定它們的形式。

GitHub 與 Facebook 都不發 ID token,以前有後端的應用程式只能讓前端送 access token 給後端。現在這兩家都已用真實帳號經由後端中轉登入過,主控台範例加上 --back-end 跑的就是這個流程。


6️⃣ 升級前要注意的地方

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


圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言