@smartsamurai/krapi-sdk
Version:
KRAPI TypeScript SDK - Easy-to-use client SDK for connecting to self-hosted KRAPI servers (like Appwrite SDK)
1,500 lines (1,370 loc) • 45.4 kB
text/typescript
/**
* Auth Service for BackendSDK
*
* Provides comprehensive authentication and session management functionality including:
* - Admin and project user authentication
* - Session creation and management
* - Password hashing and validation
* - API key authentication
* - Token generation and validation
*
* @class AuthService
* @example
* const authService = new AuthService(dbConnection, logger);
* const result = await authService.authenticateAdmin({ username: 'admin', password: 'pass' });
*/
import crypto from "crypto";
import bcrypt from "bcryptjs";
import { DatabaseConnection, Logger } from "./core";
import { KrapiError } from "./core/krapi-error";
import { PasswordHashRow, EmailRow } from "./database-types";
import { normalizeError } from "./utils/error-handler";
export interface AdminUser {
id: string;
username: string;
email: string;
password_hash: string;
role: string;
access_level: string;
permissions: string[];
active: boolean;
created_at: string;
updated_at: string;
last_login?: string;
api_key?: string;
login_count?: number;
}
export interface ProjectUser {
id: string;
project_id: string;
username?: string;
email?: string;
external_id?: string;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
last_login?: string;
is_active: boolean;
}
export interface Session {
id: string;
user_id: string;
user_type: "admin" | "project";
project_id?: string;
token: string;
scopes: string[];
expires_at: string;
created_at: string;
last_used_at?: string;
ip_address?: string;
user_agent?: string;
is_active: boolean;
}
export interface LoginRequest {
username?: string;
email?: string;
password: string;
project_id?: string;
remember_me?: boolean;
}
export interface LoginResponse {
success: boolean;
token: string;
expires_at: string;
user: AdminUser | ProjectUser;
scopes: string[];
session_id: string;
}
export interface ApiKeyAuthRequest {
api_key: string;
}
export interface ApiKeyAuthResponse {
success: boolean;
token: string;
expires_at: string;
user: AdminUser | null;
scopes: string[];
session_id: string;
}
export interface PasswordChangeRequest {
current_password: string;
new_password: string;
}
export interface PasswordResetRequest {
email: string;
reset_token?: string;
new_password?: string;
}
export class AuthService {
private db: DatabaseConnection;
private logger: Logger;
/**
* Create a new AuthService instance
*
* @param {DatabaseConnection} databaseConnection - Database connection
* @param {Logger} logger - Logger instance
*/
constructor(databaseConnection: DatabaseConnection, logger: Logger) {
this.db = databaseConnection;
this.logger = logger;
}
/**
* Get scopes for a given admin role
*
* Derives scopes from user role when permissions field is empty or null.
* This ensures sessions always have appropriate scopes based on role.
*
* @param {string} role - User role (e.g., 'master_admin', 'admin', etc.)
* @returns {string[]} Array of scope strings
*
* @example
* const scopes = authService.getScopesForRole('master_admin');
* // Returns: ['master']
*/
private getScopesForRole(role: string): string[] {
// Normalize role to lowercase for comparison
const normalizedRole = role?.toLowerCase() || "";
// Map roles to scopes
switch (normalizedRole) {
case "master_admin":
case "super_admin":
// Master admin gets full access
// Use uppercase "MASTER" to match Scope.MASTER enum value
return ["MASTER"];
case "admin":
// Regular admin gets comprehensive admin scopes
return [
"admin:read",
"admin:write",
"admin:delete",
"projects:read",
"projects:write",
"projects:delete",
"collections:read",
"collections:write",
"collections:delete",
"documents:read",
"documents:write",
"documents:delete",
"storage:read",
"storage:write",
"storage:delete",
"users:read",
"users:write",
"users:delete",
"email:send",
"email:read",
];
case "moderator":
// Moderator gets read/write access but limited delete
return [
"admin:read",
"projects:read",
"projects:write",
"collections:read",
"collections:write",
"documents:read",
"documents:write",
"storage:read",
"storage:write",
"users:read",
"email:read",
];
case "developer":
// Developer gets read/write access to projects and data
return [
"projects:read",
"projects:write",
"collections:read",
"collections:write",
"collections:delete",
"documents:read",
"documents:write",
"documents:delete",
"storage:read",
"storage:write",
"functions:execute",
"functions:write",
];
case "project_admin":
// Project admin gets full access within projects
return [
"projects:read",
"projects:write",
"collections:read",
"collections:write",
"collections:delete",
"documents:read",
"documents:write",
"documents:delete",
"storage:read",
"storage:write",
"storage:delete",
"users:read",
"users:write",
"email:send",
];
case "limited_admin":
// Limited admin gets read-only access
return [
"admin:read",
"projects:read",
"collections:read",
"documents:read",
"storage:read",
"users:read",
"email:read",
];
default:
// Default: minimal read access
return ["read"];
}
}
/**
* Authenticate admin user
*
* Authenticates an admin user with username/email and password.
* Creates a session and returns login response with token and user data.
*
* @param {LoginRequest} loginData - Login credentials
* @param {string} [loginData.username] - Admin username
* @param {string} [loginData.email] - Admin email
* @param {string} loginData.password - Admin password
* @param {string} [loginData.project_id] - Project ID (for project users)
* @param {boolean} [loginData.remember_me] - Whether to remember session
* @returns {Promise<LoginResponse>} Login response with token, user, and session info
* @throws {Error} If username/email or password is missing
* @throws {Error} If credentials are invalid
*
* @example
* const result = await authService.authenticateAdmin({
* username: 'admin',
* password: 'password'
* });
*/
async authenticateAdmin(loginData: LoginRequest): Promise<LoginResponse> {
const { username, email, password } = loginData;
try {
if (!username && !email) {
throw KrapiError.validationError(
"Username or email is required",
"username",
username
);
}
if (!password) {
throw KrapiError.validationError("Password is required", "password");
}
// Get admin user by username or email
let query = "SELECT * FROM admin_users WHERE is_active = true AND ";
const params: unknown[] = [];
if (username) {
query += "username = $1";
params.push(username);
} else {
query += "email = $1";
params.push(email);
}
const result = await this.db.query(query, params);
if (result.rows.length === 0) {
throw KrapiError.authError("Invalid credentials", {
operation: "authenticateAdmin",
username,
email,
});
}
const rawUser = result.rows[0] as Record<string, unknown>;
// Parse permissions field (CRITICAL FIX: permissions stored as JSON string in database)
let permissions: string[] = [];
if (rawUser.permissions) {
if (typeof rawUser.permissions === "string") {
try {
// Parse JSON string: "[\"MASTER\"]" → ["MASTER"]
permissions = JSON.parse(rawUser.permissions) as string[];
} catch {
// If parsing fails, try to parse as empty array
permissions = [];
}
} else if (Array.isArray(rawUser.permissions)) {
permissions = rawUser.permissions as string[];
}
}
const adminUser: AdminUser = {
id: rawUser.id as string,
username: rawUser.username as string,
email: rawUser.email as string,
password_hash: rawUser.password_hash as string,
role: rawUser.role as string,
access_level: rawUser.access_level as string,
permissions,
active: Boolean(rawUser.is_active ?? rawUser.active),
created_at: rawUser.created_at as string,
updated_at: rawUser.updated_at as string,
};
// Add optional fields only if they exist
if (rawUser.last_login) {
adminUser.last_login = rawUser.last_login as string;
}
if (rawUser.api_key) {
adminUser.api_key = rawUser.api_key as string;
}
if (rawUser.login_count !== undefined) {
adminUser.login_count = rawUser.login_count as number;
}
// Validate password
const isValidPassword = await this.validatePassword(
password,
adminUser.password_hash
);
if (!isValidPassword) {
throw KrapiError.authError("Invalid credentials", {
operation: "authenticateAdmin",
username,
email,
userId: adminUser.id,
});
}
// Determine scopes based on role (CRITICAL FIX)
// Use permissions if available and non-empty, otherwise derive from role
const scopes =
permissions.length > 0
? permissions
: this.getScopesForRole(adminUser.role);
// Create session with proper type and scopes
const session = await this.createSession({
user_id: adminUser.id,
user_type: "admin",
scopes,
remember_me: loginData.remember_me ?? false,
});
// Update last login
await this.updateLastLogin(adminUser.id, "admin");
// Ensure scopes are returned as array, not string (CRITICAL FIX)
const responseScopes = Array.isArray(session.scopes)
? session.scopes
: typeof session.scopes === "string"
? (() => {
try {
return JSON.parse(session.scopes);
} catch {
return [];
}
})()
: scopes; // Fallback to derived scopes
return {
success: true,
token: session.token,
expires_at: session.expires_at,
user: {
...adminUser,
permissions: scopes, // Return derived scopes in user object
},
scopes: responseScopes, // Return as array
session_id: session.id,
};
} catch (error) {
this.logger.error("Admin authentication failed:", error);
throw normalizeError(error, "UNAUTHORIZED", {
operation: "authenticateAdmin",
username,
email,
});
}
}
/**
* Register a new admin user
*
* Creates a new admin user account with the provided credentials.
*
* @param {Object} registerData - Registration data
* @param {string} registerData.username - Username (required)
* @param {string} registerData.email - Email address (required)
* @param {string} registerData.password - Password (required)
* @param {string} [registerData.role="user"] - User role
* @param {string} [registerData.access_level="read"] - Access level
* @param {string[]} [registerData.permissions=[]] - Permission scopes
* @returns {Promise<{success: boolean, user: AdminUser}>} Registration result
* @throws {Error} If user already exists or registration fails
*
* @example
* const result = await authService.register({
* username: 'newuser',
* email: 'user@example.com',
* password: 'securepassword',
* role: 'admin'
* });
*/
async register(registerData: {
username: string;
email: string;
password: string;
role?: string;
access_level?: string;
permissions?: string[];
}): Promise<{ success: boolean; user: AdminUser }> {
const {
username,
email,
password,
role = "user",
access_level = "read",
permissions = [],
} = registerData;
try {
// Check if user already exists
const existingUser = await this.db.query(
"SELECT id FROM admin_users WHERE username = $1 OR email = $2",
[username, email]
);
if (existingUser.rows.length > 0) {
throw KrapiError.conflict("User already exists", {
resource: "admin_users",
operation: "registerAdmin",
username,
email,
});
}
// Hash password
const passwordHash = await this.hashPassword(password);
// Generate user ID (SQLite doesn't support RETURNING *)
const userId = crypto.randomUUID();
const now = new Date().toISOString();
// SQLite-compatible INSERT (no RETURNING *)
await this.db.query(
`INSERT INTO admin_users (id, username, email, password_hash, role, access_level, permissions, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
userId,
username,
email,
passwordHash,
role,
access_level,
permissions,
1,
now,
now,
]
);
// Query back the inserted row
const result = await this.db.query(
"SELECT * FROM admin_users WHERE id = $1",
[userId]
);
if (result.rows.length === 0) {
throw normalizeError(
new Error("Failed to create user"),
"INTERNAL_ERROR",
{
operation: "registerAdmin",
username,
email,
}
);
}
const newUser = result.rows[0] as AdminUser;
return {
success: true,
user: newUser,
};
} catch (error) {
this.logger.error("User registration failed:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "registerAdmin",
username,
email,
});
}
}
/**
* Logout and revoke session
*
* Logs out a user by revoking their session token.
*
* @param {string} [sessionId] - Optional session ID to revoke (if not provided, revokes current session)
* @returns {Promise<{success: boolean}>} Logout result
*
* @example
* await authService.logout('session-id');
*/
async logout(sessionId?: string): Promise<{ success: boolean }> {
try {
if (sessionId) {
// Revoke specific session
await this.revokeSession(sessionId);
}
// Always return success for logout
return { success: true };
} catch (error) {
this.logger.error("Logout failed:", error);
// Don't throw error on logout failure
return { success: true };
}
}
/**
* Authenticate admin user with API key
*
* Authenticates an admin user using an API key instead of username/password.
*
* @param {ApiKeyAuthRequest} apiKeyData - API key authentication data
* @param {string} apiKeyData.api_key - API key value
* @returns {Promise<ApiKeyAuthResponse>} Authentication response with token and user
* @throws {Error} If API key is invalid or expired
*
* @example
* const result = await authService.authenticateAdminWithApiKey({
* api_key: 'ak_...'
* });
*/
async authenticateAdminWithApiKey(
apiKeyData: ApiKeyAuthRequest
): Promise<ApiKeyAuthResponse> {
const { api_key } = apiKeyData;
try {
if (!api_key) {
throw KrapiError.validationError("API key is required", "api_key");
}
// Get admin user by API key
const result = await this.db.query(
"SELECT * FROM admin_users WHERE api_key = $1 AND is_active = true",
[api_key]
);
if (result.rows.length === 0) {
throw KrapiError.authError("Invalid API key", {
operation: "authenticateAdminWithApiKey",
});
}
const rawUser = result.rows[0] as Record<string, unknown>;
// Parse permissions field (CRITICAL FIX: permissions stored as JSON string in database)
let permissions: string[] = [];
if (rawUser.permissions) {
if (typeof rawUser.permissions === "string") {
try {
// Parse JSON string: "[\"MASTER\"]" → ["MASTER"]
permissions = JSON.parse(rawUser.permissions) as string[];
} catch {
// If parsing fails, try to parse as empty array
permissions = [];
}
} else if (Array.isArray(rawUser.permissions)) {
permissions = rawUser.permissions as string[];
}
}
const adminUser: AdminUser = {
id: rawUser.id as string,
username: rawUser.username as string,
email: rawUser.email as string,
password_hash: rawUser.password_hash as string,
role: rawUser.role as string,
access_level: rawUser.access_level as string,
permissions,
active: Boolean(rawUser.is_active ?? rawUser.active),
created_at: rawUser.created_at as string,
updated_at: rawUser.updated_at as string,
};
// Add optional fields only if they exist
if (rawUser.last_login) {
adminUser.last_login = rawUser.last_login as string;
}
if (rawUser.api_key) {
adminUser.api_key = rawUser.api_key as string;
}
if (rawUser.login_count !== undefined) {
adminUser.login_count = rawUser.login_count as number;
}
// Determine scopes based on role (CRITICAL FIX)
// Use permissions if available and non-empty, otherwise derive from role
const scopes =
permissions.length > 0
? permissions
: this.getScopesForRole(adminUser.role);
// Create session with proper scopes
const session = await this.createSession({
user_id: adminUser.id,
user_type: "admin",
scopes,
});
// Update last login
await this.updateLastLogin(adminUser.id, "admin");
// Ensure scopes are returned as array, not string (CRITICAL FIX)
const responseScopes = Array.isArray(session.scopes)
? session.scopes
: typeof session.scopes === "string"
? (() => {
try {
return JSON.parse(session.scopes);
} catch {
return [];
}
})()
: scopes; // Fallback to derived scopes
return {
success: true,
token: session.token,
expires_at: session.expires_at,
user: {
...adminUser,
permissions: scopes, // Return derived scopes in user object
},
scopes: responseScopes, // Return as array
session_id: session.id,
};
} catch (error) {
this.logger.error("API key authentication failed:", error);
throw normalizeError(error, "UNAUTHORIZED", {
operation: "authenticateAdminWithApiKey",
});
}
}
/**
* Regenerate API key for admin user
*
* Generates a new API key for the authenticated admin user.
* Note: This is a placeholder implementation.
*
* @param {unknown} _req - Request object (currently unused)
* @returns {Promise<{success: boolean, data?: {apiKey: string}, error?: string}>} API key generation result
*
* @example
* const result = await authService.regenerateApiKey(request);
* if (result.success) {
* console.log(`New API Key: ${result.data?.apiKey}`);
* }
*/
async regenerateApiKey(
_req: unknown
): Promise<{ success: boolean; data?: { apiKey: string }; error?: string }> {
try {
// For now, this is a placeholder implementation
// In a real implementation, this would:
// 1. Validate the request (user authentication, permissions)
// 2. Generate a new API key
// 3. Update the database
// 4. Return the new key
const newApiKey = `ak_${Math.random()
.toString(36)
.substring(2, 15)}${Math.random()
.toString(36)
.substring(2, 15)}${Date.now()}`;
return {
success: true,
data: { apiKey: newApiKey },
};
} catch (error) {
this.logger.error("Failed to regenerate API key:", error);
return {
success: false,
error: "Failed to regenerate API key",
};
}
}
/**
* Authenticate project user
*
* Authenticates a project-specific user with username/email and password.
*
* @param {LoginRequest} loginData - Login credentials
* @param {string} loginData.project_id - Project ID (required)
* @param {string} [loginData.username] - Username
* @param {string} [loginData.email] - Email
* @param {string} loginData.password - Password
* @param {boolean} [loginData.remember_me] - Whether to remember session
* @returns {Promise<LoginResponse>} Login response with token and user
* @throws {Error} If credentials are invalid or project ID missing
*
* @example
* const result = await authService.authenticateProjectUser({
* project_id: 'project-id',
* username: 'user',
* password: 'password'
* });
*/
async authenticateProjectUser(
loginData: LoginRequest
): Promise<LoginResponse> {
try {
const { username, email, password, project_id } = loginData;
if (!project_id) {
throw KrapiError.validationError(
"Project ID is required for project user authentication",
"project_id"
);
}
if (!username && !email) {
throw KrapiError.validationError(
"Username or email is required",
"username",
username
);
}
if (!password) {
throw KrapiError.validationError("Password is required", "password");
}
// Get project user by username/email and project
let query =
"SELECT * FROM project_users WHERE project_id = $1 AND is_active = true AND ";
const params: unknown[] = [project_id];
if (username) {
query += "username = $2";
params.push(username);
} else {
query += "email = $2";
params.push(email);
}
const result = await this.db.query(query, params);
if (result.rows.length === 0) {
throw KrapiError.authError("Invalid credentials", {
operation: "login",
username,
email,
});
}
const projectUser = result.rows[0] as ProjectUser & {
password_hash?: string;
scopes?: string[];
permissions?: string[];
role?: string;
};
// Validate password (assuming project users also have password_hash)
if (!projectUser.password_hash) {
throw KrapiError.authError("Invalid credentials", {
operation: "login",
username,
email,
userId: projectUser.id,
});
}
const isValidPassword = await this.validatePassword(
password,
projectUser.password_hash
);
if (!isValidPassword) {
throw KrapiError.authError("Invalid credentials", {
operation: "login",
username,
email,
userId: projectUser.id,
});
}
// Determine scopes: prefer user scopes, then permissions, then default project scopes
let scopes: string[] = [];
if (
projectUser.scopes &&
Array.isArray(projectUser.scopes) &&
projectUser.scopes.length > 0
) {
scopes = projectUser.scopes;
} else if (
projectUser.permissions &&
Array.isArray(projectUser.permissions) &&
projectUser.permissions.length > 0
) {
scopes = projectUser.permissions;
} else {
// Default project user scopes (CRITICAL FIX: ensure scopes are never empty)
scopes = [
"projects:read",
"projects:write",
"collections:read",
"collections:write",
"documents:read",
"documents:write",
];
}
// Create session with proper scopes
const session = await this.createSession({
user_id: projectUser.id,
user_type: "project",
project_id,
scopes,
remember_me: loginData.remember_me ?? false,
});
// Update last login
await this.updateLastLogin(projectUser.id, "project");
return {
success: true,
token: session.token,
expires_at: session.expires_at,
user: projectUser,
scopes: session.scopes,
session_id: session.id,
};
} catch (error) {
this.logger.error("Project user authentication failed:", error);
throw normalizeError(error, "UNAUTHORIZED", {
operation: "login",
username: loginData.username,
email: loginData.email,
});
}
}
/**
* Create a new session
*
* Creates a new authentication session for a user.
*
* @param {Object} sessionData - Session data
* @param {string} sessionData.user_id - User ID
* @param {"admin" | "project"} sessionData.user_type - User type
* @param {string} [sessionData.project_id] - Project ID (for project users)
* @param {string[]} sessionData.scopes - Permission scopes
* @param {boolean} [sessionData.remember_me=false] - Whether to extend session (30 days vs 1 hour)
* @param {string} [sessionData.ip_address] - Client IP address
* @param {string} [sessionData.user_agent] - Client user agent
* @returns {Promise<Session>} Created session
* @throws {Error} If session creation fails
*
* @example
* const session = await authService.createSession({
* user_id: 'user-id',
* user_type: 'admin',
* scopes: ['admin:read', 'admin:write'],
* remember_me: true
* });
*/
async createSession(sessionData: {
user_id: string;
user_type: "admin" | "project";
project_id?: string;
scopes: string[];
remember_me?: boolean;
ip_address?: string;
user_agent?: string;
}): Promise<Session> {
try {
const sessionToken = this.generateSessionToken();
const expiresAt = new Date();
// Set expiration based on remember_me
if (sessionData.remember_me) {
expiresAt.setDate(expiresAt.getDate() + 30); // 30 days
} else {
expiresAt.setHours(expiresAt.getHours() + 1); // 1 hour
}
// Map user_type to SessionType for the type field
// This ensures type is set correctly (not null) for middleware compatibility
const sessionType =
sessionData.user_type === "admin"
? "admin"
: sessionData.user_type === "project"
? "project"
: "user";
// Insert session with both user_type and type fields
// type field is used by middleware for session type checking
// CRITICAL FIX: Store scopes as JSON string (database expects TEXT/JSON)
const scopesJson = JSON.stringify(sessionData.scopes);
// Generate session ID (SQLite doesn't support RETURNING *)
const sessionId = crypto.randomUUID();
// SQLite-compatible INSERT (no RETURNING *)
await this.db.query(
`INSERT INTO sessions (id, user_id, user_type, type, project_id, token, scopes, expires_at, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
sessionId,
sessionData.user_id,
sessionData.user_type,
sessionType, // Set type field explicitly (CRITICAL FIX)
sessionData.project_id,
sessionToken,
scopesJson, // Store as JSON string
expiresAt.toISOString(),
sessionData.ip_address,
sessionData.user_agent,
]
);
// Query back the inserted row
const result = await this.db.query(
"SELECT * FROM sessions WHERE id = $1",
[sessionId]
);
// Parse scopes back from JSON string when returning session
const rawSession = result.rows[0] as Record<string, unknown>;
let parsedScopes: string[] = [];
if (rawSession.scopes) {
if (typeof rawSession.scopes === "string") {
try {
parsedScopes = JSON.parse(rawSession.scopes) as string[];
} catch {
parsedScopes = sessionData.scopes; // Fallback to original scopes
}
} else if (Array.isArray(rawSession.scopes)) {
parsedScopes = rawSession.scopes as string[];
}
} else {
// Fallback to original scopes if parsing fails
parsedScopes = sessionData.scopes;
}
return {
...rawSession,
scopes: parsedScopes,
} as Session;
} catch (error) {
this.logger.error("Failed to create session:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "createSession",
user_id: sessionData.user_id,
user_type: sessionData.user_type,
});
}
}
/**
* Create session from API key
*
* Creates a session token from a valid API key.
*
* @param {string} apiKey - API key value
* @returns {Promise<Object>} Session information
* @returns {string} returns.session_token - Session token
* @returns {string} returns.expires_at - Expiration timestamp
* @returns {"admin" | "project"} returns.user_type - User type
* @returns {string[]} returns.scopes - Permission scopes
* @throws {Error} If API key is invalid or expired
*
* @example
* const session = await authService.createSessionFromApiKey('ak_...');
* console.log(`Session token: ${session.session_token}`);
*/
async createSessionFromApiKey(apiKey: string): Promise<{
session_token: string;
expires_at: string;
user_type: "admin" | "project";
scopes: string[];
}> {
try {
// First, find the API key and get user information
const apiKeyResult = await this.db.query(
`SELECT ak.*, au.username, au.role, au.permissions, au.id as user_id
FROM api_keys ak
JOIN admin_users au ON ak.owner_id = au.id
WHERE ak.key = $1 AND ak.is_active = true AND ak.expires_at > CURRENT_TIMESTAMP`,
[apiKey]
);
if (apiKeyResult.rows.length === 0) {
throw KrapiError.authError("Invalid or expired API key", {
operation: "createSessionFromApiKey",
});
}
const rawApiKeyData = apiKeyResult.rows[0] as Record<string, unknown>;
// Parse permissions field (CRITICAL FIX: permissions stored as JSON string in database)
let userPermissions: string[] = [];
if (rawApiKeyData.permissions) {
if (typeof rawApiKeyData.permissions === "string") {
try {
// Parse JSON string: "[\"MASTER\"]" → ["MASTER"]
userPermissions = JSON.parse(rawApiKeyData.permissions) as string[];
} catch {
userPermissions = [];
}
} else if (Array.isArray(rawApiKeyData.permissions)) {
userPermissions = rawApiKeyData.permissions as string[];
}
}
const apiKeyData = {
user_id: rawApiKeyData.user_id as string,
role: rawApiKeyData.role as string | undefined,
scopes: rawApiKeyData.scopes as string[] | undefined,
permissions: userPermissions,
};
const userType: "admin" | "project" = "admin"; // API keys are admin-only for now
// Determine scopes: prefer API key scopes, then user permissions, then derive from role
let scopes: string[] = [];
if (
apiKeyData.scopes &&
Array.isArray(apiKeyData.scopes) &&
apiKeyData.scopes.length > 0
) {
scopes = apiKeyData.scopes;
} else if (
apiKeyData.permissions &&
Array.isArray(apiKeyData.permissions) &&
apiKeyData.permissions.length > 0
) {
scopes = apiKeyData.permissions;
} else if (apiKeyData.role) {
// Derive scopes from user role if API key and permissions are empty (CRITICAL FIX)
scopes = this.getScopesForRole(apiKeyData.role);
} else {
// Fallback: minimal read access
scopes = ["read"];
}
// Create a new session with proper scopes
const session = await this.createSession({
user_id: apiKeyData.user_id,
user_type: userType,
scopes,
remember_me: false,
});
return {
session_token: session.token,
expires_at: session.expires_at,
user_type: userType,
scopes: session.scopes,
};
} catch (error) {
this.logger.error("Failed to create session from API key", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "createSessionFromApiKey",
});
}
}
/**
* Validate session token
*
* Validates a session token and returns the session if valid and not expired.
* Updates the last_used_at timestamp.
*
* @param {string} token - Session token
* @returns {Promise<Session | null>} Session if valid, null if invalid/expired
*
* @example
* const session = await authService.validateSession('st_...');
* if (session) {
* console.log(`User: ${session.user_id}, Scopes: ${session.scopes}`);
* }
*/
async validateSession(token: string): Promise<Session | null> {
try {
const result = await this.db.query(
`SELECT * FROM sessions
WHERE token = $1 AND is_active = true AND expires_at > CURRENT_TIMESTAMP`,
[token]
);
if (result.rows.length === 0) {
return null;
}
const session = result.rows[0] as Session;
// Update last used timestamp
await this.db.query(
"UPDATE sessions SET last_used_at = CURRENT_TIMESTAMP WHERE id = $1",
[session.id]
);
return session;
} catch (error) {
this.logger.error("Failed to validate session:", error);
return null;
}
}
/**
* Revoke a session
*
* Invalidates a session by marking it as inactive.
*
* @param {string} token - Session token to revoke
* @returns {Promise<boolean>} True if session was revoked
* @throws {Error} If revocation fails
*
* @example
* const revoked = await authService.revokeSession('st_...');
*/
async revokeSession(token: string): Promise<boolean> {
try {
const result = await this.db.query(
"UPDATE sessions SET is_active = false WHERE token = $1",
[token]
);
return result.rowCount > 0;
} catch (error) {
this.logger.error("Failed to revoke session:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "revokeSession",
token,
});
}
}
/**
* Revoke all sessions for a user
*
* Invalidates all active sessions for a specific user.
*
* @param {string} userId - User ID
* @param {"admin" | "project"} userType - User type
* @returns {Promise<number>} Number of sessions revoked
* @throws {Error} If revocation fails
*
* @example
* const count = await authService.revokeAllUserSessions('user-id', 'admin');
* console.log(`Revoked ${count} sessions`);
*/
async revokeAllUserSessions(
userId: string,
userType: "admin" | "project"
): Promise<number> {
try {
const result = await this.db.query(
"UPDATE sessions SET is_active = false WHERE user_id = $1 AND user_type = $2",
[userId, userType]
);
return result.rowCount;
} catch (error) {
this.logger.error("Failed to revoke user sessions:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "revokeAllUserSessions",
userId,
userType,
});
}
}
/**
* Cleanup expired sessions
*
* Marks all expired sessions as inactive.
*
* @returns {Promise<number>} Number of sessions cleaned up
* @throws {Error} If cleanup fails
*
* @example
* const count = await authService.cleanupExpiredSessions();
* console.log(`Cleaned up ${count} expired sessions`);
*/
async cleanupExpiredSessions(): Promise<number> {
try {
const result = await this.db.query(
"UPDATE sessions SET is_active = false WHERE expires_at <= CURRENT_TIMESTAMP AND is_active = true"
);
return result.rowCount;
} catch (error) {
this.logger.error("Failed to cleanup expired sessions:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "cleanupExpiredSessions",
});
}
}
/**
* Change user password
*
* Changes a user's password after validating the current password.
*
* @param {string} userId - User ID
* @param {"admin" | "project"} userType - User type
* @param {PasswordChangeRequest} passwordData - Password change data
* @param {string} passwordData.current_password - Current password
* @param {string} passwordData.new_password - New password
* @returns {Promise<boolean>} True if password changed successfully
* @throws {Error} If current password is incorrect or change fails
*
* @example
* const changed = await authService.changePassword('user-id', 'admin', {
* current_password: 'oldpass',
* new_password: 'newpass'
* });
*/
async changePassword(
userId: string,
userType: "admin" | "project",
passwordData: PasswordChangeRequest
): Promise<boolean> {
try {
const { current_password, new_password } = passwordData;
// Get current user
const table = userType === "admin" ? "admin_users" : "project_users";
const result = await this.db.query(
`SELECT password_hash FROM ${table} WHERE id = $1`,
[userId]
);
if (result.rows.length === 0) {
throw KrapiError.notFound(`User '${userId}' not found`, {
userId,
operation: "changePassword",
userType,
});
}
const currentPasswordHash = (result.rows[0] as PasswordHashRow)
.password_hash;
// Validate current password
const isValidPassword = await this.validatePassword(
current_password,
currentPasswordHash
);
if (!isValidPassword) {
throw KrapiError.authError("Current password is incorrect", {
operation: "changePassword",
userId,
userType,
});
}
// Hash new password
const newPasswordHash = await this.hashPassword(new_password);
// Update password
const updateResult = await this.db.query(
`UPDATE ${table} SET password_hash = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2`,
[newPasswordHash, userId]
);
return updateResult.rowCount > 0;
} catch (error) {
this.logger.error("Failed to change password:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "changePassword",
userId,
userType,
});
}
}
/**
* Reset user password
*
* Initiates or completes a password reset process.
* If no reset_token provided, generates and stores a reset token.
* If reset_token provided, validates it and updates the password.
*
* @param {PasswordResetRequest} resetData - Password reset data
* @param {string} resetData.email - User email
* @param {string} [resetData.reset_token] - Reset token (for completing reset)
* @param {string} [resetData.new_password] - New password (required when reset_token provided)
* @returns {Promise<{success: boolean, reset_token?: string}>} Reset result
* @throws {Error} If reset fails or token is invalid/expired
*
* @example
* // Initiate reset
* const { reset_token } = await authService.resetPassword({ email: 'user@example.com' });
*
* // Complete reset
* await authService.resetPassword({
* email: 'user@example.com',
* reset_token: 'rt_...',
* new_password: 'newpassword'
* });
*/
async resetPassword(
resetData: PasswordResetRequest
): Promise<{ success: boolean; reset_token?: string }> {
try {
if (!resetData.reset_token) {
// Generate and send reset token
const resetToken = this.generateResetToken();
// Store reset token (you might want to add a password_resets table)
await this.db.query(
`INSERT INTO password_resets (email, reset_token, expires_at)
VALUES ($1, $2, $3)
ON CONFLICT (email) DO UPDATE SET reset_token = $2, expires_at = $3, created_at = CURRENT_TIMESTAMP`,
[resetData.email, resetToken, new Date(Date.now() + 3600000)] // 1 hour expiry
);
return { success: true, reset_token: resetToken };
} else {
// Validate reset token and update password
if (!resetData.new_password) {
throw KrapiError.validationError(
"New password is required",
"new_password"
);
}
const result = await this.db.query(
`SELECT email FROM password_resets
WHERE reset_token = $1 AND expires_at > CURRENT_TIMESTAMP`,
[resetData.reset_token]
);
if (result.rows.length === 0) {
throw KrapiError.authError("Invalid or expired reset token", {
operation: "resetPassword",
});
}
const email = (result.rows[0] as EmailRow).email;
const newPasswordHash = await this.hashPassword(resetData.new_password);
// Update password in admin_users table
await this.db.query(
"UPDATE admin_users SET password_hash = $1, updated_at = CURRENT_TIMESTAMP WHERE email = $2",
[newPasswordHash, email]
);
// Remove used reset token
await this.db.query(
"DELETE FROM password_resets WHERE reset_token = $1",
[resetData.reset_token]
);
return { success: true };
}
} catch (error) {
this.logger.error("Failed to reset password:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "resetPassword",
});
}
}
// Utility Methods
private async hashPassword(password: string): Promise<string> {
try {
const saltRounds = 12;
return await bcrypt.hash(password, saltRounds);
} catch {
// Fallback for development
return `hashed_${password}`;
}
}
private async validatePassword(
password: string,
hash: string
): Promise<boolean> {
try {
return await bcrypt.compare(password, hash);
} catch {
// Fallback for development
return `hashed_${password}` === hash;
}
}
private generateSessionToken(): string {
return `st_${Math.random().toString(36).substring(2, 15)}${Math.random()
.toString(36)
.substring(2, 15)}${Date.now()}`;
}
private generateResetToken(): string {
return `rt_${Math.random().toString(36).substring(2, 15)}${Math.random()
.toString(36)
.substring(2, 15)}`;
}
private async updateLastLogin(
userId: string,
userType: "admin" | "project"
): Promise<void> {
try {
const table = userType === "admin" ? "admin_users" : "project_users";
await this.db.query(
`UPDATE ${table} SET last_login = CURRENT_TIMESTAMP, login_count = COALESCE(login_count, 0) + 1 WHERE id = $1`,
[userId]
);
} catch (error) {
this.logger.error("Failed to update last login:", error);
// Don't throw here as this shouldn't break the main authentication flow
}
}
// Session queries
/**
* Get session by ID
*
* Retrieves a session by its ID.
*
* @param {string} sessionId - Session ID
* @returns {Promise<Session | null>} Session or null if not found
* @throws {Error} If query fails
*
* @example
* const session = await authService.getSessionById('session-id');
*/
async getSessionById(sessionId: string): Promise<Session | null> {
try {
const result = await this.db.query(
"SELECT * FROM sessions WHERE id = $1",
[sessionId]
);
return result.rows.length > 0 ? (result.rows[0] as Session) : null;
} catch (error) {
this.logger.error("Failed to get session by ID:", error);
return null;
}
}
async getUserSessions(
userId: string,
userType: "admin" | "project"
): Promise<Session[]> {
try {
const result = await this.db.query(
"SELECT * FROM sessions WHERE user_id = $1 AND user_type = $2 AND is_active = true ORDER BY created_at DESC",
[userId, userType]
);
return result.rows as Session[];
} catch (error) {
this.logger.error("Failed to get user sessions:", error);
throw normalizeError(error, "INTERNAL_ERROR", {
operation: "getUserSessions",
userId,
userType,
});
}
}
}