OAuth 2.0 的身份認證:OpenID Connect

OAuth 2.0 的身份認證:OpenID Connect
Photo by Tim Mossholder / Unsplash

OAuth 2 讓網路服務可以存取第三方的受保護資源,因此,有些開發者會進一步利用 OAuth 2 來進行使用者認證。但這中間存在著一些語義落差,因為 OAuth 2 當初設計目的是「授權」而不是「認證」,兩者關注的焦點會有些不同。OpenID Connect 是基於 OAuth 2 的一套身份認證協定,讓開發者可以在 OAuth 2 授權的基礎上,再加入標準的認證流程。在這篇文章中,我會說明授權跟認證的場景有何差異,並講解 OpenID Connect 如何滿足認證需求。

因為 OpenID Connect 是建構在 OAuth 2 的基礎上,我會假設這篇文章的讀者已經知道 OAuth 2 的組件與流程,如果你不熟悉,可以先閱讀另外兩篇文章

認證 vs 授權

認證(Authentication) 是證明身份(identity)的機制,它包含兩個問題:你是誰?你怎麼證明?想想餐廳訂位的例子,你需要告知服務生你的姓名與手機電話,她才會幫你帶位。在網路服務中,這兩個問題最常見的形式是:你的帳號是什麼?密碼是什麼?通常這兩個問題會一起問,以防止惡意人士探查特定的人(identity)是否是服務的使用者。當然,如果只憑單項證明,只要證明洩漏,身份就會被冒用,因此像是銀行開戶,會需要攜帶雙證件,讓身份更為安全,在資安上,這稱為多重要素認證(MFA)。

授權(Authorization) 則是授予資源存取權限的機制,它包含的問題是:要開放哪些資源?開放給誰?假設你有物品遺忘在車上,你將車鑰匙給朋友,請他去車上幫忙把東西拿過來。你就開放了車子這項資源,而對象則是你的朋友。授權跟身份不一定有關,像是 Facebook 的動態可以只設為私人消息,只開放給特定的朋友;也可以設為公開消息,讓所有人都看到。如果你只開放給特定的人,顯然在看到你的訊息前,就需要先經過認證了。

以 OAuth 2 的流程來說,Client 會要求使用者授權,讓它可以存取第三方資源。當使用者被導向到 Authorization Server 後,Authorization Server 會先請使用者認證,等確認身份沒問題,就會緊接著要求授權。授權結果會用 Token 交給 Client,讓 Client 可以存取受保護資源。

OAuth 2 的問題

既然 OAuth 2 的流程有要求使用者認證,也有把授權結果轉成 Token 交給 Client。那為什麼還有問題呢?回到原點,我們來問問 Client 要如何回答認證的兩個問題。首先是「我是誰?」,Client 知道使用者是誰嗎?因為使用者是跟 Authorization Server 認證,Authorization Server 知道使用者的身份,但 Authorization Server 交給 Client 只是個 Token,而依照 OAuth 2 的規範,這個 Token 應該要是不透明的

Access tokens are credentials used to access protected resources. An access token is a string representing an authorization issued to the client. The string is usually opaque to the client.

訪問令牌是用來訪問受保護資源的憑證。訪問令牌是個字串,用以表達簽發給 Client 的授權。這個字串通常對 Client 來說不透明。

因此,Client 只知道自己拿到授權,如果要知道是「誰」授權的,它需要用 Access Token 存取有使用者身份的資源,例如跟帳號有關的 API,才能知道使用者身份。

再來,Client 是依據哪項證明,知道跟 Client 互動的是使用者本人呢?它同樣是透過授權結果來得知,如果 Access Token 能拿到使用者資料,代表使用者有經過 Authorization Server 認證,Client 就能把 Access Token 的有效性當成使用者身份的證明。

換句話說,在流程上我們可以看到,Client 需要存取受保護資源來達成認證,而在 OAuth 2 中,沒有規範使用者身份的格式跟存取端點。每家 Provider 的實作可能不同,例如 A 有支援使用者的 email 而 B 沒有;或者 A、B 都支援,但 response body 的字段名稱不同;又或者字段名稱也相同,但存放在不同的 Endpoint 中。這些差異需要 Client 的開發者開發一套中間層處理,也不利於開放認證環境的推廣。

OpenID Connect

為了讓使用者能用 OAuth 的基礎建設進行認證,OpenID Foundation 設計出 OpenID Connect。因應認證的場景,它定義出兩個新角色,並將它們映射到 OAuth 的角色上

  • OpenID Provider 負責認證使用者,並提供身份證明給 Relying Party,讓 Relying Party 知道認證發生還有使用者是誰。這可以對照到 OAuth 2 的 Authorization Server 跟 Protected Resource。
  • Relying Party 從 OpenID Provider 獲得認證憑證與使用者資訊。對照到 OAuth 2 的 Client。

