@gftdcojp/gftd-orm
Version:
Enterprise-grade real-time data platform with ksqlDB, inspired by Supabase architecture
565 lines (559 loc) • 18.3 kB
JavaScript
/**
* Auth0 Custom Session Store
*
* セッションストアの抽象化とカスタム実装
* Database、Redis、Memory、File system対応
*/
import { log } from './utils/logger';
import { AuditLogManager, AuditLogLevel, AuditEventType } from './types';
/**
* 🗄️ Memory Session Store
* 開発・テスト用のインメモリストア
*/
export class MemorySessionStore {
constructor(options = {}) {
this.options = options;
this.sessions = new Map();
this.userSessions = new Map();
this.cleanupInterval = null;
// 自動クリーンアップを開始
if (this.options.cleanupIntervalMs) {
this.startCleanup();
}
}
async save(sessionId, session) {
this.sessions.set(sessionId, { ...session, sessionId });
// ユーザーセッションマップを更新
if (!this.userSessions.has(session.userId)) {
this.userSessions.set(session.userId, new Set());
}
this.userSessions.get(session.userId).add(sessionId);
log.debug(`Session saved: ${sessionId}`);
}
async get(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) {
return null;
}
// 有効期限チェック
if (Date.now() > session.expiresAt * 1000) {
await this.delete(sessionId);
return null;
}
// 最終アクセス時刻を更新
session.lastAccessedAt = Math.floor(Date.now() / 1000);
return session;
}
async delete(sessionId) {
const session = this.sessions.get(sessionId);
if (session) {
this.sessions.delete(sessionId);
// ユーザーセッションマップからも削除
const userSessions = this.userSessions.get(session.userId);
if (userSessions) {
userSessions.delete(sessionId);
if (userSessions.size === 0) {
this.userSessions.delete(session.userId);
}
}
}
}
async update(sessionId, updates) {
const session = this.sessions.get(sessionId);
if (session) {
Object.assign(session, updates);
log.debug(`Session updated: ${sessionId}`);
}
}
async getByUserId(userId) {
const sessionIds = this.userSessions.get(userId);
if (!sessionIds) {
return [];
}
const sessions = [];
for (const sessionId of sessionIds) {
const session = await this.get(sessionId);
if (session) {
sessions.push(session);
}
}
return sessions;
}
async deleteByUserId(userId) {
const sessionIds = this.userSessions.get(userId);
if (sessionIds) {
for (const sessionId of sessionIds) {
await this.delete(sessionId);
}
}
}
async cleanup() {
const now = Date.now();
let cleanedCount = 0;
for (const [sessionId, session] of this.sessions) {
if (now > session.expiresAt * 1000) {
await this.delete(sessionId);
cleanedCount++;
}
}
if (cleanedCount > 0) {
log.info(`Cleaned up ${cleanedCount} expired sessions`);
}
}
async health() {
return {
status: 'ok',
message: `${this.sessions.size} sessions in memory`,
};
}
startCleanup() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
this.cleanupInterval = setInterval(async () => {
await this.cleanup();
}, this.options.cleanupIntervalMs);
}
/**
* ストアを終了
*/
destroy() {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = null;
}
this.sessions.clear();
this.userSessions.clear();
}
}
/**
* 🗃️ Database Session Store
* PostgreSQL、MySQL、SQLite対応
*/
export class DatabaseSessionStore {
constructor(db, // Database connection
options = {}) {
this.db = db;
this.options = options;
this.options = {
tableName: 'auth0_sessions',
schemaName: 'public',
...options,
};
}
get tableName() {
return this.options.schemaName
? `${this.options.schemaName}.${this.options.tableName}`
: this.options.tableName;
}
async save(sessionId, session) {
const query = `
INSERT INTO ${this.tableName} (
session_id, user_id, access_token, id_token, refresh_token,
expires_at, created_at, last_accessed_at, user_data, metadata
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (session_id) DO UPDATE SET
access_token = EXCLUDED.access_token,
id_token = EXCLUDED.id_token,
refresh_token = EXCLUDED.refresh_token,
expires_at = EXCLUDED.expires_at,
last_accessed_at = EXCLUDED.last_accessed_at,
user_data = EXCLUDED.user_data,
metadata = EXCLUDED.metadata
`;
await this.db.query(query, [
sessionId,
session.userId,
session.accessToken,
session.idToken,
session.refreshToken,
new Date(session.expiresAt * 1000),
new Date(session.createdAt * 1000),
new Date(session.lastAccessedAt * 1000),
JSON.stringify(session.user),
JSON.stringify(session.metadata || {}),
]);
log.debug(`Session saved to database: ${sessionId}`);
}
async get(sessionId) {
const query = `
SELECT * FROM ${this.tableName}
WHERE session_id = ? AND expires_at > NOW()
`;
const results = await this.db.query(query, [sessionId]);
if (results.length === 0) {
return null;
}
const row = results[0];
// 最終アクセス時刻を更新
await this.updateLastAccessed(sessionId);
return {
sessionId: row.session_id,
userId: row.user_id,
accessToken: row.access_token,
idToken: row.id_token,
refreshToken: row.refresh_token,
expiresAt: Math.floor(row.expires_at.getTime() / 1000),
createdAt: Math.floor(row.created_at.getTime() / 1000),
lastAccessedAt: Math.floor(Date.now() / 1000),
user: JSON.parse(row.user_data),
metadata: JSON.parse(row.metadata || '{}'),
};
}
async delete(sessionId) {
const query = `DELETE FROM ${this.tableName} WHERE session_id = ?`;
await this.db.query(query, [sessionId]);
}
async update(sessionId, updates) {
const setParts = [];
const values = [];
if (updates.accessToken) {
setParts.push('access_token = ?');
values.push(updates.accessToken);
}
if (updates.idToken) {
setParts.push('id_token = ?');
values.push(updates.idToken);
}
if (updates.refreshToken) {
setParts.push('refresh_token = ?');
values.push(updates.refreshToken);
}
if (updates.expiresAt) {
setParts.push('expires_at = ?');
values.push(new Date(updates.expiresAt * 1000));
}
if (updates.user) {
setParts.push('user_data = ?');
values.push(JSON.stringify(updates.user));
}
if (updates.metadata) {
setParts.push('metadata = ?');
values.push(JSON.stringify(updates.metadata));
}
if (setParts.length === 0) {
return;
}
setParts.push('last_accessed_at = NOW()');
values.push(sessionId);
const query = `
UPDATE ${this.tableName}
SET ${setParts.join(', ')}
WHERE session_id = ?
`;
await this.db.query(query, values);
}
async getByUserId(userId) {
const query = `
SELECT * FROM ${this.tableName}
WHERE user_id = ? AND expires_at > NOW()
`;
const results = await this.db.query(query, [userId]);
return results.map((row) => ({
sessionId: row.session_id,
userId: row.user_id,
accessToken: row.access_token,
idToken: row.id_token,
refreshToken: row.refresh_token,
expiresAt: Math.floor(row.expires_at.getTime() / 1000),
createdAt: Math.floor(row.created_at.getTime() / 1000),
lastAccessedAt: Math.floor(row.last_accessed_at.getTime() / 1000),
user: JSON.parse(row.user_data),
metadata: JSON.parse(row.metadata || '{}'),
}));
}
async deleteByUserId(userId) {
const query = `DELETE FROM ${this.tableName} WHERE user_id = ?`;
const result = await this.db.query(query, [userId]);
// 監査ログ記録
AuditLogManager.log({
level: AuditLogLevel.INFO,
eventType: AuditEventType.AUTH_LOGOUT,
userId,
result: 'SUCCESS',
message: `All sessions deleted for user`,
details: { deletedCount: result.affectedRows },
});
}
async cleanup() {
const query = `DELETE FROM ${this.tableName} WHERE expires_at <= NOW()`;
const result = await this.db.query(query);
if (result.affectedRows > 0) {
log.info(`Cleaned up ${result.affectedRows} expired sessions from database`);
}
}
async health() {
try {
const query = `SELECT COUNT(*) as count FROM ${this.tableName}`;
const results = await this.db.query(query);
return {
status: 'ok',
message: `${results[0].count} sessions in database`,
};
}
catch (error) {
return {
status: 'error',
message: `Database health check failed: ${error}`,
};
}
}
async updateLastAccessed(sessionId) {
const query = `
UPDATE ${this.tableName}
SET last_accessed_at = NOW()
WHERE session_id = ?
`;
await this.db.query(query, [sessionId]);
}
/**
* Database schema creation
*/
async createSchema() {
const query = `
CREATE TABLE IF NOT EXISTS ${this.tableName} (
session_id VARCHAR(255) PRIMARY KEY,
user_id VARCHAR(255) NOT NULL,
access_token TEXT NOT NULL,
id_token TEXT NOT NULL,
refresh_token TEXT,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL,
last_accessed_at TIMESTAMP NOT NULL,
user_data TEXT NOT NULL,
metadata TEXT DEFAULT '{}',
INDEX idx_user_id (user_id),
INDEX idx_expires_at (expires_at),
INDEX idx_last_accessed_at (last_accessed_at)
)
`;
await this.db.query(query);
log.info(`Session table created: ${this.tableName}`);
}
}
/**
* 🔴 Redis Session Store
* Redis対応のセッションストア
*/
export class RedisSessionStore {
constructor(redis, // Redis client
options = {}) {
this.redis = redis;
this.options = options;
this.options = {
keyPrefix: 'auth0:session:',
defaultTtl: 7 * 24 * 60 * 60, // 7日
...options,
};
}
sessionKey(sessionId) {
return `${this.options.keyPrefix}${sessionId}`;
}
userSessionsKey(userId) {
return `${this.options.keyPrefix}user:${userId}`;
}
async save(sessionId, session) {
const key = this.sessionKey(sessionId);
const userKey = this.userSessionsKey(session.userId);
const ttl = session.expiresAt - Math.floor(Date.now() / 1000);
// Pipeline for atomic operations
const pipeline = this.redis.pipeline();
// Save session data
pipeline.setex(key, ttl, JSON.stringify(session));
// Add session to user's session set
pipeline.sadd(userKey, sessionId);
pipeline.expire(userKey, ttl);
await pipeline.exec();
log.debug(`Session saved to Redis: ${sessionId}`);
}
async get(sessionId) {
const key = this.sessionKey(sessionId);
const data = await this.redis.get(key);
if (!data) {
return null;
}
try {
const session = JSON.parse(data);
// 最終アクセス時刻を更新
session.lastAccessedAt = Math.floor(Date.now() / 1000);
// セッションを再保存(TTL更新)
const ttl = session.expiresAt - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await this.redis.setex(key, ttl, JSON.stringify(session));
}
return session;
}
catch (error) {
log.error(`Failed to parse session data: ${error}`);
await this.redis.del(key);
return null;
}
}
async delete(sessionId) {
const key = this.sessionKey(sessionId);
// セッションデータを取得してユーザーIDを確認
const data = await this.redis.get(key);
if (data) {
try {
const session = JSON.parse(data);
const userKey = this.userSessionsKey(session.userId);
// Pipeline for atomic operations
const pipeline = this.redis.pipeline();
pipeline.del(key);
pipeline.srem(userKey, sessionId);
await pipeline.exec();
}
catch (error) {
log.error(`Failed to parse session data during deletion: ${error}`);
await this.redis.del(key);
}
}
}
async update(sessionId, updates) {
const session = await this.get(sessionId);
if (!session) {
return;
}
const updatedSession = { ...session, ...updates };
await this.save(sessionId, updatedSession);
}
async getByUserId(userId) {
const userKey = this.userSessionsKey(userId);
const sessionIds = await this.redis.smembers(userKey);
if (sessionIds.length === 0) {
return [];
}
const sessions = [];
for (const sessionId of sessionIds) {
const session = await this.get(sessionId);
if (session) {
sessions.push(session);
}
}
return sessions;
}
async deleteByUserId(userId) {
const userKey = this.userSessionsKey(userId);
const sessionIds = await this.redis.smembers(userKey);
if (sessionIds.length === 0) {
return;
}
// Pipeline for atomic operations
const pipeline = this.redis.pipeline();
for (const sessionId of sessionIds) {
pipeline.del(this.sessionKey(sessionId));
}
pipeline.del(userKey);
await pipeline.exec();
// 監査ログ記録
AuditLogManager.log({
level: AuditLogLevel.INFO,
eventType: AuditEventType.AUTH_LOGOUT,
userId,
result: 'SUCCESS',
message: `All sessions deleted for user`,
details: { deletedCount: sessionIds.length },
});
}
async cleanup() {
// Redis TTLによる自動削除に依存
log.debug('Redis sessions cleanup - relying on TTL expiration');
}
async health() {
try {
await this.redis.ping();
// セッション数をカウント
const pattern = `${this.options.keyPrefix}*`;
const keys = await this.redis.keys(pattern);
return {
status: 'ok',
message: `${keys.length} sessions in Redis`,
};
}
catch (error) {
return {
status: 'error',
message: `Redis health check failed: ${error}`,
};
}
}
}
/**
* 🔧 Session Store Factory
*/
export class SessionStoreFactory {
/**
* Memory store を作成
*/
static createMemoryStore(options) {
return new MemorySessionStore(options);
}
/**
* Database store を作成
*/
static createDatabaseStore(db, options) {
return new DatabaseSessionStore(db, options);
}
/**
* Redis store を作成
*/
static createRedisStore(redis, options) {
return new RedisSessionStore(redis, options);
}
/**
* 設定から適切なストアを作成
*/
static createFromConfig(config) {
switch (config.type) {
case 'memory':
return this.createMemoryStore(config.options);
case 'database':
return this.createDatabaseStore(config.connection, config.options);
case 'redis':
return this.createRedisStore(config.connection, config.options);
default:
throw new Error(`Unknown session store type: ${config.type}`);
}
}
}
/**
* 🎯 Session Store 使用例
*/
export const sessionStoreExamples = {
memory: `
// Memory Store (開発用)
const memoryStore = SessionStoreFactory.createMemoryStore({
cleanupIntervalMs: 5 * 60 * 1000, // 5分毎にクリーンアップ
});
`,
database: `
// Database Store (本番用)
import mysql from 'mysql2/promise';
const db = await mysql.createConnection({
host: 'localhost',
user: 'auth0',
password: 'password',
database: 'sessions',
});
const dbStore = SessionStoreFactory.createDatabaseStore(db, {
tableName: 'auth0_sessions',
schemaName: 'auth',
});
// テーブル作成
await dbStore.createSchema();
`,
redis: `
// Redis Store (本番用)
import Redis from 'ioredis';
const redis = new Redis({
host: 'localhost',
port: 6379,
password: 'password',
});
const redisStore = SessionStoreFactory.createRedisStore(redis, {
keyPrefix: 'app:auth0:session:',
defaultTtl: 7 * 24 * 60 * 60, // 7日
});
`,
};
//# sourceMappingURL=auth0-session-store.js.map