# AWS Cognito で企業SSOを実装する完全ガイド：SAML/OIDC連携（Azure AD・Okta・Google）とUSER_AUTH選択式認証

> AWS Cognito User Poolsで企業SSO（Azure AD/Okta/Google）とパスワードレス認証（パスキー/OTP）を実装する2026年最新ガイド。SAML/OIDC連携、USER_AUTH選択式認証、カスタム認証フローのLambdaトリガー、マルチテナント設計、JWT検証まで公式ドキュメント準拠の実コード付きで解説します。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: AWS, Cognito, SAML, OIDC, SSO, 認証, パスワードレス, Passkey, Azure AD, Okta, Terraform, セキュリティ
- URL: https://tomodahinata.com/blog/aws-cognito-saml-oidc-enterprise-sso
- カテゴリ: 認証・認可
- 総合ガイド: https://tomodahinata.com/blog/auth-platform-selection-2026-cognito-auth0-clerk-supabase

## 要点

- 2024年導入の機能プラン（Lite/Essentials/Plus）で使える認証機能が変わり、B2B SaaSの標準はEssentials
- 認証方式は企業顧客＝フェデレーション、個人＝パスワードレス、独自要件のみCUSTOM_AUTHと選び分ける
- OTPやパスキー目的ならCUSTOM_AUTHを自作せず、公式のUSER_AUTH（選択式サインイン）を使う
- マルチテナントはPre Token Generation V2.0でtenant_idをアクセストークンに注入し、バックエンドの認可で一貫して使う
- JWT検証はaws-jwt-verifyに任せ、iss・aud/client_id・token_use・署名を自前で書かない

---

「エンタープライズ顧客から『Azure AD（Microsoft Entra ID）でのSSOに対応してほしい』と要望が来たが、実装方法が分からない」

「AWS CognitoでSAML/OIDC統合を試みたが、設定項目が多すぎて挫折した」

「マルチテナントSaaSで、顧客ごとに異なるIdentity Providerを統合する方法が知りたい」

「2024年以降に出た『パスキー』『選択式認証』『料金プラン（Lite/Essentials/Plus）』が、古い記事と食い違っていて混乱している」

B2B SaaS開発者が直面するこれらの課題。私自身、経済産業大臣賞を受賞したB2Bサブスクリプション SaaS（木材流通DX）の開発で、Cognitoによる認証基盤をRS256のJWT検証込みで設計・運用しました。同じ壁にぶつかった経験から、この記事を書いています。

この記事は **AWS公式ドキュメント（2026年6月時点）に忠実** に、かつ「どの場面で・どう使うか」を実コード付きで解説します。各セクションには参照した公式ドキュメントのリンクを明記しているので、自分の環境で一次情報を確認しながら実装を進められます。

> **この記事で扱う範囲**
> 1. 2026年のCognito全体像（料金プラン・マネージドログイン）
> 2. 認証方式の選び方（フェデレーション / パスワードレス / カスタムフロー）
> 3. SAML（Azure AD）・OIDC（Google / Okta）連携の実装
> 4. カスタム認証フロー（3つのLambdaトリガー）
> 5. `USER_AUTH` 選択式・パスワードレス認証（パスキー / OTP）
> 6. マルチテナント設計とトークンへのテナント注入（Pre Token Generation V2.0）
> 7. JWT検証（`aws-jwt-verify`）とトラブルシューティング

---

## まず押さえる：2026年のCognitoは「料金プラン」で機能が変わる

2024年11月、Cognito User Poolsに **機能プラン（feature plans）** が導入され、「どの認証機能が使えるか」がプランで決まるようになりました。**ここを理解せずに古い記事のコードをコピーすると「APIは通るのに機能が有効化されない」事故が起きます。** 最初に全体像を押さえましょう。

| プラン | 主な対象機能 | 想定ユース |
|--------|------------|-----------|
| **Lite** | 基本認証 + 旧 Hosted UI（クラシック） | 最小構成・コスト最優先。パスキー/アクセストークンのカスタマイズ不可 |
| **Essentials**（新規プールの既定） | 選択式サインイン（`USER_AUTH`）、パスワードレス（パスキー/OTP）、メールMFA、**マネージドログイン**、アクセストークンのクレーム拡張 | 大多数のB2B/B2C SaaSの標準解 |
| **Plus** | Essentials + **脅威防御（threat protection）**：漏洩認証情報の検知・アダプティブ認証・ログエクスポート | 高セキュリティ要件・規制業種 |

ポイント（公式の仕様）：

- **アクセストークンのクレーム拡張は Essentials 以上**。IDトークンのクレーム拡張は全プランで可能。
- **パスキー / OTPパスワードレス / メールMFA は Essentials 以上**（パスキーは Lite 以外で利用可）。
- 旧称「高度なセキュリティ機能（Advanced Security Features）」は **「脅威防御（threat protection）」へ製品名が変更**。ただし **API のenumは従来どおり `AdvancedSecurityMode`（`OFF`/`AUDIT`/`ENFORCED`）** のままです。`ENFORCED`を設定するとプランは自動的に `PLUS` へ引き上げられます。

> 出典: [Understanding feature plans](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-sign-in-feature-plans.html) / [Threat protection](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pool-settings-threat-protection.html)

