はじめに
Amplify Gen 2のCognito認証にGitHubアカウントでのOAuthログインを追加してみました。
GitHub は OIDC Discovery エンドポイントを公開していないため、Cognito のカスタム OIDC プロバイダーとして直接統合できません。そこで Lambda + API Gateway で OIDC 準拠のプロキシを構築し、Cognito 経由で GitHub ログインを実現しています。
本記事では、GitHub 用 OIDC プロキシの仕組みと実装手順を紹介します。
シリーズ記事
本記事は「Amplify Gen 2 に OAuth認証を追加してみよう!」シリーズの一つです。
| プロバイダー | 方式 | 記事 |
|---|---|---|
| ネイティブ | 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 にアクセスします:
-
デプロイ時:
issuerUrl/.well-known/openid-configurationにアクセスして OIDC メタデータを検証 -
ログイン時:
/tokenにトークン交換リクエストを送信 -
ログイン時:
/jwksから公開鍵を取得して ID トークンの署名を検証 -
ログイン時:
/userinfoからユーザー属性を取得
実装してみよう
前提条件
- Node.js 22.x 以上
- AWS CLI(設定済み)
- GitHub のアカウント
- Amplify Gen 2 プロジェクト(ベーステンプレートはこちら)
※今回は、Amplify Gen 2 のクイックスタートを使いました
全体の流れ
- GitHub OAuth App の作成
- ソースコードの追加(
backend.ts+ プロキシ + フロントエンド) - Amplify デプロイ(初回 → シークレット未設定で失敗)
- Amplify シークレット設定 & 再デプロイ(API Gateway URL 取得)
- RSA 署名鍵ペアの生成と SSM 登録
-
auth/resource.ts変更 & コールバック URL 設定 & 最終デプロイ - 動作確認
1. GitHub OAuth App の作成
1.1 OAuth App の作成
- GitHub にサインイン
- 右上のプロフィールアイコン → 「Settings」
- 左メニューの「Developer settings」
- 「OAuth Apps」→「New OAuth App」
- 以下を入力:
-
Application name: 任意のアプリ名(例:
My App - GitHub Login) -
Homepage URL: 仮の値を入力(例:
http://localhost:3000) -
Authorization callback URL: 仮の値を入力(例:
http://localhost:3000)
-
Application name: 任意のアプリ名(例:
- 「Register application」をクリック
1.2 クライアント認証情報の取得
- 作成した OAuth App の設定ページを開く
-
Client ID を控える(
GITHUB_CLIENT_IDとして使用) - 「Generate a new client secret」をクリックし、Client Secret を控える(
GITHUB_CLIENT_SECRETとして使用)
Client Secret は作成時にのみ表示されます。紛失した場合は再生成が必要です。
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 を作成
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 を追加します。
import "aws-amplify/auth/enable-oauth-listener";
ログインボタンの実装
signInWithRedirect を使って、provider に auth/resource.ts で設定した name を指定します。Authenticator コンポーネントの components prop を使って、サインイン画面のフッターにボタンを追加できます。
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 に接続してデプロイします。
- AWS コンソール → Amplify → 「新しいアプリ」→ GitHub リポジトリを接続
- ブランチ
mainを選択 - デプロイを実行
続いて、急いでシークレットを設定します。
Amplify コンソール → アプリ設定 →「シークレット」から以下を登録します。
| 変数 | 値 |
|---|---|
GITHUB_CLIENT_ID |
手順 1 で取得した Client ID |
GITHUB_CLIENT_SECRET |
手順 1 で取得した Client Secret |
シークレットが未設定のままだと、ビルドエラーで失敗するため、急いでシークレットを設定する必要があります。
間に合わずに失敗した場合は、デプロイ画面から「このバージョンを再デプロイ」を実行します。
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));"
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 を設定します。
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
コールバック 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」をクリックします。
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 .
ブラウザでの認証フロー確認
初回ログイン時に Read timed out エラーでログイン画面に戻される場合
Cognito は外部 OIDC プロバイダーのトークンエンドポイントに対して 約 5 秒のタイムアウト を持っています。初回認証時は Lambda Cold Start が発生するため、以下の処理が直列で実行されると 5 秒を超える可能性があります:
- Lambda Cold Start(ランタイム初期化)
- GitHub へのトークン交換 API 呼び出し
- GitHub へのユーザー情報 API 呼び出し
- GitHub へのメール取得 API 呼び出し
- SSM から RSA 秘密鍵の取得
- ID トークンの署名生成
2 回目以降は Lambda が Warm 状態なので正常にログインできます。
対策: Token Lambda のメモリサイズを増やすことで Cold Start 時間を短縮できます(本記事の CDK コンストラクトでは memorySize: 1024 に設定済み)。メモリを増やすと CPU 割り当ても比例して増えるため、初期化と処理の両方が高速化します。
Cognito ユーザーの確認
Cognito User Pool のユーザ一覧で、GitHubで作成されたユーザが確認できます。

トラブルシューティング
GitHub の認証画面にリダイレクトされない
-
amplify/auth/resource.tsのissuerUrlが正しい API Gateway URL に設定されているか確認 - 2 回目のデプロイが完了しているか確認
- Cognito コンソールで OIDC プロバイダー「GitHub」が正しく登録されているか確認
JWKS エンドポイントが PLACEHOLDER を返す
SSM Parameter Store に RSA 公開鍵が登録されていません。手順 3 を実行してください。
認証後にアプリに戻らない
- GitHub OAuth App のコールバック URL が Cognito の
oauth2/idpresponseURL と一致しているか確認 -
amplify/auth/resource.tsのcallbackUrlsにhttp://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 非対応プロバイダーを束ねたい場合や運用負荷を下げたい場合は検討してみてください。
本記事の実装コードは以下のリポジトリで公開していますが、コピペで真似できますので、皆さんもいちどご自身で試してみてください。
シリーズ記事
- Google ログイン機能を追加
- LINE ログイン機能を追加
- X (Twitter) ログイン機能を追加
- GitHub ログイン機能を追加(本記事)








