1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Amplify Gen 2 に OAuth認証を追加してみよう! - X (Twitter) ログイン機能を追加

1
Posted at

はじめに

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 ネイティブ 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 が追加で必要です。

  1. Cognito → Authorize Proxy: openid スコープ除去、PKCE 生成、長い state を短い UUID に置換、redirect_uri をプロキシの /callback に差替え。すべて DynamoDB に保存
  2. Authorize Proxy → X 認可画面: 短い state + code_challenge + プロキシの /callback で X にリダイレクト
  3. X → Callback Proxy: code と短い state を受信。DynamoDB から元の state と code_verifier を取得。Cognito にリダイレクト
  4. Cognito → Token Proxy: code で DynamoDB から code_verifier を取得。X にトークン交換。ユーザー情報取得後、ID トークンを RS256 署名で生成して返却
  5. Cognito → UserInfo Proxy: X Users API からユーザー情報を取得し、OIDC 形式に変換
  6. 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 プロジェクト(ベーステンプレートはこちら)

全体の流れ

  1. ソースコードの追加(backend.ts + プロキシ + フロントエンド)
  2. Amplify デプロイ(API Gateway URL + Amplify URL 取得)
  3. X Developer Portal でアプリ作成 & OAuth 有効化
  4. Amplify シークレット設定
  5. RSA 署名鍵ペアの生成と SSM 登録
  6. OIDC プロバイダー設定 & 最終デプロイ
  7. 動作確認

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 スタックを追加します。

amplify/backend.ts
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 リスナーを有効化しています。

src/main.tsx
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 への組み込みを追加しています。

src/App.tsx
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 に接続してデプロイします。

  1. AWS コンソール → Amplify → 「新しいアプリ」→ GitHub リポジトリを接続
  2. ブランチ main を選択
  3. デプロイを実行

AmplifyCognitoXtwitterDeploy.png

デプロイが開始されたら、すぐに Amplify コンソール → アプリ設定 →「シークレット」から以下をダミー値で登録します(X Developer Portal の設定はまだ完了していないため)。

変数 値
X_CLIENT_ID dummy(手順 3 で取得後に更新)
X_CLIENT_SECRET dummy(手順 3 で取得後に更新)

AmplifyCognitoXtwitterSecretDummy.png

シークレットが未設定だとビルドエラーになるため、ダミー値でも先に登録しておく必要があります。デプロイに失敗した場合は、シークレット設定後に「このバージョンを再デプロイ」でデプロイします。

デプロイが成功すると、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 プロジェクトとアプリの作成

  1. X Developer Portal にサインイン
  2. 「アプリ」を開いて右上の「+」から新しいクライアントアプリケーションを作成
    AmplifyCognitoXtwitterDevPortalTop.png
  3. アプリケーション名と環境を入力してアプリを作成
    AmplifyCognitoXtwitterDevPortalNewApp.png
    • アプリケーション名:任意のアプリ名(例: My App - X Login)
    • 環境(Environment):なんでも。迷ったらDevelopment

3.2 OAuth 2.0 の有効化

  1. 作成したアプリを開く
  2. 「ユーザー認証設定」の「セットアップ」をクリック
    AmplifyCognitoXtwitterDevPortalSetting.png
  3. 以下を設定:
    • アプリの権限: 「読む(Read)」を選択
    • Request email from users: トグルをオン
      AmplifyCognitoXtwitterDevPortalAppAuth.png
    • アプリの種類: 「ウェブアプリ、自動化アプリまたはボット」を選択
      AmplifyCognitoXtwitterDevPortalAppType.png
    • Callback URI / Redirect URL: 手順 2 で取得した API Gateway URL + /callback
    • Website URL: 手順 2 で作成した Amplify URL
    • 利用規約: Amplify URL + /terms
    • プライバシーポリシー: Amplify URL + /privacy
      AmplifyCognitoXtwitterDevPortalAppURLs.png
  4. 「変更を保存する」をクリック

コールバック URL は Cognito の oauth2/idpresponse ではなく、プロキシの /callback を設定します。X からのコールバックはプロキシが受け取り、state パラメータを復元してから Cognito にリダイレクトする必要があるためです。

3.3 クライアント認証情報の取得

OAuth 2.0 有効化後に表示される以下を控えます:

  • Client ID(X_CLIENT_ID として使用)
  • Client Secret(X_CLIENT_SECRET として使用)

AmplifyCognitoXtwitterDevPortalSecret.png

Client Secret は作成時にのみ表示されます。紛失した場合は再生成が必要です。

4. Amplify シークレット更新

手順 2 でダミー値を設定したシークレットを、手順 3 で取得した実際の値に更新します。

Amplify コンソール → アプリ設定 →「シークレット」から以下を更新:

変数 値
X_CLIENT_ID 手順 3 で取得した Client ID
X_CLIENT_SECRET 手順 3 で取得した Client Secret

AmplifyCognitoSecretUpdate.png

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 を設定します。

amplify/auth/resource.ts
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 公開鍵が含まれていれば正常です。

ブラウザでの認証フロー確認

  1. ブラウザで Amplify URL にアクセス
  2. Login with Xボタンをクリック
    AmplifyCognitoXtwitterTestLogin.png
  3. X の認証画面で認可
    AmplifyCognitoXtwitterTestAuth.png
  4. 認証後、アプリに戻ることを確認
    AmplifyCognitoXtwitterTestAppScreen.png

初回ログイン時に 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という架空のメールアドレスで登録されています。
AmplifyCognitoXtwitterTestCognito.png

Dynamo DBの確認

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

お片付け

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からのログイン機能を実装できることがわかりました。

また、本記事の実装コードは以下のリポジトリで公開しています。

シリーズ記事

参考リンク

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?