はじめに
Amplify Gen 2のCognito認証にX(旧Twitter)アカウントでのOAuthログインを追加してみました。
前回の記事では GitHub ログインを OIDC プロキシで実現しましたが、X は GitHub よりもさらに多くの制約があり、構成が複雑になります。
X は OIDC Discovery エンドポイントを公開しておらず、Cognito のカスタム OIDC プロバイダーとして直接統合できません。加えて PKCE 必須・state 長さ制限・openid スコープ拒否など、GitHub にはなかった制約が多数あります。そこで Lambda 6 本 + DynamoDB の OIDC プロキシを構築し、Cognito 経由で X ログインを実現しています。
本記事では、X 固有の制約とその対応策、OIDC プロキシの仕組みと実装手順を紹介します。
X Developer Portal の Free プランは新規受付を終了しています(2026年4月時点)。OAuth 2.0 を利用するには pay-per-use プラン以上が必要で、最低 $5 のクレジット入金が求められます。ただし、OAuth 認証で使う分にはクレジットは消費されないようです。
シリーズ記事
本記事は「Amplify Gen 2 に OAuth認証を追加してみよう!」シリーズの一つです。
| プロバイダー | 方式 | 記事 |
|---|---|---|
| ネイティブ | Google ログイン機能を追加 | |
| LINE | カスタム OIDC | LINE ログイン機能を追加 |
| GitHub | OIDC プロキシ | GitHub ログイン機能を追加 |
| X (Twitter) | OIDC プロキシ | 本記事 |
GitHub プロキシとの違い
GitHub も OIDC 非対応ですが、X は追加の制約が多く構成が複雑になります。
| 観点 | GitHub プロキシ | X (Twitter) プロキシ |
|---|---|---|
| Lambda 数 | 4 本 | 6 本 |
| DynamoDB | 不要 | 必要(PKCE state 管理) |
| PKCE | 不要 | 必須 |
| state パラメータ | 制限なし | 長さ制限あり(短縮が必要) |
| メールアドレス | 実メール取得可能 | 条件付きで取得可能(X アカウントにメール登録がある場合) |
| 認可エンドポイント | GitHub に直接リダイレクト | プロキシ経由 |
| コールバック URL | Cognito の oauth2/idpresponse
|
プロキシの /callback |
なぜ OIDC プロキシが必要なのか
Google や LINE は OIDC に準拠しているため、Cognito に直接接続できます。しかし X には以下の制約があり、間にプロキシを挟む必要があります。
| X の制約 | 影響 | プロキシでの対応 |
|---|---|---|
| OIDC Discovery 非対応 | Cognito が認識できない | Discovery Lambda で OIDC メタデータを提供 |
openid スコープを拒否 |
Cognito は OIDC に openid を必須で送信 |
Authorize Proxy で除去 |
| PKCE 必須 | Cognito は PKCE パラメータを送信しない | Authorize Proxy で生成、DynamoDB で管理 |
state の長さ制限 |
Cognito の state は 700 文字以上で X が拒否 |
UUID に短縮、Callback Proxy で復元 |
| ID トークンを返さない | Cognito は ID トークンを期待 | Token Proxy で RS256 署名の ID トークン生成 |
| メールアドレス非提供 | Cognito が email 必須の場合エラー |
confirmed_email フィールドで取得を試み、なければダミーメール生成 |
これらの制約は X のドキュメントには明記されておらず、実際の検証で発見されたものです。特に state の長さ制限と openid スコープの拒否は、ハマりポイントでした。
アーキテクチャ
構成図
ブラウザ → Cognito → OIDC Proxy (API Gateway + Lambda) → X (Twitter)
├── Discovery Lambda : OIDC メタデータ返却
├── Authorize Proxy : openid 除去 + PKCE 生成 + state 短縮
├── Callback Proxy : state 復元 + code_verifier 引き継ぎ
├── Token Proxy Lambda : トークン交換 + ID トークン生成
├── UserInfo Lambda : ユーザー情報中継
└── JWKS Lambda : 公開鍵返却
認証フロー
GitHub プロキシでは Cognito が GitHub に直接リダイレクトできましたが、X プロキシでは Authorize Proxy と Callback Proxy が追加で必要です。
-
Cognito → Authorize Proxy:
openidスコープ除去、PKCE 生成、長いstateを短い UUID に置換、redirect_uriをプロキシの/callbackに差替え。すべて DynamoDB に保存 -
Authorize Proxy → X 認可画面: 短い
state+code_challenge+ プロキシの/callbackで X にリダイレクト -
X → Callback Proxy:
codeと短いstateを受信。DynamoDB から元のstateとcode_verifierを取得。Cognito にリダイレクト -
Cognito → Token Proxy:
codeで DynamoDB からcode_verifierを取得。X にトークン交換。ユーザー情報取得後、ID トークンを RS256 署名で生成して返却 - Cognito → UserInfo Proxy: X Users API からユーザー情報を取得し、OIDC 形式に変換
- Cognito → JWKS Lambda: ID トークン署名検証用の RSA 公開鍵を返却
使用する AWS リソース
| リソース | 用途 |
|---|---|
| API Gateway REST API | Cognito がアクセスする OIDC エンドポイント(6 ルート)。issuerUrl に設定する URL |
| Lambda × 6 | 各エンドポイントのハンドラー。X API との通信、PKCE 管理、ID トークン生成を担当 |
| DynamoDB | PKCE state / code_verifier の一時保存(TTL で自動削除)。X 固有の制約対応に必要 |
| SSM Parameter Store | RSA 鍵ペア(秘密鍵: SecureString / 公開鍵: String)。ID トークンの署名と検証に使用 |
なぜ DynamoDB が必要なのか(GitHub プロキシとの違い)
GitHub プロキシでは Lambda 4 本 + SSM だけで済みましたが、X プロキシでは DynamoDB が追加で必要です。理由は X の以下の制約にあります:
1. PKCE 必須だが Cognito は送信しない
X はトークン交換時に code_verifier を要求しますが、Cognito はカスタム OIDC プロバイダーに PKCE パラメータを送信しません。そのため:
- Authorize Proxy で
code_verifierを生成し、DynamoDB に保存 - Token Proxy がトークン交換時に DynamoDB から
code_verifierを取得
2. state パラメータの長さ制限
Cognito が生成する state は 700 文字以上ですが、X はこれを拒否します。そのため:
- Authorize Proxy で短い UUID に置換し、元の
stateを DynamoDB に保存 - Callback Proxy が DynamoDB から元の
stateを復元して Cognito に返却
DynamoDB のレコードは TTL(10 分)で自動削除されるため、ゴミデータが溜まることはありません。
実装してみよう
前提条件
- Node.js 22.x 以上
- AWS CLI(設定済み)
- X Developer Portal のアカウント(pay-per-use プラン以上)
- Amplify Gen 2 プロジェクト(ベーステンプレートはこちら)
全体の流れ
- ソースコードの追加(
backend.ts+ プロキシ + フロントエンド) - Amplify デプロイ(API Gateway URL + Amplify URL 取得)
- X Developer Portal でアプリ作成 & OAuth 有効化
- Amplify シークレット設定
- RSA 署名鍵ペアの生成と SSM 登録
- OIDC プロバイダー設定 & 最終デプロイ
- 動作確認
1. ソースコードの追加
追加・変更するファイルの全体像です。
custom-resources/ 以下はすべて新規作成となります。
src/ にはXからメールアドレスを取得するために利用規約とプライバシーポリシーのページを追加します。
amplify/
auth/resource.ts ← 変更(OIDC プロバイダー追加 — 手順 6 で実施)
backend.ts ← 変更(プロキシスタック追加)
custom-resources/ ← 新規作成
x-oidc-proxy/
construct.ts ← CDK コンストラクト
handlers/
discovery.ts ← Discovery Lambda
authorize.ts ← Authorize Proxy Lambda
callback.ts ← Callback Proxy Lambda
token.ts ← Token Proxy Lambda
userinfo.ts ← UserInfo Proxy Lambda
jwks.ts ← JWKS Lambda
types.ts ← 型定義
utils/
x-api.ts ← X API クライアント
jwt.ts ← JWT 生成・署名ユーティリティ
src/
main.tsx ← 変更(ルーティング + OAuth リスナー追加)
App.tsx ← 変更(Authenticator + X ログインボタン)
pages/ ← 新規作成
TermsOfService.tsx ← 利用規約ページ
PrivacyPolicy.tsx ← プライバシーポリシーページ
1.1 ライブラリの追加
ルートの package.json に必要なライブラリを追加します。
npm install jose @aws-sdk/client-dynamodb react-router-dom
npm install -D @types/node tsx
jose は Lambda で JWT 署名に、@aws-sdk/client-dynamodb は PKCE state の管理に使用します。react-router-dom は利用規約・プライバシーポリシーページのルーティングに使用します。いずれも本番で実行されるため dependencies に入れます。
1.2 バックエンド定義
amplify/backend.ts に X OIDC プロキシの CDK スタックを追加します。
import { defineBackend, secret } from '@aws-amplify/backend';
import { CDKContextKey } from '@aws-amplify/platform-core';
import { Construct } from 'constructs';
import { auth } from './auth/resource';
import { data } from './data/resource';
import { XOidcProxyConstruct } from './custom-resources/x-oidc-proxy/construct';
const backend = defineBackend({
auth,
data,
});
// X OIDC プロキシの CDK コンストラクトを追加
const proxyStack = backend.createStack('XOidcProxyStack');
const backendIdentifier = {
namespace: proxyStack.node.getContext(CDKContextKey.BACKEND_NAMESPACE) as string,
name: proxyStack.node.getContext(CDKContextKey.BACKEND_NAME) as string,
type: proxyStack.node.getContext(CDKContextKey.DEPLOYMENT_TYPE) as 'sandbox' | 'branch',
};
const xClientIdSecret = secret('X_CLIENT_ID');
const xClientSecretSecret = secret('X_CLIENT_SECRET');
new XOidcProxyConstruct(proxyStack as unknown as Construct, 'XOidcProxy', {
xClientId: xClientIdSecret.resolve(proxyStack, backendIdentifier).unsafeUnwrap(),
xClientSecret: xClientSecretSecret.resolve(proxyStack, backendIdentifier).unsafeUnwrap(),
});
1.3 CDK コンストラクト
OIDC プロキシの CDK コンストラクトは以下のリソースを作成します:
- API Gateway REST API(6 エンドポイント)
- Lambda 関数 × 6
- DynamoDB テーブル(PKCE state 管理、TTL 付き)
- IAM ロール(SSM 読み取り + DynamoDB 読み書き)
construct.ts — CDK コンストラクト(クリックで展開)
/**
* X (Twitter) OIDC プロキシ CDK コンストラクト
*
* 作成するリソース:
* - API Gateway REST API(6 エンドポイント)
* - Lambda 関数 × 6(Discovery, Authorize, Callback, Token, UserInfo, JWKS)
* - DynamoDB テーブル(PKCE state 管理)
* - IAM ロール(Lambda 用、SSM 読み取り + DynamoDB 読み書き権限)
*/
import { Construct } from 'constructs';
import {
Duration,
Fn,
RemovalPolicy,
Stack,
aws_lambda_nodejs as nodejs,
aws_lambda as lambda,
aws_apigateway as apigateway,
aws_ssm as ssm,
aws_dynamodb as dynamodb,
} from 'aws-cdk-lib';
import * as path from 'path';
import { fileURLToPath } from 'url';
export interface XOidcProxyConstructProps {
xClientId: string;
xClientSecret: string;
keyId?: string;
}
export class XOidcProxyConstruct extends Construct {
public readonly issuerUrl: string;
constructor(scope: Construct, id: string, props: XOidcProxyConstructProps) {
super(scope, id);
const keyId = props.keyId ?? 'x-oidc-proxy-key-1';
// ★ SSM Parameter Store パラメータ名
const privateKeyParamName = `/x-oidc-proxy/${id}/private-key`;
const publicKeyParamName = `/x-oidc-proxy/${id}/public-key`;
const privateKeyParam = ssm.StringParameter.fromSecureStringParameterAttributes(
this, 'RsaPrivateKey', { parameterName: privateKeyParamName },
);
const publicKeyParam = ssm.StringParameter.fromStringParameterName(
this, 'RsaPublicKey', publicKeyParamName,
);
// ★ DynamoDB テーブル(PKCE state / code_verifier 管理)
// X は PKCE 必須だが Cognito は送信しないため、プロキシ側で管理する
const pkceTable = new dynamodb.Table(this, 'PkceTable', {
partitionKey: { name: 'state', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
removalPolicy: RemovalPolicy.DESTROY,
timeToLiveAttribute: 'ttl',
});
// __dirname equivalent for ESM
const currentFilePath = fileURLToPath(import.meta.url);
const currentDir = path.dirname(currentFilePath);
const handlersDir = path.join(currentDir, 'handlers');
// ★ API Gateway — Cognito が issuerUrl としてアクセスするエンドポイント
const api = new apigateway.RestApi(this, 'XOidcProxyApi', {
restApiName: 'X OIDC Proxy',
description: 'OIDC proxy for X (Twitter) OAuth 2.0 integration with Cognito',
deployOptions: { stageName: 'prod' },
});
// ★ issuerUrl を restApiId + region から構築(循環依存を回避)
const region = Stack.of(this).region;
const issuerUrl = Fn.join('', [
'https://', api.restApiId, '.execute-api.', region, '.amazonaws.com/prod',
]);
this.issuerUrl = issuerUrl;
const commonBundling: nodejs.BundlingOptions = {
format: nodejs.OutputFormat.ESM,
mainFields: ['module', 'main'],
sourceMap: true,
};
// Discovery Lambda
const discoveryFn = new nodejs.NodejsFunction(this, 'DiscoveryFunction', {
entry: path.join(handlersDir, 'discovery.ts'),
handler: 'handler',
runtime: lambda.Runtime.NODEJS_22_X,
timeout: Duration.seconds(5),
environment: { ISSUER_URL: issuerUrl },
bundling: commonBundling,
});
// ★ Authorize Proxy Lambda
// openid スコープ除去 + PKCE 生成 + state 短縮
const authorizeFn = new nodejs.NodejsFunction(this, 'AuthorizeFunction', {
entry: path.join(handlersDir, 'authorize.ts'),
handler: 'handler',
runtime: lambda.Runtime.NODEJS_22_X,
timeout: Duration.seconds(5),
environment: {
PKCE_TABLE_NAME: pkceTable.tableName,
ISSUER_URL: issuerUrl,
},
bundling: commonBundling,
});
// ★ Callback Proxy Lambda
// X からのコールバックを受け取り、元の state を復元して Cognito にリダイレクト
const callbackFn = new nodejs.NodejsFunction(this, 'CallbackFunction', {
entry: path.join(handlersDir, 'callback.ts'),
handler: 'handler',
runtime: lambda.Runtime.NODEJS_22_X,
timeout: Duration.seconds(5),
environment: { PKCE_TABLE_NAME: pkceTable.tableName },
bundling: commonBundling,
});
// Token Proxy Lambda
const tokenFn = new nodejs.NodejsFunction(this, 'TokenFunction', {
entry: path.join(handlersDir, 'token.ts'),
handler: 'handler',
runtime: lambda.Runtime.NODEJS_22_X,
memorySize: 1024,
timeout: Duration.seconds(30),
environment: {
ISSUER_URL: issuerUrl,
X_CLIENT_ID: props.xClientId,
X_CLIENT_SECRET: props.xClientSecret,
SSM_PRIVATE_KEY_NAME: privateKeyParamName,
KEY_ID: keyId,
PKCE_TABLE_NAME: pkceTable.tableName,
},
bundling: commonBundling,
});
// UserInfo Proxy Lambda
const userInfoFn = new nodejs.NodejsFunction(this, 'UserInfoFunction', {
entry: path.join(handlersDir, 'userinfo.ts'),
handler: 'handler',
runtime: lambda.Runtime.NODEJS_22_X,
timeout: Duration.seconds(15),
environment: { ISSUER_URL: issuerUrl },
bundling: commonBundling,
});
// JWKS Lambda
const jwksFn = new nodejs.NodejsFunction(this, 'JwksFunction', {
entry: path.join(handlersDir, 'jwks.ts'),
handler: 'handler',
runtime: lambda.Runtime.NODEJS_22_X,
timeout: Duration.seconds(10),
environment: {
SSM_PUBLIC_KEY_NAME: publicKeyParam.parameterName,
KEY_ID: keyId,
},
bundling: commonBundling,
});
// ★ IAM 権限
privateKeyParam.grantRead(tokenFn);
publicKeyParam.grantRead(jwksFn);
pkceTable.grantWriteData(authorizeFn);
pkceTable.grantReadWriteData(callbackFn);
pkceTable.grantReadWriteData(tokenFn);
// ★ API Gateway エンドポイント(6 ルート)
const wellKnown = api.root.addResource('.well-known');
wellKnown.addResource('openid-configuration')
.addMethod('GET', new apigateway.LambdaIntegration(discoveryFn));
api.root.addResource('authorize')
.addMethod('GET', new apigateway.LambdaIntegration(authorizeFn));
api.root.addResource('callback')
.addMethod('GET', new apigateway.LambdaIntegration(callbackFn));
api.root.addResource('token')
.addMethod('POST', new apigateway.LambdaIntegration(tokenFn));
api.root.addResource('userinfo')
.addMethod('GET', new apigateway.LambdaIntegration(userInfoFn));
api.root.addResource('jwks')
.addMethod('GET', new apigateway.LambdaIntegration(jwksFn));
}
}
1.4 Lambda ハンドラー(ソースコード)
各 Lambda の役割:
| ハンドラー | 役割 |
|---|---|
discovery.ts |
OIDC メタデータを返却。authorization_endpoint はプロキシ自身の /authorize
|
authorize.ts |
openid スコープ除去、PKCE 生成、state 短縮して X にリダイレクト |
callback.ts |
X からのコールバックを受け取り、元の state を復元して Cognito にリダイレクト |
token.ts |
認可コードで X にトークン交換 → ユーザー情報取得 → ID トークン RS256 署名生成 |
userinfo.ts |
X Users API → OIDC UserInfo 形式に変換 |
jwks.ts |
SSM から RSA 公開鍵を取得して JWKS 形式で返却 |
以下にすべてのソースコードを掲載します。
amplify/custom-resources/x-oidc-proxy/handlers/ 配下の各ファイルにコピペします。
types.ts — 型定義(クリックで展開)
/**
* X (Twitter) OIDC プロキシ - 型定義
*/
// ID トークン(JWT)ペイロード — Cognito に返却する OIDC 準拠トークン
export interface IdTokenPayload {
sub: string; // X のユーザー ID
iss: string; // プロキシの API Gateway URL
aud: string; // X の client_id
exp: number; // 有効期限(UNIX タイムスタンプ)
iat: number; // 発行時刻
email?: string; // ダミーメール(X はメールを提供しない)
preferred_username?: string; // X のユーザー名
name?: string; // 表示名
picture?: string; // プロフィール画像 URL
}
// JWT ヘッダー
export interface JwtHeader {
alg: 'RS256';
typ: 'JWT';
kid: string;
}
// X Users API レスポンス(GET /2/users/me)
export interface XUsersResponse {
data: {
id: string;
username: string;
name: string;
profile_image_url?: string;
/** ユーザーの確認済みメールアドレス(confirmed_email フィールド指定時) */
confirmed_email?: string;
};
}
// X トークンレスポンス(POST /2/oauth2/token)
export interface XTokenResponse {
access_token: string;
token_type: string;
expires_in: number;
scope: string;
refresh_token?: string;
}
// OIDC Discovery メタデータ
export interface OidcDiscoveryMetadata {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
userinfo_endpoint: string;
jwks_uri: string;
response_types_supported: string[];
subject_types_supported: string[];
id_token_signing_alg_values_supported: string[];
scopes_supported: string[];
token_endpoint_auth_methods_supported: string[];
claims_supported: string[];
}
// JWKS レスポンス
export interface JwkKey {
kty: 'RSA';
kid: string;
use: 'sig';
alg: 'RS256';
n: string;
e: string;
}
export interface JwksResponse {
keys: JwkKey[];
}
utils/jwt.ts — JWT 生成・署名ユーティリティ(クリックで展開)
import { SignJWT, importJWK } from 'jose';
import type { IdTokenPayload, JwkKey } from '../types.js';
/**
* ID トークン(JWT)を RS256 署名で生成する。
* ★ X は ID トークンを返さないため、プロキシ側で生成する必要がある
*/
export async function generateIdToken(
payload: IdTokenPayload,
privateKeyJwk: JsonWebKey,
keyId: string,
): Promise<string> {
const privateKey = await importJWK(privateKeyJwk, 'RS256');
const jwt = await new SignJWT({ ...payload })
.setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: keyId })
.sign(privateKey);
return jwt;
}
/**
* RSA 公開鍵を JWKS 形式にエクスポートする。
* Cognito が ID トークンの署名検証に使用する。
*/
export function exportPublicKeyAsJwk(
publicKeyJwk: JsonWebKey,
keyId: string,
): JwkKey {
if (!publicKeyJwk.n || !publicKeyJwk.e) {
throw new Error('Invalid RSA public key: missing n or e component');
}
return {
kty: 'RSA',
kid: keyId,
use: 'sig',
alg: 'RS256',
n: publicKeyJwk.n,
e: publicKeyJwk.e,
};
}
utils/x-api.ts — X API クライアント(クリックで展開)
import type { XTokenResponse, XUsersResponse } from '../types.js';
const X_TOKEN_ENDPOINT = 'https://api.x.com/2/oauth2/token';
const X_USERS_ENDPOINT =
'https://api.x.com/2/users/me?user.fields=id,username,name,profile_image_url,confirmed_email';
const REQUEST_TIMEOUT_MS = 10_000;
/** Basic 認証ヘッダーを生成(X はクライアント認証に Basic 認証を使用) */
export function buildBasicAuthHeader(
clientId: string,
clientSecret: string,
): string {
const credentials = `${clientId}:${clientSecret}`;
const encoded = Buffer.from(credentials).toString('base64');
return `Basic ${encoded}`;
}
/**
* X トークンエンドポイントに認可コードを送信してアクセストークンを取得する。
* ★ ポイント: code_verifier を含める(X は PKCE 必須)
*/
export async function exchangeToken(
code: string,
redirectUri: string,
clientId: string,
clientSecret: string,
codeVerifier: string,
): Promise<XTokenResponse> {
const body = new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier, // ★ X は PKCE 必須
});
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(X_TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: buildBasicAuthHeader(clientId, clientSecret),
},
body: body.toString(),
signal: controller.signal,
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`X token endpoint returned HTTP ${response.status}: ${errorBody}`,
);
}
return (await response.json()) as XTokenResponse;
} finally {
clearTimeout(timeoutId);
}
}
/** X Users API からユーザー情報を取得する */
export async function fetchUserInfo(
accessToken: string,
): Promise<XUsersResponse> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(X_USERS_ENDPOINT, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
signal: controller.signal,
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(
`X Users API returned HTTP ${response.status}: ${errorBody}`,
);
}
return (await response.json()) as XUsersResponse;
} finally {
clearTimeout(timeoutId);
}
}
handlers/discovery.ts — Discovery Lambda(クリックで展開)
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import type { OidcDiscoveryMetadata } from './types.js';
export const handler = async (
event: APIGatewayProxyEvent,
): Promise<APIGatewayProxyResult> => {
const issuerUrl = process.env.ISSUER_URL ?? '';
const metadata: OidcDiscoveryMetadata = {
issuer: issuerUrl,
// ★ ポイント: プロキシ自身の /authorize を指定
// X は openid スコープを拒否し、PKCE 必須、state 長さ制限があるため
// Authorize Proxy で前処理してから X にリダイレクトする
authorization_endpoint: `${issuerUrl}/authorize`,
token_endpoint: `${issuerUrl}/token`,
userinfo_endpoint: `${issuerUrl}/userinfo`,
jwks_uri: `${issuerUrl}/jwks`,
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
scopes_supported: ['openid', 'tweet.read', 'users.read', 'offline.access'],
token_endpoint_auth_methods_supported: [
'client_secret_basic',
'client_secret_post',
],
claims_supported: [
'sub', 'iss', 'aud', 'exp', 'iat',
'preferred_username', 'name', 'picture',
],
};
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(metadata),
};
};
handlers/authorize.ts — Authorize Proxy Lambda(クリックで展開)
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';
import { randomBytes, createHash, randomUUID } from 'crypto';
const X_AUTHORIZE_ENDPOINT = 'https://x.com/i/oauth2/authorize';
const PKCE_TTL_SECONDS = 600; // 10 分
const dynamoClient = new DynamoDBClient({});
/** RFC 7636 準拠の code_verifier を生成 */
function generateCodeVerifier(): string {
return randomBytes(32).toString('base64url');
}
/** code_verifier から code_challenge を生成(S256) */
function generateCodeChallenge(codeVerifier: string): string {
return createHash('sha256').update(codeVerifier).digest('base64url');
}
export const handler = async (
event: APIGatewayProxyEvent,
): Promise<APIGatewayProxyResult> => {
const tableName = process.env.PKCE_TABLE_NAME ?? '';
const issuerUrl = process.env.ISSUER_URL ?? '';
const params = new URLSearchParams();
if (event.queryStringParameters) {
for (const [key, value] of Object.entries(event.queryStringParameters)) {
if (value !== undefined) {
params.set(key, value);
}
}
}
// ★ ポイント1: scope から openid を除去(X は openid スコープを拒否する)
const scope = params.get('scope') ?? '';
const filteredScopes = scope
.split(/[\s+]+/)
.filter((s) => s !== 'openid' && s.length > 0)
.join(' ');
if (filteredScopes.length > 0) {
params.set('scope', filteredScopes);
} else {
params.delete('scope');
}
// ★ ポイント2: PKCE を生成(X は PKCE 必須だが Cognito は送信しない)
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
params.set('code_challenge', codeChallenge);
params.set('code_challenge_method', 'S256');
// ★ ポイント3: Cognito の長い state を短い UUID に置き換え
// X は state が 700 文字以上だと拒否する
const originalState = params.get('state') ?? '';
const originalRedirectUri = params.get('redirect_uri') ?? '';
const shortState = randomUUID();
params.set('state', shortState);
// redirect_uri をプロキシの /callback に差し替え
params.set('redirect_uri', `${issuerUrl}/callback`);
// DynamoDB に保存(Callback Proxy と Token Proxy で使用)
if (tableName) {
const ttl = Math.floor(Date.now() / 1000) + PKCE_TTL_SECONDS;
await dynamoClient.send(
new PutItemCommand({
TableName: tableName,
Item: {
state: { S: shortState },
code_verifier: { S: codeVerifier },
original_state: { S: originalState },
original_redirect_uri: { S: originalRedirectUri },
ttl: { N: String(ttl) },
},
}),
);
}
return {
statusCode: 302,
headers: { Location: `${X_AUTHORIZE_ENDPOINT}?${params.toString()}` },
body: '',
};
};
handlers/callback.ts — Callback Proxy Lambda(クリックで展開)
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { DynamoDBClient, GetItemCommand, PutItemCommand } from '@aws-sdk/client-dynamodb';
const dynamoClient = new DynamoDBClient({});
export const handler = async (
event: APIGatewayProxyEvent,
): Promise<APIGatewayProxyResult> => {
const tableName = process.env.PKCE_TABLE_NAME ?? '';
const code = event.queryStringParameters?.['code'] ?? '';
const shortState = event.queryStringParameters?.['state'] ?? '';
const error = event.queryStringParameters?.['error'] ?? '';
// エラーの場合はそのまま Cognito にリダイレクト
if (error) {
let originalState = shortState;
let originalRedirectUri = '';
if (shortState && tableName) {
try {
const result = await dynamoClient.send(
new GetItemCommand({
TableName: tableName,
Key: { state: { S: shortState } },
}),
);
originalState = result.Item?.original_state?.S ?? shortState;
originalRedirectUri = result.Item?.original_redirect_uri?.S ?? '';
} catch { /* DynamoDB エラーの場合はそのまま返す */ }
}
if (originalRedirectUri) {
const errorDescription = event.queryStringParameters?.['error_description'] ?? '';
const params = new URLSearchParams({
error,
...(errorDescription && { error_description: errorDescription }),
state: originalState,
});
return {
statusCode: 302,
headers: { Location: `${originalRedirectUri}?${params.toString()}` },
body: '',
};
}
return {
statusCode: 400,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error, error_description: 'Authorization failed' }),
};
}
if (!code || !shortState) {
return {
statusCode: 400,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'invalid_request', error_description: 'Missing code or state' }),
};
}
// ★ DynamoDB から元の state、redirect_uri、code_verifier を取得
let originalState = shortState;
let originalRedirectUri = '';
let codeVerifier = '';
if (tableName) {
try {
const result = await dynamoClient.send(
new GetItemCommand({
TableName: tableName,
Key: { state: { S: shortState } },
}),
);
originalState = result.Item?.original_state?.S ?? shortState;
originalRedirectUri = result.Item?.original_redirect_uri?.S ?? '';
codeVerifier = result.Item?.code_verifier?.S ?? '';
} catch {
return {
statusCode: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'server_error', error_description: 'Failed to retrieve session' }),
};
}
}
if (!originalRedirectUri) {
return {
statusCode: 400,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'invalid_request', error_description: 'Session not found or expired' }),
};
}
// ★ code → code_verifier のマッピングを DynamoDB に保存
// Token Proxy が後でトークン交換時に code_verifier を取得するために使用
if (code && codeVerifier && tableName) {
const ttl = Math.floor(Date.now() / 1000) + 300; // 5 分
await dynamoClient.send(
new PutItemCommand({
TableName: tableName,
Item: {
state: { S: `code:${code}` },
code_verifier: { S: codeVerifier },
ttl: { N: String(ttl) },
},
}),
);
}
// 元の state を復元して Cognito にリダイレクト
const params = new URLSearchParams({ code, state: originalState });
return {
statusCode: 302,
headers: { Location: `${originalRedirectUri}?${params.toString()}` },
body: '',
};
};
handlers/token.ts — Token Proxy Lambda(クリックで展開)
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm';
import { DynamoDBClient, GetItemCommand, DeleteItemCommand } from '@aws-sdk/client-dynamodb';
import type { IdTokenPayload } from './types.js';
import { exchangeToken, fetchUserInfo } from './utils/x-api.js';
import { generateIdToken } from './utils/jwt.js';
const ssmClient = new SSMClient({});
const dynamoClient = new DynamoDBClient({});
function parseFormBody(body: string | null): Record<string, string> {
if (!body) return {};
const params = new URLSearchParams(body);
const result: Record<string, string> = {};
for (const [key, value] of params.entries()) {
result[key] = value;
}
return result;
}
export const handler = async (
event: APIGatewayProxyEvent,
): Promise<APIGatewayProxyResult> => {
const issuerUrl = process.env.ISSUER_URL ?? '';
const xClientId = process.env.X_CLIENT_ID ?? '';
const xClientSecret = process.env.X_CLIENT_SECRET ?? '';
const ssmPrivateKeyName = process.env.SSM_PRIVATE_KEY_NAME ?? '';
const keyId = process.env.KEY_ID ?? '';
const pkceTableName = process.env.PKCE_TABLE_NAME ?? '';
// 1. Cognito からの form-urlencoded リクエストをパース
const body = event.isBase64Encoded
? Buffer.from(event.body ?? '', 'base64').toString('utf-8')
: event.body ?? '';
const params = parseFormBody(body);
const code = params['code'];
const redirectUri = params['redirect_uri'];
const clientId = params['client_id'] || xClientId;
const clientSecret = params['client_secret'] || xClientSecret;
// ★ DynamoDB から code_verifier を取得(Callback Proxy が保存したもの)
let codeVerifier = params['code_verifier'] ?? '';
if (!codeVerifier && code && pkceTableName) {
try {
const result = await dynamoClient.send(
new GetItemCommand({
TableName: pkceTableName,
Key: { state: { S: `code:${code}` } },
}),
);
codeVerifier = result.Item?.code_verifier?.S ?? '';
if (codeVerifier) {
// 使用済みの code_verifier を削除
await dynamoClient.send(
new DeleteItemCommand({
TableName: pkceTableName,
Key: { state: { S: `code:${code}` } },
}),
);
}
} catch { /* code_verifier 取得失敗 */ }
}
if (!code || !redirectUri) {
return {
statusCode: 400,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'invalid_request', error_description: 'Missing code or redirect_uri' }),
};
}
// 2. X にトークン交換
// ★ redirect_uri は認可時にプロキシの /callback を使用したため、同じ URL を指定
const xRedirectUri = `${issuerUrl}/callback`;
const tokenResponse = await exchangeToken(code, xRedirectUri, clientId, clientSecret, codeVerifier);
const accessToken = tokenResponse.access_token;
// 3. X Users API からユーザー情報取得
const userInfo = await fetchUserInfo(accessToken);
const userId = userInfo.data.id;
const username = userInfo.data.username;
// 4. SSM から RSA 秘密鍵を取得(ID トークン署名用)
const ssmResponse = await ssmClient.send(
new GetParameterCommand({ Name: ssmPrivateKeyName, WithDecryption: true }),
);
const privateKeyJwk = JSON.parse(ssmResponse.Parameter?.Value ?? '') as JsonWebKey;
// 5. ID トークン生成(RS256 署名)
const now = Math.floor(Date.now() / 1000);
const idTokenPayload: IdTokenPayload = {
sub: userId,
iss: issuerUrl,
aud: clientId,
exp: now + 3600,
iat: now,
// ★ X の confirmed_email があればそれを使用、なければダミーメールを生成
email: userInfo.data.confirmed_email || `${username}@x-user.local`,
preferred_username: username,
name: userInfo.data.name,
picture: userInfo.data.profile_image_url,
};
const idToken = await generateIdToken(idTokenPayload, privateKeyJwk, keyId);
// 6. Cognito に OIDC トークンレスポンスを返却
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
access_token: accessToken,
token_type: 'bearer',
expires_in: tokenResponse.expires_in,
id_token: idToken,
scope: tokenResponse.scope,
}),
};
};
handlers/userinfo.ts — UserInfo Proxy Lambda(クリックで展開)
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import type { XUsersResponse } from './types.js';
import { fetchUserInfo } from './utils/x-api.js';
function extractBearerToken(
headers: Record<string, string | undefined> | null,
): string | null {
if (!headers) return null;
// API Gateway がヘッダー名を小文字に正規化する場合があるため case-insensitive で検索
const authKey = Object.keys(headers).find(
(key) => key.toLowerCase() === 'authorization',
);
if (!authKey) return null;
const match = headers[authKey]?.match(/^Bearer\s+(.+)$/i);
return match ? match[1] : null;
}
export const handler = async (
event: APIGatewayProxyEvent,
): Promise<APIGatewayProxyResult> => {
const accessToken = extractBearerToken(event.headers);
if (!accessToken) {
return {
statusCode: 401,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'invalid_token', error_description: 'Missing Authorization header' }),
};
}
// X Users API からユーザー情報を取得
const xResponse: XUsersResponse = await fetchUserInfo(accessToken);
// ★ OIDC UserInfo 形式に変換して返却
// X の confirmed_email があればそれを使用、なければダミーメールを生成
const userInfo = {
sub: xResponse.data.id,
preferred_username: xResponse.data.username,
email: xResponse.data.confirmed_email || `${xResponse.data.username}@x-user.local`,
name: xResponse.data.name,
picture: xResponse.data.profile_image_url,
};
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(userInfo),
};
};
handlers/jwks.ts — JWKS Lambda(クリックで展開)
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm';
import type { JwksResponse } from './types.js';
import { exportPublicKeyAsJwk } from './utils/jwt.js';
const ssmClient = new SSMClient({});
export const handler = async (
event: APIGatewayProxyEvent,
): Promise<APIGatewayProxyResult> => {
const ssmPublicKeyName = process.env.SSM_PUBLIC_KEY_NAME ?? '';
const keyId = process.env.KEY_ID ?? '';
// ★ SSM から RSA 公開鍵を取得
// Cognito は ID トークンの署名検証時にこのエンドポイントを呼び出す
const ssmResponse = await ssmClient.send(
new GetParameterCommand({ Name: ssmPublicKeyName }),
);
const keyValue = ssmResponse.Parameter?.Value;
if (!keyValue) {
return {
statusCode: 500,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ error: 'server_error', error_description: 'Failed to retrieve public key' }),
};
}
const publicKeyJwk = JSON.parse(keyValue) as JsonWebKey;
const jwkKey = exportPublicKeyAsJwk(publicKeyJwk, keyId);
const jwksResponse: JwksResponse = { keys: [jwkKey] };
return {
statusCode: 200,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(jwksResponse),
};
};
1.5 利用規約・プライバシーポリシーページの追加
X Developer Portal で OAuth 2.0 を有効化する際、メールアドレスを取得できるようにするために、利用規約とプライバシーポリシーの URL が必要です。以下のファイルを追加します。
src/pages/TermsOfService.tsx — 利用規約(クリックで展開)
import { Link } from "react-router-dom";
export default function TermsOfService() {
return (
<main style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
<h1>利用規約</h1>
<p>最終更新日: 2025年6月1日</p>
<h2>第1条(適用)</h2>
<p>
本利用規約(以下「本規約」)は、本サービスの利用に関する条件を定めるものです。
ユーザーは本規約に同意の上、本サービスを利用するものとします。
</p>
<h2>第2条(利用登録)</h2>
<p>
本サービスの利用を希望する方は、X(旧Twitter)アカウントを使用してログインすることにより、
利用登録を行うものとします。登録時にXアカウントに関連付けられたメールアドレスを取得する場合があります。
</p>
<h2>第3条(禁止事項)</h2>
<p>ユーザーは以下の行為を行ってはなりません。</p>
<ul>
<li>法令または公序良俗に違反する行為</li>
<li>犯罪行為に関連する行為</li>
<li>本サービスの運営を妨害する行為</li>
<li>他のユーザーに迷惑をかける行為</li>
<li>不正アクセスを試みる行為</li>
</ul>
<h2>第4条(サービスの変更・停止)</h2>
<p>
運営者は、事前の通知なく本サービスの内容を変更、または提供を停止することができるものとします。
</p>
<h2>第5条(免責事項)</h2>
<p>
運営者は、本サービスに起因してユーザーに生じたあらゆる損害について、一切の責任を負いません。
</p>
<h2>第6条(規約の変更)</h2>
<p>
運営者は、必要と判断した場合には、ユーザーに通知することなく本規約を変更することができるものとします。
</p>
<p style={{ marginTop: "2rem" }}>
<Link to="/">← トップページに戻る</Link>
</p>
</main>
);
}
src/pages/PrivacyPolicy.tsx — プライバシーポリシー(クリックで展開)
import { Link } from "react-router-dom";
export default function PrivacyPolicy() {
return (
<main style={{ maxWidth: "800px", margin: "0 auto", padding: "2rem" }}>
<h1>プライバシーポリシー</h1>
<p>最終更新日: 2025年6月1日</p>
<h2>1. 収集する情報</h2>
<p>本サービスでは、X(旧Twitter)アカウントを通じたログイン時に以下の情報を取得する場合があります。</p>
<ul>
<li>Xアカウントのユーザー名・表示名</li>
<li>Xアカウントに登録されたメールアドレス</li>
<li>プロフィール画像</li>
</ul>
<h2>2. 情報の利用目的</h2>
<p>取得した情報は以下の目的で利用します。</p>
<ul>
<li>ユーザー認証およびアカウント管理</li>
<li>サービスの提供・改善</li>
<li>お問い合わせへの対応</li>
</ul>
<h2>3. 第三者への提供</h2>
<p>
法令に基づく場合を除き、ユーザーの同意なく個人情報を第三者に提供することはありません。
</p>
<h2>4. 情報の管理</h2>
<p>
取得した個人情報は、AWS(Amazon Web Services)上で適切なセキュリティ対策を講じて管理します。
</p>
<h2>5. Cookieの使用</h2>
<p>
本サービスでは、認証状態の維持のためにCookieを使用する場合があります。
</p>
<h2>6. ポリシーの変更</h2>
<p>
本プライバシーポリシーは、必要に応じて変更されることがあります。
変更後のポリシーは本ページに掲載した時点で効力を生じるものとします。
</p>
<h2>7. お問い合わせ</h2>
<p>
本ポリシーに関するお問い合わせは、サービス内のお問い合わせ機能よりご連絡ください。
</p>
<p style={{ marginTop: "2rem" }}>
<Link to="/">← トップページに戻る</Link>
</p>
</main>
);
}
内容はサンプルです。実際のサービスに合わせて編集してください。
1.6 フロントエンド(ログインボタン)
カスタム OIDC プロバイダーは Authenticator コンポーネントが自動でログインボタンを表示しないため、自分で実装する必要があります。
main.tsx の変更
src/main.tsx を以下に置き換えます。react-router-dom でルーティングを追加し、利用規約・プライバシーポリシーページと OAuth リスナーを有効化しています。
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import App from "./App.tsx";
import TermsOfService from "./pages/TermsOfService.tsx";
import PrivacyPolicy from "./pages/PrivacyPolicy.tsx";
import "./index.css";
import { Amplify } from "aws-amplify";
import outputs from "../amplify_outputs.json";
import "aws-amplify/auth/enable-oauth-listener";
Amplify.configure(outputs);
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route path="/" element={<App />} />
<Route path="/terms" element={<TermsOfService />} />
<Route path="/privacy" element={<PrivacyPolicy />} />
</Routes>
</BrowserRouter>
</React.StrictMode>
);
App.tsx の変更
src/App.tsx を以下に置き換えます。X ログインボタンと Authenticator への組み込みを追加しています。
import { useEffect, useState } from "react";
import { Authenticator } from "@aws-amplify/ui-react";
import { signInWithRedirect } from "aws-amplify/auth";
import "@aws-amplify/ui-react/styles.css";
import type { Schema } from "../amplify/data/resource";
import { generateClient } from "aws-amplify/data";
const client = generateClient<Schema>();
// ★ X ログインボタン
function XLoginButton() {
const handleClick = () => {
// "XTwitter" は amplify/auth/resource.ts の name: "XTwitter" と一致させる
signInWithRedirect({ provider: { custom: "XTwitter" } });
};
return (
<button
type="button"
onClick={handleClick}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
width: "100%",
padding: "0.75rem 1rem",
marginTop: "1rem",
backgroundColor: "#000000",
color: "#fff",
border: "none",
borderRadius: "4px",
fontSize: "1rem",
fontWeight: 500,
cursor: "pointer",
}}
>
Login with X
</button>
);
}
// ★ Authenticator のサインイン画面フッターに X ボタンを追加
const authenticatorComponents = {
SignIn: {
Footer() {
return <XLoginButton />;
},
},
};
function App() {
const [todos, setTodos] = useState<Array<Schema["Todo"]["type"]>>([]);
useEffect(() => {
client.models.Todo.observeQuery().subscribe({
next: (data) => setTodos([...data.items]),
});
}, []);
function createTodo() {
client.models.Todo.create({ content: window.prompt("Todo content") });
}
return (
<Authenticator components={authenticatorComponents}>
{({ signOut }) => (
<main>
<h1>My todos</h1>
<button onClick={createTodo}>+ new</button>
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.content}</li>
))}
</ul>
<div>
🥳 App successfully hosted. Try creating a new todo.
<br />
<a href="https://docs.amplify.aws/react/start/quickstart/#make-frontend-updates">
Review next step of this tutorial.
</a>
</div>
<button onClick={signOut} style={{ marginTop: "1rem" }}>
Sign out
</button>
</main>
)}
</Authenticator>
);
}
export default App;
provider: { custom: "XTwitter" } の "XTwitter" は amplify/auth/resource.ts の name: "XTwitter" と完全に一致させてください。大文字小文字も区別されます。
2. Amplify デプロイ(API Gateway URL + Amplify URL 取得)
ソースコードを GitHub にプッシュし、Amplify Hosting に接続してデプロイします。
- AWS コンソール → Amplify → 「新しいアプリ」→ GitHub リポジトリを接続
- ブランチ
mainを選択 - デプロイを実行
デプロイが開始されたら、すぐに Amplify コンソール → アプリ設定 →「シークレット」から以下をダミー値で登録します(X Developer Portal の設定はまだ完了していないため)。
| 変数 | 値 |
|---|---|
X_CLIENT_ID |
dummy(手順 3 で取得後に更新) |
X_CLIENT_SECRET |
dummy(手順 3 で取得後に更新) |
シークレットが未設定だとビルドエラーになるため、ダミー値でも先に登録しておく必要があります。デプロイに失敗した場合は、シークレット設定後に「このバージョンを再デプロイ」でデプロイします。
デプロイが成功すると、API Gateway + Lambda + DynamoDB が作成されます。
以下を控えておきます。
-
Amplify URL:
https://<branch>.<app-id>.amplifyapp.com/(デプロイ完了画面で確認) - API Gateway URL: 以下のコマンドで確認
aws cloudformation describe-stacks \
--query "Stacks[?contains(StackName, 'XOidcProxyStack')].Outputs" \
--output table
URL は以下の形式です:
https://{api-id}.execute-api.{region}.amazonaws.com/prod
3. X Developer Portal でアプリ作成
API Gateway URL と Amplify URL が確定したので、X Developer Portal でアプリを作成し OAuth 2.0 を有効化します。
3.1 プロジェクトとアプリの作成
- X Developer Portal にサインイン
- 「アプリ」を開いて右上の「+」から新しいクライアントアプリケーションを作成
- アプリケーション名と環境を入力してアプリを作成
- アプリケーション名:任意のアプリ名(例: My App - X Login)
-
環境(Environment):なんでも。迷ったら
Development
3.2 OAuth 2.0 の有効化
コールバック URL は Cognito の oauth2/idpresponse ではなく、プロキシの /callback を設定します。X からのコールバックはプロキシが受け取り、state パラメータを復元してから Cognito にリダイレクトする必要があるためです。
3.3 クライアント認証情報の取得
OAuth 2.0 有効化後に表示される以下を控えます:
-
Client ID(
X_CLIENT_IDとして使用) -
Client Secret(
X_CLIENT_SECRETとして使用)
Client Secret は作成時にのみ表示されます。紛失した場合は再生成が必要です。
4. Amplify シークレット更新
手順 2 でダミー値を設定したシークレットを、手順 3 で取得した実際の値に更新します。
Amplify コンソール → アプリ設定 →「シークレット」から以下を更新:
| 変数 | 値 |
|---|---|
X_CLIENT_ID |
手順 3 で取得した Client ID |
X_CLIENT_SECRET |
手順 3 で取得した Client Secret |
5. RSA 署名鍵ペアの生成と SSM 登録
Token Proxy Lambda が ID トークンを RS256 で署名するため、RSA 鍵ペアが必要です。
SSM Parameter Store のパラメータ名はソースコード(CDK コンストラクト)で /x-oidc-proxy/XOidcProxy/private-key と /x-oidc-proxy/XOidcProxy/public-key を参照しています。
5.1 鍵ペアの生成
node -e "const crypto = require('crypto'); const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'jwk' }, privateKeyEncoding: { type: 'pkcs8', format: 'jwk' } }); console.log('=== Private Key (JWK) ==='); console.log(JSON.stringify(privateKey)); console.log(''); console.log('=== Public Key (JWK) ==='); console.log(JSON.stringify(publicKey));"
5.2 SSM Parameter Store への登録
生成した JWK をファイルに保存してから SSM に登録します。
# 秘密鍵をファイルに保存
cat > /tmp/x-oidc-private-key.json << 'EOF'
{"kty":"RSA","n":"...生成した値...","e":"...","d":"...","p":"...","q":"...","dp":"...","dq":"...","qi":"..."}
EOF
# 公開鍵をファイルに保存
cat > /tmp/x-oidc-public-key.json << 'EOF'
{"kty":"RSA","n":"...生成した値...","e":"..."}
EOF
# SSM に登録
aws ssm put-parameter \
--name "/x-oidc-proxy/XOidcProxy/private-key" \
--type SecureString \
--value "file:///tmp/x-oidc-private-key.json" \
--overwrite
aws ssm put-parameter \
--name "/x-oidc-proxy/XOidcProxy/public-key" \
--type String \
--value "file:///tmp/x-oidc-public-key.json" \
--overwrite
# 一時ファイルの削除
rm /tmp/x-oidc-private-key.json /tmp/x-oidc-public-key.json
5.3 登録確認
aws ssm get-parameter \
--name "/x-oidc-proxy/XOidcProxy/public-key" \
--query "Parameter.Value" \
--output text
6. OIDC プロバイダー設定 & 再デプロイ
auth/resource.ts を変更し、手順 2 で取得した Amplify URL と API Gateway URL を設定します。
import { defineAuth, secret } from "@aws-amplify/backend";
export const auth = defineAuth({
loginWith: {
email: true,
externalProviders: {
oidc: [
{
name: "XTwitter",
clientId: secret("X_CLIENT_ID"),
clientSecret: secret("X_CLIENT_SECRET"),
// ★ 手順 2 で取得した API Gateway URL を設定
issuerUrl: "https://{api-id}.execute-api.{region}.amazonaws.com/prod",
scopes: ["openid", "tweet.read", "users.read", "users.email", "offline.access"],
attributeMapping: {
email: "email",
preferredUsername: "preferred_username",
profilePicture: "picture",
},
},
],
callbackUrls: [
"http://localhost:3000/",
// ★ 手順 2 で取得した Amplify URL を設定
"https://<your-app>.amplifyapp.com/",
],
logoutUrls: [
"http://localhost:3000/",
"https://<your-app>.amplifyapp.com/",
],
},
},
});
auth/resource.ts の変更を GitHub にプッシュして再デプロイを実行します。これで Cognito が OIDC プロキシの Discovery エンドポイントにアクセスできるようになり、X ログインが有効になります。
callbackUrls と logoutUrls の URL末尾の / を入力し忘れると、リダイレクト時に502エラーになります。忘れないように。
7. 動作確認
Discovery エンドポイントの確認
curl -s https://{api-id}.execute-api.{region}.amazonaws.com/prod/.well-known/openid-configuration | jq .
JWKS エンドポイントの確認
curl -s https://{api-id}.execute-api.{region}.amazonaws.com/prod/jwks | jq .
keys 配列に RSA 公開鍵が含まれていれば正常です。
ブラウザでの認証フロー確認
初回ログイン時に Read timed out エラーでログイン画面に戻される場合
Cognito は外部 OIDC プロバイダーのトークンエンドポイントに対して 約 5 秒のタイムアウト を持っています。初回認証時は Lambda Cold Start が発生するため、5 秒を超える可能性があります。2 回目以降は Lambda が Warm 状態なので正常にログインできます。
対策: Token Lambda のメモリサイズを増やすことで Cold Start 時間を短縮できます(CDK コンストラクトで memorySize: 1024 に設定推奨)。
Cognito ユーザーの確認
aws cognito-idp list-users \
--user-pool-id {user-pool-id} \
--filter "username ^= \"XTwitter_\"" \
--query "Users[].{Username:Username,Status:UserStatus}" \
--output table
コンソールで確認するとこんな感じ。メールアドレスの登録がない場合は@x-user.localという架空のメールアドレスで登録されています。

