0
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認証を追加してみよう! - GitHub ログイン機能を追加

0
Posted at

はじめに

Amplify Gen 2のCognito認証にGitHubアカウントでのOAuthログインを追加してみました。

GitHub は OIDC Discovery エンドポイントを公開していないため、Cognito のカスタム OIDC プロバイダーとして直接統合できません。そこで Lambda + API Gateway で OIDC 準拠のプロキシを構築し、Cognito 経由で GitHub ログインを実現しています。

本記事では、GitHub 用 OIDC プロキシの仕組みと実装手順を紹介します。

シリーズ記事

本記事は「Amplify Gen 2 に OAuth認証を追加してみよう!」シリーズの一つです。

プロバイダー 方式 記事
Google ネイティブ Google ログイン機能を追加
LINE カスタム OIDC LINE ログイン機能を追加
GitHub OIDC プロキシ 本記事
X (Twitter) OIDC プロキシ X ログイン機能を追加(近日公開)

なぜ OIDC プロキシが必要なのか

Google や LINE は OIDC に準拠しているため、Cognito に直接接続できます。しかし GitHub は OIDC Discovery エンドポイントを公開していないため、間にプロキシを挟む必要があります。

プロキシは Lambda + API Gateway で構成し、Cognito が期待する OIDC の各エンドポイント(Discovery、Token、UserInfo、JWKS)を提供します。

同じ OIDC プロキシのアプローチで X (Twitter) ログインも実現できますが、X は PKCE 必須・state 長さ制限・openid スコープ拒否など追加の制約があるため、より複雑な構成になります。詳しくは X 記事(近日公開)を参照してください。

OIDC プロキシとは

OIDC(OpenID Connect)プロキシとは、OIDC に非対応なサービスを あたかも OIDC プロバイダーであるかのように見せる中間サーバー です。

Cognito のカスタム OIDC プロバイダーは、接続先が以下のエンドポイントを持っていることを前提としています:

エンドポイント 役割
/.well-known/openid-configuration OIDC メタデータ(各エンドポイントの URL 一覧)
/token 認可コードをアクセストークン + ID トークンに交換
/userinfo アクセストークンからユーザー情報を返却
/jwks ID トークンの署名検証用公開鍵

GitHub はこれらを持っていないので、Lambda + API Gateway で「GitHub の OAuth 2.0 を裏で呼びつつ、Cognito には OIDC 準拠のレスポンスを返す」プロキシを構築します。

Cognito  ←→  OIDC プロキシ(Lambda)  ←→  GitHub OAuth 2.0
         OIDC 準拠                      GitHub 独自仕様

Cognito から見ると「普通の OIDC プロバイダー」に見え、GitHub から見ると「普通の OAuth クライアント」に見える。この橋渡しが OIDC プロキシの役割です。

Google の場合(OIDC 準拠 → プロキシ不要)

Google は OIDC に準拠しているため、Cognito が直接やり取りできます。

GitHub の場合(OIDC 非準拠 → プロキシが必要)

GitHub は OIDC に非対応のため、間に OIDC プロキシを挟んで Cognito が期待するレスポンスを生成しています。特に ID トークンの生成と署名 がプロキシの核心部分です。

アーキテクチャ

構成図

ブラウザ → Cognito → GitHub(認可リダイレクト、直接)
              ↓
         OIDC Proxy (API Gateway + Lambda)
              ├── Discovery Lambda    : OIDC メタデータ返却
              ├── Token Proxy Lambda  : トークン交換 + ID トークン生成
              ├── UserInfo Lambda     : ユーザー情報中継
              └── JWKS Lambda         : 公開鍵返却

使用する AWS リソース

リソース 用途
API Gateway REST API Cognito がアクセスする OIDC エンドポイント(4 ルート)。issuerUrl に設定する URL
Lambda × 4 各エンドポイントのハンドラー。GitHub API との通信と ID トークン生成を担当
SSM Parameter Store RSA 鍵ペア(秘密鍵: SecureString / 公開鍵: String)。ID トークンの署名と検証に使用

なぜ API Gateway + Lambda なのか

Cognito のカスタム OIDC プロバイダーは、issuerUrl に設定された URL に対して HTTPS でアクセスします。

  • LINE の場合: issuerUrl: "https://access.line.me" — LINE が OIDC エンドポイントを公開しているのでそのまま設定するだけ
  • GitHub の場合: GitHub は OIDC エンドポイントを公開していないので、自前で HTTPS エンドポイントを用意する必要がある