### マネージドログイン vs クラシック Hosted UI

| | マネージドログイン（新） | クラシック Hosted UI（旧・第一世代） |
|--|------------------------|--------------------------------|
| ブランディング | ノーコードのビジュアルエディタ（ロゴ・配色・角丸・ライト/ダーク） | ロゴ + CSS ファイルのみ |
| パスキーサインイン | ✅ 対応 | ❌ 非対応 |
| 必要プラン | Essentials / Plus | Lite でも可 |
| 多言語化 | ✅ | ❌ |

エンドポイントのパス（`/oauth2/authorize`、`/oauth2/idpresponse`、`/saml2/idpresponse`、`/saml2/logout`、`/logout`）は両者で共通です。**新規プールは既定でマネージドログイン**になります。なお公式はクラシック Hosted UI を「非推奨（deprecated）」とは明言していませんが、「第一世代」と位置づけ、パスキー等の新機能は提供されません。新規実装はマネージドログイン一択です。

> 出典: [Managed login](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-managed-login.html)

---

## 認証方式の選び方：3つの軸で判断する

Cognitoの認証は大きく3系統あります。**まずここを選び違えないことが最重要** です。要望別の早見表を用意しました。

| 要望・場面 | 選ぶべき方式 | Cognitoの実装手段 |
|-----------|------------|------------------|
| 顧客企業のIdP（Azure AD/Okta/ADFS）に認証を委譲したい | **フェデレーション** | SAML / OIDC IdP連携 |
| 自社でユーザーを持ち、パスワードを廃したい（パスキー・OTP） | **パスワードレス** | `USER_AUTH`（選択式サインイン） |
| 独自のチャレンジ（CAPTCHA・秘密の質問・外部リスクエンジン照合）を挟みたい | **カスタム認証フロー** | `CUSTOM_AUTH` + 3つのLambdaトリガー |

重要な判断ポイント：

- **OTPやパスキーが目的なら、もう `CUSTOM_AUTH` を自作しない。** 2024年以降は `USER_AUTH`（`EMAIL_OTP`/`SMS_OTP`/`WEB_AUTHN`）が公式機能として提供され、Lambdaの自前実装が不要になりました。`CUSTOM_AUTH` は「公式機能で表現できない独自チャレンジ」専用と考えるべきです。
- **`USER_AUTH` は `CUSTOM_AUTH` を廃止しません。** 両者は共存します。置き換わったのは「OTP/パスキーを `CUSTOM_AUTH` で手組みする旧来のユースケース」だけです。
- B2B SaaSの現実解は **「フェデレーション（企業顧客）＋ パスワードレス or パスワード（個人・小規模顧客）」のハイブリッド**。本記事の後半で、ログイン画面でこれを出し分ける設計を示します。

---

## SAML vs OIDC：どちらで連携するか

| 項目 | SAML 2.0 | OIDC (OpenID Connect) |
|-----|---------|----------------------|
| 仕様 | XMLベース | JSON + OAuth 2.0 |
| 複雑性 | 高い | 低い |
| モバイル対応 | 困難 | 容易 |
| 主な採用 | Azure AD（エンタープライズアプリ）, Okta, ADFS | Google, Okta（OIDC App）, 各種モダンIdP |
| Cognito対応 | ✅ `ProviderType: SAML` | ✅ `ProviderType: OIDC`（汎用）/ `Google` 等 |

**判断基準**：

- **SAML**：顧客がレガシー（ADFS等）を使う、またはAzure ADで「エンタープライズアプリケーション」として登録するポリシーがある場合。
- **OIDC**：モダンIdP、モバイル対応、実装のシンプルさ重視。Oktaは両対応なので、どちらでも可。
- **現実**：顧客のIdP環境に合わせて両方サポートできる設計にしておくのが理想（Cognitoは1つのユーザープールに複数IdPを同居できます）。

---

## 実装例1：Azure AD / Microsoft Entra ID（SAML）統合

### システム全体像

```text
[ユーザー] → [SaaSフロント(Next.js/Amplify)]
   → マネージドログインへリダイレクト
   → [Cognito User Pool] が SAMLRequest 生成
   → [顧客IdP: Azure AD] で認証 + SAML Assertion
   → [Cognito] が ACS (/saml2/idpresponse) で受領 → ユーザー自動作成 → JWT発行
   → [SaaSバック] が JWT を aws-jwt-verify で検証 → テナントID抽出 → API認可
```

### ステップ1：Cognito側の「SP情報」をIdPに登録する

Azure AD側で必要になるCognitoの値は、**公式に決まった2つのパターン** です。**Cognitoは独自のSPメタデータURLを公開していません** ので、以下を手動で登録します（ここを誤解して `/saml2/metadata` のようなURLを探すと時間を溶かします）。

| Azure ADの項目 | 設定値 |
|---------------|--------|
| 識別子（エンティティID / Audience） | `urn:amazon:cognito:sp:<userPoolId>` |
| 応答URL（Assertion Consumer Service） | `https://<your-domain>.auth.<region>.amazoncognito.com/saml2/idpresponse` |
| サインオンURL | `https://<your-app>/login` |
| ログアウトURL（SLO使用時） | `https://<your-domain>.auth.<region>.amazoncognito.com/saml2/logout` |

