Cognito における複数IdPユーザー統合の現在地
認証基盤における「ユーザー統合問題」の本質
エンタープライズやSaaSプロダクトにおいて、ユーザー認証の多様化は避けて通れない道です。あるユーザーは「メールアドレス」でサインアップし、別の機会には「Googleアカウント」でログインしたいと考えます。さらには「社内のSAML認証」でアクセスする必要がある場合もあるでしょう。
この時、システム側では同一人物として扱いたいのに、Cognitoでは別々のユーザーとして登録されてしまう。この「ユーザー統合問題」は、多くのプロジェクトで直面する課題です。
2024年から2025年にかけて、AWSはこの問題に対する公式ソリューションを段階的に強化してきました。特に「AdminLinkProviderForUser」APIの正式化により、従来のワークアラウンドが不要になったのは大きな前進です。
アーキテクチャの根本的な変化
ユーザープール中心設計への転換
従来のCognito実装では「Identity Pool」を中心に据えることが多かったのですが、2025年の現在では「ユーザープール」を中心とした設計が主流となっています。この変化には明確な理由があります。
ユーザープールは現在、Facebook、Google、Sign in with Appleなどのソーシャル認証に加え、OIDC、SAML連携を直接サポートしています。これらの外部IdPから取得した情報を「Managed Login」(旧Hosted UI)経由で統一的なユーザープールトークンに変換できるため、アプリケーション側の実装が大幅にシンプルになりました。
Identity Poolの役割の明確化
一方で「Identity Pool」の役割も明確になりました。Identity Poolは「AWS一時認証情報が必要な場合のみ」使用するという位置づけです。
例えば、クライアントアプリケーションから直接S3へファイルアップロードする場合や、DynamoDBへ直接アクセスする場合などです。この時、logins
マップに複数IdPのトークンを渡すことで、同一の「IdentityId」に紐づけることができます。
2025年版・実装パターンの詳細解説
パターン1:AdminLinkProviderForUserを使用したネイティブ統合(推奨)
基本的な実装フロー
現在最も推奨される実装方法は、「AdminLinkProviderForUser」APIを活用したアカウントリンクです。このAPIにより、既存のCognitoユーザーに対して、外部IdPのユーザーをリンクすることができます。
実装の流れを具体的に見ていきましょう。
- まず、ユーザープールに外部IdPを追加します(Facebook、Google、Apple、OIDC、SAML等)
- 「Managed Login」を有効化し、属性マッピングで
email
やemail_verified
等をユーザープール属性に揃えます - 既存ユーザー(DestinationUser)に対し、外部IdPユーザー(SourceUser)を「AdminLinkProviderForUser」でリンクします
実装上の重要な制約
ここで注意すべき制約がいくつかあります。これらは実際のプロジェクトで見落としがちなポイントです。
表 AdminLinkProviderForUser APIの制約事項
制約項目 | 詳細 | 対処方法 |
---|---|---|
リンク数の上限 | 1ユーザーに最大5つのFederated IDまで | 事前に必要なIdPを精査し、優先度を決めておく |
実行方法 | コンソールからは実行不可(API/CLIのみ) | Lambda関数やバックエンドAPIで実装 |
既存Federatedユーザー | すでに自動生成されている場合はリンク不可 | リンク前に該当ユーザーを削除する必要あり |
属性の要件 | リンク対象の属性はミュータブル(変更可能)である必要 | immutableな属性でのリンクは避ける |
この制約の中でも特に「コンソールから実行不可」という点は、運用設計に大きく影響します。管理者が手動でユーザーをリンクする運用は現実的ではないため、自動化の仕組みが必須となります。
Pre Sign-upトリガーを使用した自動リンク
実践的な実装としては、「Pre Sign-upトリガー」を活用した自動リンクが効果的です。
Lambda関数で既存ユーザーの検出と「AdminLinkProviderForUser」の呼び出しを行うことで、ユーザーが初めて外部IdPでサインインする前に自動的にリンクを完了させることができます。以下のような実装イメージになります。
import {
CognitoIdentityProviderClient,
ListUsersCommand,
AdminLinkProviderForUserCommand,
ListUsersCommandInput,
AdminLinkProviderForUserCommandInput,
UserType
} from '@aws-sdk/client-cognito-identity-provider';
// Cognito Pre Sign-up トリガーイベントの型定義
interface PreSignUpTriggerEvent {
version: string;
region: string;
userPoolId: string;
userName: string;
callerContext: {
awsSdkVersion: string;
clientId: string;
};
triggerSource: string;
request: {
userAttributes: {
[key: string]: string;
};
validationData?: {
[key: string]: string;
};
clientMetadata?: {
[key: string]: string;
};
};
response: {
autoConfirmUser: boolean;
autoVerifyEmail: boolean;
autoVerifyPhone: boolean;
};
}
// Lambda Context の型定義
interface Context {
functionName: string;
functionVersion: string;
invokedFunctionArn: string;
memoryLimitInMB: string;
awsRequestId: string;
logGroupName: string;
logStreamName: string;
getRemainingTimeInMillis: () => number;
}
// Cognito クライアントの初期化
const cognitoClient = new CognitoIdentityProviderClient({
region: process.env.AWS_REGION || 'ap-northeast-1'
});
/**
* メールアドレスで既存ユーザーを検索
* @param email メールアドレス
* @param userPoolId ユーザープールID
* @returns 既存ユーザー情報(存在しない場合はnull)
*/
async function findExistingUserByEmail(
email: string,
userPoolId: string
): Promise<UserType | null> {
try {
const listUsersParams: ListUsersCommandInput = {
UserPoolId: userPoolId,
Filter: `email = "${email}"`,
Limit: 1
};
const command = new ListUsersCommand(listUsersParams);
const response = await cognitoClient.send(command);
if (response.Users && response.Users.length > 0) {
console.log(`Found existing user with email: ${email}`);
return response.Users[0];
}
console.log(`No existing user found with email: ${email}`);
return null;
} catch (error) {
console.error('Error finding user by email:', error);
throw new Error(`Failed to search for existing user: ${error}`);
}
}
/**
* 外部IdPユーザーを既存ユーザーにリンク
* @param destinationUser リンク先ユーザー(既存ユーザー)
* @param sourceUser リンク元ユーザー(外部IdPユーザー)
* @param providerName プロバイダー名
* @param userPoolId ユーザープールID
*/
async function linkProviderForUser(
destinationUser: string,
sourceUser: string,
providerName: string,
userPoolId: string
): Promise<void> {
try {
// プロバイダー名の形式を調整(例: "Google" → "Google", "Facebook" → "Facebook")
// SAML/OIDCの場合は完全なプロバイダー名が必要
const formattedProviderName = formatProviderName(providerName);
const linkParams: AdminLinkProviderForUserCommandInput = {
UserPoolId: userPoolId,
DestinationUser: {
ProviderName: 'Cognito', // 既存ユーザーは通常Cognitoユーザー
ProviderAttributeName: 'USERNAME',
ProviderAttributeValue: destinationUser
},
SourceUser: {
ProviderName: formattedProviderName,
ProviderAttributeName: 'Cognito_Subject',
ProviderAttributeValue: sourceUser
}
};
const command = new AdminLinkProviderForUserCommand(linkParams);
const response = await cognitoClient.send(command);
console.log('Successfully linked provider for user:', {
destinationUser,
sourceUser,
provider: formattedProviderName
});
} catch (error) {
console.error('Error linking provider for user:', error);
throw new Error(`Failed to link provider: ${error}`);
}
}
/**
* プロバイダー名をフォーマット
* @param providerName 元のプロバイダー名
* @returns フォーマット済みプロバイダー名
*/
function formatProviderName(providerName: string): string {
// プロバイダー名のマッピング(必要に応じて調整)
const providerMap: { [key: string]: string } = {
'google': 'Google',
'facebook': 'Facebook',
'loginwithamazon': 'LoginWithAmazon',
'signinwithapple': 'SignInWithApple'
};
const lowerCaseProvider = providerName.toLowerCase();
// マッピングに存在する場合は変換、それ以外はそのまま返す(SAML/OIDC等)
return providerMap[lowerCaseProvider] || providerName;
}
/**
* Pre Sign-up Lambda ハンドラー
*/
export const handler = async (
event: PreSignUpTriggerEvent,
context: Context
): Promise<PreSignUpTriggerEvent> => {
console.log('Pre Sign-up trigger event:', JSON.stringify(event, null, 2));
try {
// 外部IdPからのサインアップの場合
if (event.triggerSource === 'PreSignUp_ExternalProvider') {
const email = event.request.userAttributes?.email;
if (!email) {
console.warn('No email attribute found in external provider signup');
return event;
}
console.log(`Processing external provider signup for email: ${email}`);
// 既存ユーザーの検索
const existingUser = await findExistingUserByEmail(email, event.userPoolId);
if (existingUser && existingUser.Username) {
console.log(`Linking external provider user to existing user: ${existingUser.Username}`);
// AdminLinkProviderForUserでリンク
await linkProviderForUser(
existingUser.Username,
event.userName,
event.request.userAttributes['cognito:username'] || event.userName.split('_')[0], // プロバイダー名の取得
event.userPoolId
);
// 自動生成を防ぐためにエラーを返す
// Cognitoはこのエラーをキャッチして、新規ユーザー作成をスキップし、
// 既存ユーザーでのサインインフローに移行します
throw new Error('LINK_ACCOUNT_TO_EXISTING_USER');
} else {
console.log('No existing user found, proceeding with new user creation');
// 新規ユーザーとして作成を続行
// 必要に応じて自動確認の設定
event.response.autoConfirmUser = true;
event.response.autoVerifyEmail = true;
}
} else {
console.log(`Trigger source ${event.triggerSource} does not require processing`);
}
return event;
} catch (error) {
// LINK_ACCOUNT_TO_EXISTING_USER エラーは意図的なものなので再スロー
if (error instanceof Error && error.message === 'LINK_ACCOUNT_TO_EXISTING_USER') {
throw error;
}
// その他のエラーはログに記録して、ユーザー作成を続行
console.error('Error in Pre Sign-up trigger:', error);
// エラーが発生しても新規ユーザー作成を妨げない場合
// (ビジネス要件に応じて調整)
return event;
// エラー時にユーザー作成を阻止したい場合は以下のようにthrow
// throw new Error(`Pre Sign-up processing failed: ${error}`);
}
};
/**
* 環境変数の検証(オプション)
*/
function validateEnvironment(): void {
const requiredEnvVars = ['AWS_REGION'];
const missingVars = requiredEnvVars.filter(varName => !process.env[varName]);
if (missingVars.length > 0) {
throw new Error(`Missing required environment variables: ${missingVars.join(', ')}`);
}
}
// Lambda の初期化時に環境変数を検証
validateEnvironment();
パターン2:Identity Poolを使用したID統合(限定的な使用)
適用すべきケース
Identity Poolを使用したID統合は、以下のような限定的なケースで有効です。
クライアントアプリケーションから直接AWSリソースにアクセスする必要がある場合の実装例を見てみましょう。S3への直接アップロードやDynamoDBへの直接クエリなど、サーバーレスアーキテクチャを採用する際に有効です。
実装の注意点
Identity Poolで複数IdPのユーザーを統合する場合、logins
マップに複数のIdPトークンを設定します。
import { CognitoIdentityClient, GetIdCommand, GetCredentialsForIdentityCommand } from "@aws-sdk/client-cognito-identity";
import { fromCognitoIdentityPool } from "@aws-sdk/credential-provider-cognito-identity";
interface CognitoCredentialsConfig {
identityPoolId: string;
userPoolId: string;
region: string;
idToken: string;
facebookAccessToken?: string;
}
class CognitoIdentityService {
private client: CognitoIdentityClient;
private identityPoolId: string;
private region: string;
constructor(region: string, identityPoolId: string) {
this.region = region;
this.identityPoolId = identityPoolId;
this.client = new CognitoIdentityClient({ region });
}
/**
* 複数のIdPトークンから統合されたAWS認証情報を取得
*/
async getCredentials(config: CognitoCredentialsConfig): Promise<AWS.Credentials> {
const logins: Record<string, string> = {
[`cognito-idp.${config.region}.amazonaws.com/${config.userPoolId}`]: config.idToken,
};
// Facebookトークンがある場合は追加
if (config.facebookAccessToken) {
logins['graph.facebook.com'] = config.facebookAccessToken;
}
// Identity IDを取得
const getIdCommand = new GetIdCommand({
IdentityPoolId: this.identityPoolId,
Logins: logins,
});
const { IdentityId } = await this.client.send(getIdCommand);
if (!IdentityId) {
throw new Error('Failed to get Identity ID');
}
// 一時的なAWS認証情報を取得
const getCredentialsCommand = new GetCredentialsForIdentityCommand({
IdentityId,
Logins: logins,
});
const response = await this.client.send(getCredentialsCommand);
if (!response.Credentials) {
throw new Error('Failed to get credentials');
}
return {
accessKeyId: response.Credentials.AccessKeyId!,
secretAccessKey: response.Credentials.SecretKey!,
sessionToken: response.Credentials.SessionToken!,
expiration: response.Credentials.Expiration!,
};
}
/**
* fromCognitoIdentityPoolを使用した実装(より簡潔)
*/
getCredentialProvider(config: CognitoCredentialsConfig) {
const logins: Record<string, string> = {
[`cognito-idp.${config.region}.amazonaws.com/${config.userPoolId}`]: config.idToken,
};
if (config.facebookAccessToken) {
logins['graph.facebook.com'] = config.facebookAccessToken;
}
return fromCognitoIdentityPool({
client: this.client,
identityPoolId: this.identityPoolId,
logins,
});
}
}
// 使用例
async function example() {
const service = new CognitoIdentityService(
'ap-northeast-1',
'ap-northeast-1:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
);
try {
// 方法1: 直接認証情報を取得
const credentials = await service.getCredentials({
identityPoolId: 'ap-northeast-1:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
userPoolId: 'ap-northeast-1_xxxxxxxxx',
region: 'ap-northeast-1',
idToken: 'eyJhbGciOiJSUzI1NiIs...', // 実際のIDトークン
facebookAccessToken: 'EAABsbCS...', // オプション: Facebookアクセストークン
});
console.log('AWS Credentials:', credentials);
// 方法2: S3クライアントなどで使用する場合
const credentialProvider = service.getCredentialProvider({
identityPoolId: 'ap-northeast-1:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
userPoolId: 'ap-northeast-1_xxxxxxxxx',
region: 'ap-northeast-1',
idToken: 'eyJhbGciOiJSUzI1NiIs...',
facebookAccessToken: 'EAABsbCS...',
});
// S3クライアントの例
const { S3Client, ListBucketsCommand } = await import('@aws-sdk/client-s3');
const s3Client = new S3Client({
region: 'ap-northeast-1',
credentials: credentialProvider,
});
const buckets = await s3Client.send(new ListBucketsCommand({}));
console.log('S3 Buckets:', buckets);
} catch (error) {
console.error('Error:', error);
}
}
この実装により、どちらのIdPで認証しても同じ「IdentityId」が返却されます。ただし、あくまでもAWSリソースへのアクセス権限を付与するためのIDであり、アプリケーション上のユーザー統合は別途考慮する必要があります。
パターン3:独自マッピングテーブル(最終手段)
なぜ「最終手段」なのか
独自のマッピングテーブルを実装する方法は、現在では「最終手段」として位置づけられます。なぜなら、Cognitoネイティブの機能で大部分のユースケースをカバーできるようになったからです。
ただし、以下のような特殊な要件がある場合は、依然として有効な選択肢となります。
特殊な要件の具体例を挙げてみましょう。
- 複数のユーザープールを跨いだユーザー統合が必要
- Cognitoがサポートしていない独自の認証プロバイダーとの連携
- 既存の認証基盤との段階的な移行期間における暫定対応
実装時の設計ポイント
独自マッピングを実装する場合でも、「sub」属性を主キーとして使用することが重要です。「email」は変更可能であり、重複の可能性もあるため、主キーには適しません。
表 ユーザー識別子の選定基準
識別子 | 特性 | 用途 | 注意点 |
---|---|---|---|
sub | 不変・一意 | 主キー | 変更不可、ユーザーには見せない |
変更可能 | サインイン識別子 | 重複可能性あり、変更時の考慮必要 | |
preferred_username | 変更可能 | 表示名 | ユニーク制約の設定次第 |
phone_number | 変更可能 | 二要素認証 | 国際化対応の考慮必要 |
実装における落とし穴と対策
セキュリティ面での考慮事項
PKCEの必須化
2025年現在、フロントエンドの実装では「Authorization Code + PKCE」が強く推奨されています。Implicit Flowは非推奨となっており、新規実装では避けるべきです。
PKCEを実装することで、認可コードの横取り攻撃を防ぐことができます。特にSPAやモバイルアプリケーションでは必須の実装となります。
属性の検証
外部IdPから取得した属性の信頼性は、プロバイダーによって異なります。
例えば、email_verified
属性の扱いには注意が必要です。すべての外部IdPが検証済みメールアドレスを保証するわけではありません。アプリケーション側で追加の検証ロジックを実装することを検討すべきです。
運用面での課題
MAU課金への影響
Cognitoの課金体系では、1プロファイル = 1MAUとしてカウントされます。重要なのは、リンクされたFederated IDの数に関わらず、1ユーザーとして課金される点です。
これは従来の独自マッピング実装と比較して、コスト面で有利になるケースが多いです。複数のユーザープールエントリーが作成されないため、MAUの重複カウントを避けることができます。
Managed Loginの機能プラン選択
Managed Loginには「Lite」「Essentials」「Plus」の3つのプランが存在します。「Plus」プランではパスワードレス認証などの先進的な機能が利用可能です。
プラン選択の判断基準を整理してみましょう。
- Lite: 基本的な認証機能のみで十分な場合
- Essentials: MFAやカスタマイズが必要な場合
- Plus: パスワードレス認証やAdvanced Security Featuresが必要な場合
トラブルシューティングのベストプラクティス
リンクエラーへの対処
「AdminLinkProviderForUser」の実行時によく発生するエラーとその対処法を整理しました。
表 よく発生するリンクエラーと対処法
エラー種別 | 原因 | 対処法 |
---|---|---|
ResourceNotFoundException | 指定したユーザーが存在しない | ユーザーの存在確認ロジックを追加 |
InvalidParameterException | すでにリンク済み | リンク状態の事前確認を実装 |
LimitExceededException | 5つの制限を超過 | 既存リンクの整理または設計見直し |
AliasExistsException | エイリアスが重複 | エイリアスの一意性チェックを強化 |
ログ設計の重要性
認証フローのデバッグには、適切なログ設計が不可欠です。CloudWatch Logsに出力する際は、個人情報のマスキングに注意しながら、以下の情報を記録することを推奨します。
ログに記録すべき重要な情報の例を挙げます。
- 認証試行のタイムスタンプと結果
- 使用されたIdPの種類
- リンク処理の成功/失敗とその理由
- エラーコードとスタックトレース(開発環境のみ)
今後の展望と準備
認証基盤の進化トレンド
パスワードレス認証への移行
2025年から2026年にかけて、パスワードレス認証の採用が急速に進むと予想されます。CognitoのPlusプランで提供されるパスワードレス機能は、この流れに対応するための重要な選択肢となります。
WebAuthnやFIDO2といった標準規格への対応も進んでおり、生体認証やセキュリティキーを使用した認証が一般化していくでしょう。現時点でアーキテクチャを設計する際は、将来的なパスワードレス移行を視野に入れた拡張性を持たせることが重要です。
AIを活用したリスクベース認証
機械学習を活用したリスクベース認証も、今後の重要なトレンドとなります。Cognitoの「Advanced Security Features」では、すでに不正なサインイン試行の検出が可能ですが、今後はより高度な異常検知が実装される可能性があります。
マイグレーション戦略
既存システムからの移行
既存の認証システムから「AdminLinkProviderForUser」を活用した新しいアーキテクチャへの移行は、段階的に行うことが重要です。
移行戦略の具体的なステップを提案します。
- 新規ユーザーから新アーキテクチャを適用
- 既存ユーザーのデータをエクスポートし、マッピング情報を準備
- バッチ処理でAdminLinkProviderForUserを実行
- 移行完了後、旧システムを段階的に廃止
ロールバック計画
新しい認証フローへの移行時は、必ずロールバック計画を準備しておく必要があります。Feature Flagを活用して、問題が発生した場合に即座に旧フローに戻せるようにしておくことが重要です。
実装チェックリストと次のステップ
必須実装項目
プロジェクトを開始する前に、以下のチェックリストで準備状況を確認してください。
- Managed Loginを有効化し、必要なIdP(Facebook/Google/Apple/OIDC/SAML)を追加
- 属性マッピングで
email
・email_verified
などをユーザープール標準属性に正規化 - サインイン識別子(エイリアス)を決定(例:Emailのみ)
- Pre Sign-upトリガーで既存ユーザー検出・AdminLinkProviderForUser呼び出しを自動化
- リンク制約の考慮(最大5プロバイダー/コンソール不可/既生成Federatedユーザーは削除してからリンク)
- Auth Code + PKCEでフロント実装
- Identity Pool(必要時のみ):
logins
で複数IdPトークンを同一IdentityIdに関連付け - 主キーは
sub
を使用(メール変更・重複に備える)
パフォーマンス最適化
レスポンスタイムの改善
認証フローのレスポンスタイムは、ユーザー体験に直結する重要な指標です。以下の最適化手法を検討してください。
Lambda関数のコールドスタート対策として有効な手法を紹介します。
- Provisioned Concurrencyの活用で初回実行時のレイテンシを削減
- Lambda関数のメモリ割り当てを適切に調整(推奨:1024MB以上)
- 不要な外部APIコールの削減とキャッシュの活用
スケーラビリティの確保
ユーザー数の増加に備えて、以下の点を考慮した設計が必要です。
表 スケーラビリティ確保のための設計指針
項目 | 推奨設定 | 理由 |
---|---|---|
Lambda同時実行数 | 予約済み同時実行数を設定 | スロットリング回避 |
DynamoDBキャパシティ | オンデマンドモード | 急激なトラフィック増加に対応 |
API Gatewayレート制限 | 段階的に調整 | DDoS対策とコスト管理 |
CloudFrontキャッシュ | 静的コンテンツは積極的にキャッシュ | オリジンの負荷軽減 |
まとめ:2025年のCognito実装の要諦
技術選定の判断基準
Cognitoにおける複数IdPユーザーの統合は、「AdminLinkProviderForUser」の登場により大きく様変わりしました。技術選定においては、以下の判断基準を持つことが重要です。
まず第一に、「ユーザープール中心」の設計を基本とし、Identity Poolは「AWS一時認証情報が必要な場合のみ」という明確な使い分けを行うことです。これにより、アーキテクチャの複雑性を大幅に削減できます。
第二に、運用の自動化を前提とした設計を行うことです。「AdminLinkProviderForUser」がコンソールから実行できないという制約は、逆に自動化を促進する良い機会と捉えるべきでしょう。
実装者へのアドバイス
私がこれまで多くのプロジェクトで認証基盤を構築してきた経験から、最も重要だと感じるのは「将来の拡張性」を常に意識することです。
認証要件は時間とともに必ず変化します。今は不要と思われるMFAやパスワードレス認証も、1年後には必須要件になるかもしれません。「Managed Login」のプラン選択や、属性マッピングの設計時には、この点を十分に考慮してください。
また、セキュリティは「後から追加する」ものではなく、「最初から組み込む」ものです。PKCEの実装や適切なログ設計は、プロジェクト初期から取り組むべき事項です。
本記事で紹介した実装パターンは2025年8月時点でのベストプラクティス
AWSは継続的にCognitoの機能を強化しているため、定期的な情報のアップデートが必要です。
実装を開始する前に、まずは小規模なPoCで「AdminLinkProviderForUser」の動作を確認することをお勧めします。特に、既存Federatedユーザーの削除タイミングや、Pre Sign-upトリガーの挙動は、実際に動かしてみることで理解が深まります。
認証基盤は、アプリケーションの土台となる重要なコンポーネントです。適切な技術選定と実装により、ユーザーに優れた体験を提供しながら、開発者にとっても保守しやすいシステムを構築できることを願っています。