Lambda だけでは外部から HTTPS でアクセスできないため、API Gateway を入り口として配置しています。Cognito は以下のタイミングで API Gateway の URL にアクセスします:

  1. デプロイ時: issuerUrl/.well-known/openid-configuration にアクセスして OIDC メタデータを検証
  2. ログイン時: /token にトークン交換リクエストを送信
  3. ログイン時: /jwks から公開鍵を取得して ID トークンの署名を検証
  4. ログイン時: /userinfo からユーザー属性を取得

実装してみよう

前提条件

  • Node.js 22.x 以上
  • AWS CLI(設定済み)
  • GitHub のアカウント
  • Amplify Gen 2 プロジェクト(ベーステンプレートはこちら
    ※今回は、Amplify Gen 2 のクイックスタートを使いました

全体の流れ

  1. GitHub OAuth App の作成
  2. ソースコードの追加(backend.ts + プロキシ + フロントエンド)
  3. Amplify デプロイ(初回 → シークレット未設定で失敗)
  4. Amplify シークレット設定 & 再デプロイ(API Gateway URL 取得)
  5. RSA 署名鍵ペアの生成と SSM 登録
  6. auth/resource.ts 変更 & コールバック URL 設定 & 最終デプロイ
  7. 動作確認

1. GitHub OAuth App の作成

1.1 OAuth App の作成

  1. GitHub にサインイン
  2. 右上のプロフィールアイコン → 「Settings」
  3. 左メニューの「Developer settings」
  4. 「OAuth Apps」→「New OAuth App」
    AmplifyCognitoGithubSetting.png
  5. 以下を入力:
    • Application name: 任意のアプリ名(例: My App - GitHub Login
    • Homepage URL: 仮の値を入力(例: http://localhost:3000
    • Authorization callback URL: 仮の値を入力(例: http://localhost:3000
  6. 「Register application」をクリック
    AmplifyCognitoGithubInit.png

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

  1. 作成した OAuth App の設定ページを開く
  2. Client ID を控える(GITHUB_CLIENT_ID として使用)
  3. 「Generate a new client secret」をクリックし、Client Secret を控える(GITHUB_CLIENT_SECRET として使用)

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

AmplifyCognitoGithubSecret.png

1.3 必要なスコープ

スコープ 用途
read:user ユーザー情報(ID、ユーザー名、表示名、プロフィール画像)の取得
user:email ユーザーのメールアドレスの取得

2. ソースコードの追加

追加・変更するファイルの全体像です。
custom-resources/以下はすべて新規作成となります。

amplify/
  auth/resource.ts                          ← 変更(OIDC プロバイダー追加)
  backend.ts                                ← 変更(プロキシスタック追加)
  custom-resources/                         ← 新規作成
    github-oidc-proxy/
      construct.ts                          ← CDK コンストラクト
      handlers/
        discovery.ts                        ← Discovery Lambda
        token.ts                            ← Token Proxy Lambda
        userinfo.ts                         ← UserInfo Proxy Lambda
        jwks.ts                             ← JWKS Lambda
        types.ts                            ← 型定義
        utils/
          github-api.ts                     ← GitHub API クライアント
          jwt.ts                            ← JWT 生成・署名ユーティリティ

2.1 ライブラリの追加

まず、ルートの package.json に 今回必要なライブラリを追加します。

  • dependencies:
    jose — OIDC プロキシの JWT 署名/検証用
  • devDependencies:
    @types/node — amplify/ 内の Node.js コードの型定義
    tsx — @aws-amplify/backend-cli が内部的に必要
npm install jose
npm install -D @types/node tsx

2.2 バックエンド定義

amplify/backend.ts に GitHub OIDC プロキシの CDK スタックを追加します。

  • Amplify のシークレットを CDK トークンとして解決
  • GitHubOidcProxyConstruct に渡して API Gateway + Lambda を作成
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 { GitHubOidcProxyConstruct } from './custom-resources/github-oidc-proxy/construct';

const backend = defineBackend({
  auth,
  data,
});

// GitHub OIDC プロキシの CDK コンストラクトを追加
const githubProxyStack = backend.createStack('GitHubOidcProxyStack');

const githubBackendIdentifier = {
  namespace: githubProxyStack.node.getContext(CDKContextKey.BACKEND_NAMESPACE) as string,
  name: githubProxyStack.node.getContext(CDKContextKey.BACKEND_NAME) as string,
  type: githubProxyStack.node.getContext(CDKContextKey.DEPLOYMENT_TYPE) as 'sandbox' | 'branch',
};

const githubClientIdSecret = secret('GITHUB_CLIENT_ID');
const githubClientSecretSecret = secret('GITHUB_CLIENT_SECRET');

new GitHubOidcProxyConstruct(githubProxyStack as unknown as Construct, 'GitHubOidcProxy', {
  githubClientId: githubClientIdSecret.resolve(githubProxyStack, githubBackendIdentifier).unsafeUnwrap(),
  githubClientSecret: githubClientSecretSecret.resolve(githubProxyStack, githubBackendIdentifier).unsafeUnwrap(),
});

2.3 CDK コンストラクト

GitHub 用の OIDC プロキシは DynamoDB が不要で、Lambda も 4 本だけのシンプルな構成です。
amplify/custom-resources/github-oidc-proxy/construct.ts を編集。

construct.ts — CDK コンストラクト(クリックで展開)
/**
 * GitHub OIDC プロキシ CDK コンストラクト
 *
 * 作成するリソース:
 * - API Gateway REST API(4 エンドポイント)
 * - Lambda 関数 × 4(Discovery, Token, UserInfo, JWKS)
 * - IAM ロール(Lambda 用、SSM 読み取り権限)
 */

import { Construct } from 'constructs';
import {
  Duration,
  Fn,
  Stack,
  aws_lambda_nodejs as nodejs,
  aws_lambda as lambda,
  aws_apigateway as apigateway,
  aws_ssm as ssm,
} from 'aws-cdk-lib';
import * as path from 'path';
import { fileURLToPath } from 'url';

export interface GitHubOidcProxyConstructProps {
  githubClientId: string;
  githubClientSecret: string;
  keyId?: string;
}

export class GitHubOidcProxyConstruct extends Construct {
  public readonly issuerUrl: string;

  constructor(scope: Construct, id: string, props: GitHubOidcProxyConstructProps) {
    super(scope, id);

    const keyId = props.keyId ?? 'github-oidc-proxy-key-1';

    // ★ SSM Parameter Store パラメータ名
    // RSA 鍵ペアはセットアップ手順で手動登録する
    const privateKeyParamName = `/github-oidc-proxy/${id}/private-key`;
    const publicKeyParamName = `/github-oidc-proxy/${id}/public-key`;

    const privateKeyParam = ssm.StringParameter.fromSecureStringParameterAttributes(
      this, 'RsaPrivateKey', { parameterName: privateKeyParamName },
    );
    const publicKeyParam = ssm.StringParameter.fromStringParameterName(
      this, 'RsaPublicKey', publicKeyParamName,
    );

    // __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, 'GitHubOidcProxyApi', {
      restApiName: 'GitHub OIDC Proxy',
      description: 'OIDC proxy for GitHub 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,
    });

    // 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,
        GITHUB_CLIENT_ID: props.githubClientId,
        GITHUB_CLIENT_SECRET: props.githubClientSecret,
        SSM_PRIVATE_KEY_NAME: privateKeyParamName,
        KEY_ID: keyId,
      },
      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: SSM 読み取り権限
    privateKeyParam.grantRead(tokenFn);
    publicKeyParam.grantRead(jwksFn);

    // ★ API Gateway エンドポイント
    const wellKnown = api.root.addResource('.well-known');
    wellKnown.addResource('openid-configuration')
      .addMethod('GET', new apigateway.LambdaIntegration(discoveryFn));

    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));
  }
}

