はじめに
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をマッピングして照合 -
InitiateAuthはusername必須のため、事前に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をマッピングして照合 -
InitiateAuthはusername必須のため、事前にKeycloak Tokenからmapping先Cognito subとUsernameを特定する必要あり
最後に、GMOコネクトではサービス開発支援や技術支援をはじめ、幅広い支援を行っておりますので、何かありましたらお気軽にお問合せください。