Azure ADの「アプリのフェデレーション メタデータ URL」（`https://login.microsoftonline.com/<tenant-id>/federationmetadata/2007-06/federationmetadata.xml`）をコピーしておきます。これをCognitoに渡します。

### ステップ2：Cognito User Pool（Terraform / 2026年版）

```hcl
# --- User Pool 本体（料金プランを明示）---
resource "aws_cognito_user_pool" "main" {
  name = "my-saas-user-pool"

  # 2024/11 導入: 機能プラン。アクセストークン拡張・パスワードレスを使うなら ESSENTIALS 以上。
  user_pool_tier = "ESSENTIALS"

  # マルチテナント用カスタム属性（IdPから受け取るテナントID）
  schema {
    name                = "tenant_id"
    attribute_data_type = "String"
    mutable             = true # IdP マッピング属性は mutable 必須
    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" # supported_identity_providers と app側の指定で使う名前
  provider_type = "SAML"

  provider_details = {
    MetadataURL = "https://login.microsoftonline.com/<tenant-id>/federationmetadata/2007-06/federationmetadata.xml"
    # シングルログアウト（IdP起点・SP起点）を有効化する場合
    IDPSignout = "true"
    # Cognito が送る SAMLRequest の署名アルゴリズム（推奨）
    RequestSigningAlgorithm = "rsa-sha256"
  }

  # 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:tenant_id"  = "http://schemas.microsoft.com/identity/claims/tenantid"
  }
}

# --- ドメイン（マネージドログイン）---
resource "aws_cognito_user_pool_domain" "main" {
  domain       = "my-saas-domain"
  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

  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"]

  # 認証フロー（後述の USER_AUTH を使う場合は ALLOW_USER_AUTH を追加）
  explicit_auth_flows = ["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_USER_SRP_AUTH"]

  # トークン有効期限（単位を必ず明示する。既定は hours だが意図を明確に）
  access_token_validity  = 1
  id_token_validity      = 1
  refresh_token_validity = 30
  token_validity_units {
    access_token  = "hours"
    id_token      = "hours"
    refresh_token = "days"
  }
}
```

> **公式ドキュメントとの整合（重要）**：`SLORedirectBindingURI` / `SSORedirectBindingURI` / `ActiveEncryptionCertificate` は **Describe（取得）時のレスポンス専用フィールド** であり、`CreateIdentityProvider` の入力には指定できません。古い記事でこれらを `provider_details` に書いている例がありますが、APIエラーになります。
> 出典: [CreateIdentityProvider API](https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_CreateIdentityProvider.html) / [SAML IdP](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-saml-idp.html)

### 属性マッピングの落とし穴（公式準拠）

- **`NameID` は「一意かつ大文字小文字を区別する」フェデレーション識別子** です。可変なemailではなく、安定した不変属性（Azure ADなら `objectid`/`http://schemas.microsoft.com/identity/claims/objectidentifier`）から導出するのが鉄則。emailにマッピングすると、ユーザーのメール変更でアカウントが分裂します。
- **マッピングしたemailは既定で「未検証（unverified）」扱い**。検証必須のフローでは注意。
- カスタム属性は **mutable かつ writable** でないとマッピングできません。

---

## 実装例2：OIDC統合（Google / Okta）

### Google（専用プロバイダ）

```hcl
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" # openid は必須
  }

  attribute_mapping = {
    email       = "email"
    given_name  = "given_name"
    family_name = "family_name"
    username    = "sub"
  }
}
```

Google Cloud Console側の「承認済みリダイレクトURI」には次を登録します：
`https://<your-domain>.auth.<region>.amazoncognito.com/oauth2/idpresponse`

### 汎用OIDC（Okta など）— issuer自動探索を使う

汎用OIDCプロバイダ（OktaのOIDCアプリ等）は、`oidc_issuer` を渡せば `.well-known/openid-configuration` から **authorize/token/userInfo/jwks の各エンドポイントが自動探索** されます。エンドポイントを一つずつ書く必要はありません。

```hcl
resource "aws_cognito_identity_provider" "okta_oidc" {
  user_pool_id  = aws_cognito_user_pool.main.id
  provider_name = "Okta"
  provider_type = "OIDC"

  provider_details = {
    client_id                 = var.okta_client_id
    client_secret             = var.okta_client_secret
    authorize_scopes          = "openid email profile" # openid 必須
    oidc_issuer               = "https://<your-org>.okta.com"
    attributes_request_method = "GET" # userInfo の取得メソッド
  }

  attribute_mapping = {
    email       = "email"
    given_name  = "given_name"
    family_name = "family_name"
    username    = "sub"
  }
}
```

> **公式準拠の注意点**（多くの記事が省略）：
> - Cognitoは **`client_secret_post`** を要求し、**`client_secret_basic` には非対応**。IdP側のクライアント認証方式を合わせてください。
> - `oidc_issuer` は `https://` 始まりで、**末尾スラッシュは付けない**。
> - エンドポイントはHTTPS、ポートは 80/443 のみ。
> - `sub → username` は自動マッピング（usernameにプロバイダ名が前置されます）。
>
> 出典: [OIDC IdP](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-oidc-idp.html)

---

## カスタム認証フロー（CUSTOM_AUTH）：3つのLambdaトリガー

タイトルの核心、**カスタム認証フロー** です。「CAPTCHA」「秘密の質問」「自社の不正検知エンジンへの照合」など、**Cognitoの標準機能で表現できない独自チャレンジ** を挟みたいときに使います。

仕組みは **3つのLambdaトリガーが連携してチャレンジのループを回す** だけです。`InitiateAuth(AuthFlow=CUSTOM_AUTH)` で開始し、`RespondToAuthChallenge` で回答していきます。

```text
InitiateAuth(CUSTOM_AUTH)
        │
        ▼
DefineAuthChallenge ──「次は何のチャレンジ？／合否は？」を判断（司令塔）
        │  challengeName = CUSTOM_CHALLENGE
        ▼
CreateAuthChallenge ──チャレンジの中身を生成（OTPコード生成＆送信など）
        │  publicChallengeParameters（クライアントへ）/ privateChallengeParameters（答え）
        ▼
（クライアントが RespondToAuthChallenge で ANSWER を送信）
        │
        ▼
VerifyAuthChallengeResponse ──答え合わせ（answerCorrect: true/false）
        │
        ▼
DefineAuthChallenge ──結果を見て「トークン発行」or「もう一度」or「失敗」を決定
```

### ① DefineAuthChallenge（司令塔）

`event.request.session` に過去のチャレンジ結果（`ChallengeResult`）の配列が入ります。これを見て次を決めます。`issueTokens=true` で成功（JWT発行）、`failAuthentication=true` で失敗、両方falseで継続です。

```python
# define_auth_challenge.py
def lambda_handler(event, context):
    session = event["request"]["session"]

    if len(session) == 0:
        # 初回: 独自チャレンジを提示
        event["response"]["challengeName"] = "CUSTOM_CHALLENGE"
        event["response"]["issueTokens"] = False
        event["response"]["failAuthentication"] = False
    elif (
        len(session) == 1
        and session[0]["challengeName"] == "CUSTOM_CHALLENGE"
        and session[0]["challengeResult"] is True
    ):
        # 1回正解したらトークン発行
        event["response"]["issueTokens"] = True
        event["response"]["failAuthentication"] = False
    else:
        # それ以外は失敗（無限ループ・総当たり防止）
        event["response"]["issueTokens"] = False
        event["response"]["failAuthentication"] = True

    return event
```

### ② CreateAuthChallenge（チャレンジ生成）

`challengeName === "CUSTOM_CHALLENGE"` のときだけ呼ばれます。`publicChallengeParameters` はクライアントに渡る公開情報、`privateChallengeParameters` は**検証用の答え（クライアントには渡らない）** です。

```python
# create_auth_challenge.py
import secrets
import boto3

ses = boto3.client("ses")

def lambda_handler(event, context):
    if event["request"]["challengeName"] != "CUSTOM_CHALLENGE":
        return event

    # 例: 6桁のワンタイムコードを生成して送信（独自のCAPTCHA等でも同様）
    code = f"{secrets.randbelow(1_000_000):06d}"
    email = event["request"]["userAttributes"]["email"]

    ses.send_email(
        Source="no-reply@my-saas.com",
        Destination={"ToAddresses": [email]},
        Message={
            "Subject": {"Data": "ログイン確認コード"},
            "Body": {"Text": {"Data": f"確認コード: {code}（5分間有効）"}},
        },
    )

    # クライアントに見せてよい情報のみ public へ
    event["response"]["publicChallengeParameters"] = {"medium": "EMAIL"}
    # 答えは private（応答には絶対に含めない）
    event["response"]["privateChallengeParameters"] = {"answer": code}
    event["response"]["challengeMetadata"] = "EMAIL_ONE_TIME_CODE"
    return event
```

### ③ VerifyAuthChallengeResponse（答え合わせ）

```python
# verify_auth_challenge.py
def lambda_handler(event, context):
    expected = event["request"]["privateChallengeParameters"]["answer"]
    provided = event["request"]["challengeAnswer"]
    event["response"]["answerCorrect"] = secrets_equal(expected, provided)
    return event

def secrets_equal(a: str, b: str) -> bool:
    # タイミング攻撃を避けるため定数時間比較
    import hmac
    return hmac.compare_digest(a, b)
```

> **設計上の判断**：上記は「メールOTP」を例にしていますが、**単なるメール/SMSのOTPなら自作せず後述の `USER_AUTH`（`EMAIL_OTP`/`SMS_OTP`）を使うべき** です。`CUSTOM_AUTH` の出番は「reCAPTCHA検証」「自社リスクスコアAPI照合」「デバイス信頼度チェック」など、公式機能に無いロジックを挟むときに限定しましょう。
> 出典: [Custom authentication challenge Lambda triggers](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-challenge.html)

---

## USER_AUTH：選択式・パスワードレス認証（パスキー / OTP）

2024年後半に追加された **`USER_AUTH`** は、「パスワード」「ワンタイムコード（メール/SMS）」「パスキー（WebAuthn）」を **ユーザーに選ばせる（choice-based）** 公式フローです。**OTPやパスキーのためにLambdaを書く時代は終わりました。**

### 必要な設定

| 設定箇所 | 値 |
|---------|----|
| ユーザープールのプラン | **Essentials 以上** |
| App Client の `explicit_auth_flows` | `ALLOW_USER_AUTH` を追加 |
| ユーザープールの `sign_in_policy.allowed_first_auth_factors` | `PASSWORD` / `EMAIL_OTP` / `SMS_OTP` / `WEB_AUTHN` から選択 |

```hcl
resource "aws_cognito_user_pool" "main" {
  # ... 既出の設定 ...
  user_pool_tier = "ESSENTIALS"

  # 第一認証要素として許可する方式（WEB_AUTHN は他要素と併用が必須）
  sign_in_policy {
    allowed_first_auth_factors = ["PASSWORD", "EMAIL_OTP", "WEB_AUTHN"]
  }
}

resource "aws_cognito_user_pool_client" "web_client" {
  # ... 既出の設定 ...
  explicit_auth_flows = [
    "ALLOW_USER_AUTH",            # 選択式サインインを有効化
    "ALLOW_REFRESH_TOKEN_AUTH",
  ]
}
```

### フローの仕組み

1. `InitiateAuth(AuthFlow=USER_AUTH)` を `USERNAME` 付きで呼ぶ。
2. `PREFERRED_CHALLENGE` を指定しない場合、Cognitoは `ChallengeName: SELECT_CHALLENGE` と **`AvailableChallenges`（利用可能な方式の配列）** を返す。
3. クライアントは `SELECT_CHALLENGE` に対し、選んだ方式を `ANSWER` に入れて応答。
4. 以降は方式ごとのチャレンジ（`EMAIL_OTP` なら `EMAIL_OTP_CODE`、`WEB_AUTHN` なら `CREDENTIAL`）に答える。

| 方式 | チャレンジ名 | 回答キー |
|------|------------|---------|
| パスキー（WebAuthn） | `WEB_AUTHN` | `CREDENTIAL`（W3C `AuthenticationResponseJSON`） |
| メールOTP | `EMAIL_OTP` | `EMAIL_OTP_CODE` |
| SMS OTP | `SMS_OTP` | `SMS_OTP_CODE` |
| パスワード | `PASSWORD` / `PASSWORD_SRP` | — |

> 補足：パスキーは ES256 + RS256 に対応し、1ユーザーあたり最大20個まで登録可。登録状況は `GetUserAuthFactors` で確認できます。第一要素のOTP（`EMAIL_OTP`/`SMS_OTP`）は、第二要素のMFAコード（`SMS_MFA`/`EMAIL_MFA`/`SOFTWARE_TOKEN_MFA`）とは **別物** である点に注意。
> 出典: [Authentication flows (USER_AUTH / choice-based)](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-authentication-flow-methods.html)

### フロントエンド（AWS Amplify v6 / Gen 2）

Amplifyはv6で名前空間APIに刷新されました（旧 `Auth.signIn` 等は廃止）。`USER_AUTH` を使うパスワードレスはこう書きます。

```ts
// パスワードレス・サインイン（メールOTPを選好）
import { signIn, confirmSignIn } from "aws-amplify/auth";

async function startPasswordless(username: string) {
  const { nextStep } = await signIn({
    username,
    options: { authFlowType: "USER_AUTH", preferredChallenge: "EMAIL_OTP" },
  });

  if (nextStep.signInStep === "CONFIRM_SIGN_IN_WITH_EMAIL_CODE") {
    // ユーザーがメールで受け取ったコードを入力 → confirmSignIn で送信
  }
}

async function submitOtp(code: string) {
  const { isSignedIn } = await confirmSignIn({ challengeResponse: code });
  return isSignedIn;
}
```

```ts
// パスキー（WebAuthn）の登録（ログイン後に呼ぶ）
import { associateWebAuthnCredential } from "aws-amplify/auth";

async function registerPasskey() {
  // ブラウザのWebAuthn APIをAmplifyが内部で起動し、資格情報をCognitoに登録
  await associateWebAuthnCredential();
}
```

---

## マルチテナント設計：顧客ごとにIdPを出し分ける

### 課題

- 顧客A → Azure AD（SAML）、顧客B → Okta（OIDC）、顧客C → Google、個人ユーザー → パスワードレス。
- 1つのログイン画面で、入力メールに応じて適切な認証方式に誘導したい。

### 解決策：メールドメインでテナント判定 → IdPへ誘導

```tsx
// LoginPage.tsx（Amplify v6 / Next.js）
import { signInWithRedirect, signIn } from "aws-amplify/auth";
import { useState } from "react";

type Tenant = { id: string; name: string; idp: "AzureAD" | "Google" | "Okta" | null };

export function LoginPage() {
  const [email, setEmail] = useState("");
  const [tenant, setTenant] = useState<Tenant | null>(null);

  // メールドメインからテナントを判定
  async function detectTenant(value: string) {
    const res = await fetch("/api/detect-tenant", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email: value }),
    });
    setTenant((await res.json()).tenant);
  }

  // 企業SSO（フェデレーション）へ
  async function loginWithSSO() {
    if (!tenant?.idp) return;
    await signInWithRedirect({ provider: { custom: tenant.idp } });
  }

  // 個人ユーザーはパスワードレスへ
  async function loginPasswordless() {
    await signIn({
      username: email,
      options: { authFlowType: "USER_AUTH", preferredChallenge: "EMAIL_OTP" },
    });
  }

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        tenant?.idp ? loginWithSSO() : loginPasswordless();
      }}
    >
      <label htmlFor="email">メールアドレス</label>
      <input
        id="email"
        type="email"
        autoComplete="email webauthn"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        onBlur={() => detectTenant(email)}
      />
      <button type="submit">
        {tenant?.idp ? `${tenant.name} のSSOでログイン` : "確認コードでログイン"}
      </button>
    </form>
  );
}
```

> アクセシビリティの要点：`label` と `input` を `htmlFor`/`id` で関連付け、`autoComplete="email webauthn"` を付けると、対応ブラウザがパスキーをオートフィル候補に出します。送信は`<form>` の `onSubmit` で受けると Enter キー操作にも対応できます。

### テナント検出API（FastAPI）

```python
# api/detect_tenant.py
from fastapi import APIRouter
from pydantic import BaseModel, EmailStr
import boto3

router = APIRouter()
tenants = boto3.resource("dynamodb").Table("tenants")

class Req(BaseModel):
    email: EmailStr

@router.post("/api/detect-tenant")
async def detect_tenant(req: Req):
    domain = req.email.split("@")[1]
    res = tenants.query(
        IndexName="domain-index",
        KeyConditionExpression="#d = :d",
        ExpressionAttributeNames={"#d": "domain"},
        ExpressionAttributeValues={":d": domain},
    )
    if not res["Items"]:
        return {"tenant": None}  # 未登録 → 個人ユーザー扱い
    t = res["Items"][0]
    return {"tenant": {"id": t["tenant_id"], "name": t["company_name"], "idp": t.get("idp")}}
```

> セキュリティ補足：テナント検出APIは「このドメインにIdPがあるか」を返すだけに留め、**ユーザーの存在有無を漏らさない**（user enumeration対策）。Cognito側も `PreventUserExistenceErrors=ENABLED` を設定しておきます。

---

## トークンへテナントを注入する：Pre Token Generation V2.0

マルチテナントの肝は **「アクセストークンに `tenant_id` を載せ、バックエンドの認可で使う」** ことです。フェデレーションで来たユーザーは属性マッピングで `custom:tenant_id` を持ちますが、**アクセストークンのクレーム拡張は Pre Token Generation トリガーの V2.0 以降（Essentials以上）** が必要です。

| バージョン | 対象トークン | 必要プラン |
|-----------|------------|-----------|
| `V1_0` | IDトークンのみ | 全プラン（Liteも可） |
| `V2_0` | ID + **アクセストークン**（ユーザーID） | Essentials / Plus |
| `V3_0` | ID + アクセス（**M2M クライアントクレデンシャル含む**） | Essentials / Plus |

```hcl
resource "aws_cognito_user_pool" "main" {
  # ... 既出の設定 ...
  lambda_config {
    pre_token_generation_config {
      lambda_arn     = aws_lambda_function.pre_token.arn
      lambda_version = "V2_0" # アクセストークンに載せるなら V2_0 以上
    }
  }
}
```

```python
# pre_token_generation.py （V2_0）
def lambda_handler(event, context):
    attrs = event["request"]["userAttributes"]
    tenant_id = attrs.get("custom:tenant_id", "")

    event["response"]["claimsAndScopeOverrideDetails"] = {
        # アクセストークンにテナントIDとスコープを注入
        "accessTokenGeneration": {
            "claimsToAddOrOverride": {"tenant_id": tenant_id},
            "scopesToAdd": [f"tenant/{tenant_id}"],
        },
        # 必要ならIDトークンにも
        "idTokenGeneration": {
            "claimsToAddOrOverride": {"tenant_id": tenant_id},
        },
        # cognito:groups を上書きしてテナント別ロールを反映することも可能
        # "groupOverrideDetails": {"groupsToOverride": [f"tenant-{tenant_id}-member"]},
    }
    return event
```

> **公式の制約**（事故りやすい点）：
> - `sub` / `iss` / `token_use` / `cognito:*` 等の **予約クレームは追加・変更・抑制できません**。
> - 例外的に、アクセストークンへ `aud` を追加することは可能ですが、その値は `event.callerContext.clientId` と一致している必要があります。
> - `groupOverrideDetails` を空/nullにすると `cognito:groups` が **抑制（消える）** されるので、上書き時は全グループを明示すること。
> - V1.0コンテナ名は `claimsOverrideDetails`、V2.0以降は `claimsAndScopeOverrideDetails` と **名前が異なります**。
>
> 出典: [Pre token generation Lambda trigger](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-token-generation.html)

---

## JWT検証：公式推奨の `aws-jwt-verify` を使う

