目次
#1.はじめに
#2.環境
#3.要件定義
#4.システム構成
#5.環境構築と実際の画面
#6.おわりに
1.はじめに
何ヶ月か前の話かつ私事となりますが、先日子供が生まれました!
妻の妊娠中に何かお祝いのためにできることがないか考え、Kiroを活用してアニバーサリーサイトを作成してみました。
本記事はアニバーサリーサイト作成までの要件整理や構成内容についてご紹介します。
2.環境
OS:macOS
IDE:Kiro(モデルAuto)
KiroではClaudeなどモデルを個別に指定することができますが、Autoモードにしてモデルの選択をお任せするとクレジット倍率を1.0xにして消費クレジットを節約することが可能です。
今回は無料枠で開発を行うためにAutoで利用しています
参考ですが以前Windowsの環境でインストールからタスク管理サイトの作成までを実施した記事は以下となります。
https://qiita.com/ss_IT_study/items/2bc4a34c0f2df79dde7b
3.要件定義
Kiroは今までのバイブコーディングと違いスペック駆動開発が可能という特徴があります。
今回要件はKiroと壁打ちしながら整理をしたかったので、スペックモードでの開発を行いました。
Kiroのスペック駆動開発では、ユーザーとKiroで会話を行いながら、requirements.mdというファイルに要件を書き出していきます。その後、design.mdに設計を書き出し、最後にtasks.mdに実装、テストなどのタスクを書き出して、開発を進めていきます。
今回の要件定義ではまず実現したい機能要件を伝えたところ、s3の署名つきURLでの実装を提案されました。個人サイトなのでケースによってはこれでもOKなのですが、私は念の為セキュリティも強化したいという要件を伝え、認証機能や境界防御も含むように要件定義を進めていきました。
具体的には以下のような変更が発生しています。
-
署名付きURL
→URLを知っている人ならアクセスできる
→アカウント管理をして認証機能を追加したい
→Cognitoを使って認証付きに変更 -
境界防御なし
→インターネットに公開するため最低限のWAFを採用
4.システム構成
要件定義の結果、以下の構成でサイトを作成しました。
このmermaid形式の構成図や認証フローも要件定義の内容を元にKiroが作成してくれています。
システム構成図
認証フロー詳細
利用サービス一覧
| サービス | リージョン | 用途 |
|---|---|---|
| Amazon S3 | ap-northeast-1(東京) | 静的ファイル(HTML / CSS / JS / 画像)のホスティング。OACで直接アクセスを遮断 |
| Amazon CloudFront | Global | CDN。S3へのアクセスを OAC 経由に集約し、Lambda@Edge を Viewer Request でフック |
| AWS Lambda@Edge | us-east-1(バージニア北部) | CloudFront の Viewer Request を横断するエッジ関数。JWT 検証・OAuth2 コールバック処理・ログアウト処理を担当 |
| Amazon Cognito | us-east-1(バージニア北部) | ユーザープール管理・OAuth2 / OIDC 認証基盤。家族アカウントの管理と JWT 発行を担当 |
| AWS WAF | Global(CloudFront に紐付け) | レートベースルール(5分間100リクエスト上限)でブルートフォース・DDoS 攻撃を緩和 |
| AWS Budgets | Global | 月次コスト予算($10)を設定し、超過時にメールアラートを送信 |
セキュリティのポイント
- S3 は完全非公開 — パブリックアクセスをすべてブロック。CloudFront の OAC(Origin Access Control)経由のみアクセス可能
- JWT はサーバー側(Lambda@Edge)で検証 — ブラウザの JavaScript には秘密情報を持たせない
-
HttpOnly Cookie —
id_tokenをHttpOnly; Secure; SameSite=Strictで保存し、XSS によるトークン窃取を防止 - OAuth2 State パラメータ — CSRF 攻撃対策として State を生成・検証
- WAF レートリミット — 同一 IP からの短時間多量リクエストをブロック
Lambda@Edgeの実装
実装コード
import { createPublicKey, createVerify } from 'crypto';
import https from 'https';
// ===== 設定値(書き換えてください) =====
const COGNITO_REGION = '';
const USER_POOL_ID = '';
const CLIENT_ID = '';
const COGNITO_DOMAIN = '';
const CLOUDFRONT_DOMAIN = '';
// =========================================
const JWKS_URL = `https://cognito-idp.${COGNITO_REGION}.amazonaws.com/${USER_POOL_ID}/.well-known/jwks.json`;
let cachedJwks = null;
function httpsGet(url) {
return new Promise((resolve, reject) => {
https.get(url, (res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => resolve(JSON.parse(data)));
}).on('error', reject);
});
}
function httpsPost(url, body) {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const options = {
hostname: urlObj.hostname,
path: urlObj.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body),
},
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => resolve(JSON.parse(data)));
});
req.on('error', reject);
req.write(body);
req.end();
});
}
async function getJwks() {
if (cachedJwks) return cachedJwks;
cachedJwks = await httpsGet(JWKS_URL);
return cachedJwks;
}
function base64urlDecode(str) {
return Buffer.from(str.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
}
async function verifyToken(token) {
try {
const [headerB64, payloadB64, signatureB64] = token.split('.');
const header = JSON.parse(base64urlDecode(headerB64).toString());
const payload = JSON.parse(base64urlDecode(payloadB64).toString());
if (payload.exp < Math.floor(Date.now() / 1000)) return false;
const expectedIss = `https://cognito-idp.${COGNITO_REGION}.amazonaws.com/${USER_POOL_ID}`;
if (payload.iss !== expectedIss) return false;
if (payload.token_use !== 'id') return false;
if (payload.aud !== CLIENT_ID && payload.client_id !== CLIENT_ID) return false;
const jwks = await getJwks();
const jwk = jwks.keys.find(k => k.kid === header.kid);
if (!jwk) return false;
const publicKey = createPublicKey({ key: jwk, format: 'jwk' });
const verifier = createVerify('RSA-SHA256');
verifier.update(`${headerB64}.${payloadB64}`);
return verifier.verify(publicKey, base64urlDecode(signatureB64));
} catch {
return false;
}
}
function parseCookies(cookieHeader) {
const cookies = {};
if (!cookieHeader || !cookieHeader[0]) return cookies;
cookieHeader[0].value.split(';').forEach(c => {
const [k, ...v] = c.trim().split('=');
cookies[k.trim()] = v.join('=');
});
return cookies;
}
function buildCognitoLoginUrl(redirectUri, state) {
const params = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: redirectUri,
scope: 'openid email',
state,
});
return `${COGNITO_DOMAIN}/oauth2/authorize?${params}`;
}
function generateState() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
return Array.from({ length: 32 }, () => chars[Math.floor(Math.random() * chars.length)]).join('');
}
export const handler = async (event) => {
const request = event.Records[0].cf.request;
const cookies = parseCookies(request.headers['cookie']);
const idToken = cookies['id_token'];
const redirectUri = `${CLOUDFRONT_DOMAIN}/callback`;
// /logout: Cookie削除 → Cognitoログアウトへリダイレクト
if (request.uri.startsWith('/logout')) {
const logoutUrl = `${COGNITO_DOMAIN}/logout?client_id=${CLIENT_ID}&logout_uri=${encodeURIComponent(CLOUDFRONT_DOMAIN + '/')}`;
return {
status: '302',
headers: {
location: [{ key: 'Location', value: logoutUrl }],
'set-cookie': [
{ key: 'Set-Cookie', value: `id_token=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0` },
{ key: 'Set-Cookie', value: `oauth_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0` },
],
'cache-control': [{ key: 'Cache-Control', value: 'no-store' }],
},
};
}
// /callback: state検証 → トークン交換 → Cookie保存
if (request.uri.startsWith('/callback')) {
const qs = new URLSearchParams(request.querystring || '');
const code = qs.get('code');
const returnedState = qs.get('state');
const savedState = cookies['oauth_state'];
if (!returnedState || returnedState !== savedState || !code) {
return {
status: '302',
headers: {
location: [{ key: 'Location', value: buildCognitoLoginUrl(redirectUri, generateState()) }],
'cache-control': [{ key: 'Cache-Control', value: 'no-store' }],
},
};
}
try {
const body = new URLSearchParams({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
redirect_uri: redirectUri,
code,
}).toString();
const tokens = await httpsPost(`${COGNITO_DOMAIN}/oauth2/token`, body);
if (!tokens.id_token) {
return {
status: '302',
headers: {
location: [{ key: 'Location', value: buildCognitoLoginUrl(redirectUri, generateState()) }],
'cache-control': [{ key: 'Cache-Control', value: 'no-store' }],
},
};
}
return {
status: '302',
headers: {
location: [{ key: 'Location', value: CLOUDFRONT_DOMAIN + '/' }],
'set-cookie': [
{ key: 'Set-Cookie', value: `id_token=${tokens.id_token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600` },
{ key: 'Set-Cookie', value: `oauth_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0` },
],
'cache-control': [{ key: 'Cache-Control', value: 'no-store' }],
},
};
} catch (e) {
return {
status: '302',
headers: {
location: [{ key: 'Location', value: buildCognitoLoginUrl(redirectUri, generateState()) }],
'cache-control': [{ key: 'Cache-Control', value: 'no-store' }],
},
};
}
}
// その他: JWT検証
if (idToken && await verifyToken(idToken)) {
return request;
}
// 未認証 → Cognitoログイン画面へ
const state = generateState();
return {
status: '302',
headers: {
location: [{ key: 'Location', value: buildCognitoLoginUrl(redirectUri, state) }],
'set-cookie': [{ key: 'Set-Cookie', value: `oauth_state=${state}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=300` }],
'cache-control': [{ key: 'Cache-Control', value: 'no-store' }],
},
};
};
5.環境構築と実際の画面
KiroではAWSリソースに対する変更なども可能なのですが、今回はクレジットの節約とハンズオン観点で環境構築はマネジメントコンソールを用いて手動で行いました。
ただし、今回の要件に応じたリソース作成の手順やトラブルシューティングはKiroに助けてもらっています。
実際に手を動かすと気づきもあり、全自動と手動どちらもいいところがあるなというのが私の感想です。(Lambda@Edgeの理解がかなり薄かったのですが、役割や東京リージョンで使えない制約など学びもありました)
実際の画面はこんな感じです。マスクをかけているので簡易な内容ではありますが、簡単なクイズやギャラリーも含めて家族には喜んでもらえました。(この辺のマスク作業も全部Kiroがやってくれています)
6.おわりに
Kiroを用いてアニバーサリーサイトの要件定義、実装、公開を行なったプロセスを紹介しました。
私は基盤寄りのエンジニアなので、アプリケーションの作成についてあまり知見はないのですが、AWSやKiroを利用することで個人のサイトが柔軟かつ簡単に公開できました。
知識で知っていても実際にやってみるといろいろな気づきがあるので、今後もKiroを活用してみたいなと思います。
(先日のAWS SummitでSWAGは残念ながら手に入れられませんでしたが、おばけのキャラクターもかわいくてお気に入りです)

