212 lines
6.2 KiB
TypeScript
212 lines
6.2 KiB
TypeScript
// ============================================================================
|
|
// OrbiQuant IA - Token Service
|
|
// ============================================================================
|
|
|
|
import jwt from 'jsonwebtoken';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import crypto from 'crypto';
|
|
import { config } from '../../../config';
|
|
import { db } from '../../../shared/database';
|
|
import type {
|
|
User,
|
|
AuthTokens,
|
|
JWTPayload,
|
|
JWTRefreshPayload,
|
|
Session,
|
|
} from '../types/auth.types';
|
|
|
|
export class TokenService {
|
|
private readonly accessTokenSecret: string;
|
|
private readonly refreshTokenSecret: string;
|
|
private readonly accessTokenExpiry: string;
|
|
private readonly refreshTokenExpiry: string;
|
|
private readonly refreshTokenExpiryMs: number;
|
|
|
|
constructor() {
|
|
this.accessTokenSecret = config.jwt.accessSecret;
|
|
this.refreshTokenSecret = config.jwt.refreshSecret;
|
|
this.accessTokenExpiry = config.jwt.accessExpiry;
|
|
this.refreshTokenExpiry = config.jwt.refreshExpiry;
|
|
this.refreshTokenExpiryMs = this.parseExpiry(config.jwt.refreshExpiry);
|
|
}
|
|
|
|
private parseExpiry(expiry: string): number {
|
|
const match = expiry.match(/^(\d+)([smhd])$/);
|
|
if (!match) return 7 * 24 * 60 * 60 * 1000; // default 7 days
|
|
|
|
const value = parseInt(match[1], 10);
|
|
const unit = match[2];
|
|
|
|
switch (unit) {
|
|
case 's': return value * 1000;
|
|
case 'm': return value * 60 * 1000;
|
|
case 'h': return value * 60 * 60 * 1000;
|
|
case 'd': return value * 24 * 60 * 60 * 1000;
|
|
default: return 7 * 24 * 60 * 60 * 1000;
|
|
}
|
|
}
|
|
|
|
generateAccessToken(user: User): string {
|
|
const payload: Omit<JWTPayload, 'iat' | 'exp'> = {
|
|
sub: user.id,
|
|
email: user.email,
|
|
role: user.role,
|
|
provider: user.primaryAuthProvider,
|
|
};
|
|
|
|
return jwt.sign(payload, this.accessTokenSecret, {
|
|
expiresIn: this.accessTokenExpiry as jwt.SignOptions['expiresIn'],
|
|
});
|
|
}
|
|
|
|
generateRefreshToken(userId: string, sessionId: string): string {
|
|
const payload: Omit<JWTRefreshPayload, 'iat' | 'exp'> = {
|
|
sub: userId,
|
|
sessionId,
|
|
};
|
|
|
|
return jwt.sign(payload, this.refreshTokenSecret, {
|
|
expiresIn: this.refreshTokenExpiry as jwt.SignOptions['expiresIn'],
|
|
});
|
|
}
|
|
|
|
verifyAccessToken(token: string): JWTPayload | null {
|
|
try {
|
|
return jwt.verify(token, this.accessTokenSecret) as JWTPayload;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
verifyRefreshToken(token: string): JWTRefreshPayload | null {
|
|
try {
|
|
return jwt.verify(token, this.refreshTokenSecret) as JWTRefreshPayload;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async createSession(
|
|
userId: string,
|
|
userAgent?: string,
|
|
ipAddress?: string,
|
|
deviceInfo?: Record<string, unknown>
|
|
): Promise<{ session: Session; tokens: AuthTokens }> {
|
|
const sessionId = uuidv4();
|
|
const refreshTokenValue = crypto.randomBytes(32).toString('hex');
|
|
const expiresAt = new Date(Date.now() + this.refreshTokenExpiryMs);
|
|
|
|
const result = await db.query<Session>(
|
|
`INSERT INTO sessions (id, user_id, refresh_token, user_agent, ip_address, device_info, expires_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING *`,
|
|
[sessionId, userId, refreshTokenValue, userAgent, ipAddress, JSON.stringify(deviceInfo), expiresAt]
|
|
);
|
|
|
|
const session = result.rows[0];
|
|
|
|
// Get user for access token
|
|
const userResult = await db.query<User>(
|
|
'SELECT * FROM users WHERE id = $1',
|
|
[userId]
|
|
);
|
|
const user = userResult.rows[0];
|
|
|
|
const accessToken = this.generateAccessToken(user);
|
|
const refreshToken = this.generateRefreshToken(userId, sessionId);
|
|
|
|
return {
|
|
session,
|
|
tokens: {
|
|
accessToken,
|
|
refreshToken,
|
|
expiresIn: this.parseExpiry(this.accessTokenExpiry) / 1000,
|
|
tokenType: 'Bearer',
|
|
},
|
|
};
|
|
}
|
|
|
|
async refreshSession(refreshToken: string): Promise<AuthTokens | null> {
|
|
const decoded = this.verifyRefreshToken(refreshToken);
|
|
if (!decoded) return null;
|
|
|
|
// Check session exists and is valid
|
|
const sessionResult = await db.query<Session>(
|
|
`SELECT * FROM sessions
|
|
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > NOW()`,
|
|
[decoded.sessionId, decoded.sub]
|
|
);
|
|
|
|
if (sessionResult.rows.length === 0) return null;
|
|
|
|
// Update last active
|
|
await db.query(
|
|
'UPDATE sessions SET last_active_at = NOW() WHERE id = $1',
|
|
[decoded.sessionId]
|
|
);
|
|
|
|
// Get user
|
|
const userResult = await db.query<User>(
|
|
'SELECT * FROM users WHERE id = $1',
|
|
[decoded.sub]
|
|
);
|
|
|
|
if (userResult.rows.length === 0) return null;
|
|
|
|
const user = userResult.rows[0];
|
|
const newAccessToken = this.generateAccessToken(user);
|
|
const newRefreshToken = this.generateRefreshToken(user.id, decoded.sessionId);
|
|
|
|
return {
|
|
accessToken: newAccessToken,
|
|
refreshToken: newRefreshToken,
|
|
expiresIn: this.parseExpiry(this.accessTokenExpiry) / 1000,
|
|
tokenType: 'Bearer',
|
|
};
|
|
}
|
|
|
|
async revokeSession(sessionId: string, userId: string): Promise<boolean> {
|
|
const result = await db.query(
|
|
`UPDATE sessions SET revoked_at = NOW()
|
|
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL`,
|
|
[sessionId, userId]
|
|
);
|
|
|
|
return (result.rowCount ?? 0) > 0;
|
|
}
|
|
|
|
async revokeAllUserSessions(userId: string, exceptSessionId?: string): Promise<number> {
|
|
let query = 'UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL';
|
|
const params: (string | undefined)[] = [userId];
|
|
|
|
if (exceptSessionId) {
|
|
query += ' AND id != $2';
|
|
params.push(exceptSessionId);
|
|
}
|
|
|
|
const result = await db.query(query, params);
|
|
return result.rowCount ?? 0;
|
|
}
|
|
|
|
async getActiveSessions(userId: string): Promise<Session[]> {
|
|
const result = await db.query<Session>(
|
|
`SELECT * FROM sessions
|
|
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW()
|
|
ORDER BY last_active_at DESC`,
|
|
[userId]
|
|
);
|
|
|
|
return result.rows;
|
|
}
|
|
|
|
generateEmailToken(): string {
|
|
return crypto.randomBytes(32).toString('hex');
|
|
}
|
|
|
|
hashToken(token: string): string {
|
|
return crypto.createHash('sha256').update(token).digest('hex');
|
|
}
|
|
}
|
|
|
|
export const tokenService = new TokenService();
|