7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【AWS】KeycloakマルチRealm × Cognitoカスタム認証で「力技SSO」を実装した話

Posted at

はじめに

GMOコネクトの永田です。

「Cognito使ってるんだけど、外部IdPとSSOできる?」
この相談を受けたとき、私は軽い気持ちで「できますよ!」と答えました。

CognitoにはHosted UIがあり、SAML/OIDCの外部IdP連携も標準サポート。
設定画面でポチポチやれば一晩で終わるはず……そう思っていました。


「あ、IdPはKeycloakで、realmが企業ごとに分かれてて」
「しかも動的に増えるから事前登録は無理で」
「あと、既存実装はCognitoトークンのまま使いたくて」

** 詰んだ😇 **

Cognitoの標準SSO機能は、IdPのエンドポイントを事前に固定する前提。
Keycloakの動的realmのような「エンドポイントが可変」なケースには対応していません。

かくして、Cognitoカスタム認証Lambdaによる力技SSOの実装が始まりました。

この記事で分かること

  • Keycloakマルチrealm × CognitoでSSO実装する方法
  • Cognitoカスタム認証Lambda(Define/Create/Verify)の実装パターン
  • 公開エンドポイントを扱う際のセキュリティ設計
  • InitiateAuthのUSERNAME必須問題の解決策

想定読者

  • Cognito × 外部IdPのSSO実装で詰まっている方
  • Keycloakマルチテナント環境での認証連携を検討中の方
  • Cognitoカスタム認証の実践例を探している方

前提: Cognito User Poolへのユーザー登録および外部IdP subject ↔ Cognito subjectのマッピングは別途実施済みとします。本記事では既存Cognitoユーザーの認証フローに焦点を当てます。


背景と要件

それでは、まず要件の全貌から見ていきましょう。

ざっくりSSO要件

  • 外部ID Provider(IdP)としてKeycloakを利用
    • マルチテナント運用であり、realmは利用者ごとに異なる(企業単位でrealm定義)
    • realmは動的に増減があり、件数もかなり多い🤯
  • Cognito Hosted UIはIdP設定(Authorization Endpointなど)を事前に固定する前提
    • Keycloakのマルチテナント(動的realm)には適用できない
    • realm毎にCognito Identity Providerを追加すると運用が回らない
  • RP(Relying Party)およびRS(Resource Server)の既存実装を変更せず、Cognitoトークンのまま利用したい
    • 外部IdPトークンとCognitoトークンの両方を扱う改修は、やんごとなき事情により不可😡

KeycloakのrealmとOIDC Endpoint

KeycloakのOIDC Endpointには、realmが含まれます。

realmとは?
Cognito User Poolに相当する単位で、ユーザーや設定を独立させる単位です。

Authorization endpoint
/realms/{realm-name}/protocol/openid-connect/auth

今回は、利用者が所属する企業毎にKeycloakの設定(パスワードポリシーなど)を分離しており、企業毎にrealmを分けるマルチテナント運用でした。

Cognitoカスタム認証とは

Cognitoの認証機能をLambdaでカスタマイズできる機能です。

詳細は上記のAWSリファレンスを参照してください。

💡 補足
公式ドキュメントは難解なので、まずは解説記事を読み、実装して動かしてから読むと理解しやすいです😇

ということで、Cognitoカスタム認証でKeycloakマルチrealm SSOを実装することにしました!

最初にまとめ

  • Cognitoカスタム認証で独自チャレンジを実装し、realmとKeycloak Tokenをチャレンジ回答に載せてToken検証
  • Cognitoカスタム認証は公開エンドポイントから叩けるため、Verify Lambdaで外部IdPトークンを厳密に検証し、IdPのsubとCognitoのsubをマッピングして照合
  • InitiateAuthusername必須のため、事前にKeycloak Tokenからmapping先Cognito subとUsernameを特定する必要あり

実装してみた

Keycloakマルチrealm SSO 全体像