CognitoのJWTは **RS256** で署名され、検証では以下を必ずチェックします。手書きのJWKSパースは事故の温床なので、**AWS公式が推奨する [`aws-jwt-verify`](https://github.com/awslabs/aws-jwt-verify) を使う** のが2026年の標準です。

| 検証項目 | 値 |
|---------|----|
| `iss` | `https://cognito-idp.<region>.amazonaws.com/<userPoolId>` |
| 対象クライアント | **IDトークンは `aud`、アクセストークンは `client_id`**（どちらもApp Client ID） |
| `token_use` | `"id"` または `"access"`（用途に合致しているか） |
| `exp` | 失効していないか |
| `alg` / `kid` | `RS256` / JWKSの鍵IDと一致 |

> 注：Cognitoは1プールにつき **RSA鍵ペアを2つ**（アクセス用・ID用）持ちます。`kid` でキャッシュし、未知の `kid` が来たらJWKSを再取得する設計に。`aws-jwt-verify` はこれを自動で行います。

### TypeScript（バックエンド / Next.js Route Handler や Lambda）

```ts
import { CognitoJwtVerifier } from "aws-jwt-verify";

// アクセストークン用検証器（client_id と token_use を自動チェック）
const verifier = CognitoJwtVerifier.create({
  userPoolId: process.env.COGNITO_USER_POOL_ID!,
  tokenUse: "access",
  clientId: process.env.COGNITO_CLIENT_ID!,
});

export async function authorize(authHeader: string | null) {
  const token = authHeader?.replace(/^Bearer\s+/i, "");
  if (!token) throw new Response("Unauthorized", { status: 401 });

  try {
    const payload = await verifier.verify(token); // 署名 + iss + exp + client_id + token_use
    return {
      userId: payload.sub,
      tenantId: payload["tenant_id"] as string | undefined, // Pre Token Gen で注入
      groups: (payload["cognito:groups"] as string[] | undefined) ?? [],
    };
  } catch {
    throw new Response("Invalid token", { status: 401 });
  }
}
```

### Python（FastAPI など）

```python
# middleware/auth.py
from fastapi import HTTPException, Security
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt
from jwt import PyJWKClient
import os

security = HTTPBearer()
REGION = os.environ["COGNITO_REGION"]
POOL_ID = os.environ["COGNITO_USER_POOL_ID"]
CLIENT_ID = os.environ["COGNITO_CLIENT_ID"]
ISSUER = f"https://cognito-idp.{REGION}.amazonaws.com/{POOL_ID}"
jwks = PyJWKClient(f"{ISSUER}/.well-known/jwks.json")

def verify_access_token(creds: HTTPAuthorizationCredentials = Security(security)) -> dict:
    token = creds.credentials
    try:
        key = jwks.get_signing_key_from_jwt(token).key
        payload = jwt.decode(token, key, algorithms=["RS256"], issuer=ISSUER)
        # アクセストークンは aud を持たないため client_id / token_use を手動検証
        if payload.get("token_use") != "access" or payload.get("client_id") != CLIENT_ID:
            raise jwt.InvalidTokenError("token_use / client_id mismatch")
        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: {e}")
```

> 出典: [Verifying a JSON Web Token](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html)

---

## トラブルシューティング

### SAML Assertion 署名検証失敗

```text
Invalid SAML response: Signature validation failed
```

- **原因**：IdP側の署名証明書がローテーションされた／CognitoのメタデータURLが古い。
- **対処**：`MetadataURL` を使っていれば再 `terraform apply` で最新化。手動メタデータ（`MetadataFile`）運用なら証明書を差し替える。**`MetadataURL` 運用を強く推奨**（証明書の自動追従）。

### 属性が空 / `email` が無いと言われる

```text
InvalidParameterException: User does not have a valid email
```

- **原因**：SAML AssertionにマッピングしたクレームURIが実際の送信内容と違う。
- **デバッグ**：ブラウザ拡張「SAML-tracer」で実Assertionを確認 → どのクレーム名で来ているかを見て `attribute_mapping` を修正。Azure ADはデフォルトで `.../emailaddress` を使うが、設定により `.../name` のこともある。

### リダイレクトループ

- **原因**：`callback_urls` とフロントの設定が不一致。
- **対処**：Amplify v6の設定で `redirectSignIn` を **完全一致** させる。

```ts
import { Amplify } from "aws-amplify";

Amplify.configure({
  Auth: {
    Cognito: {
      userPoolId: "ap-northeast-1_XXXXXXXXX",
      userPoolClientId: "xxxxxxxxxxxxxxxxxxxx",
      loginWith: {
        oauth: {
          domain: "my-saas-domain.auth.ap-northeast-1.amazoncognito.com",
          scopes: ["openid", "email", "profile"],
          redirectSignIn: ["https://my-app.com/auth/callback"], // 末尾スラッシュ含め完全一致
          redirectSignOut: ["https://my-app.com/logout"],
          responseType: "code",
        },
      },
    },
  },
});
```

---

## セキュリティ・運用のチェックリスト

- **認可コードフロー（`code`）+ PKCE** を使う（Implicitは使わない）。
- **`PreventUserExistenceErrors=ENABLED`**：ユーザー列挙攻撃を防ぐ。
- **脅威防御（threat protection / Plus）**：漏洩認証情報の検知・アダプティブ認証。API上は `AdvancedSecurityMode=ENFORCED`（設定するとプランは自動でPLUS）。
- **トークン有効期限**：アクセス/IDは短く（例：1時間）、リフレッシュは要件に応じて。`token_validity_units` を必ず明示。
- **グローバルログアウト**：`signOut({ global: true })` で全デバイスのリフレッシュトークンを失効。
- **SAML SLO**：`IDPSignout=true` 設定時、IdPの `SingleLogoutService` へ署名付き `SAMLRequest` が飛び、`sessionIndex` の一致が必要。

---

## コスト試算：2026年の料金プラン前提で考える

旧来の「最初の50,000 MAUは無料／Advanced Securityは+$0.05/MAU」という説明は **現在のプラン制では不正確** です。現在は **選んだプラン（Lite/Essentials/Plus）× MAU** で課金されます（Lite < Essentials < Plus）。

判断の指針：

- **個人/小規模中心、UIは最小で良い** → Lite。ただしパスキー・パスワードレス・アクセストークン拡張は不可。
- **B2B SaaSの標準**（マネージドログイン・パスワードレス・テナント注入が欲しい） → **Essentials**。本記事の構成はこれが前提。
- **規制業種・高セキュリティ**（漏洩検知・アダプティブ認証・ログ輸出） → Plus。

正確な単価はリージョン・時期で変わるため、**必ず公式の料金ページで最新値を確認** してください。
出典: [Amazon Cognito Pricing](https://aws.amazon.com/cognito/pricing/)

Lambdaトリガー（PreSignUp / Pre Token Generation / カスタム認証3種）は通常、無料枠〜ごく少額に収まります（ログインのたびに数十〜百ms程度の実行）。

---

## よくある質問（FAQ）

**Q. `CUSTOM_AUTH` と `USER_AUTH` は何が違う？どちらを使う？**
A. `USER_AUTH` はパスキー/OTP/パスワードを「選ばせる」公式のパスワードレス基盤（Essentials以上）。`CUSTOM_AUTH` は3つのLambdaで独自チャレンジを組む仕組み。**OTP・パスキー目的なら `USER_AUTH`、公式機能に無い独自ロジック（CAPTCHA・外部リスク照合）なら `CUSTOM_AUTH`**。両者は共存可能で、`USER_AUTH` が `CUSTOM_AUTH` を廃止したわけではありません。

**Q. アクセストークンにテナントIDを入れたいのにクレームが反映されない。**
A. **アクセストークンのクレーム拡張は Essentials 以上 + Pre Token Generation `V2_0`** が必要です。Liteや `V1_0` ではIDトークンしか拡張できません。

**Q. SAMLのSPメタデータURLはどこ？**
A. Cognitoは **SPメタデータURLを公開していません**。エンティティID `urn:amazon:cognito:sp:<userPoolId>` とACS `https://<domain>/saml2/idpresponse` を手動でIdPに登録します。

**Q. IDトークンとアクセストークン、どちらをAPI認可に使う？**
A. **API認可はアクセストークン**（`scope` / `cognito:groups` / `client_id` を持つ）。IDトークンはユーザー属性の伝達用で、`aud` を持ちます。検証時は `token_use` の一致を必ず確認。

**Q. Amplifyの `Auth.signIn` が動かない。**
A. Amplify v6で名前空間APIに変更されました。`import { signIn } from "aws-amplify/auth"` を使い、設定は `Amplify.configure({ Auth: { Cognito: {...} } })` 形式です。

---

## まとめ：エンタープライズ認証基盤の設計指針

1. **まずプランを決める**：B2B SaaSの標準は Essentials。これでマネージドログイン・パスワードレス・アクセストークン拡張が揃う。
2. **方式を選び違えない**：企業顧客はフェデレーション（SAML/OIDC）、個人はパスワードレス（`USER_AUTH`）、独自要件のみ `CUSTOM_AUTH`。
3. **テナント分離はトークンに載せる**：Pre Token Generation V2.0 で `tenant_id` をアクセストークンへ注入し、バックエンドの認可で一貫して使う。
4. **検証は `aws-jwt-verify` に任せる**：`iss`/`aud`or`client_id`/`token_use`/署名を自前で書かない。
5. **公式ドキュメントを一次情報にする**：Cognitoは更新が速い。本記事の各リンクから最新を確認する習慣を。

### 設計・運用で得た実感（木材流通DXプロジェクト）

経済産業大臣賞を受賞したB2BサブスクリプションSaaSでは、Cognitoを認証基盤として採用し、RS256でのJWT検証・マルチテナント前提の認可・複数IdPの受け入れを前提に設計しました。経験上、**つまずきの大半は「属性マッピングの食い違い」と「プラン/トークン種別の理解不足」** に集約されます。本記事の早見表とチェックリストは、その実地の知見を反映したものです。

---

## 次のステップ

AWS CognitoでのエンタープライズSSO・パスワードレス認証・マルチテナント認可の設計でお悩みなら、お気軽にご相談ください。SAML/OIDC統合からトークン設計、本番運用の可観測性まで、一気通貫で支援します。

[お問い合わせはこちら](/contact)

---

**参考（AWS公式ドキュメント）**:
- [Understanding feature plans（料金プラン）](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-sign-in-feature-plans.html)
- [Managed login（マネージドログイン）](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-managed-login.html)
- [Authentication flows（USER_AUTH / 選択式）](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-authentication-flow-methods.html)
- [Custom authentication challenge Lambda triggers](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-challenge.html)
- [SAML IdP](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-saml-idp.html) / [OIDC IdP](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-oidc-idp.html)
- [Pre token generation Lambda trigger](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-pre-token-generation.html)
- [Verifying a JWT](https://docs.aws.amazon.com/cognito/latest/developerguide/amazon-cognito-user-pools-using-tokens-verifying-a-jwt.html) / [aws-jwt-verify](https://github.com/awslabs/aws-jwt-verify)