Dynamo DBの確認
DynamoDBにも中間データが登録されていることが確認できます。TTLにて10分後には自動削除されます。

お片付け
Amplify アプリの削除
AWS コンソール → Amplify → アプリを選択 → 「アプリの設定」→「全般」→「アプリを削除」
SSM パラメータの削除
aws ssm delete-parameter --name "/x-oidc-proxy/XOidcProxy/private-key"
aws ssm delete-parameter --name "/x-oidc-proxy/XOidcProxy/public-key"
X Developer Portal
アプリの「User authentication settings」から OAuth 2.0 を無効化するか、アプリ自体を削除します。
まとめ
X(旧Twitter)は OIDC に準拠していないため、Google や LINE と比べると実装の手間が大きくなります。GitHub の OIDC プロキシ(Lambda 4 本)と比較しても、X 固有の制約に対応するために Lambda 6 本 + DynamoDB が必要です。
特に以下の点がハマりポイントでした:
-
openidスコープの拒否(ドキュメントに明記なし) -
stateパラメータの長さ制限(ドキュメントに明記なし) - PKCE 必須だが Cognito は OIDC プロバイダーに PKCE を送信しない
Lambda 6 本 + DynamoDB という構成は一見複雑ですが、CDK コンストラクトとしてまとめることで再利用しやすくなっています。
ちょっと大変ではありますが、AWSリソースのみでXからのログイン機能を実装できることがわかりました。
また、本記事の実装コードは以下のリポジトリで公開しています。
シリーズ記事
- Google ログイン機能を追加
- LINE ログイン機能を追加
- GitHub ログイン機能を追加
- X (Twitter) ログイン機能を追加(本記事)