2.4 Lambda ハンドラー(ソースコード)

次に Lambda を作成していきます。各 Lambda の役割は次の通り。

ハンドラー 役割
discovery.ts OIDC メタデータを返却。authorization_endpoint に GitHub の認可 URL を直接指定
token.ts 認可コードで GitHub にトークン交換 → ユーザー情報 + メール取得 → ID トークン RS256 署名生成
userinfo.ts GitHub Users API + Emails API → OIDC UserInfo 形式に変換
jwks.ts SSM から RSA 公開鍵を取得して JWKS 形式で返却

以下にすべてのソースコードを掲載します。
amplify/custom-resources/github-oidc-proxy/handlers/配下の各ファイルにコピペします。

types.ts — 型定義(クリックで展開)
/**
 * GitHub OIDC プロキシ - 型定義
 */

// GitHub Users API レスポンス(GET /user)
export interface GitHubUserResponse {
  id: number;
  login: string;
  name: string | null;
  avatar_url: string;
}

// GitHub User Emails API レスポンスの各要素(GET /user/emails)
export interface GitHubEmail {
  email: string;
  primary: boolean;
  verified: boolean;
  visibility: string | null;
}

// GitHub トークンエンドポイントレスポンス
export interface GitHubTokenResponse {
  access_token: string;
  token_type: string;
  scope: string;
}

