デザイン&エンジニアリング部の大和です。

最近は多くのシステムで外部ID連携というものが割と標準搭載されるようになったかと思います。

とても便利な反面、連携先がたくさん用意されていると、どのアカウントで連携したか思い出せなくてログイン画面を前にフリーズすることもしばしば…。

さて、今回はそんな外部ID連携を実装することになり、その中でOpenID Connectの比較的深いところまで触れることができたので、記録を残そうと思います。

環境

環境は、以下の通りです。ここでは本記事で言及する外部ID連携に関するもののみを記載し、アプリケーション本体は本記事で登場しないため割愛します。

  • Amazon Cognito
  • AWS Lambda

外部ID連携とは

外部ID連携(フェデレーションとも呼ばれる)とは、例えばSNSサービスのアカウント情報で別サイトにログインできる機能のことです。最近では多くのWebサイトで見受けられるようになってきました。

昨今セキュリティ対策のため「パスワードはWebサイトごとに分けて」「パスワードの桁数もいくつ以上で」とユーザ側に負担を強いることが多い中、1つのアカウント情報を持っていれば複数のサイトにログインできるようになるこの仕組みは新規ユーザ流入の後押しになるとされています。

 

少し脱線しましたがこの外部ID連携をサーバサイドで実現するために、ユーザの端末以外に2つの登場人物がいます。
外部IDプロバイダー(外部IdP)とRelying Party(RP、Service Providerとも呼ばれる)です。

外部ID連携を実装するWebサイト側ではRPを実装することになります。(外部IdPとはここではSNSサービス等の別サイトの認証サーバを指します。)

今回は題材として、RPにAmazon Cognitoを利用します。

Amazon Cognitoとは

Amazon Cognitoとは、Amazon Web Services(以降、AWS)社が提供する認証認可に関する機能を備えたフルマネージドサービスです。
セキュアなID・パスワード管理に加えて多要素認証や外部ID連携など多くの機能が提供されています。

詳細な機能については日々アップデートもあるため、以下の公式サイトをご覧ください。
最近ではAmazon Cognitoのプランが細分化されて、必要な機能の分だけコストを払えばよくなり私がかかわった案件の多くではAmazon Cognitoの費用が安く済むようになりました。
https://aws.amazon.com/jp/cognito/

OpenID Connectとは

OpenID Connect(以降、OIDC)とは、IETFのRFC 6749及び6750に定められたOAuth 2.0認証フレームワーク仕様に基づいた認証プロトコルです。

サーバから払い出されたトークンをAuthorizationヘッダーに乗せてリクエストすることで、APIを認証済みユーザとして実行することができ、SPA(シングルページアプリケーション)+APIサーバ構成のシステムなどでログイン管理を行う際に利用されます。

OIDC自体に関する詳しい説明は、以下のサイトをご覧ください。
https://openid.net/developers/how-connect-works/

OIDCの認証方式のパターン

本記事のタイトルにもあるclient_secret_postを始めとしたOIDCによる認証方式について軽く紹介します。

詳細は、RFC 6749の2.3.1.Client Password(OIDCにおけるクライアント認証に必要な情報の定義)とOpenID Connect Core 1.0の9.Client Authentication(クライアント認証の方法)を参照ください。

https://tex2e.github.io/rfc-translater/html/rfc6749.html#2-3-1–Client-Password

https://openid-foundation-japan.github.io/openid-connect-core-1_0.ja.html#ClientAuthentication

RFC 6749で規定されているクライアント認証に必要な情報はclient_idとclient_secretの2つです。
そしてOpenID Connect Core 1.0では、これらを外部IdPに渡す方法として以下の認証方式を定義しています。(他にもありますが本記事に登場しないため割愛いたします。)

  • client_secret_basic:API通信のAuthorizationヘッダーにclient_idとclient_secretを持たせる
  • client_secret_post:POSTリクエストのボディ部にclient_idとclient_secretを持たせる

client_secret_basicで認証したい(本題)

問題の概要

ここで突然本題ですが、Amazon Cognitoは外部IdPとの認証方式としてclient_secret_postのみをサポートしています。

https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/cognito-user-pools-oidc-idp.html#cognito-user-pools-oidc-idp-prerequisites

AWSでRPを構築する場合、フルマネージドサービスであるAmazon Cognitoが第一候補に挙がると思いますが、この制約下で外部ID連携を実装することとなります。

そして今回、client_secret_basicのみ提供する外部IdPと接続する要件が発生したため、認証方式の不一致が発生しました。

解決策の概要

今回、対象システムの要件として、Amazon Cognitoの利用が前提となっていました。
そこで、client_secret_postで通信しようとするAmazon Cognitoと、client_secret_basicで受け付ける外部IdPの間に、認証方式を変換するAWS Lambdaを構築することでこの問題を解決しました。

解決策

この問題を解決するために構築したシステム構成を紹介します。

今回作成したシステムの構成図

Amazon Cognitoとclient_secret_basicで受け付ける外部IdPの間に、認証方式を変換するAWS Lambdaの構成図

RPであるAmazon Cognitoからは、client_secret_postで外部IdPに対して認証を試みます。
しかし外部IdPはclient_secret_basicしか受け付けられなかったので、フェデレーション通信の対向先を外部IdPではなくAWS Lambdaに変えて、AWS Lambdaの中でリクエスト内容をclient_secret_basicに変換して外部IdPに中継しました。

AWS環境構築手順

