友田 陽大
← ブログ一覧に戻る

AWS Cognito カスタム認証フロー実装:SAML/OIDC企業SSO統合の完全ガイド

エンタープライズ顧客向けSSO(Azure AD/Okta/Google Workspace)をAWS Cognitoで実装する実践ガイド。SAML/OIDC統合、Lambda Trigger、マルチテナント設計、トラブルシューティングまで実コード付きで解説します。

2025/1/1010分友田 陽大
AWS
Cognito
SAML
OIDC
SSO
認証
Azure AD
Okta
Terraform
セキュリティ

「エンタープライズ顧客から『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を求めるのか

エンタープライズ顧客の要求

  1. セキュリティポリシー: 「全システムで自社のIdentity Provider(IdP)経由の認証必須」
  2. ユーザー管理の一元化: 退職者のアクセス権を即座に全システムで無効化
  3. コンプライアンス: SOC 2 / ISO 27001 取得要件

SSOなしで失う機会

  • 大型案件損失: エンタープライズ契約(年間1,000万円以上)を獲得できない
  • 競合劣位: 競合がSSO対応している場合、選定で不利
  • セキュリティ事故リスク: パスワード管理の甘さによる情報漏洩

AWS Cognitoを選ぶ理由

選択肢メリットデメリット
AWS CognitoAWS統合容易、スケーラブル、コスパ良設定複雑、ドキュメント分散
Auth0UI優秀、ドキュメント充実コスト高($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.0OIDC (OpenID Connect)
仕様XML ベースJSON + OAuth 2.0
複雑性高い低い
モバイル対応困難容易
主な採用企業Azure AD, Okta, ADFSGoogle, 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.userprincipalnameemailメールアドレス
user.givennamegiven_name
user.surnamefamily_name
user.companynamecustom: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が間違っている

デバッグ方法:

  1. SAML Tracerブラウザ拡張を使用(Chrome/Firefox)

    • SAML Responseの内容を確認
    • どの属性が実際に送信されているか確認
  2. CloudWatch Logsで確認

    # Cognito User Poolのログを有効化(Terraform)
    resource "aws_cognito_user_pool" "main" {
      # ...
    
      user_pool_add_ons {
        advanced_security_mode = "ENFORCED"
      }
    }
    
  3. 正しい属性名を確認

    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統合の成功要因

技術的ポイント

  1. SAML/OIDC両対応: 顧客のIdP環境に柔軟に対応
  2. Lambda Triggerでテナント検証: 不正アクセス防止
  3. JWTにテナントID埋め込み: バックエンドでのデータ分離

運用的ポイント

  1. テナントオンボーディングの自動化: 管理画面でIdP設定可能に
  2. トラブルシューティングドキュメント: SAML Tracerの使い方等
  3. 顧客サポート体制: IdP設定を支援できる体制

実績データ(私のプロジェクト)

  • 統合IdP数: Azure AD 3社、Google Workspace 2社、Okta 1社
  • ログイン成功率: 99.5%(初回設定後)
  • 平均オンボーディング時間: 2時間/社(初回)、30分/社(2回目以降)

次のステップ

AWS CognitoでのエンタープライズSSO統合でお悩みなら、お気軽にご相談ください。SAML/OIDC統合の実践知を、あなたのB2B SaaSに活かします。

お問い合わせはこちら


関連記事:

同様の課題はありませんか?

あなたのビジネス課題も、最新の技術で解決できるかもしれません。
まずは30分、無料技術相談で状況をお聞かせください。

無料技術相談を予約する

※ プロジェクト単位(請負)・技術顧問、どちらも対応可能です