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

CDN 的快取失效設計:內容平台場景

CDN 的快取失效設計:內容平台場景

Phil Karlton 有句名言:「計算機科學中只有兩件難事:快取失效和命名。」 想像你在管理網站,因為傳輸速度與伺服器效能問題,網站讀取速度很差,特別是當你的使用者來自地球另外一端,常常需要等待幾秒才能看到畫面,這讓他們的使用體驗大打折扣。身為一名重視使用體驗的開發者,你肯定知道該如何解決這問題,沒錯,答案就是 CDN(內容傳遞網路)。 CDN 可以看成是服務商在全球各地建置伺服器,當你的網站內容(例如圖片、CSS、JavaScript、影片等)流經這些伺服器時,它會保留一份複本(稱為快取),等到下次有人讀取同樣的內容,CDN 會拿出複本給使用者。因為全球各地都有 CDN 節點,美國的使用者可以由美國節點提供,日本的使用者可以由日本節點提供。這樣既加速網路傳遞效率,也降低來源伺服器的效能壓力,可謂一舉兩得。 當然這是有條件的。CDN 會使用網址來判斷快取是否是相同檔案,假設你的內容以圖片為主,通常來說,當你更換圖片,新舊兩張圖片會有不同網址,被當成兩個不同的檔案,新圖使用新快取,舊快取留著也沒差;但如果你的內容是文字,新舊版文字很可能有相同網址,

By Ken Chen
收拾行李搬家去:從 Medium 到 Ghost

收拾行李搬家去:從 Medium 到 Ghost

想搬家想很久,連身邊的朋友都搬完了,我還沒動工。 原因是我懶,我討厭麻煩,每次有人問我吃什麼,我都回答麥當勞。搬家是一件麻煩事,我已經有一份很讚的工作了,全副精神都放在工作上,偶爾才會想起來,反正家什麼時候都能搬,一點也不急,有什麼好急的呢對吧。這樣一拖,就拖到現在。 繼續用 Medium 不好嗎? 跟男女朋友分手一樣,通常被問到:「對方不好嗎?」得到回答是:「也沒有不好啦,只是……(以下開放填空)。」 從優點開始講吧!Medium 的編輯器很棒,它是 WYSIWYG(所見即所得)類型的編輯器,能讓創作者快速發佈內容,也因為它讓內容發佈更容易了,它開始吸引一批優秀的創作者,這批創作者持續創作內容,又吸引來更多讀者,更多讀者激勵創作者產出內容,內容又再吸引讀者……這形成一個增強迴圈。Medium 還能支援多人協作,拜它時尚簡約的風格所賜,科技公司會使用 Medium 來打造品牌形象,例如我前公司的 Tech Blog

By Ken Chen
OpenTelemetry 的可觀察性工程:以 Sentry 為例

OpenTelemetry 的可觀察性工程:以 Sentry 為例

點進 OpenTelemetry 的官方文件,它最先映入眼中的句子是「什麼是 OpenTelemetry」。例如,它是套可觀察性框架,用於檢測、蒐集與導出遙測數據;它是開源且供應商中立,能搭配其他的開源工具,像 Jaeger 或 Prometheus;它能將應用程式與系統儀表化,無關是用 Go 還是 .NET 開發,也無關部署在 AWS 還是 GCP 上。 但是身為一名開發者,當下我們想的是:「公司常開發一些沒人要用的功能,聽說 OpenTelemetry 可以提高可觀察性,也許我們應該放棄開發功能,轉頭建立更好的開發環境。」「AWS 常常要不到需要的數據,也許我們應該改用另一套工具,像是 OpenTelemetry,來解決這件事。」我們想像 OpenTelemetry 「應該」要能解決目前面臨到的一些問題,就像在技術的鏡像中尋找願望一樣。 如果已經有在用 Sentry,還需要導入 OpenTelemetry

By Ken Chen
標準化之路:Go 1.23 中的迭代器

標準化之路:Go 1.23 中的迭代器

Ian Lance Taylor 在 "Range Over Function Types" 這篇文章聊到 iterator 誕生的原因。如果我們有兩個容器,稱為集合(Set),想要取得這兩個集合中的不重複元素,加到新的集合中形成聯集,我們可以寫個 Union 函式來執行 // Set holds a set of elements. type Set[E comparable] struct { m map[E]struct{} } // Union returns the union of two sets. func Union[E comparable](s1, s2 *Set[

By Ken Chen