// ID トークン(JWT)ペイロード — Cognito に返却する OIDC 準拠トークン
export interface IdTokenPayload {
  sub: string;       // GitHub ユーザー ID(文字列化)
  iss: string;       // プロキシの API Gateway URL
  aud: string;       // GitHub の client_id
  exp: number;       // 有効期限(UNIX タイムスタンプ)
  iat: number;       // 発行時刻
  email?: string;    // GitHub のプライマリメール(取得失敗時はフォールバック)
  preferred_username?: string;  // GitHub ログイン名
  name?: string;     // 表示名
  picture?: string;  // アバター URL
}

// 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/github-api.ts — GitHub API クライアント(クリックで展開)
import type {
  GitHubTokenResponse,
  GitHubUserResponse,
  GitHubEmail,
} from '../types.js';

const GITHUB_TOKEN_ENDPOINT = 'https://github.com/login/oauth/access_token';
const GITHUB_USERS_ENDPOINT = 'https://api.github.com/user';
const GITHUB_USER_EMAILS_ENDPOINT = 'https://api.github.com/user/emails';
const REQUEST_TIMEOUT_MS = 10_000;
// GitHub API は User-Agent ヘッダーが必須
const USER_AGENT = 'github-oidc-proxy';

/**
 * 認可コードを GitHub トークンエンドポイントに送信してアクセストークンを取得する。
 * ポイント: GitHub は Basic 認証ではなく POST ボディでクライアント認証を行う。
 */
export async function exchangeToken(
  code: string,
  redirectUri: string,
  clientId: string,
  clientSecret: string,
): Promise<GitHubTokenResponse> {
  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: redirectUri,
    client_id: clientId,
    client_secret: clientSecret,
  });

  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);

  try {
    const response = await fetch(GITHUB_TOKEN_ENDPOINT, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        // Accept: application/json を指定しないと form-urlencoded で返ってくる
        Accept: 'application/json',
        'User-Agent': USER_AGENT,
      },
      body: body.toString(),
      signal: controller.signal,
    });

    if (!response.ok) {
      const errorBody = await response.text();
      throw new Error(
        `GitHub token endpoint returned HTTP ${response.status}: ${errorBody}`,
      );
    }

    return (await response.json()) as GitHubTokenResponse;
  } finally {
    clearTimeout(timeoutId);
  }
}

/** GitHub Users API からユーザー情報を取得する */
export async function fetchUserInfo(
  accessToken: string,
): Promise<GitHubUserResponse> {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);

  try {
    const response = await fetch(GITHUB_USERS_ENDPOINT, {
      method: 'GET',
      headers: {
        Authorization: `Bearer ${accessToken}`,
        Accept: 'application/json',
        'User-Agent': USER_AGENT,
      },
      signal: controller.signal,
    });

    if (!response.ok) {
      const errorBody = await response.text();
      throw new Error(
        `GitHub Users API returned HTTP ${response.status}: ${errorBody}`,
      );
    }

    return (await response.json()) as GitHubUserResponse;
  } finally {
    clearTimeout(timeoutId);
  }
}

/** GitHub User Emails API からメールアドレス一覧を取得する */
export async function fetchUserEmails(
  accessToken: string,
): Promise<GitHubEmail[]> {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);

  try {
    const response = await fetch(GITHUB_USER_EMAILS_ENDPOINT, {
      method: 'GET',
      headers: {
        Authorization: `Bearer ${accessToken}`,
        Accept: 'application/json',
        'User-Agent': USER_AGENT,
      },
      signal: controller.signal,
    });

    if (!response.ok) {
      const errorBody = await response.text();
      throw new Error(
        `GitHub User Emails API returned HTTP ${response.status}: ${errorBody}`,
      );
    }

    return (await response.json()) as GitHubEmail[];
  } finally {
    clearTimeout(timeoutId);
  }
}

