デモアプリの Cognito 認証(おすすめ構成)
デモアプリ・Webアプリの認証は、特に指定がなければこの構成にする:「Googleで続ける」か「メール+パスキー」。
動く実物は minorun365/marp-agent。
迷ったら実物を読む。設計の背景は同リポジトリの docs/authentication-options.md。
| ファイル | 中身 |
|---|---|
infra/lib/auth-stack.ts |
User Pool / App Client / Google IdP / Cognito ドメイン |
src/components/Auth/AuthScreen.tsx |
ログイン画面(326行。移植元) |
src/components/Auth/AuthScreen.css |
同スタイル |
infra/lambda/auth/google-idp-manager/handler.ts |
Google IdP を作るカスタムリソース |
infra/lambda/auth/google-link/handler.ts |
preSignUp。既存メールユーザーへGoogleを連携 |
1. 利用者から見た形
| 経路 | 初回 | 2回目以降 |
|---|---|---|
| 「Googleで続ける」で登録とログインが同時に終わる | ||
| メール | メールアドレス+パスワードで登録し、確認コードでメールを確認 | パスキー(顔・指紋)。パスワードも使える |
- メールのワンタイムコードログインは採用しない。 SES の本番利用申請を認証の前提にしない。Cognito 標準メールは新規登録の確認とパスワード再設定だけに使う(AWSアカウントあたり1日50通)。
- パスキーはパスワードの置き換えではない。 登録しなくても期限なくパスワードで使える。
- Google 利用者へパスキー登録を案内しない。 本人確認は Google 側が担当している。
2. ログイン画面のUX規約
1画面目は 2択だけに絞る。「Googleで続ける」と、メールアドレス入力+「メールで続ける」。
メールを入れた次の画面では、パスキーとパスワードを常に並べて出す。
⚠️ パスキー登録の有無で画面を出し分けない。 出し分けると「そのメールアドレスが登録済みか」を第三者へ教えることになる。同じ理由で、存在しないアドレスも認証失敗も表示は「メールアドレスまたは認証情報を確認してください。」に統一し、Cognito 側も
preventUserExistenceErrors: trueにする。
パスワードでログインした利用者には、成功後に一度だけパスキー登録を案内する。
- 「パスキーを登録」と「あとで」の2つだけ
- 「あとで」を押されたら 30日間は再案内しない(
localStorageに押した時刻を持つ) - 専用の設定画面は作らない。ログイン後のメイン画面も変えない
3. CDK(User Pool)
パスキーは設定が1つでも欠けるとエラーにならず、ただ使えない形で失敗する。次の5点をまとめて入れる。
this.userPool = new cognito.UserPool(this, 'UserPool', {
featurePlan: cognito.FeaturePlan.ESSENTIALS, // ① LITE ではパスキーを使えない
mfa: cognito.Mfa.OFF, // ② 明示しないとOPTIONALへ補完されることがある
selfSignUpEnabled: true,
signInAliases: { email: true },
signInCaseSensitive: false,
autoVerify: { email: true },
standardAttributes: { email: { required: true, mutable: true } },
accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
signInPolicy: {
allowedFirstAuthFactors: { password: true, passkey: true }, // ③ パスキーを第1認証要素に
},
passkeyRelyingPartyId: props.appDomain, // ④ 配信ドメインと完全一致させる
passkeyUserVerification: cognito.PasskeyUserVerification.PREFERRED,
passwordPolicy: { minLength: 8, requireDigits: false, requireLowercase: false, requireSymbols: false, requireUppercase: false },
});
this.userPoolClient = this.userPool.addClient('WebClient', {
generateSecret: false,
preventUserExistenceErrors: true,
authFlows: { user: true, userPassword: true, userSrp: true }, // ⑤ user:true = USER_AUTH。無いとパスキーを選べない
accessTokenValidity: cdk.Duration.minutes(60),
idTokenValidity: cdk.Duration.minutes(60),
refreshTokenValidity: cdk.Duration.days(30),
});
- ④ の RP ID は「利用者がアクセスするドメイン」。
dxxxx.cloudfront.netのような配信基盤の既定ドメインでもよいが、後からカスタムドメインへ移すと登録済みのパスキーは全部使えなくなる(RP ID が変わるため)。カスタムドメインを付ける予定があるなら、パスキーを入れる前に先に付ける。 - パスワードポリシーを厳しくしない。 パスキーを入れる目的は入力を減らすこと。記号必須・12文字のような設定はデモの初回登録を重くするだけ。
featurePlan: ESSENTIALSは MAU 課金が発生する。デモ規模なら無視できるが、LITE からの変更なので認識しておく。
フロント側(aws-amplify v6)
// パスキーでログイン
await signIn({ username: email, options: { authFlowType: 'USER_AUTH', preferredChallenge: 'WEB_AUTHN' } });
// パスワードでログイン
await signIn({ username: email, password, options: { authFlowType: 'USER_PASSWORD_AUTH' } });
// ログイン後にパスキーを登録
await associateWebAuthnCredential();
Amplify.configure の Auth.Cognito に userPoolId / userPoolClientId を渡すだけでよい。Google を使うときだけ loginWith.oauth を足す(次節)。
4. Google ログイン
OAuth クライアントはデモごとに作らず、共通の1つを使い回す
同意画面のアプリ名は GCP プロジェクト単位で1つなので、デモごとにクライアントを作っても名前は分けられない。 デモ用の中立な名前でプロジェクトとクライアントを1つ用意し、以後のデモは全部これを使い回す。
| 項目 | 決め方 |
|---|---|
| GCP プロジェクト | デモ専用に1つ作る |
| 同意画面のアプリ名 | 利用者に見えるのはこれ。特定のデモ名にせず、中立な名前にする |
| 公開ステータス | 本番環境(テストユーザーの登録なしで誰でもログインできる) |
| Client ID | CDK のコンテキストで渡す(秘密ではない) |
| Client Secret | デプロイ先の AWS アカウントへ置く(下の「client secret の置き場」) |
新しいデモを足すときの作業は2つだけ。
- Google Cloud Console でそのクライアントを開き、承認済みリダイレクト URI に新しい Cognito ドメインの
/oauth2/idpresponseを1行足す - そのデモの AWS アカウントへ Secret を入れ、CDK に
googleClientIdを渡す
⚠️ 組織の Google Workspace 配下の GCP プロジェクトは、同意画面が「内部」(
orgInternalOnly)になっていることがある。 その場合は組織のアカウントしかログインできず、外部の人が触るデモでは機能しない。プロジェクトを選ぶ前に同意画面のユーザータイプを確かめる。⚠️
gcloudでも API でも、クライアントの作成も編集もできない。リダイレクトURIの追記も含めて Console のブラウザ操作が唯一の手段(2026-08 時点)。根拠は3つ:
gcloud alpha iap oauth-clientsは IAP 専用で、ヘルプ自身が「プロジェクト内の全 OAuth クライアントの管理 API としては使えない」と明記。加えて IAP OAuth Admin API は 2026-03-19 に完全停止iap v1の discovery を見るとprojects.brands.identityAwareProxyClientsの method はcreate/get/list/delete/resetSecretだけで、更新系が無い。停止していなくてもURIは足せない- Google の公開 API 一覧(
https://www.googleapis.com/discovery/v1/apis)に、OAuth クライアントを管理する API 自体が存在しない人に操作を頼むときは、クライアントの編集画面まで開けるURLを渡す。 URLはこの形:
https://console.cloud.google.com/auth/clients/<クライアントID>?project=<プロジェクトID>
実装に必要なもの
Cognito ドメイン(Hosted UI 用)、Google の OAuth クライアント ID / Secret、IdP 登録、App Client の OAuth 設定。
supportedIdentityProviders: [cognito.UserPoolClientIdentityProvider.COGNITO, cognito.UserPoolClientIdentityProvider.GOOGLE],
oAuth: {
flows: { authorizationCodeGrant: true },
scopes: [cognito.OAuthScope.OPENID, cognito.OAuthScope.EMAIL, cognito.OAuthScope.PROFILE],
callbackUrls: [`https://${appDomain}/`, 'http://localhost:5173/'],
logoutUrls: [`https://${appDomain}/`, 'http://localhost:5173/'],
},
フロントは signInWithRedirect({ provider: 'Google' }) を呼ぶだけ。
⚠️ Amplify Gen2 の defineAuth では「スコープ」が2か所にあり、書き方が逆になる。 ここを取り違えると
デプロイも構成検査も通ったうえで、Googleのログイン画面が invalid_scope で開かない。
| 場所 | 何のスコープか | 書き方 |
|---|---|---|
externalProviders.scopes |
Cognito のアプリクライアントが出すトークンの範囲 | 大文字の列挙(['EMAIL','PROFILE','OPENID']) |
externalProviders.google.scopes |
Google へそのまま渡る生の文字列 | 小文字(['openid','email','profile']) |
後者に大文字を書くと authorize_scopes が "EMAIL PROFILE OPENID" として IdP に入り、Google が
invalid=[OPENID] を返す(EMAIL / PROFILE は通ってしまうので、症状が OPENID だけに出て気づきにくい)。
この種の失敗は AWS 側をいくら見ても分からない。 デプロイ後に Cognito の認可エンドポイントへ
リクエストを投げ、accounts.google.com のログイン画面へ着地するかを確かめる(サインインは不要)。
エラー時は authError クエリが base64 で理由を持っている:
curl -s -o /dev/null -L -w '%{url_effective}\n' \
"https://<Cognitoドメイン>.auth.<region>.amazoncognito.com/oauth2/authorize?identity_provider=Google&client_id=<アプリクライアントID>&response_type=code&scope=openid+email+profile&redirect_uri=https%3A%2F%2F<配信ドメイン>%2F"
.../signin/oauth/error?authError=... へ着地したら失敗。authError を base64url デコードすると
invalid_scope / redirect_uri_mismatch などの理由が平文で読める。
client secret の置き場
cognito.UserPoolIdentityProviderGoogle の L2 は secret を CloudFormation テンプレートへ平文で残す。marp-agent は、秘密値を state に残さないカスタムリソース(google-idp-manager)で IdP を作る形にしている。
⚠️ 組織の管理下にある AWS アカウントでは、SCP で
secretsmanager:CreateSecretが拒否されていることがある。 marp-agent の handler は Secrets Manager から読む実装なので、その場合は SSM Parameter Store の SecureString に置き換える。Lambda 実行ロールにssm:GetParameterと、SecureString を復号するkms:Decryptを付ける。
段階的に入れてよい
Google は GCP 側の作業(OAuth クライアント作成、承認済みリダイレクトURIの追加)が要るので、「クライアントIDが渡されたときだけ Google を有効にする」実装にしておくと、パスキーだけ先にリリースできる。marp-agent は this.node.tryGetContext('googleClientId') の有無で分岐している。
同じメールアドレスの重複プロフィール
Cognito は、メールアドレスが同じでもローカルユーザーと Google ユーザーを自動で統合しない。放置すると同じ人に2つのプロフィールができる。marp-agent は preSignUp トリガー(google-link)で、確認済みメールアドレスが一致する既存プロフィールへ AdminLinkProviderForUser でリンクしている。デモアプリで既存利用者がいないなら、初期は省いてよい。
5. デモアプリでの判断(外部の人が触る場合)
- Google の同意画面に出るアプリ名は GCP プロジェクトのブランディングで決まる。クライアントIDを別に作っても、同じプロジェクトなら名前は同じ。外部の人に見せるデモで、無関係な別アプリの名前が出る状態にしない。
- 管理を楽にするなら、デモ共通の中立な名前でプロジェクトとクライアントを1つ用意し、以後のデモはリダイレクトURIを1行足すだけにする。デモごとに作らない。
- セキュリティ要件の厳しい組織の利用者は、業務用 Google アカウントを外部アプリへ繋ぐことに抵抗がある。Google を出しても、メール経路を同格で並べる(片方だけにしない)。
6. 検証(実装完了と言う前に)
パスキーは「画面が出た」では動作確認にならない。次を実操作で通す。
- メールで新規登録 → 確認コード → ログイン
- パスキー登録 → いったんサインアウト → パスキーでログイン
- パスキー未登録の状態でパスキーを押し、共通エラーが出てパスワードへ戻れる
- Google ログイン(有効にした場合)
- PC と iPhone の両方(iOS Simulator か実機の Safari)
localhost は RP ID が localhost になるため、本番ドメインで登録したパスキーはローカルでは使えない。ローカルではパスワード経路で確認する。