実装ポイント

  • Keycloakとは普通のOIDC Authorization Code Flowの流れ
    • Authorization CodeをKeycloakで発行した後、Cognitoカスタム認証Lambdaでがんばる
  • Cognito InitiateAuthは、AuthParameters.USERNAME='<username>' が必須。subは渡せないので事前にUsernameを解決する
    • そのため、Cognito InitiateAuth呼び出し前に、Keycloak Token発行(Authorization Codeから引き換え)、Keycloak sub --> Cognito sub --> Cognito Usernameの解決を実施
  • RespondToAuthChallenge: ChallengeName='CUSTOM_CHALLENGE' に対し、USERNAMEとシリアライズしたANSWER(provider/access_token/realm)を送る。

Cognitoカスタム認証Lambda コードスニペット

Define(チャレンジ開始判定)

  • challengeNameとして、SSOだよを明示
  • Verify Lambdaの結果(challengeName, challengeResult)がカスタム認証かつVerifyOKなら、Cognito Token発行
/** Decide next step based on session history. */
exports.handler = async (event) => {
  // First call: start external SSO check
  if (event.request.session && event.request.session.length === 0) {
    event.response.challengeName = 'EXTERNAL_SSO_CHECK';
    event.response.issueTokens = false;
    event.response.failAuthentication = false;
  }
  // Previous CUSTOM_CHALLENGE succeeded: issue tokens
  else if (
    event.request.session.length === 1 &&
    event.request.session[0].challengeName === 'CUSTOM_CHALLENGE' &&
    event.request.session[0].challengeResult === true
  ) {
    event.response.issueTokens = true;
  }
  // Anything else: fail
  else {
    event.response.failAuthentication = true;
  }
  return event;
};

Create(チャレンジ生成)

  • 特段実装するものなし
  • 将来の拡張用にchallengeNameでの分岐を導入
/** Emit the external SSO challenge with public/private params. */
exports.handler = async (event) => {
  if (event.request.challengeName === 'EXTERNAL_SSO_CHECK') {
    event.response.publicChallengeParameters = { challenge: 'EXTERNAL_SSO_CHECK' };
    event.response.privateChallengeParameters = { expectedAnswer: 'true' };
    event.response.challengeMetadata = 'EXTERNAL_SSO_CHECK';
  }
  return event;
};

Verify(トークン検証とsubjectバインド)

  • ここが一番実装をがんばるところです
  • 実装の要点:
    • Keycloak Introspection APIでトークンの有効性とKeycloak sub取得
    • 内部で保持しているKeycloak sub --> Cognito subから、SSO先のCognito Userを特定
      • マッピングデータはRDS/DynamoDB/S3等の外部ソースより取得(実装方法は任意)
    • Cognitoの認証セッションのsub(event.request.userAttributes.sub)との一致を確認
/**
 * Validate external IdP token and bind IdP subject to Cognito subject.
 * Helper functions getExternalIdPConfig/getSubjectMapping/introspectToken are omitted here.
 */
exports.handler = async (event) => {
  try {
    // Require challenge answer
    if (!event.request.challengeAnswer) {
      event.response.answerCorrect = false;
      return event;
    }

    // Parse and validate required fields (provider/access_token/realm)
    const answer = JSON.parse(event.request.challengeAnswer);
    if (answer.provider !== 'external-idp' || !answer.access_token || !answer.realm) {
      event.response.answerCorrect = false;
      return event;
    }

    // Load IdP client config (client_id/secret/domain)
    const idpConfig = await getExternalIdPConfig();

    // Introspect access_token at realm-specific endpoint; require active + sub
    const introspection = await introspectToken(answer.access_token, answer.realm, idpConfig);
    if (!introspection.active || !introspection.sub) {
      event.response.answerCorrect = false;
      return event;
    }

    // Map IdP sub -> expected Cognito sub
    const mapping = await getSubjectMapping();
    const expectedCognitoSub = mapping[introspection.sub];
    if (!expectedCognitoSub) {
      event.response.answerCorrect = false;
      return event;
    }

    // Enforce subject binding against Cognito session attributes
    const actualCognitoSub = event.request.userAttributes.sub;
    if (expectedCognitoSub !== actualCognitoSub) {
      event.response.answerCorrect = false;
      return event;
    }

    event.response.answerCorrect = true;
  } catch {
    event.response.answerCorrect = false;
  }
  return event;
};