/**
 * メールアドレス一覧から最適なメールを選択する。
 * 優先順位: primary+verified > verified > フォールバック({login}@github-user.local)
 */
export function selectPrimaryEmail(emails: GitHubEmail[], login: string): string {
  const primaryVerified = emails.find(
    (e) => e.primary === true && e.verified === true,
  );
  if (primaryVerified) return primaryVerified.email;

  const verified = emails.find((e) => e.verified === true);
  if (verified) return verified.email;

  return `${login}@github-user.local`;
}
utils/jwt.ts — JWT 生成・署名ユーティリティ(クリックで展開)
import { SignJWT, importJWK } from 'jose';
import type { IdTokenPayload, JwkKey } from '../types.js';

/**
 * ID トークン(JWT)を RS256 署名で生成する。
 * ★ GitHub は 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,
  };
}
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,
    // ★ ポイント: GitHub の認可 URL を直接指定(Authorize Proxy 不要)
    // X プロキシではプロキシ自身の /authorize を指定していたが、
    // GitHub は PKCE 不要 & state 長さ制限なしのため直接リダイレクト可能
    authorization_endpoint: 'https://github.com/login/oauth/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', 'read:user', 'user:email'],
    token_endpoint_auth_methods_supported: ['client_secret_post'],
    claims_supported: [
      'sub', 'iss', 'aud', 'exp', 'iat',
      'preferred_username', 'name', 'picture', 'email',
    ],
  };

  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(metadata),
  };
};
token.ts — Token Proxy Lambda(クリックで展開)
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import { SSMClient, GetParameterCommand } from '@aws-sdk/client-ssm';
import type { IdTokenPayload } from './types.js';
import {
  exchangeToken,
  fetchUserInfo,
  fetchUserEmails,
  selectPrimaryEmail,
} from './utils/github-api.js';
import { generateIdToken } from './utils/jwt.js';

const ssmClient = new SSMClient({});

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 githubClientId = process.env.GITHUB_CLIENT_ID ?? '';
  const githubClientSecret = process.env.GITHUB_CLIENT_SECRET ?? '';
  const ssmPrivateKeyName = process.env.SSM_PRIVATE_KEY_NAME ?? '';
  const keyId = process.env.KEY_ID ?? '';

  // 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'] || githubClientId;
  const clientSecret = params['client_secret'] || githubClientSecret;

  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. GitHub にトークン交換(PKCE 不要なので code_verifier なし)
  const tokenResponse = await exchangeToken(code, redirectUri, clientId, clientSecret);
  const accessToken = tokenResponse.access_token;

  // 3. GitHub Users API からユーザー情報取得
  const userInfo = await fetchUserInfo(accessToken);

  // 4. GitHub User Emails API からメールアドレス取得
  //    ★ X プロキシとの違い: GitHub は実メールを取得可能
  let email: string;
  try {
    const emails = await fetchUserEmails(accessToken);
    email = selectPrimaryEmail(emails, userInfo.login);
  } catch {
    // メール取得失敗時はフォールバック(Cognito の email 必須制約を満たすため)
    email = `${userInfo.login}@github-user.local`;
  }

  // 5. SSM から RSA 秘密鍵を取得(ID トークン署名用)
  const ssmResponse = await ssmClient.send(
    new GetParameterCommand({ Name: ssmPrivateKeyName, WithDecryption: true }),
  );
  const privateKeyJwk = JSON.parse(ssmResponse.Parameter?.Value ?? '') as JsonWebKey;

  // 6. ID トークン生成(RS256 署名)
  //    ★ GitHub は ID トークンを返さないため、プロキシ側で生成する
  const now = Math.floor(Date.now() / 1000);
  const idTokenPayload: IdTokenPayload = {
    sub: String(userInfo.id),
    iss: issuerUrl,
    aud: clientId,
    exp: now + 3600,
    iat: now,
    email,
    preferred_username: userInfo.login,
    name: userInfo.name ?? undefined,
    picture: userInfo.avatar_url,
  };

  const idToken = await generateIdToken(idTokenPayload, privateKeyJwk, keyId);

  // 7. Cognito に OIDC トークンレスポンスを返却
  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      access_token: accessToken,
      token_type: 'bearer',
      id_token: idToken,
      scope: tokenResponse.scope || 'read:user,user:email',
    }),
  };
};
userinfo.ts — UserInfo Proxy Lambda(クリックで展開)
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
import type { GitHubUserResponse } from './types.js';
import { fetchUserInfo, fetchUserEmails, selectPrimaryEmail } from './utils/github-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' }),
    };
  }

  // GitHub Users API からユーザー情報を取得
  const ghUser: GitHubUserResponse = await fetchUserInfo(accessToken);

  // GitHub User Emails API からメールアドレスを取得
  let email: string;
  try {
    const emails = await fetchUserEmails(accessToken);
    email = selectPrimaryEmail(emails, ghUser.login);
  } catch {
    email = `${ghUser.login}@github-user.local`;
  }

  // ★ OIDC UserInfo 形式に変換して返却
  // Cognito はこのレスポンスを attributeMapping に従ってユーザー属性にマッピングする
  const userInfo: Record<string, string | undefined> = {
    sub: String(ghUser.id),
    preferred_username: ghUser.login,
    email,
  };

  if (ghUser.name !== null && ghUser.name !== undefined) {
    userInfo.name = ghUser.name;
  }
  if (ghUser.avatar_url) {
    userInfo.picture = ghUser.avatar_url;
  }

  return {
    statusCode: 200,
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(userInfo),
  };
};
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),
  };
};

2.5 フロントエンド(ログインボタン)

Google のようなネイティブプロバイダーは Authenticator コンポーネントが自動でログインボタンを表示してくれますが、カスタム OIDC プロバイダーを追加する際は自分でボタンを実装する必要があります

OAuth リスナーの有効化

Amplify を初期化している src/main.tsx に以下の import を追加します。

main.tsx(追加)
import "aws-amplify/auth/enable-oauth-listener";

ログインボタンの実装

signInWithRedirect を使って、providerauth/resource.ts で設定した name を指定します。Authenticator コンポーネントの components prop を使って、サインイン画面のフッターにボタンを追加できます。

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>();

function GitHubLoginButton() {
  const handleClick = () => {
    signInWithRedirect({ provider: { custom: "GitHub" } });
  };

  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: "#24292e",
        color: "#fff",
        border: "none",
        borderRadius: "4px",
        fontSize: "1rem",
        fontWeight: 500,
        cursor: "pointer",
      }}
    >
      Login with GitHub
    </button>
  );
}

const authenticatorComponents = {
  SignIn: {
    Footer() {
      return <GitHubLoginButton />;
    },
  },
};

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;

3. Amplify デプロイ & シークレット設定

ソースコードを GitHub にプッシュし、Amplify Hosting に接続してデプロイします。

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

AmplifyCognitoGithubDeploy.png

続いて、急いでシークレットを設定します。
Amplify コンソール → アプリ設定 →「シークレット」から以下を登録します。

変数
GITHUB_CLIENT_ID 手順 1 で取得した Client ID
GITHUB_CLIENT_SECRET 手順 1 で取得した Client Secret

AmplifyCognitoGithubAmplifySecret.png

シークレットが未設定のままだと、ビルドエラーで失敗するため、急いでシークレットを設定する必要があります。
間に合わずに失敗した場合は、デプロイ画面から「このバージョンを再デプロイ」を実行します。

4. API Gateway URL 取得

デプロイ完了後、API Gateway URL を確認します:

aws cloudformation describe-stacks \
  --query "Stacks[?contains(StackName, 'GitHubOidcProxyStack')].Outputs" \
  --output table

URL は以下の形式です:

https://{api-id}.execute-api.{region}.amazonaws.com/prod

5. RSA 署名鍵ペアの生成と SSM 登録

Token Proxy Lambda が ID トークンを RS256 で署名するため、RSA 鍵ペアが必要です。SSM Parameter Store のパラメータ名はソースコード(CDK コンストラクト)で /github-oidc-proxy/GitHubOidcProxy/private-key/github-oidc-proxy/GitHubOidcProxy/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));"

実行すると、以下のように生成されます。
AmplifyCognitoGithubKeyPair.png

5.2 SSM Parameter Store への登録

生成した JWK をファイルに保存してから SSM に登録します。

# 秘密鍵をファイルに保存
cat > /tmp/github-oidc-private-key.json << 'EOF'
{"kty":"RSA","n":"...生成した値...","e":"...","d":"...","p":"...","q":"...","dp":"...","dq":"...","qi":"..."}
EOF

# 公開鍵をファイルに保存
cat > /tmp/github-oidc-public-key.json << 'EOF'
{"kty":"RSA","n":"...生成した値...","e":"..."}
EOF

# SSM に登録
aws ssm put-parameter \
  --name "/github-oidc-proxy/GitHubOidcProxy/private-key" \
  --type SecureString \
  --value "file:///tmp/github-oidc-private-key.json" \
  --overwrite

aws ssm put-parameter \
  --name "/github-oidc-proxy/GitHubOidcProxy/public-key" \
  --type String \
  --value "file:///tmp/github-oidc-public-key.json" \
  --overwrite

# 一時ファイルの削除
rm /tmp/github-oidc-private-key.json /tmp/github-oidc-public-key.json

5.3 登録確認

aws ssm get-parameter \
  --name "/github-oidc-proxy/GitHubOidcProxy/public-key" \
  --query "Parameter.Value" \
  --output text

6. OIDC プロバイダー設定 & 再デプロイ

auth/resource.ts を変更し、手順 3 で生成された Amplify ドメインの URL と、手順 4 で取得した API Gateway URL を設定します。

amplify/auth/resource.ts
import { defineAuth, secret } from "@aws-amplify/backend";

export const auth = defineAuth({
  loginWith: {
    email: true,
    externalProviders: {
      oidc: [
        {
          name: "GitHub",
          clientId: secret("GITHUB_CLIENT_ID"),
          clientSecret: secret("GITHUB_CLIENT_SECRET"),
          // ★ 手順 4 で取得した API Gateway URL を設定
          issuerUrl: "https://{api-id}.execute-api.{region}.amazonaws.com/prod",
          scopes: ["openid", "read:user", "user:email"],
          attributeMapping: {
            email: "email",
            preferredUsername: "preferred_username",
            profilePicture: "picture",
          },
        },
      ],
      callbackUrls: [
        "http://localhost:3000/",
        // ★ 手順 3 で生成された Amplify ドメインの URL を設定
        "https://<your-app>.amplifyapp.com/",
      ],
      logoutUrls: [
        "http://localhost:3000/",
        // ★ 手順 3 で生成された Amplify ドメインの URL を設定
        "https://<your-app>.amplifyapp.com/",
      ],
    },
  },
});

auth/resource.ts の変更を GitHub にプッシュして最終デプロイを実行します。これで Cognito が OIDC プロキシの Discovery エンドポイントにアクセスできるようになり、GitHub ログインが有効になります。

7. GitHub OAuth App のコールバック URL 設定

Amplify の再デプロイが完了すると、Cognito ドメインが設定されるので、それを用いて GitHub のコールバック URL を設定します。
GitHub OAuth App に設定するコールバック URL は Cognito の oauth2/idpresponse エンドポイントです。

https://{cognito-domain}.auth.{region}.amazoncognito.com/oauth2/idpresponse

AmplifyCognitoGithubCognitoDomain.png

コールバック URL はプロキシの /callback ではなく、Cognito の oauth2/idpresponse を直接設定します。GitHub は PKCE を要求せず、state パラメータの長さ制限もないため、Cognito が GitHub の認可エンドポイントに直接リダイレクトでき、プロキシ側でコールバックを受け取る必要がないからです。

GitHub → Settings → Developer settings → OAuth Apps で作成したアプリを開き、「Homepage URL」に Amplify URLを、「Authorization callback URL」に Cognito のコールバック URL を設定して「Update application」をクリックします。

AmplifyCognitoGithubModifySetting.png

8. 動作確認

Discovery エンドポイントの確認

curl -s https://{api-id}.execute-api.{region}.amazonaws.com/prod/.well-known/openid-configuration | jq .

以下のような JSON が返れば正常です。

{
  "issuer": "https://{api-id}.execute-api.{region}.amazonaws.com/prod",
  "authorization_endpoint": "https://github.com/login/oauth/authorize",
  "token_endpoint": "https://{api-id}.execute-api.{region}.amazonaws.com/prod/token",
  "userinfo_endpoint": "https://{api-id}.execute-api.{region}.amazonaws.com/prod/userinfo",
  "jwks_uri": "https://{api-id}.execute-api.{region}.amazonaws.com/prod/jwks",
  "response_types_supported": ["code"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"]
}

JWKS エンドポイントの確認

以下を実行して、設定済みの鍵情報が表示されればOK。

curl -s https://{api-id}.execute-api.{region}.amazonaws.com/prod/jwks | jq .

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

  1. ブラウザで Amplify URL にアクセスし、Login with GitHub をクリック
    AmplifyCognitoGithubLogin1.png

  2. GitHub の認証画面で認可
    AmplifyCognitoGithubLogin2.png

  3. 認証後、アプリに戻ることを確認(Amplifyクイックスタートのサンプル画面が表示)
    AmplifyCognitoGithubLogin3.png

初回ログイン時に Read timed out エラーでログイン画面に戻される場合

Cognito は外部 OIDC プロバイダーのトークンエンドポイントに対して 約 5 秒のタイムアウト を持っています。初回認証時は Lambda Cold Start が発生するため、以下の処理が直列で実行されると 5 秒を超える可能性があります:

  1. Lambda Cold Start(ランタイム初期化)
  2. GitHub へのトークン交換 API 呼び出し
  3. GitHub へのユーザー情報 API 呼び出し
  4. GitHub へのメール取得 API 呼び出し
  5. SSM から RSA 秘密鍵の取得
  6. ID トークンの署名生成

2 回目以降は Lambda が Warm 状態なので正常にログインできます。

対策: Token Lambda のメモリサイズを増やすことで Cold Start 時間を短縮できます(本記事の CDK コンストラクトでは memorySize: 1024 に設定済み)。メモリを増やすと CPU 割り当ても比例して増えるため、初期化と処理の両方が高速化します。

Cognito ユーザーの確認

Cognito User Pool のユーザ一覧で、GitHubで作成されたユーザが確認できます。
AmplifyCognitoGithubCognitoUserpool.png

トラブルシューティング

GitHub の認証画面にリダイレクトされない

  • amplify/auth/resource.tsissuerUrl が正しい API Gateway URL に設定されているか確認
  • 2 回目のデプロイが完了しているか確認
  • Cognito コンソールで OIDC プロバイダー「GitHub」が正しく登録されているか確認

JWKS エンドポイントが PLACEHOLDER を返す

SSM Parameter Store に RSA 公開鍵が登録されていません。手順 3 を実行してください。

認証後にアプリに戻らない

  • GitHub OAuth App のコールバック URL が Cognito の oauth2/idpresponse URL と一致しているか確認
  • amplify/auth/resource.tscallbackUrlshttp://localhost:3000/ が含まれているか確認

トークン交換エラー

CloudWatch Logs で Token Proxy Lambda のログを確認します。

aws logs tail /aws/lambda/{function-name} --since 1h --format short
エラー 原因 対処
bad_verification_code 認可コードの期限切れまたは再利用 再度ログインを試行
incorrect_client_credentials Client ID / Secret が不正 Amplify シークレットの値を再確認
redirect_uri_mismatch コールバック URL の不一致 GitHub OAuth App のコールバック URL を確認
SSM GetParameter failed SSM から秘密鍵を取得できない パラメータ名と IAM 権限を確認

お片付け

Amplify アプリの削除

アプリケーションの設定 → 全般設定 → アプリの削除 から作成したアプリケーションを削除します。

SSM パラメータの削除

aws ssm delete-parameter --name "/github-oidc-proxy/GitHubOidcProxy/private-key"
aws ssm delete-parameter --name "/github-oidc-proxy/GitHubOidcProxy/public-key"

GitHub OAuth App の削除

GitHub → Settings → Developer settings → OAuth Apps で作成したアプリを開き、「Delete application」をクリックします。

まとめ

GitHub は OIDC に非対応ですが、Lambda + API Gateway で OIDC プロキシを構築することで Cognito と統合できます。

項目 内容
Lambda 4 本(Discovery / Token / UserInfo / JWKS)
DynamoDB 不要
メール GitHub の実メールを取得可能
コールバック URL Cognito の oauth2/idpresponse を直接設定
費用 GitHub OAuth App は無料

OIDC プロキシのパターンを理解すれば、他の OIDC 非対応プロバイダーにも応用できます。CDK コンストラクトとしてまとめておくことで、プロジェクト間での再利用も容易です。

なお、Okta や Auth0 を間に挟めば OIDC プロキシを自前で構築せずに済みます。複数の OIDC 非対応プロバイダーを束ねたい場合や運用負荷を下げたい場合は検討してみてください。

本記事の実装コードは以下のリポジトリで公開していますが、コピペで真似できますので、皆さんもいちどご自身で試してみてください。

シリーズ記事

参考リンク

0
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
0
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?