TL;DR
- OpenID Connect (OIDC)・OAuth2の仕組みを理解するために、Goで認証フローを実装してみました
- 認証のフローやその背景となるセキュリティ上の考慮点を理解することができました
はじめに
突然ですが皆さんは、ECサイトやWebサービスを利用する際に、「Googleでログイン」・「Xでログイン」というボタンを見たことがないでしょうか? また、組織内のサイトにログインする際に社内の認証基盤を利用してログインすることもあると思います。
これを実現するための仕組みとして、OpenID Connect(OIDC)という認証の仕組みがあります。 この仕組みは、OAuth2という認可の仕組みの拡張としてGoogleやXなどの認証基盤(Identity Provider・IdP)へのログイン情報を利用して他のサービスにログインすることを可能とする仕組みであり、ユーザーにとってはサービス毎にパスワードを管理する必要がないという利点があります。
私はこれまでにこれらの仕組みを利用したことがあったものの、ちゃんと仕組みを理解しないままなんとなくで使っていたこともあり、今回改めて自分で実装することで理解を深めることにしました。
OpenID Connect・OAuth2とは
OAuth2とは
OpenID Connectは、OAuth2という別の仕組みをベースとしている仕組みのため、まずはOAuth2から説明していきます。
OAuth2を一言で言うと、ユーザーが第三者アプリケーションに対して、保護されたリソースへの限定的なアクセス権を委譲するための仕組みです。
「限定的なアクセス権」と言っているのは、例としては金融機関が家計簿アプリケーションに対し、ユーザーの銀行口座の残高を取得するという機能のみのアクセス権限を与えることが挙げられます。 この仕組みがないとユーザーは、銀行口座の残高を取得するだけの家計簿アプリケーションに対し、パスワードを伝える必要がありセキュリティリスクが高くなってしまいます。
OAuth2は、2010年あたりから様々なサービスがAPIを介して連携するAPIエコノミーが発展していくのに伴い、よりセキュアな方法で外部アプリケーションの情報を利用していけるように策定された仕組みです。 そしてその仕組みはRFC 6749: The OAuth 2.0 Authorization Frameworkとして標準化されており、ドラフトですが発展形のOAuth 2.1も議論がなされています。
OAuth2の基本
ここまでがOAuth2の概要ですが、ここからはOAuth2による認可の基本的な仕組みについて説明します。
OAuth2において認可を行う際には、以下の4つの役割が存在します。
- resource owner: 認可を最終的に承認する主体。家計簿アプリの例だと金融機関のユーザー
- resource server: resource ownerの保有する保護されたリソースを提供するサーバー。家計簿アプリの例だと金融機関のシステム
- client: resource serverにアクセスしてresource ownerの保護されたリソースを利用するアプリケーション。家計簿アプリの例だと家計簿アプリケーション
- authorization server: clientに対して認可を行うための認可サーバー。家計簿アプリの例だと金融機関の認可基盤
そして、認可の流れは以下のようになります。 厳密に説明するともう少し複雑な流れとなりますが、ここではあくまでも概念的な流れを説明しています。 興味のある方はRFC 6749を参照してみてください。
このように、OAuth2では、resource ownerから承認を受けたclientが、authorization serverからアクセストークンを取得し、そのアクセストークンを利用してresource serverから保護されたリソースのみを取得するというフローで部分的な認可を実現しています。
OpenID Connectとは
ここまで認可を行うための仕組みであるOAuth2について説明してきましたが、OpenID Connectはこの仕様をベースとして、認可ではなく認証を行うための仕組みを提供します。 OIDCはOpenID Foundationによって策定されており、RFCとはなっていませんが、仕様がオープンに公開されています。
ベースとなる発想は、ユーザーの個人情報をOAuth2における「リソース」として扱う、というものとなります。 つまり、GoogleやXなどが保有するユーザーの個人情報を、ユーザーの許諾を受けた上で他のアプリケーションから利用する、というものになります。 そのため、OIDCにおいてはOAuth2での役割を以下のように読み替えます。1
- resource owner: 実際に認証するエンドユーザー
- resource server/authorization server: 認証を行い個人情報を保持する認証基盤
- client: 認証基盤を使ってログインを行うアプリケーション
とはいえ、一般的な認可を取り扱うOAuth2のフローのみで認証に関連する流れを網羅することは難しいため、OpenID ConnectではOAuth2を拡張した仕様を策定しています。
その一つがIDトークンです。 OAuth2ではアクセストークンを利用して認可を行っていましたが、OpenID ConnectではIDトークンを利用して認証を行います。 アクセストークンのみであっても認証されたユーザーの個人情報を取得することは可能(後述のように実際にそうしています)ですが、IDトークンを使うことでclientはより直接的に「認証基盤による認証がなされた」ことを示すことができます。2
IDトークンはRFC 7519: JSON Web Tokenによって策定されているJSON Web Token (JWT)という署名付きJSON形式を取り、例えば以下のような情報が含まれます。 IDトークンを使うことによって、clientはアクセストークンを使って認証基盤に問い合わせることなく、正しい認証基盤によって認証されたユーザーであることを確認することができます。
{ "iss": "https://server.example.com", // 認証基盤のURL "sub": "24400320", // 認証されたユーザーの識別子 "aud": "s6BhdRkqt3", // IDトークンを受け取るクライアントの識別子 "exp": 1311281970, // 各種タイムスタンプ "iat": 1311280970, "auth_time": 1311280969, ...}ここで、上のIDトークンには認証されたことを示す情報は含まれているものの、ユーザー名やメールアドレス等のいわゆる個人情報が含まれていないことに気づいた方もいると思います。 clientである外部アプリケーションからすると、ユーザーの個人情報を認証基盤から取得することができれば自前でプロフィール情報を管理する必要がなくなるため、認証基盤から個人情報を取得することができれば便利です。
OIDCでは、これを実現するために、ユーザーの個人情報を取得するためのUserInfoエンドポイントを定義しています。 このエンドポイントは、「個人情報」というリソースを提供するresource serverとしての認証基盤が提供するエンドポイントであり、clientはIDトークンと同時に取得したアクセストークンによってアクセスすることで、以下のようにユーザーから許諾を受けた範囲で個人情報を取得することができます。3
{ "sub": "248289761001", "name": "Jane Doe", "given_name": "Jane", "family_name": "Doe", "preferred_username": "j.doe", "picture": "http://example.com/janedoe/me.jpg"}UserInfoエンドポイントへのアクセス時の認可自体はOAuth2の仕組みをそのまま利用しており、OIDCではあくまでもエンドポイントの存在とスキーマを策定しています。
実装
認可コードフローについて
OIDCについて概要を把握したところで、ここからは実際にOIDCを利用した認証を実装していくことで理解を深めていきます。
OIDCには認証を行うための手順として、いくつかのフローが用意されています。 今回はその中で最も一般的に使用されている認可コードフロー(Authorization Code Flow)を利用して実装していきます。
以下に認可コードフローにおける認証の流れを示します。
先ほどのOAuth2における認可の概念的な流れと比較すると、IDトークンの取得が追加されているほか、リソースへのアクセスの部分がUserInfoエンドポイントへのアクセスに代わっていますが、これは先ほど説明したOIDCによる拡張の部分となります。
また、認証・許諾を終えた後に直接各種トークンを取得するのではなく、直接的には認可コードを取得しその認可コードを使って各種トークンを取得するという点に関しては後述するようにセキュリティ上の理由があります。 この時点では、認可コードを経由するものの、トークンを取得していることには変わりはないという点だけ覚えていれば十分です。
以降の実装サンプルはポイントに絞っており、実装の全体はGitHubで確認できるので興味のある方は参照してみてください。
なお、このサンプルは学習用となっており複数ユーザーの同時アクセスなどの考慮をしていないので、実際のサービスに組み込む際には注意してください。
Oktaについて
実際のOIDCの認可コードフローを実装するにあたっては、認証基盤を用意する必要があります。
OIDCに準拠している認証基盤であればなんでも良いのですが、今回はOktaというサービスを利用しています。 このサービスはエンタープライズにおける社内認証基盤サービスとして有名な製品ですが、開発者向けに無料で使えるプランが用意されており、今回のような検証にはもってこいです。
登録したら、Terraformを使ってOkta上にリソースを作成していきます。 以下のコードは、2人のサンプルユーザーと、認可コードフローを有効にした2つのClientを作成しています。 詳細はコードを参照してみてください。
Oktaのリソースを作成するTerraformコード
terraform { required_providers { okta = { source = "okta/okta" version = "~> 6" } }}
locals { base_url = "http://localhost:8080" // アプリケーションをホストするURL}
// ##### ユーザーとグループの作成 ######resource "okta_group" "oidc-oauth2-learning" { name = "oidc-oauth2-learning"}
resource "okta_user" "test" { count = 2 first_name = "User${count.index + 1}" last_name = "Test" primary_phone = "000-0000-000" email = "test${count.index + 1}@example.com" login = "test${count.index + 1}@example.com" password = "Passw0rd!!!"}
resource "okta_group_memberships" "test" { group_id = okta_group.oidc-oauth2-learning.id users = [for user in okta_user.test : user.id]}
// ##### Okta上にClientを作成 ######resource "okta_app_oauth" "oidc-oauth2-learning" { count = 2 // 2つのClientを作成する
label = "oidc-oauth2-learning-${count.index + 1}" type = "web"
// 認可コードフロー・リフレッシュトークン(後述)を有効化する grant_types = [ "authorization_code", "refresh_token", ] response_types = [ "code", ] // Clientの認証方式をクライアントシークレット方式(後述)とする token_endpoint_auth_method = "client_secret_post"
// 認証基盤からのリダイレクト先のURL(後述)を指定する redirect_uris = [ "${local.base_url}/${count.index + 1}/callback", ] post_logout_redirect_uris = [ "${local.base_url}/${count.index + 1}", ]
// フロントチャネルログアウト(後述)を有効化する participate_slo = true frontchannel_logout_uri = "${local.base_url}/${count.index + 1}/logout-fc"
// パスワードのみでログインできるようなポリシーをセットする(下記参照) authentication_policy = okta_app_signon_policy.only-password.id}
// 作成したグループをClientに割り当てるresource "okta_app_group_assignment" "oidc-oauth2-learning" { count = 2 app_id = okta_app_oauth.oidc-oauth2-learning[count.index].id group_id = okta_group.oidc-oauth2-learning.id}
// ##### その他設定 #####// パスワードのみの認証ポリシーをセットする// OktaはデフォルトでOkta Verify等の多要素認証が必須となっているが、検証目的なのでパスワードのみとするresource "okta_app_signon_policy" "only-password" { name = "Only Password" description = "Only allow password authentication"}
resource "okta_app_signon_policy_rule" "only-password" { policy_id = okta_app_signon_policy.only-password.id name = "Only Password Rule"
re_authentication_frequency = "PT43800S" # Authenticate per session factor_mode = "1FA" constraints = [ jsonencode({ "knowledge" : { "types" : ["password"] }, }) ]}作成したら、各ClientのClient IDとClient Secretを控えておきます。 これは、Clientが認証基盤に対して正当なClientであることを示すためのクレデンシャルとなるので、外部に漏れないように注意してください。

基本的な認証
それでは実際に認可コードフローを使った認証を実装していきます。
今回はリッチなWebアプリは作らずにGoによる簡易的なサーバーアプリケーションを作成していきます。 アプリケーションではAPIによって認証フローを開始するほか、現在のログイン情報を表示するAPI等を用意します。
なお、2つあるClientを使い分けるために、パスの途中にClientの番号を入れることで区別しています。 これらは同じアプリケーションにあるものの、内部のデータは共有しておらず実質的に2つの別アプリケーションとして動作するようになっています。
また、ライブラリとしてgolang.org/x/oauth2・github.com/coreos/go-oidc/v3/oidcを使っています。 「実装して理解する」と言っている割にライブラリを使っていますが、これらのライブラリは完全にブラックボックスとして動作するわけではなく型やラッパーを用意しているだけであり、認証フローは自分で実装する必要があります。
まず、アプリケーションの初期化時には、IdPの情報を取得します。
// idpBaseUrlには、https://{OrganizationのID}.okta.com のようなOktaのベースURLを指定するprovider, err := oidc.NewProvider(ctx, idpBaseUrl)このとき、内部ではOpenID Connect Discoveryで策定されているメタデータURLにアクセスすることで、後続の処理で使うエンドポイント情報や利用可能なフローなどの情報を取得しています。
// GET https://{OrganizationのID}.okta.com/.well-known/openid-configuration
{ "issuer": "https://{OrganizationのID}.okta.com", "authorization_endpoint": "https://{OrganizationのID}.okta.com/oauth2/v1/authorize", "token_endpoint": "https://{OrganizationのID}.okta.com/oauth2/v1/token", "userinfo_endpoint": "https://{OrganizationのID}.okta.com/oauth2/v1/userinfo", "jwks_uri": "https://{OrganizationのID}.okta.com/oauth2/v1/keys", "end_session_endpoint": "https://{OrganizationのID}.okta.com/oauth2/v1/logout", ...}実際に認証のフローを開始する際には、IdPの認可エンドポイント(メタデータエンドポイントで取得したauthorization_endpoint)にアクセスしてユーザー認証・許諾を行います。
type SampleOIDCHandle struct { baseUrl string oauth2Config *oauth2.Config provider *oidc.Provider endSessionEndpoint string ...}
handle := SampleOIDCHandle{ baseUrl: baseUrl, oauth2Config: &oauth2.Config{ ClientID: clientId, ClientSecret: clientSecret, Endpoint: provider.Endpoint(), RedirectURL: fmt.Sprintf("%s/callback", baseUrl), // openid scopeを指定することでOIDCであることを指定する Scopes: []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess, "phone"}, }, provider: provider, endSessionEndpoint: claims.EndSessionEndpoint, ...}
func (o *SampleOIDCHandle) handleAuth(w http.ResponseWriter, r *http.Request) { // CSRF対策用のstateとPKCE用のcode_verifier(後述)を作成 state := randstr.AlphaNumeric(32) pkceVerifier := oauth2.GenerateVerifier() o.authAdapter.SaveAuthTmp(state, pkceVerifier)
// メタデータURLから取得した認可エンドポイントにリダイレクト url := o.oauth2Config.AuthCodeURL(state, oauth2.S256ChallengeOption(pkceVerifier), oauth2.AccessTypeOffline) http.Redirect(w, r, url, http.StatusFound)}なお、アプリケーションの作りとしては、/authにアクセスすることで認証フローを開始していますが、すぐにIdPにリダイレクトされているため、アプリケーションにはログイン情報は全く渡っていないということに注目してください。

この時、IdPへのリダイレクトにおける認可エンドポイントへのリクエスト内容は以下のような内容となっています。
https://{Organization ID}.okta.com/oauth2/v1/authorize?access_type=offline&client_id={Client ID}&code_challenge={PKCE用のチャレンジ}&code_challenge_method=S256&redirect_uri={アプリケーションへのリダイレクトURL}&response_type=code&scope=openid+profile+email+offline_access+phone&state={セキュリティ用state}IdPの画面での認証・許諾が完了すると、IdPは上記URL中のredirect_uriパラメータで指定したコールバックURLへとリダイレクトし、下のようにURLの中に認可コードを含めて渡してくれます。
/callback?code={認可コード}&state={セキュリティ用state}クライアントアプリケーションではコールバックのハンドラの中でこのコードを受け取ります。 このコードはJWT形式ではないOpaqueトークンなので、特にパースをする必要はありません。
func (o *SampleOIDCHandle) handleCallback(w http.ResponseWriter, r *http.Request) { ... grantCode := r.URL.Query().Get("code") ...コールバックハンドラで受け取った認可コードは、IdPのトークンエンドポイント(メタデータエンドポイントで取得したtoken_endpoint)にアクセスする際に含めることで、IDトークンとアクセストークンを取得することができます。
func (o *SampleOIDCHandle) handleCallback(w http.ResponseWriter, r *http.Request) { ... // トークンエンドポイントにアクセスしてアクセストークンとIDトークンを取得する tokenResponse, err := o.oauth2Config.Exchange(o.ctx, grantCode, oauth2.VerifierOption(pkceVerifier))
accessToken := tokenResponse.AccessToken rawIdToken, ok := tokenResponse.Extra("id_token").(string) ...トークンエンドポイントへのリクエスト内容は以下のようになっています。 認可エンドポイントへのアクセス時には行っていなかったClient自体の認証を行っている点に注目してください。
https://{Organization ID}.okta.com/oauth2/v1/token
HeaderAuthorization: Basic {Client ID:Client Secretという形式の文字列をBase64エンコードしたもの}
Bodycode={認可コード}&code_verifier={PKCE用の検証コード}&grant_type=authorization_code&redirect_uri={認可エンドポイントで指定したリダイレクトURLと同じ}レスポンス中のIDトークンは、そのまま信用してはならず、妥当性を検証する必要があります。4 具体的には、JWTトークンの電子署名やトークン中の発行者・発行対象・有効期限などの各種claimを検証します。
func (o *SampleOIDCHandle) handleCallback(w http.ResponseWriter, r *http.Request) { ... idToken, err := o.authAdapter.GetIdTokenVerifier().Verify(o.ctx, rawIdToken) ...ここまでで、正当なIdPにおいて認証が完了したことを示すIDトークンが手に入りました。 IDトークンは次のようなフォーマットとなっています。
{ "aud": "{Client ID}", // IDトークンの発行対象 // 各種タイムスタンプ "auth_time": 1784430961, "exp": 1784434569, "iat": 1784430969, "iss": "https://{Organization ID}.okta.com", // 発行主体のIdP // 認証されたユーザーの情報 "sub": "{ユーザーID}", "name": "User1 Test", ...}ここで、認証を開始した際のscope指定において、phoneというscopeを指定していたのにIDトークンには電話番号のclaimがないことに気づいた方もいるかもしれません。
これは仕様上は正しい挙動で、IDトークンはあくまでも認証自体の正当性を示すものであり、プロフィール表示等に必要な個人情報はIDトークンに含められているとは限りません。
ここで出てくるのが前述のUserInfoエンドポイントであり、アクセストークンを用いて情報を取得します。
func (o *SampleOIDCHandle) handleCallback(w http.ResponseWriter, r *http.Request) { ... userInfo, err := o.provider.UserInfo(o.ctx, o.oauth2Config.TokenSource(o.ctx, tokenResponse))
// UserInfoエンドポイントのレスポンスからClaimの内容を取得 var userInfoMap struct { Phone string `json:"phone_number"` } userInfo.Claims(&userInfoMap) o.logger.Info("User info claims", "phone_number", userInfoMap.Phone) ...リクエストとしては以下のようになります。
https://{Organization ID}.okta.com/oauth2/v1/userinfo
Header:Authorization: Bearer {アクセストークン}ここまでで認証から個人情報の取得までの流れを一通り実装できました。 OIDCを使うことでClient側ではユーザー名やパスワードの入力といった処理を一切行うことなく、IdPへのリダイレクトやコールバックを用いてセキュアに認証の連携を行えると理解できたと思います。
以降では、OIDCによる認証を行う際の発展的なトピックを説明していきます。
ログアウト
ログアウトの定義を、「アプリケーションセッションに保存している認証情報を破棄すること」とした場合、OIDCだからといって特別なことをする必要はなく、アプリケーション側でIDトークンやアクセストークンを破棄するだけで十分です。
一方で、この状態ではあくまでもそのアプリケーションからログアウトしただけであり、IdP側ではログアウトしていないため、再度アプリケーションにログインするとIdP側でのログインの必要なく元のユーザーでログインできてしまいます。
これを防ぐために、OIDCにはアプリケーションからIdPのログアウトを行うためのRP-initiated Logoutという仕様が策定されています。
とはいっても内容としてはシンプルで、メタデータエンドポイントで取得したend_session_endpointにアクセスするだけです。
func (o *SampleOIDCHandle) handleLogout(w http.ResponseWriter, r *http.Request) { // end_session_endpointに必要な情報を含めてアクセスすることでログアウトを行う logoutUrl, _ := url.Parse(o.endSessionEndpoint)
q := logoutUrl.Query() q.Set("client_id", o.oauth2Config.ClientID) q.Set("id_token_hint", idToken) q.Set("post_logout_redirect_uri", o.baseUrl)
logoutUrl.RawQuery = q.Encode()
http.Redirect(w, r, logoutUrl.String(), http.StatusFound) ...この時、実際のリクエストは以下のようになります。
https://{Organization ID}.okta.com/oauth2/v1/logout?client_id={Client ID}&id_token_hint={IDトークン}&post_logout_redirect_uri={アプリケーションのURL}また、ログアウトを行う際、同じIdPを利用している他のアプリケーションからもログアウトさせたい場合があります。 例えば、社内認証基盤によるシングルサインオンをしている場合のシングルログアウトのようなケースです。
OIDCには、これを実現するための仕様として、Front-Channel Logout・Back-Channel Logoutという仕様が策定されています。 どちらの仕様も、アプリケーション側にログアウト通知を受け取るためのエンドポイントを用意しておくという点は同じですが、通知を行う主体がIdPの場合はBack-Channel・ユーザーのブラウザの場合はFront-Channelという違いがあります。
OktaはFront-Channel Logoutのみに対応しているため、今回はFront-Channel Logoutを実装していきます。
実装自体はシンプルで、ローカルセッションを破棄するエンドポイントを用意しておくのみです。
func (o *SampleOIDCHandle) handleLogoutFC(w http.ResponseWriter, r *http.Request) { o.authAdapter.Clear() // アプリケーションセッションの破棄 ...}なお、OktaではFront-Channel Logoutはデフォルトで有効になっていないため、Admin Consoleから有効にする必要があります。

その上で、Okta上のClient設定でFront-Channel Logoutの設定をすることでシングルログアウトが可能になります。5
resource "okta_app_oauth" "oidc-oauth2-learning" { ... participate_slo = true frontchannel_logout_uri = "{Front-Channel LogoutのエンドポイントURL}" ...}リフレッシュトークン
OIDCやOAuth2によってIdPから発行されるトークンには通常有効期限が設定されています。 例えばOktaでのデフォルトは1時間となっていますが、これは1時間経過するとそのトークンを使ったアクセスができなくなるということを意味します。
有効期限を長くしてしまうと万が一トークンが漏洩した場合の悪用リスクが高くなってしまいますが、反対に短くし過ぎるとユーザーは頻繁にログインを求められることになり利便性が低くなってしまいます。
これを防ぐために、OAuth2ではリフレッシュトークンという仕組みが用意されています。 リフレッシュトークンはIDトークンやアクセストークンを取得するためだけに利用されるトークンであり、これらのトークンよりも期限が長く(1週間など)設定されています。
これによってアクセストークンやIDトークンの有効期限を短く設定しつつも頻繁なログインを不要とできます。 毎リクエストに付与するアクセストークンと比較するとリフレッシュトークンの利用頻度は少なく漏洩する確率は相対的に低いため、総合的なセキュリティの向上が見込まれます。6
OIDCにおいてもリフレッシュトークンの仕組みを使うことができますが、OAuth2との大きな違いは明示性です。
OAuth2ではリフレッシュトークンはIdPが任意で発行するとされていますが、OIDCではoffline_accessというscopeを指定することでリフレッシュトークンの発行を明示的に要求することができます。7 8
実装としては今回唯一ライブラリ側で隠蔽されている部分となってしまいますが、IdPに対して以下のようなリクエストを送ることでリフレッシュトークンを使って再認証なしにトークンを取得することができます。
https://{Organization ID}.okta.com/oauth2/v1/token
HeaderAuthorization: Basic {Client ID:Client Secretという形式の文字列をBase64エンコードしたもの}
Bodyrefresh_token={リフレッシュトークン}&grant_type=refresh_tokenボディの内容に一部違いはありますが、認可コードを用いてトークンを取得する際のやり方とほぼ同じです。
セキュリティについて
ここまででOIDCを使った認証の流れを実装してきましたが、認証というドメインからもわかるようにセキュリティの考慮は非常に重要です。
挙げ出すとキリがないので、ここでは以下の3つに絞って説明します。
- Client Secretを用いたClientの認証
- アプリケーションへのコールバックURL
- Proof Key for Code Exchange (PKCE)
まず1点目についてのClientの認証についてですが、OIDCにおいてはIdPはインターネットに公開されており、悪意のある第三者がアプリケーションを装ってアクセスしてくる可能性があります。 そのため、IdPはClientが正当なアプリケーションであることを確認する必要があり、その目的でClient IDとClient Secretというクレデンシャルを用います。 これらは外部に漏洩しないようにすることが大事です。
一方で、Single Page Application (SPA) 形式のWebアプリのようにClient Secretを安全に保持できないアプリケーションも存在します。9 そのような場合にはClient Secretは使わず、以降で述べるような対処を組み合わせることで正当なClientによるフローであることを担保します。
2点目のコールバックURLはこの目的でまず指定するべきパラメータです。 OIDCのフローでは、コールバックによってClientアプリケーションにリダイレクトされることが多いですが、このリダイレクト先のURLとしてとりうるURLを予めIdPに登録しておくことで、Clientの認証情報が漏洩した場合でも意図しないアプリケーションへのリダイレクトを防げます。
3点目のPKCEについては1,2点目では防げないさらに高度な攻撃に対する対策の一つです。 例えば、攻撃者が「正当なClientアプリケーション」で自身のアカウントで認証した際のコールバックURLをフィッシングメールなどで送りつけて踏ませることで、被害者は自身のアカウントで認証した後勘違いしたまま個人情報を登録してしまう、というCross Site Request Forgery (CSRF)攻撃のようなものが考えられます。
PKCEでは、認可コードリクエスト時に予めチャレンジとしてランダム文字列を送信しておきます。 IdPはトークン取得リクエスト時にこのチャレンジに対応する検証コードを送信することを要求し、正しい検証コードが送信されなければトークンを発行しません。 これによって、フローの中で一貫して同じ主体が実行者であることを担保します。
これ以外にも、様々なセキュリティ対策があるので興味のある方は仕様書や参考書籍を参照してみてください。10
まとめ
この記事では、OIDCにおいて最も基本的な認可コードフローを題材に、認証の流れを実装しながら理解を深めてきました。
半分自己満足で書いたためかかなり長くなってしまいましたが、この記事がOIDCによる認証フローを理解する手助けとなれば幸いです。
参考
この記事で紹介した以外の認証フローやセキュリティに関するより詳細な解説は、以下の書籍が参考になります。
OpenID Connect入門 - アプリケーション開発者のための実践技術解説
Footnotes
-
OIDCの仕様上ではclientはrelying party (RP)・resource server/authorization serverはOpenID Provider (OP)と呼ばれています。また、一般的にはOPをIdentity Provider (IdP)と呼ぶこともあります。 ↩
-
アクセストークンはJWT形式が必須ではなくresource serverのみがわかれば良いOpaqueトークンである可能性もあるほか、あくまでも発行対象はclientではなくresource serverであるため、clientが認証基盤から受け取る情報として扱うのは仕組み上不自然です。 ↩
-
レスポンスはJSON形式で返却されますが、必ずしもJWTとして返却されるわけではなく、あくまでもJSONを返すAPIというだけです。 ↩
-
アクセストークンを認証する必要はないのか?と疑問に思った方もいるかもしれません。IDトークンにおけるClientは発行対象であったのとは対照的に、アクセストークンにおけるClientはあくまでもトークンの利用者であり、検証はresource serverの役割です。さらに言うと、アクセストークンはOpaque形式の可能性もあるためClient側では内容を検証する術がない場合もあります。 ↩
-
Client側の設定は、「他のClientによるFront-Channel Logoutが行われた際にこのClientはどのエンドポイントが呼ばれるか」を指定する設定となっており、「このClientがRP-initiated Logoutを行った時に呼ばれるエンドポイント」ではないことに注意が必要です。 ↩
-
毎リクエストに付与されないとはいえリフレッシュトークンが漏洩すると長期にわたる悪用リスクが高いため、保存には気をつける必要があるほか、ローテーションといった仕組みを使うことが望ましいです。 ↩
-
オフラインとありますが、NWが切れている状態ということではなくユーザーが直接操作していなくても、という意味が近いです。例としてモバイルアプリなどでバックグラウンド動作をしている状態が挙げられます。 ↩
-
ややこしいことに
offline_accessscopeの有無とリフレッシュトークンが実際に発行されるかどうかは無関係です。このscopeがない場合でもリフレッシュトークンが発行される場合もある一方で、このscopeがあってもリフレッシュトークンが発行されない場合もあります。このことだけを見るとわざわざOIDCでscopeを定めた意味がないように思えますが、ユーザー不在時もログインできるということをscopeとすることでユーザーの許諾を得ることができる、という点に意味があるようです。 ↩ -
このようなClient認証情報を秘匿にできないClientをPublic、そうでないClientをConfidentialと呼びます。 ↩
-
例えば、認可コードフローにおいて中心的な役割を果たす認可コードですが、最初に見た時には「直接トークンを渡せば良いのでは?」と思った方もいるかもしれません(私がまさにそう)。これについてもセキュリティ上の理由があり、URLは監査ログ等に残りやすい他、Refererヘッダなどで外部サイトに漏洩しやすいという性質があるため、認可コードを一度経由しているようです。なお、最初からトークンを返すImplicit Flowというフローも存在しますが、推奨されておらずOAuth2.1では廃止される予定です。 ↩