@gftdcojp/gftd-orm
Version:
Enterprise-grade real-time data platform with ksqlDB, inspired by Supabase architecture
476 lines (464 loc) • 16.6 kB
JavaScript
/**
* Next.js Edge Runtime対応 Auth0 SDK
*
* Vercel Edge Functions、Cloudflare Workers、その他のEdge環境に対応
* Node.js APIを使用せず、Web標準APIのみで実装
*/
/**
* Edge Runtime対応 Auth0 クライアント
*/
export class EdgeAuth0Client {
constructor(config) {
this.config = {
...config,
runtime: 'edge',
scope: config.scope || 'openid profile email',
signInReturnToPath: config.signInReturnToPath || '/',
session: {
absoluteLifetime: 7 * 24 * 60 * 60, // 7日
rollingDuration: 24 * 60 * 60, // 24時間
rolling: true,
cookie: {
secure: true, // Edge Runtimeでは常にHTTPS
sameSite: 'lax',
path: '/',
...config.session?.cookie,
},
...config.session,
},
};
this.validateConfig();
}
/**
* 設定検証
*/
validateConfig() {
const required = ['domain', 'clientId', 'clientSecret', 'appBaseUrl', 'secret'];
const missing = required.filter(key => !this.config[key]);
if (missing.length > 0) {
throw new Error(`Missing required configuration: ${missing.join(', ')}`);
}
if (this.config.secret.length < 32) {
throw new Error('secret must be at least 32 characters long');
}
}
/**
* 🌐 Edge Runtime Compatible Methods
*/
/**
* Web Crypto APIを使用した暗号化
*/
async encryptSession(session) {
const encoder = new TextEncoder();
const data = encoder.encode(JSON.stringify(session));
// 暗号化キーを生成
const keyMaterial = await crypto.subtle.importKey('raw', encoder.encode(this.config.secret.slice(0, 32)), { name: 'PBKDF2' }, false, ['deriveKey']);
const key = await crypto.subtle.deriveKey({
name: 'PBKDF2',
salt: encoder.encode('auth0-session-salt'),
iterations: 100000,
hash: 'SHA-256',
}, keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt']);
// ランダムなIVを生成
const iv = crypto.getRandomValues(new Uint8Array(12));
// 暗号化
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data);
// IVと暗号化データを結合してBase64エンコード
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
return this.base64UrlEncode(combined);
}
/**
* Web Crypto APIを使用した復号化
*/
async decryptSession(encryptedSession) {
try {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const data = this.base64UrlDecode(encryptedSession);
// IV(最初の12バイト)と暗号化データを分離
const iv = data.slice(0, 12);
const encrypted = data.slice(12);
// 復号化キーを生成
const keyMaterial = await crypto.subtle.importKey('raw', encoder.encode(this.config.secret.slice(0, 32)), { name: 'PBKDF2' }, false, ['deriveKey']);
const key = await crypto.subtle.deriveKey({
name: 'PBKDF2',
salt: encoder.encode('auth0-session-salt'),
iterations: 100000,
hash: 'SHA-256',
}, keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['decrypt']);
// 復号化
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encrypted);
const sessionData = decoder.decode(decrypted);
return JSON.parse(sessionData);
}
catch (error) {
console.error('Failed to decrypt session:', error);
return null;
}
}
/**
* Base64URL エンコード(Edge Runtime互換)
*/
base64UrlEncode(buffer) {
const base64 = btoa(String.fromCharCode(...buffer));
return base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
/**
* Base64URL デコード(Edge Runtime互換)
*/
base64UrlDecode(str) {
const base64 = str
.replace(/-/g, '+')
.replace(/_/g, '/');
const padding = '='.repeat((4 - base64.length % 4) % 4);
const binaryString = atob(base64 + padding);
return new Uint8Array(binaryString.split('').map(char => char.charCodeAt(0)));
}
/**
* 🔐 Edge-compatible JWT verification
*/
async verifyJwtSignature(token) {
const [headerB64, payloadB64, signatureB64] = token.split('.');
if (!headerB64 || !payloadB64 || !signatureB64) {
throw new Error('Invalid JWT format');
}
// Header and payload decode
const header = JSON.parse(atob(headerB64));
const payload = JSON.parse(atob(payloadB64));
// JWKSを取得してキーを検証(Edge Runtime対応)
const jwksUrl = `https://${this.config.domain}/.well-known/jwks.json`;
const jwksResponse = await fetch(jwksUrl);
if (!jwksResponse.ok) {
throw new Error('Failed to fetch JWKS');
}
const jwks = await jwksResponse.json();
const key = jwks.keys.find((k) => k.kid === header.kid);
if (!key) {
throw new Error('Key not found');
}
// RSA公開キーをインポート
const publicKey = await crypto.subtle.importKey('jwk', key, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify']);
// 署名を検証
const signatureBuffer = this.base64UrlDecode(signatureB64);
const dataBuffer = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const isValid = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', publicKey, signatureBuffer, dataBuffer);
if (!isValid) {
throw new Error('Invalid JWT signature');
}
// 有効期限をチェック
const now = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < now) {
throw new Error('Token expired');
}
// issuerをチェック
if (payload.iss !== `https://${this.config.domain}/`) {
throw new Error('Invalid issuer');
}
return payload;
}
/**
* 🌍 Edge Runtime Request Handlers
*/
/**
* Edge Runtime用のミドルウェア
*/
async handleRequest(request) {
const url = new URL(request.url);
const { pathname } = url;
// 認証ルートの処理
if (pathname.startsWith('/auth/')) {
return this.handleAuthRoute(request);
}
return new Response('Not Found', { status: 404 });
}
/**
* 認証ルートハンドラー
*/
async handleAuthRoute(request) {
const url = new URL(request.url);
const { pathname } = url;
switch (pathname) {
case '/auth/login':
return this.handleLogin(request);
case '/auth/logout':
return this.handleLogout(request);
case '/auth/callback':
return this.handleCallback(request);
case '/auth/profile':
return this.handleProfile(request);
default:
return new Response('Not Found', { status: 404 });
}
}
/**
* Login handler for Edge Runtime
*/
async handleLogin(request) {
const url = new URL(request.url);
const returnTo = url.searchParams.get('returnTo') || this.config.signInReturnToPath;
const loginUrl = this.buildLoginUrl({
redirectUri: `${this.config.appBaseUrl}/auth/callback`,
scope: this.config.scope,
state: btoa(JSON.stringify({ returnTo })),
});
return Response.redirect(loginUrl, 302);
}
/**
* Logout handler for Edge Runtime
*/
async handleLogout(request) {
const logoutUrl = this.buildLogoutUrl({
returnTo: this.config.appBaseUrl,
clientId: this.config.clientId,
});
// セッションCookieをクリア
const response = Response.redirect(logoutUrl, 302);
response.headers.set('Set-Cookie', `auth0-session=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`);
return response;
}
/**
* Callback handler for Edge Runtime
*/
async handleCallback(request) {
const url = new URL(request.url);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
const error = url.searchParams.get('error');
if (error) {
return Response.redirect(`${this.config.appBaseUrl}?error=${error}`, 302);
}
if (!code) {
return Response.redirect(`${this.config.appBaseUrl}?error=invalid_request`, 302);
}
try {
// Exchange code for tokens
const tokenResponse = await this.exchangeCodeForTokens({
code,
redirectUri: `${this.config.appBaseUrl}/auth/callback`,
});
// Verify ID token
const payload = await this.verifyJwtSignature(tokenResponse.id_token);
// Create session
const now = Math.floor(Date.now() / 1000);
const session = {
user: {
sub: payload.sub,
email: payload.email,
name: payload.name,
picture: payload.picture,
},
accessToken: tokenResponse.access_token,
idToken: tokenResponse.id_token,
refreshToken: tokenResponse.refresh_token,
expiresAt: now + tokenResponse.expires_in,
createdAt: now,
};
// Encrypt session
const encryptedSession = await this.encryptSession(session);
// Set cookie
const cookieValue = `auth0-session=${encryptedSession}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${this.config.session?.absoluteLifetime || 604800}`;
// Redirect to return URL
let returnTo = this.config.signInReturnToPath || '/';
if (state) {
try {
const stateData = JSON.parse(atob(state));
returnTo = stateData.returnTo || returnTo;
}
catch (e) {
console.warn('Failed to parse state:', e);
}
}
const response = Response.redirect(`${this.config.appBaseUrl}${returnTo}`, 302);
response.headers.set('Set-Cookie', cookieValue);
return response;
}
catch (error) {
console.error('Auth callback failed:', error);
return Response.redirect(`${this.config.appBaseUrl}?error=callback_failed`, 302);
}
}
/**
* Profile handler for Edge Runtime
*/
async handleProfile(request) {
const session = await this.getSessionFromRequest(request);
if (!session) {
return new Response('Unauthorized', { status: 401 });
}
return Response.json({
user: session.user,
expiresAt: session.expiresAt,
});
}
/**
* 🔧 Helper Methods for Edge Runtime
*/
/**
* リクエストからセッションを取得
*/
async getSessionFromRequest(request) {
const cookieHeader = request.headers.get('cookie');
if (!cookieHeader) {
return null;
}
const cookies = this.parseCookies(cookieHeader);
const sessionCookie = cookies['auth0-session'];
if (!sessionCookie) {
return null;
}
const session = await this.decryptSession(sessionCookie);
if (!session) {
return null;
}
// セッション有効期限チェック
const now = Math.floor(Date.now() / 1000);
if (session.expiresAt <= now) {
return null;
}
return session;
}
/**
* Cookie文字列を解析
*/
parseCookies(cookieHeader) {
const cookies = {};
cookieHeader.split(';').forEach(cookie => {
const [name, value] = cookie.trim().split('=');
if (name && value) {
cookies[name] = decodeURIComponent(value);
}
});
return cookies;
}
/**
* Login URL生成
*/
buildLoginUrl(options) {
const params = new URLSearchParams({
client_id: this.config.clientId,
redirect_uri: options.redirectUri,
response_type: 'code',
scope: options.scope,
});
if (options.state) {
params.append('state', options.state);
}
return `https://${this.config.domain}/authorize?${params.toString()}`;
}
/**
* Logout URL生成
*/
buildLogoutUrl(options) {
const params = new URLSearchParams({
returnTo: options.returnTo,
client_id: options.clientId,
});
return `https://${this.config.domain}/v2/logout?${params.toString()}`;
}
/**
* Code exchange for tokens
*/
async exchangeCodeForTokens(options) {
const response = await fetch(`https://${this.config.domain}/oauth/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: this.config.clientId,
client_secret: this.config.clientSecret,
code: options.code,
redirect_uri: options.redirectUri,
}).toString(),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return response.json();
}
}
/**
* Edge Runtime用クライアント作成関数
*/
export function createEdgeAuth0Client(config) {
return new EdgeAuth0Client(config);
}
/**
* 🌐 Edge Runtime使用例
*/
export const edgeAuth0Examples = {
/**
* Vercel Edge Function での使用例
*/
vercelEdge: `
// middleware.ts (Vercel Edge Runtime)
import { createEdgeAuth0Client } from '@gftdcojp/gftd-orm/nextjs-auth0-edge';
const client = createEdgeAuth0Client({
domain: process.env.AUTH0_DOMAIN!,
clientId: process.env.AUTH0_CLIENT_ID!,
clientSecret: process.env.AUTH0_CLIENT_SECRET!,
appBaseUrl: process.env.APP_BASE_URL!,
secret: process.env.AUTH0_SECRET!,
});
export async function middleware(request: Request) {
return await client.handleRequest(request);
}
export const config = {
matcher: '/auth/:path*',
runtime: 'edge',
};
`,
/**
* Cloudflare Workers での使用例
*/
cloudflareWorkers: `
// worker.js (Cloudflare Workers)
import { createEdgeAuth0Client } from '@gftdcojp/gftd-orm/nextjs-auth0-edge';
const client = createEdgeAuth0Client({
domain: 'your-domain.auth0.com',
clientId: 'your-client-id',
clientSecret: 'your-client-secret',
appBaseUrl: 'https://your-app.com',
secret: 'your-32-char-secret',
});
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request));
});
async function handleRequest(request) {
const url = new URL(request.url);
if (url.pathname.startsWith('/auth/')) {
return await client.handleRequest(request);
}
return new Response('Hello World!');
}
`,
/**
* Deno Deploy での使用例
*/
denoDeploy: `
// main.ts (Deno Deploy)
import { createEdgeAuth0Client } from '@gftdcojp/gftd-orm/nextjs-auth0-edge';
const client = createEdgeAuth0Client({
domain: Deno.env.get('AUTH0_DOMAIN')!,
clientId: Deno.env.get('AUTH0_CLIENT_ID')!,
clientSecret: Deno.env.get('AUTH0_CLIENT_SECRET')!,
appBaseUrl: Deno.env.get('APP_BASE_URL')!,
secret: Deno.env.get('AUTH0_SECRET')!,
});
Deno.serve(async (request) => {
const url = new URL(request.url);
if (url.pathname.startsWith('/auth/')) {
return await client.handleRequest(request);
}
return new Response('Hello from Deno!');
});
`,
};
//# sourceMappingURL=nextjs-auth0-edge.js.map