※本記事では外部IdP側のセットアップは範囲外といたしますが、AWS公式ドキュメントにセットアップ手順が記載されているのでそちらをご確認ください。
https://docs.aws.amazon.com/ja_jp/cognito/latest/developerguide/cognito-user-pools-oidc-idp.html#cognito-user-pools-oidc-idp-prerequisites

AWS Lambda関数の作成

まず上記の認証方法の変換を行うAWS Lambda関数(以降、関数)を作成します。
AWS Management Consoleは定期的にUIが変更されるため関数自体の作成方法は割愛いたしますが、関数に設定したコードを以下に示します。ランタイムはPython 3.12を指定しました。

import requests
import base64
import json

def lambda_handler(event, context):
  url = 'https://{外部IdPのFQDN}/{path}/{to}/token'

  # POSTリクエストのbodyからclient_idとclient_secretを取り出せるようにBodyからパラメータを取り出す
  # ex. param = "client_id=abcd&redirect_uri=..."
  originBody = base64.b64decode(event.get('body').encode()).decode('utf-8')
  param = dict(x.split("=") for x in originBody.split("&"))

  # Authorizationヘッダーに"client_id:client_secret"を付与する
  headers = {'content-type': 'application/x-www-form-urlencoded', 'Authorization': 'Basic ' + base64.b64encode((param.get("client_id") + ":" + param.get("client_secret")).encode()).decode()}
  # Bodyからclient_secretを取り除かないとエラーが返ることがあるため、bodyからclient_secretを取り除く(場合によっては、このステップが必要ない外部IdPも存在するかもしれません。)
  modBody = "&".join([s for s in originBody.split("&") if not s.startswith('client_secret=')])

  response = requests.post(url, data=modBody, headers=headers)
  return {
    'statusCode': response.status_code,
    'body': response.text,
    'headers': dict(response.headers)
  }

このソースコードでは、Amazon Cognitoからのリクエストのボディ部にあるclient_idとclient_secretを取り出し、AuthorizationヘッダーにBase64エンコードして付与しています。

関数に関数URLを設定

Amazon Cognitoから呼び出すために関数URLの設定が必要です。

関数URLを設定した後、以下の設定画面に表示されている「関数URL」を控えておきます。

Amazon Cognitoの設定

次に、上記の関数を使用するAmazon Cognitoユーザープール(以降、ユーザープール)を作成します。
ユーザープールのセットアップも割愛いたしますが、関数URLに関する設定箇所について説明します。

「ソーシャルプロバイダーと外部プロバイダー」のメニューからアイデンティティプロバイダーを追加する際に、上記キャプチャのように「手動入力」を選択します。
そして「トークンエンドポイント」の項目に先ほど作成した関数の関数URLを登録します。
※今回リクエスト内容を書き換えたいリクエストは、この「トークンエンドポイント」に指定したURLを使用します。

これで、ユーザープールからトークンエンドポイントへのリクエスト時に、直接外部IdPにアクセスするのではなく関数を経由するようになるため、認証方式がclient_secret_postだったリクエスト内容がclient_secret_basicに書き換えられて外部IdPに対してリクエストされます。

動作確認

簡単なHTMLを用意して、今回作成したRPが機能することを確認します。
HTMLは以下のコードを”index.html”としてローカルに保存して、ブラウザで開いて使用します。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Sample HTML</title>
  </head>
  <body>
    <button id="toCognitoButton">Cognito Hosted UIのログインURL</button>
    <br />
    <button id="toDirectButton">アプリに埋め込むURL</button>
    <script>
      document
        .getElementById("toCognitoButton")
        .addEventListener("click", function () {
          location.href =
            "https://auth.{ユーザープールのカスタムドメイン}.com/login?client_id={ユーザープールに作成したアプリケーションクライアントのクライアントID}&response_type=code&scope=openid&redirect_uri=https%3A%2F%2Flocalhost%3A8000";
        });
      document
        .getElementById("toDirectButton")
        .addEventListener("click", function () {
          location.href =
            "https://auth.{ユーザープールのカスタムドメイン}.com/oauth2/authorize?identity_provider={ユーザープールに作成した外部プロバイダーのプロバイダー名}&redirect_uri=https://localhost:8000&response_type=CODE&client_id={ユーザープールに作成したアプリケーションクライアントのクライアントID}&scope=openid";
        });
    </script>
  </body>
</html>

1つ目のボタンを押下すると、RPとして作成したユーザープールのホストされたUIを経由して外部IdPのログイン画面に遷移してログインできます。
2つ目のボタンを押下すると、RPの画面を表示することなく、直接外部IdPのログイン画面に遷移してログインできます。

ログインに成功した場合、今回のパラメータではむりやり localhost:8000 に遷移するようにしたため以下のような画面が表示されます。
これは localhost:8000 に何もアプリを起動していないので正常であり、クエリパラメータに code={UUID形式} が付与されていれば、OIDCの方式に則って外部IDでのログインに成功したことになります。

フェデレーションログインの動作確認で、外部IdPでのログインに成功した後で遷移する画面のサンプル

今回の方法に関する注意事項とあとがき

本記事は今回紹介したソースコード及びシステム構成の動作を保証するものではなく、記事内で紹介した特定の条件下でのみ動作した事例の紹介となります。
本記事を活用してシステムを構築される場合は、各自の責任の下でシステム構成の検証及び動作確認をお願いいたします。

この機会を通して、外部ID連携の裏側でどのような通信が行われているのか、少し深いところを知ることができたと感じました。
使い方としては通常の外部ID連携を実装しただけですが、認証方式を理解して弄れたのが面白い経験でした。

それでは、よいクラウドライフを!