從 OpenID Connect 的角度來看的話,流程變成

前面的流程都相同,主要差異在 (A)、(B)、(C) 的三個步驟。我們說過,OAuth 要求 Token 對 Client 來說是不透明的,可是認證又要求 Relying Party 能夠由 OpenID Provider 回傳的資訊來知道使用者身份,要如何同時滿足這兩個需求呢?答案是,在 OpenID Provider 的回傳中,多加入一個 ID Token,而該 Token 對 Relying Party 來講可讀。如此就能在兼容 OAuth 的前提下,又取得認證資訊。

具體來說,當 Relying Party 跟 OpenID Provider 要求 Token 時,OpenID Provider 站在 Authorization Server 的角度會給出 Access Token,同時,它站在 OpenID Provider 的角度,也會附上 ID Token。在同一個 Response 中會帶有這兩項資訊

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache

{
  "access_token": "SlAV32hkKG",
  "token_type": "Bearer",
  "refresh_token": "8xLOxBtZp8",
  "expires_in": 3600,
  "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjFlOWdkazcifQ.ewogImlzc
    yI6ICJodHRwOi8vc2VydmVyLmV4YW1wbGUuY29tIiwKICJzdWIiOiAiMjQ4Mjg5
    NzYxMDAxIiwKICJhdWQiOiAiczZCaGRSa3F0MyIsCiAibm9uY2UiOiAibi0wUzZ
    fV3pBMk1qIiwKICJleHAiOiAxMzExMjgxOTcwLAogImlhdCI6IDEzMTEyODA5Nz
    AKfQ.ggW8hZ1EuVLuxNuuIJKX_V8a_OMXzR0EHR9R6jgdqrOOF4daGU96Sr_P6q
    Jp6IcmD3HP99Obi1PRs-cwh3LO-p146waJ8IhehcwL7F09JdijmBqkvPeB2T9CJ
    NqeGpe-gccMg4vfKjkM8FcGvnzZUN4_KSP0aAp1tOJ1zZwgjxqGByKHiOtX7Tpd
    QyHE5lcMiKPXfEIQILVq0pc_E2DzL7emopWoaoZTF_m0_N0YzFC6g6EJbOEoRoS
    K5hoDalrcvRYLSrQAZZKflyuVCyixEoV9GfNQC3_osjzw2PAithfubEEBLuVVk4
    XUVrWOLrLl0nx7RkKU8NXNHq-rvKMzqg"
}

ID Token 是 JWT 的格式,解碼後,內容類似

{
  "iss": "https://server.example.com",
  "sub": "24400320",
  "aud": "s6BhdRkqt3",
  "nonce": "n-0S6_WzA2Mj",
  "exp": 1311281970,
  "iat": 1311280970,
  "auth_time": 1311280969,
  "acr": "urn:mace:incommon:iap:silver"
}

其中 iss 是 ID Token 的簽核者,可以當成是 OpenID Provider 的名稱,而 sub 是簽核者用來標示使用者的唯一碼。iss 跟 sub 可以組合成一個不重複的 ID,用來辨識使用者。

然而僅僅靠 ID,在應用上仍然稍嫌不夠力,我們在自我介紹時,不會說我是 A123456789,而是會講我的名字是 Ken,居住在台北。這些個人資訊可以給 Relying Party 更充足的訊息,讓它辨識來自不同 OpenID Provider 的相同使用者。為了讓這些資訊的取得有標準可以依循,OpenID Connect 重用 OAuth 2 的 scope 並規範特定的 Endpoint。Relying Party 在向 OpenID Provider 提出授權申請時,可以在 scope 中放入

scope=openid profile email phone

只要該 OpenID Provider 有支援,Relying Party 就能用 Access Token 向 userinfo 端點發出請求

GET /userinfo HTTP/1.1
Host: server.example.com
Authorization: Bearer SlAV32hkKG

並得到使用者的完整資訊

HTTP/1.1 200 OK
Content-Type: application/json

{
  "sub": "248289761001",
  "name": "Jane Doe",
  "given_name": "Jane",
  "family_name": "Doe",
  "preferred_username": "j.doe",
  "email": "[email protected]",
  "picture": "http://example.com/janedoe/me.jpg"
}

使用 Google 帳號認證

讓我們實際看一下用 Google 來登入的例子,先給張時序圖,讓大家知道我們需要有哪些 Endpoint

Client 有三個 Endpoint,分別是 /login/callback 跟 /。使用者進到 /login 後,會導向到 OpenID Provider 進行認證與授權,之後導回 /callback,接收 ID Token 並綁定 session 後再導到首頁。

