「エンタープライズ顧客から『Azure ADでのSSOに対応してほしい』と要望が来たが、実装方法が分からない」
「AWS CognitoでSAML/OIDC統合を試みたが、設定項目が多すぎて挫折した」
「マルチテナントSaaSで、顧客ごとに異なるIdentity Providerを統合する方法が知りたい」
B2B SaaS開発者が直面するこれらの課題。私も経済産業大臣賞受賞プロダクトの開発で、同じ壁にぶつかりました。
この記事では、AWS Cognito User Poolsを使ったSAML/OIDC企業SSO統合の実装を、Terraformコード、Lambda Trigger、トラブルシューティングまで完全公開します。Azure AD、Okta、Google Workspaceとの統合実例で、あなたのB2B SaaSにエンタープライズ対応を実装できます。
前提:なぜエンタープライズ顧客はSSOを求めるのか
エンタープライズ顧客の要求
- セキュリティポリシー: 「全システムで自社のIdentity Provider(IdP)経由の認証必須」
- ユーザー管理の一元化: 退職者のアクセス権を即座に全システムで無効化
- コンプライアンス: SOC 2 / ISO 27001 取得要件
SSOなしで失う機会
- 大型案件損失: エンタープライズ契約(年間1,000万円以上)を獲得できない
- 競合劣位: 競合がSSO対応している場合、選定で不利
- セキュリティ事故リスク: パスワード管理の甘さによる情報漏洩
AWS Cognitoを選ぶ理由
| 選択肢 | メリット | デメリット |
|---|---|---|
| AWS Cognito | AWS統合容易、スケーラブル、コスパ良 | 設定複雑、ドキュメント分散 |
| Auth0 | UI優秀、ドキュメント充実 | コスト高($228/月〜) |
| Firebase Auth | 簡単、フロントエンド統合良 | SAML未対応、カスタマイズ制約 |
| 自前実装 | 完全制御 | 開発・運用コスト膨大 |
私の選択: AWS Cognito(コスパ + AWS統合)
システムアーキテクチャ全体像
┌─────────────────┐
│ エンドユーザー │
└────────┬────────┘
│ 1. ログイン要求
▼
┌──────────────────────────┐
│ SaaSアプリ(フロント) │
│ - React/Next.js │
│ - AWS Amplify Auth │
└────────┬─────────────────┘
│ 2. Cognito Hosted UI へリダイレクト
▼
┌──────────────────────────┐
│ Cognito User Pool │
│ - SAML/OIDC IdP設定 │
│ - Attribute Mapping │
│ - Lambda Triggers │
└────────┬─────────────────┘
│ 3. IdP選択 or SAML Request
▼
┌──────────────────────────┐
│ 顧客のIdP │
│ - Azure AD │
│ - Okta │
│ - Google Workspace │
└────────┬─────────────────┘
│ 4. SAML Assertion / ID Token
▼
┌──────────────────────────┐
│ Cognito User Pool │
│ - ユーザー自動作成 │
│ - JWT発行(Access/ID) │
└────────┬─────────────────┘
│ 5. トークン返却
▼
┌──────────────────────────┐
│ SaaSアプリ(バック) │
│ - トークン検証 │
│ - テナントID抽出 │
│ - API認可 │
└──────────────────────────┘
SAML vs OIDC:どちらを選ぶべきか
プロトコル比較
| 項目 | SAML 2.0 | OIDC (OpenID Connect) |
|---|---|---|
| 仕様 | XML ベース | JSON + OAuth 2.0 |
| 複雑性 | 高い | 低い |
| モバイル対応 | 困難 | 容易 |
| 主な採用企業 | Azure AD, Okta, ADFS | Google, Auth0, Cognito |
| Cognito対応 | ✅ User Pool Federation | ✅ User Pool Federation |
判断基準
SAML を選ぶべきケース:
- 顧客がレガシーシステム(ADFS等)を使用
- Azure AD環境で「エンタープライズアプリケーション」として登録必須
OIDC を選ぶべきケース:
- モダンなIdP(Google Workspace, Auth0)
- モバイルアプリ対応
- 実装のシンプルさ重視
現実: 両方対応が理想(Cognitoは両方サポート)
実装例1:Azure AD(SAML)統合
ステップ1:Azure AD側の設定
Azure Portal での操作
1. Azure Portal → Azure Active Directory → エンタープライズアプリケーション
2. 「新しいアプリケーション」→「独自のアプリケーションの作成」
3. 名前: "My SaaS (SAML)"
4. 「ギャラリーに見つからないその他のアプリケーションを統合する」を選択
5. 作成後、「シングル サインオン」→「SAML」を選択
基本SAML構成
識別子(エンティティID):
urn:amazon:cognito:sp:ap-northeast-1_XXXXXXXXX
応答URL(Assertion Consumer Service URL):
https://my-saas-domain.auth.ap-northeast-1.amazoncognito.com/saml2/idpresponse
サインオンURL:
https://my-app.com/login
リレー状態(オプション):
https://my-app.com/dashboard
属性マッピング
| Azure ADクレーム | SAML属性名 | 説明 |
|---|---|---|
user.userprincipalname | email | メールアドレス |
user.givenname | given_name | 名 |
user.surname | family_name | 姓 |
user.companyname | custom:company_id | テナントID(重要) |
SAML証明書ダウンロード
「SAML署名証明書」→「証明書(Base64)」をダウンロード
→ certificate.cer
メタデータURL取得
「アプリのフェデレーション メタデータ URL」をコピー
→ https://login.microsoftonline.com/<tenant-id>/federationmetadata/2007-06/federationmetadata.xml
ステップ2:Cognito User Pool設定(Terraform)
# Cognito User Pool
resource "aws_cognito_user_pool" "main" {
name = "my-saas-user-pool"
# カスタム属性(テナントID)
schema {
name = "company_id"
attribute_data_type = "String"
mutable = true
string_attribute_constraints {
min_length = 1
max_length = 256
}
}
# 自動検証
auto_verified_attributes = ["email"]
# パスワードポリシー(SSO時は不要だが設定)
password_policy {
minimum_length = 12
require_lowercase = true
require_uppercase = true
require_numbers = true
require_symbols = true
}
# アカウント回復
account_recovery_setting {
recovery_mechanism {
name = "verified_email"
priority = 1
}
}
}
# SAML Identity Provider(Azure AD)
resource "aws_cognito_identity_provider" "azure_ad_saml" {
user_pool_id = aws_cognito_user_pool.main.id
provider_name = "AzureAD"
provider_type = "SAML"
provider_details = {
MetadataURL = "https://login.microsoftonline.com/<tenant-id>/federationmetadata/2007-06/federationmetadata.xml"
}
# 属性マッピング(SAML → Cognito)
attribute_mapping = {
email = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"
given_name = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname"
family_name = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname"
"custom:company_id" = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/companyname"
}
}
# User Pool Domain(Hosted UI用)
resource "aws_cognito_user_pool_domain" "main" {
domain = "my-saas-domain" # <domain>.auth.ap-northeast-1.amazoncognito.com
user_pool_id = aws_cognito_user_pool.main.id
}
# App Client
resource "aws_cognito_user_pool_client" "web_client" {
name = "web-client"
user_pool_id = aws_cognito_user_pool.main.id
# OAuth設定
allowed_oauth_flows = ["code"]
allowed_oauth_scopes = ["openid", "email", "profile"]
allowed_oauth_flows_user_pool_client = true
callback_urls = ["https://my-app.com/auth/callback"]
logout_urls = ["https://my-app.com/logout"]
supported_identity_providers = ["AzureAD", "COGNITO"]
# トークン有効期限
access_token_validity = 1 # 1時間
id_token_validity = 1
refresh_token_validity = 30 # 30日
# トークンに含める属性
read_attributes = [
"email",
"given_name",
"family_name",
"custom:company_id"
]
}
ステップ3:Lambda Trigger(PreSignUp)でテナント検証
目的: 許可されたテナント(company_id)のみログイン許可
# lambda_pre_signup.py
import json
# 許可されたテナントIDのリスト(実際はDynamoDBやRDSから取得)
ALLOWED_TENANTS = {
"company-123": {"name": "Acme Corp", "plan": "enterprise"},
"company-456": {"name": "TechStart Inc", "plan": "professional"}
}
def lambda_handler(event, context):
"""
PreSignUp Lambda Trigger
外部IdP経由のユーザー作成時に実行される
"""
print(f"PreSignUp event: {json.dumps(event)}")
# ユーザー属性取得
user_attributes = event["request"]["userAttributes"]
company_id = user_attributes.get("custom:company_id")
# テナント検証
if not company_id:
raise Exception("Company ID is missing")
if company_id not in ALLOWED_TENANTS:
raise Exception(f"Company ID '{company_id}' is not authorized")
# 追加の属性を設定(オプション)
event["response"]["autoConfirmUser"] = True
event["response"]["autoVerifyEmail"] = True
# カスタム属性の追加(プラン情報等)
# 注: PreSignUpでは既存属性の上書きのみ可能、新規追加は不可
print(f"User from {ALLOWED_TENANTS[company_id]['name']} approved")
return event
Terraform: Lambda関数とトリガー設定
# Lambda関数
resource "aws_lambda_function" "pre_signup" {
filename = "lambda_pre_signup.zip"
function_name = "cognito-pre-signup"
role = aws_iam_role.lambda_exec.arn
handler = "lambda_pre_signup.lambda_handler"
runtime = "python3.11"
timeout = 10
environment {
variables = {
ALLOWED_TENANTS_TABLE = aws_dynamodb_table.tenants.name
}
}
}
# Lambda実行ロール
resource "aws_iam_role" "lambda_exec" {
name = "cognito-lambda-exec-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "lambda.amazonaws.com"
}
}]
})
}
resource "aws_iam_role_policy_attachment" "lambda_logs" {
role = aws_iam_role.lambda_exec.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
# Cognitoトリガー設定
resource "aws_lambda_permission" "cognito_trigger" {
statement_id = "AllowCognitoInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.pre_signup.function_name
principal = "cognito-idp.amazonaws.com"
source_arn = aws_cognito_user_pool.main.arn
}
# User PoolにLambdaトリガーを追加
resource "aws_cognito_user_pool" "main" {
# ... 前述の設定
lambda_config {
pre_sign_up = aws_lambda_function.pre_signup.arn
}
}
実装例2:Google Workspace(OIDC)統合
ステップ1:Google Cloud Console設定
1. Google Cloud Console → 「APIとサービス」→「認証情報」
2. 「認証情報を作成」→「OAuthクライアントID」
3. アプリケーションの種類: Webアプリケーション
4. 名前: My SaaS (Cognito)
5. 承認済みのリダイレクトURI:
https://my-saas-domain.auth.ap-northeast-1.amazoncognito.com/oauth2/idpresponse
6. 作成 → クライアントIDとシークレットを保存
ステップ2:Cognito設定(Terraform)
# OIDC Identity Provider(Google)
resource "aws_cognito_identity_provider" "google_oidc" {
user_pool_id = aws_cognito_user_pool.main.id
provider_name = "Google"
provider_type = "Google"
provider_details = {
client_id = var.google_oauth_client_id
client_secret = var.google_oauth_client_secret
authorize_scopes = "openid email profile"
}
attribute_mapping = {
email = "email"
given_name = "given_name"
family_name = "family_name"
username = "sub" # GoogleのユーザーID
}
}
# App Clientに追加
resource "aws_cognito_user_pool_client" "web_client" {
# ... 前述の設定
supported_identity_providers = [
"AzureAD",
"Google",
"COGNITO"
]
}
マルチテナント設計:顧客ごとにIdPを切り替える
課題
- 顧客A: Azure AD でSSO
- 顧客B: Okta でSSO
- 顧客C: Google Workspace でSSO
- 個人ユーザー: メール/パスワード認証
解決策:ログイン画面でテナント識別
フロントエンド実装(React + Amplify)
// LoginPage.tsx
import { Auth } from 'aws-amplify';
import { useState } from 'react';
interface Tenant {
id: string;
name: string;
idp: 'AzureAD' | 'Google' | 'Okta' | null;
}
export function LoginPage() {
const [email, setEmail] = useState('');
const [tenant, setTenant] = useState<Tenant | null>(null);
// メールアドレスからテナント検索
const detectTenant = async (email: string) => {
const response = await fetch('/api/detect-tenant', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
const data = await response.json();
setTenant(data.tenant);
};
// SSO ログイン
const loginWithSSO = async () => {
if (!tenant?.idp) return;
// Cognito Hosted UIへリダイレクト(IdP指定)
await Auth.federatedSignIn({
customProvider: tenant.idp
});
};
// メール/パスワードログイン
const loginWithPassword = async (password: string) => {
try {
await Auth.signIn(email, password);
// ログイン成功
} catch (error) {
console.error('Login failed', error);
}
};
return (
<div>
<h1>ログイン</h1>
{!tenant ? (
// Step 1: メールアドレス入力
<div>
<input
type="email"
placeholder="メールアドレス"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={() => detectTenant(email)}
/>
</div>
) : tenant.idp ? (
// Step 2a: SSO企業の場合
<div>
<p>{tenant.name} のSSOでログイン</p>
<button onClick={loginWithSSO}>
{tenant.idp} でログイン
</button>
</div>
) : (
// Step 2b: 個人ユーザーの場合
<div>
<input type="password" placeholder="パスワード" />
<button onClick={(e) => loginWithPassword(/* password */)}>
ログイン
</button>
</div>
)}
</div>
);
}
バックエンド API(テナント検出)
# api/detect_tenant.py
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, EmailStr
import boto3
router = APIRouter()
dynamodb = boto3.resource('dynamodb')
tenants_table = dynamodb.Table('tenants')
class DetectTenantRequest(BaseModel):
email: EmailStr
class TenantResponse(BaseModel):
tenant: dict | None
@router.post("/api/detect-tenant", response_model=TenantResponse)
async def detect_tenant(request: DetectTenantRequest):
"""
メールアドレスのドメインからテナントを検出
"""
# メールドメイン抽出
domain = request.email.split('@')[1]
# DynamoDBで検索
response = tenants_table.query(
IndexName='domain-index',
KeyConditionExpression='domain = :domain',
ExpressionAttributeValues={':domain': domain}
)
if response['Items']:
tenant = response['Items'][0]
return TenantResponse(tenant={
"id": tenant['tenant_id'],
"name": tenant['company_name'],
"idp": tenant.get('identity_provider') # "AzureAD" | "Google" | None
})
else:
# テナント未登録 → 個人ユーザー
return TenantResponse(tenant=None)
DynamoDBテナントテーブル設計
resource "aws_dynamodb_table" "tenants" {
name = "tenants"
billing_mode = "PAY_PER_REQUEST"
hash_key = "tenant_id"
attribute {
name = "tenant_id"
type = "S"
}
attribute {
name = "domain"
type = "S"
}
global_secondary_index {
name = "domain-index"
hash_key = "domain"
projection_type = "ALL"
}
}
# サンプルデータ
# {
# "tenant_id": "company-123",
# "company_name": "Acme Corp",
# "domain": "acme.com",
# "identity_provider": "AzureAD",
# "plan": "enterprise"
# }
JWT検証とテナント分離
バックエンドでのトークン検証
# middleware/auth.py
from fastapi import HTTPException, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt
from jwt import PyJWKClient
import os
security = HTTPBearer()
# Cognito公開鍵取得
COGNITO_REGION = os.getenv("COGNITO_REGION", "ap-northeast-1")
USER_POOL_ID = os.getenv("USER_POOL_ID")
JWKS_URL = f"https://cognito-idp.{COGNITO_REGION}.amazonaws.com/{USER_POOL_ID}/.well-known/jwks.json"
jwks_client = PyJWKClient(JWKS_URL)
async def verify_token(
credentials: HTTPAuthorizationCredentials = Security(security)
) -> dict:
"""
Cognito JWTトークンを検証し、ペイロードを返す
"""
token = credentials.credentials
try:
# 公開鍵取得
signing_key = jwks_client.get_signing_key_from_jwt(token)
# トークン検証
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience=os.getenv("COGNITO_CLIENT_ID"),
options={"verify_exp": True}
)
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except jwt.InvalidTokenError as e:
raise HTTPException(status_code=401, detail=f"Invalid token: {str(e)}")
async def get_current_user(payload: dict = Depends(verify_token)) -> dict:
"""
トークンからユーザー情報とテナントIDを抽出
"""
return {
"user_id": payload.get("sub"),
"email": payload.get("email"),
"company_id": payload.get("custom:company_id"), # テナントID(重要)
"given_name": payload.get("given_name"),
"family_name": payload.get("family_name")
}
# 使用例
from fastapi import APIRouter, Depends
router = APIRouter()
@router.get("/api/protected-resource")
async def get_protected_resource(
current_user: dict = Depends(get_current_user)
):
"""
認証必須のAPIエンドポイント
"""
company_id = current_user["company_id"]
# テナント別のデータ取得(Row Level Security)
data = await fetch_data_for_tenant(company_id)
return {"data": data, "user": current_user}
トラブルシューティング
エラー1:SAML Assertion検証失敗
エラーメッセージ:
Invalid SAML response: Signature validation failed
原因:
- Azure AD側の証明書が更新された
- CognitoのメタデータURLが古い
解決策:
# 最新のメタデータURLを再取得
# Azure Portal → エンタープライズアプリケーション → SAML → メタデータURL
# Terraformで更新
terraform apply -var="azure_metadata_url=<新しいURL>"
エラー2:Attribute Mapping問題
エラーメッセージ:
An error occurred (InvalidParameterException) when calling the AdminGetUser operation:
User does not have a valid email
原因:
- SAML Assertionに
email属性が含まれていない - Attribute Mappingが間違っている
デバッグ方法:
-
SAML Tracerブラウザ拡張を使用(Chrome/Firefox)
- SAML Responseの内容を確認
- どの属性が実際に送信されているか確認
-
CloudWatch Logsで確認
# Cognito User Poolのログを有効化(Terraform) resource "aws_cognito_user_pool" "main" { # ... user_pool_add_ons { advanced_security_mode = "ENFORCED" } } -
正しい属性名を確認
Azure ADのデフォルトSAML属性: http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress ではなく http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name の場合もある
エラー3:リダイレクトループ
症状:
- ログイン後、無限にリダイレクトされる
原因:
- Callback URLが間違っている
- CORS設定不足
解決策:
// Amplify設定(Next.js)
import { Amplify } from 'aws-amplify';
Amplify.configure({
Auth: {
region: 'ap-northeast-1',
userPoolId: 'ap-northeast-1_XXXXXXXXX',
userPoolWebClientId: 'xxxxxxxxxxxxxxxxxxxx',
oauth: {
domain: 'my-saas-domain.auth.ap-northeast-1.amazoncognito.com',
scope: ['openid', 'email', 'profile'],
redirectSignIn: 'https://my-app.com/auth/callback', // 正確に一致必須
redirectSignOut: 'https://my-app.com/logout',
responseType: 'code'
}
}
});
セキュリティ考慮事項
CSRF対策
Cognito Hosted UIは自動的にCSRFトークン(state parameter)を使用。
トークンリフレッシュ
// Amplify自動リフレッシュ
import { Auth } from 'aws-amplify';
// セッション取得(自動的にリフレッシュ)
const session = await Auth.currentSession();
const idToken = session.getIdToken().getJwtToken();
// 明示的なリフレッシュ
const cognitoUser = await Auth.currentAuthenticatedUser();
const currentSession = await Auth.currentSession();
cognitoUser.refreshSession(
currentSession.getRefreshToken(),
(err, session) => {
if (err) {
console.error('Refresh failed', err);
} else {
const newIdToken = session.getIdToken().getJwtToken();
}
}
);
ログアウト処理
// グローバルログアウト(全デバイスから)
await Auth.signOut({ global: true });
// ローカルログアウト(現在のデバイスのみ)
await Auth.signOut();
コスト試算
Cognito料金(東京リージョン)
【前提】
- 月間アクティブユーザー(MAU): 1,000人
- SAML/OIDC認証使用
【計算】
最初の50,000 MAU: 無料
→ 1,000 MAU: $0/月
【高度なセキュリティ機能(オプション)】
Advanced Security: $0.05/MAU
→ 1,000 MAU × $0.05 = $50/月
Lambda料金(PreSignUpトリガー)
【前提】
- 月間ログイン回数: 10,000回
- Lambda実行時間: 100ms
- メモリ: 256MB
【計算】
無料枠内(月100万リクエスト、40万GB秒)
→ $0/月
総コスト: 約$50/月(Advanced Security有効化時)
まとめ:エンタープライズSSO統合の成功要因
技術的ポイント
- SAML/OIDC両対応: 顧客のIdP環境に柔軟に対応
- Lambda Triggerでテナント検証: 不正アクセス防止
- JWTにテナントID埋め込み: バックエンドでのデータ分離
運用的ポイント
- テナントオンボーディングの自動化: 管理画面でIdP設定可能に
- トラブルシューティングドキュメント: SAML Tracerの使い方等
- 顧客サポート体制: IdP設定を支援できる体制
実績データ(私のプロジェクト)
- 統合IdP数: Azure AD 3社、Google Workspace 2社、Okta 1社
- ログイン成功率: 99.5%(初回設定後)
- 平均オンボーディング時間: 2時間/社(初回)、30分/社(2回目以降)
次のステップ
AWS CognitoでのエンタープライズSSO統合でお悩みなら、お気軽にご相談ください。SAML/OIDC統合の実践知を、あなたのB2B SaaSに活かします。
関連記事: