UNPKG

@gftdcojp/gftd-orm

Version:

Enterprise-grade real-time data platform with ksqlDB, inspired by Supabase architecture

476 lines (464 loc) 16.6 kB
/** * 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