🍡

IDaaS/OpenID Connectを用いたソーシャルログイン(認証)実装についてのメモ

に公開


何らかのWebサービスを公開するにあたりGoogleでログイン等のソーシャルログイン実装はほぼ避けて通れないため、前知識を得るために色々と調べたメモです。ソーシャルログイン(認証)の話になぜOAuth(認可)が出てくるのか等よくわかっていない状態から何となく納得するまで調べた内容です。ソーシャルログイン(認証)に焦点を当てているため、リソースサーバーへのアクセス等の認可については記載していません。RFC等の一時ソースをあまり直接読み込んだりしていないので不適切な内容や誤りが含まれている可能性があります。

前提

ここでは最小限理解に必要と思われる概要のみ記載する。

認証と認可

「認可」は文脈により2つの異なる意味合いを持つ。ユーザー等のアプリケーションを操作する主体のことをこのセクションでは操作主体と表現する。

  • 認証 (authentication)
    • 操作主体が誰であるか、本人かどうかを確認するプロセス
  • 認可 (authorization)
    1. 操作主体が何の権限を持っているかを確認するプロセス
    2. 操作主体が誰かに何らかの権限を与えるプロセス

OAuthでは2.を取り扱う。この記事の図の通り、認証処理は認可処理に含まれている。

OAuthとOpenID ConnectとIDaaS

OAuth2.0はあるサービス(Google等)にとってのサードパーティーアプリケーション(自分が作ったアプリ等)が(Google等の)HTTPサービスへの限定的なアクセス権を取得することを可能にする認可フレームワーク。OAuth1.0では認可フレームワークではなくプロトコルとなっていた。

OpenID Connect(以下OIDCと記載)はOAuth2.0を用いて実現する認証プロトコル。認証と認可でも記載した通り、認証処理は認可処理に含まれている。そのため、OAuthが定義された後にこれを用いた独自認証を行うサービスが多数発生したが、それらの多くがセキュリティ上の問題抱えていたらしい。これを解決するためにOpenIDがOAuthを用いた認証処理をOIDCとして標準化したらしい。

ソーシャルログインを含むIDaaSは、基本的にOAuth2.0とOIDCを実装しておりユーザー管理や認証、認可、MFA等の統合サービスを提供している。

ソーシャルログイン認証

認証フロー

フロー図

auth_code_flow

図中のプロセス/データ例

  • A: code verifier, code challenge, state, nonceを生成・計算
    const codeVerifier = base64url(crypto.getRandomValues(new Uint8Array(32))); // e.g., dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
    const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier));
    const codeChallenge = base64url(new Uint8Array(hash)); // e.g., E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
    const state = crypto.randomUUID(); // e.g., a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11
    const nonce = crypto.randomUUID(); // e.g., a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12
    
  • B: 認可URLを生成し、state値をレスポンスヘッダーのSet-Cookieに含める
    https://accounts.social.com/o/oauth2/v2/auth
      ?client_id=123456789-apps-id  // OIDCに事前登録されている値
      &redirect_uri=https%3A%2F%2Fexample.com%2Fcallback  // OIDCに事前登録したURL (https://example.com/callback)
      &response_type=code           // 認可コードを用いるフロー指定 (Authorization Code Grant Flow)
      &scope=openid+email+profile   // openid=IDトークンの要求,email=メールアドレスの要求,profile=基本情報の要求(氏名等)
      &state=a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11
      &nonce=a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12
      &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
      &code_challenge_method=S256    // code_challengeを計算したアルゴリズム
    
  • C: code_challenge, code_challenge_methodの値を送信
  • D: 今回の認可リクエストの値としてcode_challenge, code_challenge_methodの値を保存
  • E: 認可コードを含むコールバックURLへリダイレクト
    https://example.com/callback?code=AUTH_CODE&state=a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11
    
  • F: URLパラメータによる認可コードとstateの送信と同時に、Cookieのstateも送信
  • G: URLパラメータのstateとcookieのstateが同一か検証
  • H: 認可コード、code verifierを含むリクエストボディ送信
    POST https://oauth2.socialapis.com/token
    {
      code: AUTH_CODE,
      client_id: "123456789-apps-id",
      client_secret: "...",  // OIDCに事前登録した際に発行されるパスワード
      grant_type: "authorization_code",
      code_verifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
    }
    
  • I: 以下を実施
    1. 保存していたcode_challenge, code_challenge_methodの値を取得
    2. code verifierからcode_challengeを計算して一致するか検証
  • J: トークンをJSONとして送信
    response
    {
      "id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImFiYzEyMyJ9.eyJpc3MiOiJodHRwczovL2FjY291bnRzLnNvY2lhbC5jb20iLCJzdWIiOiJ1c2VyMTIzNDU2Nzg5MCIsImF1ZCI6IjEyMzQ1Njc4OS1hcHBzLWlkIiwiZXhwIjoxMjM0NTY3ODkwLCJpYXQiOjEyMzQ1Njc4OTAsIm5vbmNlIjoiYTBlZWJjOTktOWMwYi00ZWY4LWJiNmQtNmJiOWJkMzgwYTEyIiwibmFtZSI6IlVzZXIgTmFtZSIsImVtYWlsIjoidXNlckBtYWlsLmV4YW1wbGUuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWV9.signature", // 認証に使用するJWTトークン
      "access_token": "...", // リソースサーバーにアクセスする際に使用するトークン 有効期限:短
      "refresh_token": "..." // access_tokenを更新する際に使用するトークン 有効期限:長
    }
    
    decoded_id_header
    {
      "alg": "RS256", // algorithm
      "typ": "JWT", // type
      "kid": "abc123" // key id
    }
    
    decoded_id_payload
    {
      "iss": "https://accounts.social.com", // issuer 必須 発行者
      "sub": "user1234567890",    // subject 必須 ユーザーID (操作主体識別子)
      "aud": "123456789-apps-id", // audience 必須 対象アプリケーション (client_id)
      "exp": 1234567890, // expiration 必須 有効期限
      "iat": 1234567890, // issued at 必須 JWT発行時刻
      "auth_time": 1234567890, // authentication time 必須 認証された時刻
      "nonce": "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a12",
      "name": "User Name",
      "email": "user@mail.example.com",
      "email_verified": true
    }
    
  • K: 以下を実施
    1. OIDC Serverの公開鍵セットを用いてid_tokenの署名検証
    2. ペイロード内のiss, aud, exp, nonceの内容を検証
    3. セッションIDを発行しレスポンスヘッダーのSet-Cookieに含める
      • 以降通常のセッション認証と同じ