Verify を厳密にする理由

CognitoのInitiateAuth APIはAWSアカウント外からも呼び出し可能なパブリックAPIです。そのため、Verifyで以下を必ずサーバ側で実施し、トークン差し替え攻撃を防ぐ必要があります。

想定される攻撃シナリオ(トークン差し替え攻撃):

  • 攻撃者が自分の正当なKeycloakトークンを使い、他人のCognito subを指定してInitiateAuthを呼び出す
  • Verifyでsub検証を怠ると、攻撃者のKeycloakトークン(正当)+被害者のCognito sub(不正)という組み合わせで認証が通り、攻撃者が任意のユーザーになりすましてCognitoトークンを取得可能

必須の検証項目:

  • IdPトークンの有効性チェック(introspectionでactive)(重要: 不正なTokenをブロック)
  • sub(IdP)→sub(Cognito)のマッピング確認
  • セッションuserAttributes.subとのsub(Cognito)一致確認(重要: これにより、IdPトークンの対象とCognito認証セッションの対象が一致することを保証)

Cognitoカスタム認証Lambda呼び出し部分

Cognito initiateAuth

  • シーケンス図で説明した通り、Cognito initiateAuthにCognito Usernameが必須なため
    • Keycloak Authorization Code
    • --> Keycloak Token
    • --> Keycloak sub
    • --> Cognito sub
    • --> Cognito Username
    • と引き換えています
    // Step 1: Authorization CodeをTokenに交換(Keycloak Token Endpoint呼び出し)
    console.log(`[${realm}] POST Token endpoint with authorization code...`);
    const accessToken = await postTokenEndpointWithCode(code, realm, redirect_uri);

    // Step 2: Keycloak access_tokenからsubjectを抽出
    console.log(`[${realm}] Extracting Keycloak subject from token...`);
    const idpSubject = extractIdPSubject(accessToken);
    console.log(`[${realm}] Keycloak subject: ${idpSubject}`);

    // Step 3: 外部mappingでKeycloak subject → Cognito subjectをマッピング
    console.log(`[${realm}] Mapping Keycloak subject to Cognito subject...`);
    const cognitoSubject = await mapKeycloakSubjectToCognito(idpSubject);
    console.log(`[${realm}] Cognito subject: ${cognitoSubject}`);

    // Step 4: Cognito ListUsersでusernameを取得
    console.log(`[${realm}] Retrieving Cognito username...`);
    const username = await getCognitoUsername(cognitoSubject);
    console.log(`[${realm}] Cognito username: ${username}`);

    // Step 5: Cognito CUSTOM_AUTH フローを実行
    console.log(`[${realm}] Initiating Cognito CUSTOM_AUTH...`);
    const initiateResult = await initiateCustomAuth(username);

Cognito RespondToAuthChallenge

  • answerが文字列を受け取るので、JSON文字列化で複数の要素を渡しています
    • この中でinitiateAuth呼び出し前に取得したKeycloak Tokenをいれます
    • ここで指定した内容がVerify Lambdaに渡されます
    // Step 6: チャレンジに応答(Keycloak tokenを渡す)
    const challengeAnswer = JSON.stringify({
      provider: 'external-idp',
      access_token: accessToken,
      realm: realm,
    });

    console.log(`[${realm}] Responding to auth challenge...`);
    const respondResult = await respondToCustomChallenge({
      username,
      session: initiateResult.session,
      answer: challengeAnswer,
    });

(再掲)まとめ

  • Cognitoカスタム認証で独自チャレンジを実装し、realmとKeycloak Tokenをチャレンジ回答に載せてToken検証
  • Cognitoカスタム認証は公開エンドポイントから叩けるため、Verify Lambdaで外部IdPトークンを厳密に検証し、IdPのsubとCognitoのsubをマッピングして照合
  • InitiateAuthusername必須のため、事前にKeycloak Tokenからmapping先Cognito subとUsernameを特定する必要あり

最後に、GMOコネクトではサービス開発支援や技術支援をはじめ、幅広い支援を行っておりますので、何かありましたらお気軽にお問合せください。

お問合せ: https://gmo-connect.jp/contactus/

7
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?