跟 OAuth 有關的部分,在另一篇文章中有詳細解說,如果不熟的可以翻翻,這邊就不再多說明了。底下會把重點放在實現 OpenID Connect 需要的修改,首先來看端點跟配置

func main() {
    sessions = make(map[string]interface{})
    cfg = NewGoogleOAuthConfig()
    e := gin.New()
    e.GET("callback", OAuth2Callback)
    e.GET("login", Login)
    e.GET("/", GetHomePage)
    e.Run("localhost:8080")
}

func NewGoogleOAuthConfig() *oauth2.Config {
    config := &oauth2.Config{
        ClientID:     clientID,
        ClientSecret: clientSecret,
        RedirectURL:  "http://localhost:8080/callback",
        Scopes: []string{
            oidc.ScopeOpenID,
            "email",
        },
        Endpoint: google.Endpoint,
    }
    return config
}

OpenID Connect 的函式庫使用 github.com/coreos/go-oidc/v3/oidc,能替我們降低一些開發成本。在跟 OpenID Provider 要權限前,記得 Scopes 要帶上 oidc.ScopeOpenID,我們還想知道 email,因此底下也加進去。

接著來到重頭戲 /callback,用收到的授權碼兌換 Access Token 跟 ID Token

func OAuth2Callback(ctx *gin.Context) {
    // ...
    token, err := cfg.Exchange(context.Background(), code)
    if err != nil {
        ctx.AbortWithError(http.StatusInternalServerError, err)
        return
    }
    // ...
    provider, _ = oidc.NewProvider(context.TODO(), "https://accounts.google.com")
    verifier := provider.Verifier(&oidc.Config{ClientID: clientID})
    idToken, err = verifier.Verify(ctx, token.Extra("id_token").(string))
    if err != nil {
        ctx.AbortWithError(http.StatusInternalServerError, err)
        return
    }
    userInfo, err := getUserInfo(token)
    if err != nil {
        ctx.AbortWithError(http.StatusInternalServerError, err)
        return
    }
    sid := uuid.NewV4().String()
    sessions[sid] = userInfo
    ctx.SetCookie("sid", sid, 60*60, "", "", false, false)
    ctx.Redirect(http.StatusFound, "http://localhost:8080")
}

取得 ID Token 後,先驗證該 Token 沒問題,解碼後內容是

{
  "iss": "https://accounts.google.com",
  "azp": "xxxxxx.apps.googleusercontent.com",
  "aud": "xxxxxx.apps.googleusercontent.com",
  "sub": "104498875333XXXXXXXXXX",
  "email": "[email protected]",
  "email_verified": true,
  "at_hash": "2RCplQvBCznJOwok6Yl8GA",
  "iat": 1674159649,
  "exp": 1674163249
}

以 ID Token 來說,Google 給的資訊已經夠多了,很多 optional 的欄位都有值,但其他的 Provider 未必會在 ID Token 給出詳細資訊,如果沒看到想要的資訊,可以再由 /userinfo 來拿

func getUserInfo(token *oauth2.Token) (map[string]interface{}, error) {
    res, err := client.Get("https://openidconnect.googleapis.com/v1/userinfo")
    if err != nil {
        return nil, err
    }
    defer res.Body.Close()
    var resp map[string]interface{}
    data, _ := ioutil.ReadAll(res.Body)
    json.Unmarshal(data, &resp)
    return resp, nil
}

這份資料會先綁定 Session,等到使用者來拿時,再回傳回去

func GetHomePage(ctx *gin.Context) {
    sid, _ := ctx.Cookie("sid")
    userInfo := sessions[sid]
    ctx.JSON(http.StatusOK, userInfo)
}

最後得到 Response 中的個人資訊

{
    "email": "[email protected]",
    "email_verified": true,
    "picture": "https://lh3.googleusercontent.com/a-/xxxxxx",
    "sub": "104498875333XXXXXXXXXX"
}

可以看到要修改的點不多,幾乎都是單純加上 OpenID Connect 的邏輯而已,跟 OAuth 2 的相容性非常好。

小結

OpenID Connect 像是 OAuth 2 的擴充,儘管原本 OAuth 2 能做到類似認證的效果,但這是憑藉技術上的手段,而不是原本就在規範中。要知道,OAuth 2 的 Protected Resourece 沒有供應身份端點的義務,如果只是單純的 OAuth 2,客戶端沒辦法保證能做到認證的事。當然實際上,因為常串的 OAuth 2 資源都是 Meta / Microsoft / Amazon / Google 這類大廠,真的要找還是找得到,但這就跟「開放認證協定」不是同一回事了。