補足説明

安全性

  • OIDCサーバー(認可サーバー)から発行されるid_tokenがフロントエンドに渡ることはない
    • フロントエンドからは認可コードとセッションIDしか見えない
    • JWTはサーバー間でのみやり取りされるため窃取リスクが比較的低減される
    • このフローはOIDCの流れのうち、機密クライアント(Confidential Clients)の流れに相当する
      • フロントエンドのみでやり取りする場合は公開クライアント(Public Clients)の流れになる

攻撃対策

  • CSRF対策としてstateを含める

    • 攻撃シナリオ例
      1. 攻撃者が途中までログインし、攻撃者としてログインするための認可コードAを取得
      2. 認可コードAを含む以下のようなコールバックURLを対象者に踏ませる
      1. 対象者が攻撃者としてログインしてしまう
      2. 対象者が気づかずに入力した個人情報等が攻撃者アカウントに保存される
    • 正規ログイン者であればログイン開始時のstateをCookieに持つようにする
    • URLパラメータのstateとCookieのstateを比較することで正規ログイン者か検証する
      • 攻撃されている場合、Cookieに正規のstateを持たないため検出可能
  • リプレイ攻撃対策としてnonceを含める

    • 攻撃シナリオ例
      1. 攻撃者が正規ユーザーのid_tokenを盗聴
      2. 後日、そのid_tokenを再利用しようとする
    • 認可コードを用いる機密クライアントの流れであれば、攻撃リスクは比較的低い
    • 実装コストはそれほど高くないため、以下のために含めるのが無難
      • 予期しない脆弱性への備え
      • OIDC推奨仕様への準拠
  • 認可コード横取り攻撃対策を含める

    • RFC7636でProof Key for Code Exchange by OAuth Public Clients(PKCE)として定義されている
    • 認可コードの窃取例
      • フロントエンドからバックエンドへの通信傍受
      • ブラウザ履歴やリファラーからの漏洩
      • ブラウザ拡張機能による盗聴 等
    • 通常、認可コードは1回限り有効かつclient_idとも紐ついており使い回しできない
      • 機密クライアントの流れでは攻撃を成立させるのは困難のため、攻撃リスクは比較的低い
    • OIDCサーバー(認可サーバー)に以下のような実装不備がある場合、PKCEで防御が必要になる
      • 認可コードとclient_idの紐付けを検証しない
      • 認可コードの再利用を許している
      • redirect_uriの検証が甘い
    • 攻撃者が認可コードを利用しようとしてもcode verifierが不明なため利用できない

JWT署名検証用の公開鍵

id_tokenのJWT署名検証のための公開鍵セットはどこかのタイミングで取得・キャッシュしておき使い回す。

  • 公開鍵セットは以下のようなタイミングで取得する

    • アプリケーション起動時
    • 初回のトークン検証時
    • キャッシュ期限切れ
    • トークンの署名検証に失敗した場合に再取得(鍵がローテーションされた可能性)
  • 公開鍵セットは以下手順で取得する

    1. 以下のようなDiscovery Documentエンドポイントにアクセスする
      • https://{OIDC domain}/.well-known/openid-configuration
      response_example.json
      {
        "issuer": "https://accounts.social.com",
        "authorization_endpoint": "https://accounts.social.com/o/oauth2/v2/auth",
        "token_endpoint": "https://oauth2.socialapis.com/token",
        "jwks_uri": "https://accounts.social.com/.well-known/jwks.json", // JSON Web Key Set
        ...
      }
      
    2. jwks_uriのURIから以下のような公開鍵セットを取得、キャッシュする
    sample_keyset.json
    {
      "keys": [
        {
          "kty": "RSA",
          "kid": "abc123",
          "use": "sig",
          "n": "...",
          "e": "AQAB"
        },
        {
          "kty": "RSA",
          "kid": "def456",
          "use": "sig",
          "n": "...",
          "e": "AQAB"
        }
      ]
    }
    
  • 以下手順で公開鍵セットから特定のJWTに対応する公開鍵を特定する

    1. JWT形式(header.payload.signature)の中のheader部をBase64URLデコードしJSONを得る
    2. JSONの中のkidの値を公開鍵セットから見つける
      • 実際の公開鍵特定や署名検証はjoseのようなライブラリを用いるのが無難

その他

  • 複数のソーシャルログインを使用する場合、isssubの組み合わせでユーザーを特定する
  • id_tokenのemailを流用する場合はemail_verifiedtrueであることを確認する

雑記

OIDC認証だけに絞っても相当調べないとよく理解できなかったので、認証・認可まわりはやはり複雑だなぁという印象です。この記事を書くことでOIDC認証に関してはかなりクリアになった気がするので、将来の自分もこの記事を見てそれなりに実装できてほしいです。

参考文献

Discussion