OpenID Connect 最一開始是由應用程式發出身分驗證請求(Authentication Request),接著使用者會在授權伺服器上( Hydra、Login Provider、Consent Provider)上完成身分驗證,並同意應用程式請求的授權範圍後,接下來就輪到應用程式處理身分驗證回應(Authentication Response)了。
先回顧一下,在整個登入流程裡,最一開始的授權請求有這一行程式碼:
'response_type' => 'code',
它會在轉導到 Hydra 的 Query 上加上 response_type=code
這個參數。這個參數代表的意思是,請求在授權回應的時候要給應用程式 code
。
而實際執行完的結果是到下面這個網址:
http://127.0.0.1:8000/callback?code=9BRBZZPq....&scope=openid&state=1a2b3c4d
上面的 Query 參數內容就是授權回應,OpenID Connect 並沒有重新定義欄位,而是沿用 OAuth 2.0 - 4.1.2. Authorization Response 的定義。成功的身分驗證回應如下:
欄位 | 必填 | 說明 |
---|---|---|
code |
REQUIRED | 授權碼,應用程式可以拿這個參數來向授權伺服器交換 Access Token。 |
state |
REQUIRED,當身分驗證請求有傳這個欄位,則授權回應必須要有這個欄位 | 當初身分驗證請求填了什麼內容,這裡就會出現一樣的內容。這是為了避免 CSRF 攻擊 |
scope |
這個欄位並不是 OAuth 2.0 標準定義,而是 Hydra 額外回傳的。雖然原始碼看到有做開關可以控制,但實際上 Hydra 並沒有設定在處理這個開關。 | 從程式與測試結果可以看得出,這裡會放使用者已同意的授權範圍。 |
身分驗證或授權的過程也有可能失敗,這時應用程式會收到失敗的身分驗證回應,OpenID Connect Core 1.0 - 3.1.2.6. Authentication Error Response 有定義相關的欄位如下:
欄位 | 必填 | 說明 |
---|---|---|
error |
REQUIRED | 錯誤代碼,要給程式判斷使用 |
error_description |
OPTIONAL | 人看得懂的訊息,這是要給開始者參考用的。 |
error_uri |
OPTIONAL | 額外的網址,讓開發者可以在上面查到更多資訊。但 Hydra 並沒有實作這個參數,可以參考原始碼。 |
state |
REQUIRED,與成功的身分驗證回應相同。 | 與成功的身分驗證回應相同。 |
不管成功或失敗,都會有一個參數是 state
。應用程式第一步應該是要確認 state
正確性後,再開始處理身分驗證回應。處理 state 的要點在於,要確認當初發出身分驗證請求的瀏覽器或裝置,跟現在拿到身分驗證回應的的瀏覽器或裝置是一致的,也就是要避免 CSRF 攻擊。以瀏覽器的情境來說,通常會使用 Cookie 或 Session 的機制來儲存 state 的內容,接著在轉導回應用程式的時候再拿出來確認內容一致。
確認完成後,應用程式才能知道這個身分驗證回應是合法應該要處理的。以下再接著說明成功與失敗的時候該怎麼處理。
之前在快速體驗的示範裡,使用回應類型設定是 response_type=token
,因此前端可以直接拿到 Access Token,然後授權流程就結束了。
這次回應類型設定是 response_type=code
,身分驗證回應裡,只有拿到 Code 而沒有 Access Token,而 Code 的用途是要拿來跟 Hydra 交換 Access Token 用的。OpenID Connect Core 1.0 - 3.1.3.1 Token Request 有提到該如何對授權伺服器的 token 端點發送 Token 請求,這裡也是參考 OAuth 2.0 - 4.1.3. Access Token Request,欄位定義如下:
欄位 | 必填 | 說明 |
---|---|---|
grant_type |
REQUIRED | 固定為 authorization_code 。 |
code |
REQUIRED | 原文為 Authorization Code,中文直譯即「授權碼」。 |
redirect_uri |
REQUIRED,當一開始的身分驗證請求中有這個欄位時,就是必填。 | 要帶入一開始身分驗證請求裡的 redirect_uri ,而且要完全一致。 |
client_id |
REQUIRED,當應用程式一開始沒註冊驗證方法時,則此欄位必填。(以這個例子來說,是不需要給這個欄位的) | 此為應用程式的唯一識別碼。 |
Hydra 的 token 端點,可以從 SDK 的 Public API 裡可以找到對應的方法,接著可以在 callback 裡寫如下的程式,裡面有很多欄位或值都是上面表格找得到的:
public function __invoke(Request $request, PublicApi $hydra)
{
$redirectUri = 'http://127.0.0.1:8000/callback';
$tokenResponse = $hydra->oauth2Token(
grantType: 'authorization_code',
code: $request->input('code'),
redirectUri: $redirectUri
);
dump(json_decode((string)$tokenResponse, true));
return response('拿到身分驗證回應了');
}
Hydra SDK 提供的 oauth2Token()
方法,會呼叫 Hydra Public API 所提供的 Token 端點來取得 Token 資訊。但 API 也不是隨隨便便就讓別人能呼叫的,要先證明你是應用程式的持有人才行,因此這時要提供應用程式的憑證才能正常呼叫。
Hydra 的 SDK 是透過 Config 物件來設定憑證,這裡是物件初始化過程的程式碼:
$this->app->singleton(PublicApi::class, function () {
return tap(new PublicApi(), function (PublicApi $instance) {
$instance->getConfig()
// 這裡要注意,是用 Public API 的 port
->setHost('http://127.0.0.1:4444')
->setUsername('my-rp')
->setPassword('my-secret')
// 下面這行應該是 Hydra SDK 的 bug,必須要強制設定為 null 才能正常執行
->setAccessToken(null);
});
});
成功驗證就能夠拿到像下面這樣的 Token 回應:
{
"access_token": "gljqNs7XFogfPJURtAW6rygFm-KzRWphuv_EqD5LUMQ.XdXx9iDX3lJaQVQHhNnCl77IWSPf87UhaOYrlkGt9_8",
"expires_in": 3599,
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6InB1YmxpYzpoeWRyYS5vcGVuaWQuaWQtdG9rZW4iLCJ0eXAiOiJKV1QifQ.eyJhdF9oYXNoIjoiVTMxN2xqVE5zMTBBV3Z3bmgwUW9IZyIsImF1ZCI6WyJteS1ycCJdLCJhdXRoX3RpbWUiOjE2NjQzNTE0MzcsImV4cCI6MTY2NDM1NTAzOSwiaWF0IjoxNjY0MzUxNDM5LCJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjQ0NDQvIiwianRpIjoiMjEwZWRhNDMtZjhjNy00NWEzLWEyNWItYTJiZDkyMjcyOTcxIiwicmF0IjoxNjY0MzUxNDMxLCJzaWQiOiI3MTViNjNjNC0wZjJhLTRmZTUtOGQyOC1lODMwZGQzNjEzNDkiLCJzdWIiOiIxIn0.sM3A5wLTUBE1DlDAqfcF8pSUNU74AaP2GDaJ_gLLEV_uh-tG6b6B8e58fxH1vRmjtTE6aJG-chG98di12paDD8tV6JXAcuyk3p5Tw_OZnCcFVEvAYoP_m8BYhwGK7DfmiWCZVMTHLX2AFd3J93XR7tems-xV1ponXQQaPDIVKjEVe3k9aoPkgKFj-8CabMwq-aVzKmq0Zr940tynWUTbu_ylSdnnvw68q74zmL6mwhGaDQ0DKTza-fDy_dp_Ht1QujIa-aSrLLkWm9MP0zzPCiZ1YYv-cpx3xb-tpoJm3bNu1h6XMGCjsoPtBOgQzESqojea0H2Cc7DsnDlezIVKM2OphF0_REy5ljBIA3zmbN0arbVVozN2p68YHX8YVp121yWpLycw1DA9eXdu2xp4I8N2Vmng9sez7KT8vKT9vCzFJgqTNG5Jhrv2VH7F4IXzjmbLD3bQW9HAtI_SGwSZ9FsQFUifxxgs1L9FFGb_pyc9arfoTqJnIaZgfsPMOQ6_4S4GlNwvX9YugOugecQfnfXX17ddNHtU7_YOkAAccZVaG5xQZEDtnggrtOVhBKebt5oHFLNDk44eK6GzD1yV9RtO0g0IK6OTt_IFN6gcEPo5d-QTphLxpYecLSvcYciI5k_wx-RpwZdJM1IU-JHqMUdHHm-DpOPCHzTAuXu894w",
"scope": "openid",
"token_type": "bearer"
}
這裡為了方便下一次解說,先把
id_token
的值完全保留,明天再來說明詳細內容。
這個回應是 OpenID Connect Core 1.0 - 3.1.3.3. Successful Token Response 裡定義的,裡面提到這是基於 OAuth 2.0 - 4.1.4. Access Token Response(然後裡面又提到欄位定義要再參考 5.1. Successful Response)再加上新的 id_token 欄位而成的。Token 回應的欄位定義如下:
欄位 | 必填 | 說明 |
---|---|---|
access_token |
REQUIRED | 可以用來存取資源伺服器的憑證。 |
token_type |
REQUIRED | Token 的類型。 |
expires_in |
RECOMMENDED | Token 還有幾秒會過期。 |
scope |
OPTIONAL / REQUIRED | 使用者同意授權範圍,如果跟應用程式要求的範圍相同時,可以省略;如果不同則必須要回傳。 |
id_token |
REQUIRED | OpenID Connect 定義的新欄位,也可以說如果有要求授權 openid 範圍的時候,就會給予這個欄位。 |
到了這裡,因為 Token 都拿到了,所以原則上應用程式要求授權的流程基本上就結束了。再來就會是驗證 Token 是否正確,這部分留到明天繼續。
請求 Token 也有可能會失敗,回傳的訊息定義在 OAuth 2.0 - 5.2. Error Response 裡,格式跟身分驗證回應差不多,但 error
的錯誤碼,因為情境不同所以會有所不同。比方說 invalid_client
是指應用程式驗證失敗,像是密碼(secret)打錯,這是在身分驗證請求到身分驗證回應的過程中不會發生的,這部分就留給讀者閱讀。
先講失敗的授權回應。情境很好理解,參考 error
的內容做對應的業務邏輯處理即可。如:昨天實作 Consent Provider 的時候有提到被使用者拒絕授權的情境,應該要回 access_denied
,就能讓應用程式知道現在發生什麼問題,以及該出什麼樣的錯的訊息給使用者參考。範例程式碼如下:
$error = $request->input('error');
if (null !== $error) {
return match ($error) {
'access_denied' => response('使用者拒絕授權'),
default => response('未知的 error: ' . $error),
};
}
其他在 Login Provider 或 Consent Provider 處理的過程中,只要身分驗證或授權流程無法進行下去的時候,都可以回錯誤碼給應用程式。但該回哪一個,就必須要參考協定說明來決定適合的錯誤碼。
範例程式碼有實作成功的授權回應,和 access_denied
的授權回應,請讀者可以參考 GitHub Commit。