OpenID Connect 從規範上補足這件事,如果哪家廠商支援 OpenID Connect,我們可以合理預期,它會有能辨識身份的 ID Token 跟詳細資訊的 userinfo 端點,而身份資訊範圍也能從 OpenID Connect 的 scope 中看到,像是 Google 的 Discovery document 就很明確,能讓開發者在初期就確定能否拿到想要的資訊

{
  // ...
  "scopes_supported": [
    "openid",
    "email",
    "profile"
  ],
  // ...
  "claims_supported": [
    "aud",
    "email",
    "email_verified",
    "exp",
    "family_name",
    "given_name",
    "iat",
    "iss",
    "locale",
    "name",
    "picture",
    "sub"
  ],
  // ...
}

希望看完這篇文後,能幫忙釐清 OAuth 2 跟 OpenID Connect 的關係,知道角色間如何對應,也能讓開發者們在開發第三方登入的應用時,更快進入狀況。

Reference

Read more

Weekly Issue 第 12 期:Bear 修改授權條款

通常開源專案需要面對長期維護的問題,而長期維護需要人力(開發者)物力(伺服器與基礎建設),個人開發者來說是個負擔。有些專案會有企業贊助,有些專案則是替用戶提供顧問與服務來收費維持。 這期選了 Bear 修改授權的新聞,也因為這則新聞,順道看了 Sentry 的授權模式。我們都希望擁有健康的開發生態,而授權條款很大程度左右了這點。 🗞️ 熱門新聞 Bear changes license to Elastic License Blog 平台工具 Bear 修改授權,原本是 MIT,現在改用 Elastic License。 看開發者的說法,原因是有人搭便車,fork 完直接部署成服務賣錢。開源不是免費勞工,這樣確實有點過分。Elastic License 的差別是不准以託管方式提供服務,算是補上這個洞。 相對 AGPL 來講,有時這種個人開發的小型專案,也不追求產業影響力,直接用 EL

By Ken Chen

Weekly Issue 第 11 期:AI 代理人插件可能存在資安風險

Preplexity 跟 Anthropic 等公司開始讓瀏覽器 AI 代理化,資安領域專家 Simon Willison 指出這可能會導致眾多資安漏洞出現。我建議兩邊的意見都可以看看,Anthropic 為了防堵問題,也下過不少功夫,看完後你會比較知道該如何使用 AI 代理。 另外這期特別喜歡 Mike Sun 談台灣的產品經理遇到的挑戰,我現在不太建議新人直接在台灣當產品經理,舞台太小,成長空間有限,會影響日後發展。如果真的對產品很有興趣,可以先到其他地方建立起正確的產品觀後,再回到台灣發展。 🗞️ 熱門新聞 Piloting Claude for Chrome Anthropic 最近推出 Chrome 用的 Claude 插件,但是依照說明文件:「當我們在自主模式中加入安全防護機制後,成功將 23.6%的攻擊成功率降低至 11.2%。」 儘管 Anthropic 特地專文說明它們的防護措施,

By Ken Chen

Weekly Issue 第 10 期:AI 機器人正造成網站負擔

隨著 LLM 變成日常的一部分,它們也在改變原有的網路生態。Fastly 的報告顯示,AI 機器人每分鐘可對網站發起高達 39K 次請求,日後造訪網站的,可能大多是機器人,而不是真人。 🗞️ 熱門新聞 Fastly warns AI bots can hit sites 39K times per minute 繼上次 Codeberg 的新聞後,Fastly 出報告指出 AI 機器人正造成網站營運負擔。 大多觀點延續幾個月來的趨勢:「網站負載增長主要並非來自人類訪客,而是代表聊天機器人公司運作的自動爬蟲與抓取程式。 」值得注意的是,AI Fetcher 的數量也在增加中,我猜這多少暗示了用戶搜尋資料的行為正在變化。 Meta 占了所有 AI 流量的 52% 🙄 ,相對下 Anthropic 只佔 3.76%

By Ken Chen

Weekly Issue 第 9 期:Ghost 發布 6.0 版本

Ghost Release 新版了!距離上次大版號更新,已經過了 3 年多,這幾年來,創作者經濟變化得很快,Ghost 也嘗試讓創作者更容易經營自己的內容。 我會等 6.0 發布一陣子,穩定下來後才會更新。很期待他們下一步會是什麼。 🗞️ 熱門新聞 Ghost 6.0 Ghost Release 6.0。 兩個重量級更新:支援 ActivityPub,讓 Ghost 可以 Leverage 社群媒體分發渠道;以及內建 Analytics,支援流量分析。這剛好就是兩個我最想要的功能,Great Work。 常說經營內容的痛點在,不知道如何發佈內容,不知道訪客從哪來。當然這都可以用工具協助,例如設定 GA、或者使用 Postiz 等來經營社群,可是我覺得一個好的平台應該要替創作者處理掉這些事,Ghost

By Ken Chen