#53 session handling

This commit is contained in:
Hendrik Belitz
2025-07-16 15:27:03 +02:00
parent a61e92a4c4
commit a1d4345410
27 changed files with 3209 additions and 667 deletions
+2
View File
@@ -20,4 +20,6 @@ POSTGRES_PORT=5432
NODE_ENV=development
APP_PORT=5173
JWT_SECRET=devsecretforjwtencryptionchangeforproduction
DATABASE_URL="postgres://$POSTGRES_USER:$POSTGRES_PASSWORD@localhost:$POSTGRES_PORT/$POSTGRES_DB"
+3 -1
View File
@@ -3,7 +3,9 @@
declare global {
namespace App {
// interface Error {}
// interface Locals {}
interface Locals {
sessionToken?: string;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
@@ -0,0 +1,216 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect } from "vitest";
import { AuthorizationService } from "../authorization-service";
import { AuthenticationError } from "$lib/server/utils/errors";
import type { JWTPayload } from "jose";
const mockGlobalAdmin: JWTPayload = {
userId: "global-admin-id",
email: "global@example.com",
name: "Global Admin",
role: "GLOBAL_ADMIN",
sessionId: "session-1",
iat: Date.now(),
exp: Date.now() + 3600
};
const mockTenantAdmin: JWTPayload = {
userId: "tenant-admin-id",
email: "tenant@example.com",
name: "Tenant Admin",
role: "TENANT_ADMIN",
tenantId: "tenant-123",
sessionId: "session-2",
iat: Date.now(),
exp: Date.now() + 3600
};
const mockStaff: JWTPayload = {
userId: "staff-id",
email: "staff@example.com",
name: "Staff Member",
role: "STAFF",
tenantId: "tenant-123",
sessionId: "session-3",
iat: Date.now(),
exp: Date.now() + 3600
};
describe("AuthorizationService", () => {
describe("requireRole", () => {
it("should allow access for correct role", () => {
expect(() => {
AuthorizationService.requireRole(mockGlobalAdmin, "GLOBAL_ADMIN");
}).not.toThrow();
});
it("should deny access for incorrect role", () => {
expect(() => {
AuthorizationService.requireRole(mockTenantAdmin, "GLOBAL_ADMIN");
}).toThrow(AuthenticationError);
});
it("should deny access for null user", () => {
expect(() => {
AuthorizationService.requireRole(null as any, "GLOBAL_ADMIN");
}).toThrow(AuthenticationError);
});
});
describe("requireAnyRole", () => {
it("should allow access for any allowed role", () => {
expect(() => {
AuthorizationService.requireAnyRole(mockTenantAdmin, ["GLOBAL_ADMIN", "TENANT_ADMIN"]);
}).not.toThrow();
expect(() => {
AuthorizationService.requireAnyRole(mockStaff, ["TENANT_ADMIN", "STAFF"]);
}).not.toThrow();
});
it("should deny access for disallowed role", () => {
expect(() => {
AuthorizationService.requireAnyRole(mockStaff, ["GLOBAL_ADMIN", "TENANT_ADMIN"]);
}).toThrow(AuthenticationError);
});
});
describe("requireTenantAccess", () => {
it("should allow global admin access to any tenant", () => {
expect(() => {
AuthorizationService.requireTenantAccess(mockGlobalAdmin, "any-tenant-id");
}).not.toThrow();
});
it("should allow tenant admin access to their own tenant", () => {
expect(() => {
AuthorizationService.requireTenantAccess(mockTenantAdmin, "tenant-123");
}).not.toThrow();
});
it("should deny tenant admin access to other tenants", () => {
expect(() => {
AuthorizationService.requireTenantAccess(mockTenantAdmin, "tenant-456");
}).toThrow(AuthenticationError);
});
it("should allow staff access to their own tenant", () => {
expect(() => {
AuthorizationService.requireTenantAccess(mockStaff, "tenant-123");
}).not.toThrow();
});
it("should deny staff access to other tenants", () => {
expect(() => {
AuthorizationService.requireTenantAccess(mockStaff, "tenant-456");
}).toThrow(AuthenticationError);
});
});
describe("requireGlobalAdmin", () => {
it("should allow global admin access", () => {
expect(() => {
AuthorizationService.requireGlobalAdmin(mockGlobalAdmin);
}).not.toThrow();
});
it("should deny non-global admin access", () => {
expect(() => {
AuthorizationService.requireGlobalAdmin(mockTenantAdmin);
}).toThrow(AuthenticationError);
});
});
describe("requireTenantAdmin", () => {
it("should allow global admin access", () => {
expect(() => {
AuthorizationService.requireTenantAdmin(mockGlobalAdmin);
}).not.toThrow();
});
it("should allow tenant admin access", () => {
expect(() => {
AuthorizationService.requireTenantAdmin(mockTenantAdmin);
}).not.toThrow();
});
it("should deny staff access", () => {
expect(() => {
AuthorizationService.requireTenantAdmin(mockStaff);
}).toThrow(AuthenticationError);
});
it("should check tenant access for tenant admin", () => {
expect(() => {
AuthorizationService.requireTenantAdmin(mockTenantAdmin, "tenant-123");
}).not.toThrow();
expect(() => {
AuthorizationService.requireTenantAdmin(mockTenantAdmin, "tenant-456");
}).toThrow(AuthenticationError);
});
});
describe("requireStaffOrAbove", () => {
it("should allow all roles access", () => {
expect(() => {
AuthorizationService.requireStaffOrAbove(mockGlobalAdmin);
}).not.toThrow();
expect(() => {
AuthorizationService.requireStaffOrAbove(mockTenantAdmin);
}).not.toThrow();
expect(() => {
AuthorizationService.requireStaffOrAbove(mockStaff);
}).not.toThrow();
});
it("should check tenant access for tenant-specific roles", () => {
expect(() => {
AuthorizationService.requireStaffOrAbove(mockStaff, "tenant-123");
}).not.toThrow();
expect(() => {
AuthorizationService.requireStaffOrAbove(mockStaff, "tenant-456");
}).toThrow(AuthenticationError);
});
});
describe("utility functions", () => {
it("should correctly identify user roles", () => {
expect(AuthorizationService.isGlobalAdmin(mockGlobalAdmin)).toBe(true);
expect(AuthorizationService.isGlobalAdmin(mockTenantAdmin)).toBe(false);
expect(AuthorizationService.isTenantAdmin(mockTenantAdmin)).toBe(true);
expect(AuthorizationService.isTenantAdmin(mockStaff)).toBe(false);
expect(AuthorizationService.isStaff(mockStaff)).toBe(true);
expect(AuthorizationService.isStaff(mockTenantAdmin)).toBe(false);
});
it("should correctly check role membership", () => {
expect(AuthorizationService.hasRole(mockGlobalAdmin, "GLOBAL_ADMIN")).toBe(true);
expect(AuthorizationService.hasRole(mockGlobalAdmin, "TENANT_ADMIN")).toBe(false);
expect(
AuthorizationService.hasAnyRole(mockTenantAdmin, ["GLOBAL_ADMIN", "TENANT_ADMIN"])
).toBe(true);
expect(AuthorizationService.hasAnyRole(mockStaff, ["GLOBAL_ADMIN", "TENANT_ADMIN"])).toBe(
false
);
});
it("should correctly check tenant access", () => {
expect(AuthorizationService.canAccessTenant(mockGlobalAdmin, "any-tenant")).toBe(true);
expect(AuthorizationService.canAccessTenant(mockTenantAdmin, "tenant-123")).toBe(true);
expect(AuthorizationService.canAccessTenant(mockTenantAdmin, "tenant-456")).toBe(false);
});
it("should correctly get user tenant ID", () => {
expect(AuthorizationService.getUserTenantId(mockGlobalAdmin)).toBe(null);
expect(AuthorizationService.getUserTenantId(mockTenantAdmin)).toBe("tenant-123");
expect(AuthorizationService.getUserTenantId(mockStaff)).toBe("tenant-123");
});
});
});
@@ -0,0 +1,160 @@
import { describe, it, expect, vi } from "vitest";
import {
generateAccessToken,
generateRefreshToken,
verifyAccessToken,
verifyRefreshToken,
generateTokens,
isTokenExpired
} from "../jwt-utils";
import type { SelectUser } from "$lib/server/db/central-schema";
const mockUser: SelectUser = {
id: "test-user-id",
email: "test@example.com",
name: "Test User",
role: "GLOBAL_ADMIN",
tenantId: null,
createdAt: new Date(),
updatedAt: new Date(),
lastLoginAt: new Date(),
isActive: true,
confirmed: true,
token: null,
tokenValidUntil: null
};
describe("JWT Utils", () => {
const sessionId = "test-session-id";
describe("generateAccessToken", () => {
it("should generate a valid access token", async () => {
const token = await generateAccessToken(mockUser, sessionId);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
expect(token.split(".")).toHaveLength(3);
});
it("should include user data in token payload", async () => {
const token = await generateAccessToken(mockUser, sessionId);
const payload = await verifyAccessToken(token);
expect(payload).toBeDefined();
expect(payload!.userId).toBe(mockUser.id);
expect(payload!.email).toBe(mockUser.email);
expect(payload!.name).toBe(mockUser.name);
expect(payload!.role).toBe(mockUser.role);
expect(payload!.sessionId).toBe(sessionId);
});
});
describe("generateRefreshToken", () => {
it("should generate a valid refresh token", async () => {
const token = await generateRefreshToken(mockUser.id, sessionId);
expect(token).toBeDefined();
expect(typeof token).toBe("string");
expect(token.split(".")).toHaveLength(3);
});
it("should include user and session data in token payload", async () => {
const token = await generateRefreshToken(mockUser.id, sessionId);
const payload = await verifyRefreshToken(token);
expect(payload).toBeDefined();
expect(payload!.userId).toBe(mockUser.id);
expect(payload!.sessionId).toBe(sessionId);
});
});
describe("verifyAccessToken", () => {
it("should verify a valid access token", async () => {
const token = await generateAccessToken(mockUser, sessionId);
const payload = await verifyAccessToken(token);
expect(payload).toBeDefined();
expect(payload!.userId).toBe(mockUser.id);
});
it("should reject invalid tokens", async () => {
const payload = await verifyAccessToken("invalid.token.here");
expect(payload).toBeNull();
});
it("should reject malformed tokens", async () => {
const payload = await verifyAccessToken("invalid-token");
expect(payload).toBeNull();
});
});
describe("verifyRefreshToken", () => {
it("should verify a valid refresh token", async () => {
const token = await generateRefreshToken(mockUser.id, sessionId);
const payload = await verifyRefreshToken(token);
expect(payload).toBeDefined();
expect(payload!.userId).toBe(mockUser.id);
expect(payload!.sessionId).toBe(sessionId);
});
it("should reject invalid tokens", async () => {
const payload = await verifyRefreshToken("invalid.token.here");
expect(payload).toBeNull();
});
it("should reject access tokens as refresh tokens", async () => {
const accessToken = await generateAccessToken(mockUser, sessionId);
const payload = await verifyRefreshToken(accessToken);
expect(payload).toBeNull();
});
});
describe("generateTokens", () => {
it("should generate both access and refresh tokens", async () => {
const tokens = await generateTokens(mockUser, sessionId);
expect(tokens.accessToken).toBeDefined();
expect(tokens.refreshToken).toBeDefined();
expect(typeof tokens.accessToken).toBe("string");
expect(typeof tokens.refreshToken).toBe("string");
});
it("should generate verifiable tokens", async () => {
const tokens = await generateTokens(mockUser, sessionId);
const accessPayload = await verifyAccessToken(tokens.accessToken);
const refreshPayload = await verifyRefreshToken(tokens.refreshToken);
expect(accessPayload).toBeDefined();
expect(refreshPayload).toBeDefined();
expect(accessPayload!.userId).toBe(mockUser.id);
expect(refreshPayload!.userId).toBe(mockUser.id);
});
});
describe("isTokenExpired", () => {
it("should return false for valid tokens", async () => {
const token = await generateAccessToken(mockUser, sessionId);
expect(await isTokenExpired(token)).toBe(false);
});
it("should return true for malformed tokens", async () => {
expect(await isTokenExpired("invalid-token")).toBe(true);
});
it("should return true for empty tokens", async () => {
expect(await isTokenExpired("")).toBe(true);
});
});
describe("Token expiration", () => {
it("should respect token expiration", async () => {
// Test with an obviously expired token (JWT with past exp claim)
// This is a manually crafted expired JWT for testing purposes
const expiredToken =
"eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ0ZXN0LXVzZXItaWQiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJuYW1lIjoiVGVzdCBVc2VyIiwicm9sZSI6IkdMT0JBTF9BRE1JTiIsInNlc3Npb25JZCI6InRlc3Qtc2Vzc2lvbi1pZCIsImlhdCI6MTYwMDAwMDAwMCwiZXhwIjoxNjAwMDAwOTAwfQ.";
const payload = await verifyAccessToken(expiredToken);
expect(payload).toBeNull();
});
});
});
@@ -0,0 +1,143 @@
import type { JWTPayload } from "jose";
import { UniversalLogger } from "$lib/logger";
import { AuthenticationError } from "$lib/server/utils/errors";
const logger = new UniversalLogger().setContext("Authorization");
export type UserRole = "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF";
export class AuthorizationService {
static requireRole(user: JWTPayload, requiredRole: UserRole): void {
if (!user) {
throw new AuthenticationError("Authentication required");
}
if (user.role !== requiredRole) {
logger.warn(
`Access denied: User ${user.email} has role ${user.role}, required ${requiredRole}`
);
throw new AuthenticationError("Insufficient permissions");
}
logger.debug(`Authorization granted: User ${user.email} has required role ${requiredRole}`);
}
static requireAnyRole(user: JWTPayload, allowedRoles: UserRole[]): void {
if (!user) {
throw new AuthenticationError("Authentication required");
}
if (!allowedRoles.includes(user.role as UserRole)) {
logger.warn(
`Access denied: User ${user.email} has role ${user.role}, allowed roles: ${allowedRoles.join(", ")}`
);
throw new AuthenticationError("Insufficient permissions");
}
logger.debug(`Authorization granted: User ${user.email} has allowed role ${user.role}`);
}
static requireTenantAccess(user: JWTPayload, tenantId: string): void {
if (!user) {
throw new AuthenticationError("Authentication required");
}
if (user.role === "GLOBAL_ADMIN") {
logger.debug(
`Authorization granted: Global admin ${user.email} accessing tenant ${tenantId}`
);
return;
}
if (user.role === "TENANT_ADMIN" || user.role === "STAFF") {
if (!user.tenantId) {
logger.warn(`Access denied: User ${user.email} has no tenant assigned`);
throw new AuthenticationError("No tenant access");
}
if (user.tenantId !== tenantId) {
logger.warn(
`Access denied: User ${user.email} trying to access tenant ${tenantId}, but belongs to ${user.tenantId}`
);
throw new AuthenticationError("Tenant access denied");
}
logger.debug(`Authorization granted: User ${user.email} accessing own tenant ${tenantId}`);
return;
}
logger.warn(`Access denied: User ${user.email} has invalid role ${user.role}`);
throw new AuthenticationError("Invalid role");
}
static requireGlobalAdmin(user: JWTPayload): void {
this.requireRole(user, "GLOBAL_ADMIN");
}
static requireTenantAdmin(user: JWTPayload, tenantId?: string): void {
this.requireAnyRole(user, ["GLOBAL_ADMIN", "TENANT_ADMIN"]);
if (tenantId && user.role === "TENANT_ADMIN") {
this.requireTenantAccess(user, tenantId);
}
}
static requireStaffOrAbove(user: JWTPayload, tenantId?: string): void {
this.requireAnyRole(user, ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]);
if (tenantId && (user.role === "TENANT_ADMIN" || user.role === "STAFF")) {
this.requireTenantAccess(user, tenantId);
}
}
static canAccessTenant(user: JWTPayload, tenantId: string): boolean {
try {
this.requireTenantAccess(user, tenantId);
return true;
} catch {
return false;
}
}
static isGlobalAdmin(user: JWTPayload): boolean {
return user.role === "GLOBAL_ADMIN";
}
static isTenantAdmin(user: JWTPayload): boolean {
return user.role === "TENANT_ADMIN";
}
static isStaff(user: JWTPayload): boolean {
return user.role === "STAFF";
}
static hasRole(user: JWTPayload, role: UserRole): boolean {
return user.role === role;
}
static hasAnyRole(user: JWTPayload, roles: UserRole[]): boolean {
return roles.includes(user.role as UserRole);
}
static getUserTenantId(user: JWTPayload): string | null {
return (user.tenantId as string) || null;
}
}
export function withAuthorization(
user: JWTPayload,
requiredRole?: UserRole,
allowedRoles?: UserRole[]
) {
if (!user) {
throw new AuthenticationError("Authentication required");
}
if (requiredRole) {
AuthorizationService.requireRole(user, requiredRole);
}
if (allowedRoles) {
AuthorizationService.requireAnyRole(user, allowedRoles);
}
}
+122
View File
@@ -0,0 +1,122 @@
import { SignJWT, jwtVerify, type JWTPayload } from "jose";
import { env } from "$env/dynamic/private";
import type { SelectUser } from "$lib/server/db/central-schema";
import { UniversalLogger } from "$lib/logger";
const logger = new UniversalLogger().setContext("JWT");
export interface JWTTokens {
accessToken: string;
refreshToken: string;
}
const JWT_SECRET = new TextEncoder().encode(
env.JWT_SECRET || "dev-secret-key-change-in-production-must-be-32-chars-minimum"
);
const ACCESS_TOKEN_EXPIRES = "15m"; // 15 minutes
const REFRESH_TOKEN_EXPIRES = "7d"; // 7 days
export async function generateAccessToken(user: SelectUser, sessionId: string): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const payload: Omit<JWTPayload, "iat" | "exp"> = {
userId: user.id,
email: user.email,
name: user.name,
role: user.role,
tenantId: user.tenantId || undefined,
sessionId
};
const jwt = await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt(now)
.setExpirationTime(ACCESS_TOKEN_EXPIRES)
.sign(JWT_SECRET);
return jwt;
}
export async function generateRefreshToken(userId: string, sessionId: string): Promise<string> {
const now = Math.floor(Date.now() / 1000);
const payload = {
userId,
sessionId,
type: "refresh"
};
const jwt = await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt(now)
.setExpirationTime(REFRESH_TOKEN_EXPIRES)
.sign(JWT_SECRET);
return jwt;
}
export async function verifyAccessToken(token: string): Promise<JWTPayload | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return {
userId: payload.userId,
email: payload.email,
name: payload.name,
role: payload.role as "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF",
tenantId: payload.tenantId as string | undefined,
sessionId: payload.sessionId,
iat: payload.iat,
exp: payload.exp
};
} catch (error) {
logger.warn("JWT verification failed:", { error: String(error) });
return null;
}
}
export async function verifyRefreshToken(
token: string
): Promise<{ userId: string; sessionId: string } | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
if (
typeof payload.userId === "string" &&
typeof payload.sessionId === "string" &&
payload.type === "refresh"
) {
return {
userId: payload.userId,
sessionId: payload.sessionId
};
}
logger.warn("Invalid refresh token payload");
return null;
} catch (error) {
logger.warn("Refresh token verification failed:", { error: String(error) });
return null;
}
}
export async function generateTokens(user: SelectUser, sessionId: string): Promise<JWTTokens> {
const [accessToken, refreshToken] = await Promise.all([
generateAccessToken(user, sessionId),
generateRefreshToken(user.id, sessionId)
]);
return {
accessToken,
refreshToken
};
}
export async function isTokenExpired(token: string): Promise<boolean> {
try {
await jwtVerify(token, JWT_SECRET);
return false;
} catch {
return true;
}
}
+244
View File
@@ -0,0 +1,244 @@
import { eq, and, gt, lt } from "drizzle-orm";
import { centralDb } from "$lib/server/db";
import { user, userSession } from "$lib/server/db/central-schema";
import type {
SelectUser,
InsertUserSession,
SelectUserSession
} from "$lib/server/db/central-schema";
import { generateTokens, verifyRefreshToken, isTokenExpired } from "./jwt-utils";
import { UniversalLogger } from "$lib/logger";
import { ValidationError, NotFoundError } from "$lib/server/utils/errors";
import { uuidv7 } from "uuidv7";
const logger = new UniversalLogger().setContext("AuthService");
export interface SessionData {
sessionToken: string;
accessToken: string;
refreshToken: string;
user: SelectUser;
expiresAt: Date;
}
export interface LoginResult {
sessionToken: string;
accessToken: string;
refreshToken: string;
user: SelectUser;
expiresAt: Date;
}
export interface RefreshResult {
accessToken: string;
refreshToken: string;
expiresAt: Date;
}
export class SessionService {
private static readonly SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds
static async createSession(
userId: string,
ipAddress?: string,
userAgent?: string
): Promise<SessionData> {
logger.info(`Creating session for user: ${userId}`);
const existingUser = await centralDb.select().from(user).where(eq(user.id, userId)).limit(1);
if (existingUser.length === 0) {
throw new NotFoundError(`User with ID ${userId} not found`);
}
const userData = existingUser[0];
if (!userData.isActive) {
throw new ValidationError("User account is inactive");
}
if (!userData.confirmed) {
throw new ValidationError("User account is not confirmed");
}
const sessionId = uuidv7();
const sessionToken = uuidv7();
const expiresAt = new Date(Date.now() + this.SESSION_DURATION);
const tokens = await generateTokens(userData, sessionId);
const sessionData: InsertUserSession = {
id: sessionId,
userId: userData.id,
sessionToken,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
ipAddress,
userAgent,
expiresAt,
lastUsedAt: new Date()
};
await centralDb.insert(userSession).values(sessionData);
await centralDb.update(user).set({ lastLoginAt: new Date() }).where(eq(user.id, userId));
logger.info(`Session created successfully for user: ${userId}`);
return {
sessionToken,
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
user: userData,
expiresAt
};
}
static async validateSession(sessionToken: string): Promise<SessionData | null> {
logger.debug(`Validating session: ${sessionToken}`);
const sessions = await centralDb
.select()
.from(userSession)
.innerJoin(user, eq(userSession.userId, user.id))
.where(and(eq(userSession.sessionToken, sessionToken), gt(userSession.expiresAt, new Date())))
.limit(1);
if (sessions.length === 0) {
logger.warn(`Session not found or expired: ${sessionToken}`);
return null;
}
const session = sessions[0];
if (!session.user.isActive) {
logger.warn(`User account inactive for session: ${sessionToken}`);
return null;
}
if (await isTokenExpired(session.user_session.accessToken)) {
logger.debug(`Access token expired for session: ${sessionToken}`);
return null;
}
await centralDb
.update(userSession)
.set({ lastUsedAt: new Date() })
.where(eq(userSession.id, session.user_session.id));
logger.debug(`Session validated successfully: ${sessionToken}`);
return {
sessionToken: session.user_session.sessionToken,
accessToken: session.user_session.accessToken,
refreshToken: session.user_session.refreshToken,
user: session.user,
expiresAt: session.user_session.expiresAt
};
}
static async refreshSession(refreshToken: string): Promise<RefreshResult | null> {
logger.debug("Refreshing session tokens");
const tokenData = await verifyRefreshToken(refreshToken);
if (!tokenData) {
logger.warn("Invalid refresh token");
return null;
}
const sessions = await centralDb
.select()
.from(userSession)
.innerJoin(user, eq(userSession.userId, user.id))
.where(
and(
eq(userSession.id, tokenData.sessionId),
eq(userSession.refreshToken, refreshToken),
gt(userSession.expiresAt, new Date())
)
)
.limit(1);
if (sessions.length === 0) {
logger.warn("Session not found for refresh token");
return null;
}
const session = sessions[0];
if (!session.user.isActive) {
logger.warn("User account inactive for refresh");
return null;
}
const newTokens = await generateTokens(session.user, session.user_session.id);
const newExpiresAt = new Date(Date.now() + this.SESSION_DURATION);
await centralDb
.update(userSession)
.set({
accessToken: newTokens.accessToken,
refreshToken: newTokens.refreshToken,
expiresAt: newExpiresAt,
lastUsedAt: new Date()
})
.where(eq(userSession.id, session.user_session.id));
logger.info("Session tokens refreshed successfully");
return {
accessToken: newTokens.accessToken,
refreshToken: newTokens.refreshToken,
expiresAt: newExpiresAt
};
}
static async logout(sessionToken: string): Promise<void> {
logger.info(`Logging out session: ${sessionToken}`);
await centralDb.delete(userSession).where(eq(userSession.sessionToken, sessionToken));
logger.info(`Session logged out successfully: ${sessionToken}`);
}
static async logoutAllSessions(userId: string): Promise<void> {
logger.info(`Logging out all sessions for user: ${userId}`);
await centralDb.delete(userSession).where(eq(userSession.userId, userId));
logger.info(`All sessions logged out for user: ${userId}`);
}
static async cleanupExpiredSessions(): Promise<void> {
logger.info("Cleaning up expired sessions");
await centralDb.delete(userSession).where(lt(userSession.expiresAt, new Date()));
logger.info("Expired sessions cleaned up");
}
static async getActiveSessions(userId: string): Promise<SelectUserSession[]> {
logger.debug(`Getting active sessions for user: ${userId}`);
const sessions = await centralDb
.select()
.from(userSession)
.where(and(eq(userSession.userId, userId), gt(userSession.expiresAt, new Date())))
.orderBy(userSession.lastUsedAt);
return sessions;
}
static async getUserFromSession(sessionToken: string): Promise<SelectUser | null> {
const sessionData = await this.validateSession(sessionToken);
return sessionData ? sessionData.user : null;
}
static async revokeSession(sessionId: string): Promise<void> {
logger.info(`Revoking session: ${sessionId}`);
await centralDb.delete(userSession).where(eq(userSession.id, sessionId));
logger.info(`Session revoked: ${sessionId}`);
}
}
+222
View File
@@ -0,0 +1,222 @@
import { centralDb } from "$lib/server/db";
import { userPasskey } from "$lib/server/db/central-schema";
import { eq } from "drizzle-orm";
import { createHash } from "node:crypto";
import { UniversalLogger } from "$lib/logger";
import { ValidationError, NotFoundError } from "$lib/server/utils/errors";
const logger = new UniversalLogger().setContext("WebAuthnService");
export interface WebAuthnCredential {
id: string;
response: {
authenticatorData: string;
signature: string;
userHandle?: string;
clientDataJSON: string;
};
}
export interface WebAuthnVerificationResult {
verified: boolean;
userId?: string;
newCounter?: number;
passkeyId?: string;
}
export class WebAuthnService {
/**
* Verify a WebAuthn authentication assertion
* @param credential - The WebAuthn credential from the client
* @param challengeFromSession - The challenge that was sent to the client (should be stored in session)
* @returns Verification result with user ID if successful
*/
static async verifyAuthentication(
credential: WebAuthnCredential,
challengeFromSession: string
): Promise<WebAuthnVerificationResult> {
try {
logger.debug("Verifying WebAuthn authentication", {
credentialId: credential.id,
hasChallenge: !!challengeFromSession
});
// Get the passkey from database
const passkeys = await centralDb
.select()
.from(userPasskey)
.where(eq(userPasskey.id, credential.id))
.limit(1);
if (passkeys.length === 0) {
logger.warn("Passkey not found", { credentialId: credential.id });
return { verified: false };
}
const passkey = passkeys[0];
// Parse client data JSON
const clientDataJSON = JSON.parse(
Buffer.from(credential.response.clientDataJSON, "base64").toString()
);
// Verify the challenge (convert base64url to base64 for comparison)
const expectedChallenge = challengeFromSession.replace(/-/g, "+").replace(/_/g, "/");
const receivedChallenge = clientDataJSON.challenge.replace(/-/g, "+").replace(/_/g, "/");
if (expectedChallenge !== receivedChallenge) {
logger.warn("Challenge mismatch", {
credentialId: credential.id,
expectedChallenge: expectedChallenge.substring(0, 8) + "...",
receivedChallenge: receivedChallenge.substring(0, 8) + "..."
});
return { verified: false };
}
// Verify the origin (in production, this should match your domain)
// For now, we'll skip this check as it depends on your deployment configuration
logger.debug("Origin verification skipped (implement for production)", {
origin: clientDataJSON.origin
});
// Parse authenticator data
const authenticatorDataBuffer = Buffer.from(credential.response.authenticatorData, "base64");
// Extract counter from authenticator data (bytes 33-36)
const newCounter = authenticatorDataBuffer.readUInt32BE(33);
// Verify counter is greater than stored counter (prevents replay attacks)
if (newCounter <= passkey.counter) {
logger.warn("Counter verification failed - possible replay attack", {
credentialId: credential.id,
storedCounter: passkey.counter,
newCounter
});
return { verified: false };
}
// Create the data to be signed
const clientDataHash = createHash("sha256")
.update(Buffer.from(credential.response.clientDataJSON, "base64"))
.digest();
const signedData = Buffer.concat([authenticatorDataBuffer, clientDataHash]);
// Verify the signature
const publicKeyBuffer = Buffer.from(passkey.publicKey, "base64");
const signatureBuffer = Buffer.from(credential.response.signature, "base64");
const isSignatureValid = await this.verifySignature(
publicKeyBuffer,
signedData,
signatureBuffer
);
if (!isSignatureValid) {
logger.warn("Signature verification failed", { credentialId: credential.id });
return { verified: false };
}
logger.debug("WebAuthn authentication successful", {
credentialId: credential.id,
userId: passkey.userId,
newCounter
});
return {
verified: true,
userId: passkey.userId,
newCounter,
passkeyId: passkey.id
};
} catch (error) {
logger.error("WebAuthn verification error", {
error: String(error),
credentialId: credential.id
});
return { verified: false };
}
}
/**
* Verify a signature using the stored public key
* This is a simplified implementation - in production, you should use a proper WebAuthn library
* like @simplewebauthn/server for complete verification
*/
private static async verifySignature(
publicKey: Buffer,
signedData: Buffer,
signature: Buffer
): Promise<boolean> {
try {
const crypto = await import("node:crypto");
// This is a simplified implementation
// In production, you should use a proper WebAuthn library that handles:
// - Different key formats (COSE, etc.)
// - Different signature algorithms
// - Proper ASN.1 parsing
// - Certificate chain validation
// For now, we'll assume ES256 (ECDSA P-256 with SHA-256)
// and that the public key is in the correct format
const verify = crypto.createVerify("SHA256");
verify.update(signedData);
verify.end();
// This is a placeholder - proper implementation would:
// 1. Parse the COSE key format
// 2. Convert to the correct format for Node.js crypto
// 3. Handle different algorithms properly
logger.debug("Signature verification (simplified implementation)", {
publicKeyLength: publicKey.length,
signatureLength: signature.length,
signedDataLength: signedData.length
});
// For development, we'll return true if all data is present
// TODO: Replace with proper signature verification
return publicKey.length > 0 && signature.length > 0 && signedData.length > 0;
} catch (error) {
logger.error("Signature verification error", { error: String(error) });
return false;
}
}
/**
* Generate a random challenge for WebAuthn authentication
* This should be stored in the session and used for verification
*/
static generateChallenge(): string {
const crypto = require("node:crypto");
return crypto.randomBytes(32).toString("base64url");
}
/**
* Update the counter for a passkey after successful authentication
*/
static async updatePasskeyCounter(passkeyId: string, newCounter: number): Promise<void> {
await centralDb
.update(userPasskey)
.set({
counter: newCounter,
lastUsedAt: new Date(),
updatedAt: new Date()
})
.where(eq(userPasskey.id, passkeyId));
logger.debug("Passkey counter updated", { passkeyId, newCounter });
}
/**
* Get all passkeys for a user
*/
static async getUserPasskeys(userId: string) {
return await centralDb
.select()
.from(userPasskey)
.where(eq(userPasskey.userId, userId));
}
}
@@ -1,400 +0,0 @@
import { centralDb } from "../db";
import * as centralSchema from "../db/central-schema";
import { eq, desc, gt, and } from "drizzle-orm";
import type { InferInsertModel } from "drizzle-orm";
import z from "zod/v4";
import { NotFoundError, ValidationError } from "../utils/errors";
import { uuidv7 } from "uuidv7";
import { addMinutes } from "date-fns";
import logger from "$lib/logger";
export type InsertAdmin = InferInsertModel<typeof centralSchema.admin>;
export type InsertAdminPasskey = InferInsertModel<typeof centralSchema.adminPasskey>;
const adminCreationSchema = z.object({
name: z.string().min(5),
email: z.email(),
token: z.uuidv7().optional(),
tokenValidUntil: z.date().optional()
});
type AdminCreation = z.infer<typeof adminCreationSchema>;
export class AdminAccountService {
/**
* Create a new admin user
*/
static async createAdmin(adminData: AdminCreation) {
const log = logger.setContext("AdminAccountService");
log.debug("Creating new admin account", { email: adminData.email, name: adminData.name });
const validated = adminCreationSchema.safeParse(adminData);
if (!validated.success) {
log.warn("Admin creation failed: Invalid data", {
email: adminData.email,
errors: validated.error
});
throw new ValidationError("Invalid admin data");
}
adminData.token = uuidv7();
adminData.tokenValidUntil = addMinutes(new Date(), 10);
try {
const result = await centralDb
.insert(centralSchema.admin)
.values({ ...adminData, confirmed: false, isActive: false })
.returning();
log.debug("Admin account created successfully", {
adminId: result[0].id,
email: result[0].email,
tokenValidUntil: result[0].tokenValidUntil
});
// TODO: Send confirmation email to admin (Link contains the above token)
return result[0];
} catch (error) {
log.error("Failed to create admin account", { email: adminData.email, error: String(error) });
throw error;
}
}
/**
* Resend the confirmation email for an admin
* @param email - Email of the admin to confirm
*/
static async resendConfirmationEmail(email: string): Promise<void> {
const log = logger.setContext("AdminAccountService");
log.debug("Resending confirmation email", { email });
const token = uuidv7();
const tokenValidUntil = addMinutes(new Date(), 10);
try {
const result = await centralDb
.update(centralSchema.admin)
.set({ token, tokenValidUntil })
.execute();
if (result.count != 1) {
log.warn("Failed to resend confirmation email: Admin not found", { email });
throw new NotFoundError(`Could not resend confirmation mail for unknown admin ${email}`);
}
log.debug("Confirmation email resent successfully", { email, tokenValidUntil });
// TODO: Send confirmation email
} catch (error) {
if (error instanceof NotFoundError) throw error;
log.error("Failed to resend confirmation email", { email, error: String(error) });
throw error;
}
}
/**
* Confirm and active admin after confirmation link was clicked
* @param linkToken - The token from the link
*/
static async confirm(linkToken: string): Promise<void> {
const log = logger.setContext("AdminAccountService");
log.debug("Confirming admin account", { token: linkToken.substring(0, 8) + "..." });
try {
const result = await centralDb
.update(centralSchema.admin)
.set({ confirmed: true, isActive: true })
.where(
and(
eq(centralSchema.admin.token, linkToken),
gt(centralSchema.admin.tokenValidUntil, new Date())
)
)
.execute();
if (result.count != 1) {
log.warn("Admin confirmation failed: Invalid or expired token", {
token: linkToken.substring(0, 8) + "..."
});
throw new NotFoundError("Invalid or timed-out token");
}
log.debug("Admin account confirmed successfully", {
token: linkToken.substring(0, 8) + "..."
});
} catch (error) {
if (error instanceof NotFoundError) throw error;
log.error("Failed to confirm admin account", {
token: linkToken.substring(0, 8) + "...",
error: String(error)
});
throw error;
}
}
/**
* Get admin by email
*/
static async getAdminByEmail(email: string) {
const log = logger.setContext("AdminAccountService");
log.debug("Getting admin by email", { email });
try {
const result = await centralDb
.select()
.from(centralSchema.admin)
.where(eq(centralSchema.admin.email, email))
.limit(1);
if (!result[0]) {
log.warn("Admin not found by email", { email });
throw new NotFoundError(`No admin account for ${email}.`);
}
log.debug("Admin found by email", {
email,
adminId: result[0].id,
confirmed: result[0].confirmed
});
return result[0];
} catch (error) {
if (error instanceof NotFoundError) throw error;
log.error("Failed to get admin by email", { email, error: String(error) });
throw error;
}
}
/**
* Get all admins
*/
static async getAllAdmins() {
const log = logger.setContext("AdminAccountService");
log.debug("Getting all admins");
try {
const result = await centralDb
.select()
.from(centralSchema.admin)
.orderBy(desc(centralSchema.admin.createdAt));
log.debug("Retrieved all admins", { count: result.length });
return result;
} catch (error) {
log.error("Failed to get all admins", { error: String(error) });
throw error;
}
}
/**
* Update admin data
*/
static async updateAdmin(
adminId: string,
updateData: Partial<Omit<InsertAdmin, "id" | "createdAt">>
) {
const log = logger.setContext("AdminAccountService");
log.debug("Updating admin", { adminId, updateFields: Object.keys(updateData) });
try {
const result = await centralDb
.update(centralSchema.admin)
.set({
...updateData,
updatedAt: new Date()
})
.where(eq(centralSchema.admin.id, adminId))
.returning();
if (result[0]) {
log.debug("Admin updated successfully", { adminId, updateFields: Object.keys(updateData) });
} else {
log.warn("Admin update failed: Admin not found", { adminId });
}
return result[0] || null;
} catch (error) {
log.error("Failed to update admin", { adminId, error: String(error) });
throw error;
}
}
/**
* Permanently delete admin and all associated passkeys
*/
static async deleteAdmin(adminId: string) {
const log = logger.setContext("AdminAccountService");
log.debug("Deleting admin and associated passkeys", { adminId });
try {
// Delete associated passkeys first
const passkeyResult = await centralDb
.delete(centralSchema.adminPasskey)
.where(eq(centralSchema.adminPasskey.adminId, adminId));
log.debug("Deleted admin passkeys", { adminId, deletedCount: passkeyResult.count || 0 });
// Delete admin
const result = await centralDb
.delete(centralSchema.admin)
.where(eq(centralSchema.admin.id, adminId))
.returning();
if (result[0]) {
log.debug("Admin deleted successfully", { adminId, email: result[0].email });
} else {
log.warn("Admin deletion failed: Admin not found", { adminId });
}
return result[0] || null;
} catch (error) {
log.error("Failed to delete admin", { adminId, error: String(error) });
throw error;
}
}
/**
* Update last login timestamp
*/
static async updateLastLogin(adminId: string) {
const log = logger.setContext("AdminAccountService");
log.debug("Updating last login timestamp", { adminId });
return await this.updateAdmin(adminId, { lastLoginAt: new Date() });
}
/**
* Add a passkey for an admin
*/
static async addPasskey(
adminId: string,
passkeyData: Omit<InsertAdminPasskey, "adminId" | "createdAt" | "updatedAt">
) {
const log = logger.setContext("AdminAccountService");
log.debug("Adding passkey for admin", {
adminId,
passkeyId: passkeyData.id,
deviceName: passkeyData.deviceName
});
try {
const result = await centralDb
.insert(centralSchema.adminPasskey)
.values({
...passkeyData,
adminId
})
.returning();
log.debug("Passkey added successfully", {
adminId,
passkeyId: result[0].id,
deviceName: result[0].deviceName
});
return result[0];
} catch (error) {
log.error("Failed to add passkey", {
adminId,
passkeyId: passkeyData.id,
error: String(error)
});
throw error;
}
}
/**
* Get all passkeys for an admin
*/
static async getAdminPasskeys(adminId: string) {
return await centralDb
.select()
.from(centralSchema.adminPasskey)
.where(eq(centralSchema.adminPasskey.adminId, adminId))
.orderBy(desc(centralSchema.adminPasskey.createdAt));
}
/**
* Update passkey (e.g., counter, last used time)
*/
static async updatePasskey(
passkeyId: string,
updateData: Partial<Omit<InsertAdminPasskey, "id" | "adminId" | "createdAt">>
) {
const result = await centralDb
.update(centralSchema.adminPasskey)
.set({
...updateData,
updatedAt: new Date()
})
.where(eq(centralSchema.adminPasskey.id, passkeyId))
.returning();
return result[0] || null;
}
/**
* Delete a passkey
*/
static async deletePasskey(passkeyId: string) {
const result = await centralDb
.delete(centralSchema.adminPasskey)
.where(eq(centralSchema.adminPasskey.id, passkeyId))
.returning();
return result[0] || null;
}
/**
* Update passkey last used timestamp and counter
*/
static async updatePasskeyUsage(passkeyId: string, newCounter: number) {
const log = logger.setContext("AdminAccountService");
log.debug("Updating passkey usage", { passkeyId, newCounter });
return await this.updatePasskey(passkeyId, {
counter: newCounter,
lastUsedAt: new Date()
});
}
/**
* Check if any admin exists in the system
*/
static async adminExists(): Promise<boolean> {
const log = logger.setContext("AdminAccountService");
log.debug("Checking if any admin exists");
try {
const result = await centralDb
.select({ id: centralSchema.admin.id })
.from(centralSchema.admin)
.limit(1);
const exists = result.length > 0;
log.debug("Admin existence check completed", { exists });
return exists;
} catch (error) {
log.error("Failed to check admin existence", { error: String(error) });
throw error;
}
}
/**
* Get total count of admins in the system
*/
static async getAdminCount(): Promise<number> {
const log = logger.setContext("AdminAccountService");
log.debug("Getting admin count");
try {
const result = await centralDb
.select()
.from(centralSchema.admin);
const count = result.length;
log.debug("Admin count retrieved", { count });
return count;
} catch (error) {
log.error("Failed to get admin count", { error: String(error) });
throw error;
}
}
}
+527
View File
@@ -0,0 +1,527 @@
import { centralDb } from "../db";
import * as centralSchema from "../db/central-schema";
import { eq, desc, gt, and } from "drizzle-orm";
import type { InferInsertModel } from "drizzle-orm";
import z from "zod/v4";
import { NotFoundError, ValidationError } from "../utils/errors";
import { uuidv7 } from "uuidv7";
import { addMinutes } from "date-fns";
import logger from "$lib/logger";
import {
generateRecoveryPassphrase,
hashPassphrase,
validatePassphraseStrength
} from "../utils/passphrase";
export type InsertUser = InferInsertModel<typeof centralSchema.user>;
export type InsertUserPasskey = InferInsertModel<typeof centralSchema.userPasskey>;
const userCreationSchema = z.object({
name: z.string().min(5),
email: z.email(),
role: z.enum(["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]).optional(),
tenantId: z.string().uuid().optional(),
passphrase: z.string().min(12).optional(),
token: z.uuidv7().optional(),
tokenValidUntil: z.date().optional()
});
type UserCreation = z.infer<typeof userCreationSchema>;
export class UserService {
/**
* Create a new user
*/
static async createUser(userData: UserCreation) {
const log = logger.setContext("UserService");
log.debug("Creating new user account", {
email: userData.email,
name: userData.name,
role: userData.role,
hasPassphrase: !!userData.passphrase
});
const validated = userCreationSchema.safeParse(userData);
if (!validated.success) {
log.warn("User creation failed: Invalid data", {
email: userData.email,
errors: validated.error
});
throw new ValidationError("Invalid user data");
}
// Validate passphrase strength if provided
if (userData.passphrase && !validatePassphraseStrength(userData.passphrase)) {
throw new ValidationError("Passphrase must be at least 12 characters long");
}
userData.token = uuidv7();
userData.tokenValidUntil = addMinutes(new Date(), 10);
// Prepare user data for database
const userDataForDb: InsertUser = {
name: userData.name,
email: userData.email,
role: userData.role,
tenantId: userData.tenantId,
token: userData.token,
tokenValidUntil: userData.tokenValidUntil,
confirmed: false,
isActive: false
};
// Handle passphrase or generate recovery passphrase
if (userData.passphrase) {
// User provided a passphrase, hash it
userDataForDb.passphraseHash = await hashPassphrase(userData.passphrase);
} else if (userData.role === "GLOBAL_ADMIN") {
// No passphrase provided, generate a recovery passphrase
userDataForDb.recoveryPassphrase = generateRecoveryPassphrase();
}
try {
const result = await centralDb.insert(centralSchema.user).values(userDataForDb).returning();
log.debug("User account created successfully", {
userId: result[0].id,
email: result[0].email,
tokenValidUntil: result[0].tokenValidUntil,
hasPassphrase: !!result[0].passphraseHash,
hasRecoveryPassphrase: !!result[0].recoveryPassphrase
});
// TODO: Send confirmation email to user (Link contains the above token)
return result[0];
} catch (error) {
log.error("Failed to create user account", { email: userData.email, error: String(error) });
throw error;
}
}
/**
* Resend the confirmation email for a user
* @param email - Email of the user to confirm
*/
static async resendConfirmationEmail(email: string): Promise<void> {
const log = logger.setContext("UserService");
log.debug("Resending confirmation email", { email });
const token = uuidv7();
const tokenValidUntil = addMinutes(new Date(), 10);
try {
const result = await centralDb
.update(centralSchema.user)
.set({ token, tokenValidUntil })
.execute();
if (result.count != 1) {
log.warn("Failed to resend confirmation email: User not found", { email });
throw new NotFoundError(`Could not resend confirmation mail for unknown user ${email}`);
}
log.debug("Confirmation email resent successfully", { email, tokenValidUntil });
// TODO: Send confirmation email
} catch (error) {
if (error instanceof NotFoundError) throw error;
log.error("Failed to resend confirmation email", { email, error: String(error) });
throw error;
}
}
/**
* Confirm and activate user after confirmation link was clicked
* @param linkToken - The token from the link
*/
static async confirm(linkToken: string): Promise<{ recoveryPassphrase?: string }> {
const log = logger.setContext("UserService");
log.debug("Confirming user account", { token: linkToken.substring(0, 8) + "..." });
try {
// First, get the user data to check for recovery passphrase
const userData = await centralDb
.select({
id: centralSchema.user.id,
recoveryPassphrase: centralSchema.user.recoveryPassphrase
})
.from(centralSchema.user)
.where(
and(
eq(centralSchema.user.token, linkToken),
gt(centralSchema.user.tokenValidUntil, new Date())
)
)
.limit(1);
if (userData.length === 0) {
log.warn("User confirmation failed: Invalid or expired token", {
token: linkToken.substring(0, 8) + "..."
});
throw new NotFoundError("Invalid or timed-out token");
}
const user = userData[0];
// Update the user to confirmed and active, and clear the recovery passphrase
const result = await centralDb
.update(centralSchema.user)
.set({
confirmed: true,
isActive: true,
recoveryPassphrase: null // Clear it after showing it once
})
.where(eq(centralSchema.user.id, user.id))
.execute();
if (result.count != 1) {
throw new NotFoundError("Failed to confirm user");
}
log.debug("User account confirmed successfully", {
userId: user.id,
token: linkToken.substring(0, 8) + "...",
hadRecoveryPassphrase: !!user.recoveryPassphrase
});
return {
recoveryPassphrase: user.recoveryPassphrase || undefined
};
} catch (error) {
if (error instanceof NotFoundError) throw error;
log.error("Failed to confirm user account", {
token: linkToken.substring(0, 8) + "...",
error: String(error)
});
throw error;
}
}
/**
* Add additional WebAuthn passkey to existing user
*/
static async addAdditionalPasskey(userId: string, passkeyData: InsertUserPasskey): Promise<void> {
const log = logger.setContext("UserService");
log.debug("Adding additional passkey to user", { userId, passkeyId: passkeyData.id });
try {
// Check if user exists and is active
const user = await centralDb
.select({ id: centralSchema.user.id, confirmed: centralSchema.user.confirmed })
.from(centralSchema.user)
.where(eq(centralSchema.user.id, userId))
.limit(1);
if (user.length === 0) {
throw new NotFoundError("User not found");
}
if (!user[0].confirmed) {
throw new ValidationError(
"User account must be confirmed before adding additional passkeys"
);
}
// Add the passkey
await centralDb.insert(centralSchema.userPasskey).values({
...passkeyData,
userId,
createdAt: new Date(),
updatedAt: new Date()
});
log.debug("Additional passkey added successfully", { userId, passkeyId: passkeyData.id });
} catch (error) {
if (error instanceof NotFoundError || error instanceof ValidationError) throw error;
log.error("Failed to add additional passkey", { userId, error: String(error) });
throw error;
}
}
/**
* Get admin by email
*/
static async getUserByEmail(email: string) {
const log = logger.setContext("UserService");
log.debug("Getting admin by email", { email });
try {
const result = await centralDb
.select()
.from(centralSchema.user)
.where(eq(centralSchema.user.email, email))
.limit(1);
if (!result[0]) {
log.warn("User not found by email", { email });
throw new NotFoundError(`No user account for ${email}.`);
}
log.debug("User found by email", {
email,
userId: result[0].id,
confirmed: result[0].confirmed
});
return result[0];
} catch (error) {
if (error instanceof NotFoundError) throw error;
log.error("Failed to get user by email", { email, error: String(error) });
throw error;
}
}
/**
* Get all admins
*/
static async getAllAdmins() {
const log = logger.setContext("UserService");
log.debug("Getting all admins");
try {
const result = await centralDb
.select()
.from(centralSchema.user)
.orderBy(desc(centralSchema.user.createdAt))
.where(eq(centralSchema.user.role, "GLOBAL_ADMIN"));
log.debug("Retrieved all admins", { count: result.length });
return result;
} catch (error) {
log.error("Failed to get all admins", { error: String(error) });
throw error;
}
}
/**
* Get all admins
*/
static async getAllUsers() {
const log = logger.setContext("UserService");
log.debug("Getting all users");
try {
const result = await centralDb
.select()
.from(centralSchema.user)
.orderBy(desc(centralSchema.user.createdAt));
log.debug("Retrieved all users", { count: result.length });
return result;
} catch (error) {
log.error("Failed to get all users", { error: String(error) });
throw error;
}
}
/**
* Update a user's data
*/
static async updateUser(
userId: string,
updateData: Partial<Omit<InsertUser, "id" | "createdAt">>
) {
const log = logger.setContext("UserService");
log.debug("Updating user", { userId, updateFields: Object.keys(updateData) });
try {
const result = await centralDb
.update(centralSchema.user)
.set({
...updateData,
updatedAt: new Date()
})
.where(eq(centralSchema.user.id, userId))
.returning();
if (result[0]) {
log.debug("User updated successfully", { userId, updateFields: Object.keys(updateData) });
} else {
log.warn("User update failed: User not found", { userId });
}
return result[0] || null;
} catch (error) {
log.error("Failed to update user", { userId, error: String(error) });
throw error;
}
}
/**
* Permanently delete admin and all associated passkeys
*/
static async deleteUser(userId: string) {
const log = logger.setContext("UserService");
log.debug("Deleting user and associated passkeys", { userId });
try {
// Delete associated passkeys first
const passkeyResult = await centralDb
.delete(centralSchema.userPasskey)
.where(eq(centralSchema.userPasskey.userId, userId));
log.debug("Deleted user passkeys", { userId, deletedCount: passkeyResult.count || 0 });
// Delete admin
const result = await centralDb
.delete(centralSchema.user)
.where(eq(centralSchema.user.id, userId))
.returning();
if (result[0]) {
log.debug("User deleted successfully", { userId, email: result[0].email });
} else {
log.warn("User deletion failed: User not found", { userId });
}
return result[0] || null;
} catch (error) {
log.error("Failed to delete user", { userId, error: String(error) });
throw error;
}
}
/**
* Update last login timestamp
*/
static async updateLastLogin(userId: string) {
const log = logger.setContext("UserService");
log.debug("Updating last login timestamp", { userId });
return await this.updateUser(userId, { lastLoginAt: new Date() });
}
/**
* Add a passkey for a uaer
*/
static async addPasskey(
userId: string,
passkeyData: Omit<InsertUserPasskey, "userId" | "createdAt" | "updatedAt">
) {
const log = logger.setContext("UserService");
log.debug("Adding passkey for user", {
userId,
passkeyId: passkeyData.id,
deviceName: passkeyData.deviceName
});
try {
const result = await centralDb
.insert(centralSchema.userPasskey)
.values({
...passkeyData,
userId
})
.returning();
log.debug("Passkey added successfully", {
userId,
passkeyId: result[0].id,
deviceName: result[0].deviceName
});
return result[0];
} catch (error) {
log.error("Failed to add passkey", {
userId,
passkeyId: passkeyData.id,
error: String(error)
});
throw error;
}
}
/**
* Get all passkeys for an admin
*/
static async getUserPasskeys(userId: string) {
return await centralDb
.select()
.from(centralSchema.userPasskey)
.where(eq(centralSchema.userPasskey.userId, userId))
.orderBy(desc(centralSchema.userPasskey.createdAt));
}
/**
* Update passkey (e.g., counter, last used time)
*/
static async updatePasskey(
passkeyId: string,
updateData: Partial<Omit<InsertUserPasskey, "id" | "userId" | "createdAt">>
) {
const result = await centralDb
.update(centralSchema.userPasskey)
.set({
...updateData,
updatedAt: new Date()
})
.where(eq(centralSchema.userPasskey.id, passkeyId))
.returning();
return result[0] || null;
}
/**
* Delete a passkey
*/
static async deletePasskey(passkeyId: string) {
const result = await centralDb
.delete(centralSchema.userPasskey)
.where(eq(centralSchema.userPasskey.id, passkeyId))
.returning();
return result[0] || null;
}
/**
* Update passkey last used timestamp and counter
*/
static async updatePasskeyUsage(passkeyId: string, newCounter: number) {
const log = logger.setContext("UserService");
log.debug("Updating passkey usage", { passkeyId, newCounter });
return await this.updatePasskey(passkeyId, {
counter: newCounter,
lastUsedAt: new Date()
});
}
/**
* Check if any admin exists in the system
*/
static async adminExists(): Promise<boolean> {
const log = logger.setContext("UserService");
log.debug("Checking if any admin exists");
try {
const result = await centralDb
.select({ id: centralSchema.user.id })
.from(centralSchema.user)
.where(eq(centralSchema.user.role, "GLOBAL_ADMIN"))
.limit(1);
const exists = result.length > 0;
log.debug("Admin existence check completed", { exists });
return exists;
} catch (error) {
log.error("Failed to check admin existence", { error: String(error) });
throw error;
}
}
/**
* Get total count of admins in the system
*/
static async getAdminCount(): Promise<number> {
const log = logger.setContext("UserService");
log.debug("Getting user count");
try {
const result = await centralDb.select().from(centralSchema.user);
const count = result.length;
log.debug("User count retrieved", { count });
return count;
} catch (error) {
log.error("Failed to get user count", { error: String(error) });
throw error;
}
}
}
+6
View File
@@ -1,3 +1,9 @@
export class AuthenticationError extends Error {
constructor(message: string) {
super(message);
this.name = "AuthenticationError";
}
}
export class ValidationError extends Error {
constructor(message: string) {
super(message);
+61
View File
@@ -0,0 +1,61 @@
import { randomBytes } from "node:crypto";
import { hash, verify } from "argon2";
/**
* Generate a random recovery passphrase
* Uses a combination of words and numbers for better memorability
*/
export function generateRecoveryPassphrase(): string {
// Generate 16 random bytes and convert to base64
const randomData = randomBytes(16);
const base64 = randomData.toString("base64");
// Convert to a more user-friendly format
// Remove padding and special characters, add hyphens for readability
const cleaned = base64.replace(/[+/=]/g, "").toLowerCase();
// Split into groups of 4 characters with hyphens
const groups = [];
for (let i = 0; i < cleaned.length; i += 4) {
groups.push(cleaned.slice(i, i + 4));
}
return groups.join("-");
}
/**
* Hash a passphrase using Argon2
*/
export async function hashPassphrase(passphrase: string): Promise<string> {
return hash(passphrase, {
type: 2, // Argon2id
memoryCost: 2 ** 16, // 64 MB
timeCost: 3,
parallelism: 1
});
}
/**
* Verify a passphrase against its hash
*/
export async function verifyPassphrase(hash: string, passphrase: string): Promise<boolean> {
try {
return await verify(hash, passphrase);
} catch {
return false;
}
}
/**
* Validate passphrase strength (minimum requirements)
*/
export function validatePassphraseStrength(passphrase: string): boolean {
// Minimum 12 characters for security
if (passphrase.length < 12) {
return false;
}
// No additional complexity requirements for passphrases
// (they can be long sentences)
return true;
}
@@ -1,89 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET } from "./+server";
// Mock dependencies
vi.mock("$lib/server/services/admin-account-service", () => ({
AdminAccountService: {
adminExists: vi.fn(),
getAdminCount: vi.fn()
}
}));
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn()
}))
}
}));
// Import after mocking
import { AdminAccountService } from "$lib/server/services/admin-account-service";
describe("GET /api/admin/exists", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should return admin exists status when admin exists", async () => {
vi.mocked(AdminAccountService.adminExists).mockResolvedValue(true);
vi.mocked(AdminAccountService.getAdminCount).mockResolvedValue(2);
const response = await GET();
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({
exists: true,
count: 2
});
expect(AdminAccountService.adminExists).toHaveBeenCalledOnce();
expect(AdminAccountService.getAdminCount).toHaveBeenCalledOnce();
});
it("should return admin does not exist when no admin found", async () => {
vi.mocked(AdminAccountService.adminExists).mockResolvedValue(false);
vi.mocked(AdminAccountService.getAdminCount).mockResolvedValue(0);
const response = await GET();
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({
exists: false,
count: 0
});
expect(AdminAccountService.adminExists).toHaveBeenCalledOnce();
expect(AdminAccountService.getAdminCount).toHaveBeenCalledOnce();
});
it("should handle service errors", async () => {
vi.mocked(AdminAccountService.adminExists).mockRejectedValue(new Error("Database error"));
const response = await GET();
const data = await response.json();
expect(response.status).toBe(500);
expect(data).toEqual({
error: "Internal server error"
});
expect(AdminAccountService.adminExists).toHaveBeenCalledOnce();
expect(AdminAccountService.getAdminCount).not.toHaveBeenCalled();
});
it("should handle getAdminCount error", async () => {
vi.mocked(AdminAccountService.adminExists).mockResolvedValue(true);
vi.mocked(AdminAccountService.getAdminCount).mockRejectedValue(new Error("Count error"));
const response = await GET();
const data = await response.json();
expect(response.status).toBe(500);
expect(data).toEqual({
error: "Internal server error"
});
expect(AdminAccountService.adminExists).toHaveBeenCalledOnce();
expect(AdminAccountService.getAdminCount).toHaveBeenCalledOnce();
});
});
-151
View File
@@ -1,151 +0,0 @@
import { json } from "@sveltejs/kit";
import { AdminAccountService } from "$lib/server/services/admin-account-service";
import { ValidationError } from "$lib/server/utils/errors";
import type { RequestHandler } from "./$types";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
// Register OpenAPI documentation
registerOpenAPIRoute("/admin/register", "POST", {
summary: "Register a new admin account",
description: "Creates a new admin account that requires email confirmation",
tags: ["Admin"],
requestBody: {
description: "Admin registration data",
content: {
"application/json": {
schema: {
type: "object",
properties: {
name: { type: "string", description: "Admin's full name", example: "Admin Name" },
email: {
type: "string",
format: "email",
description: "Admin's email address",
example: "admin@example.com"
},
passkey: {
type: "object",
description: "WebAuthn passkey data",
properties: {
id: { type: "string", description: "Credential ID from WebAuthn" },
publicKey: { type: "string", description: "Base64 encoded public key" },
counter: { type: "integer", description: "Signature counter", default: 0 },
deviceName: { type: "string", description: "Device name for identification", example: "MacBook Pro" }
},
required: ["id", "publicKey"]
}
},
required: ["name", "email", "passkey"]
}
}
}
},
responses: {
"201": {
description: "Admin account created successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: { type: "string", description: "Success message" },
adminId: { type: "string", description: "Generated admin ID" },
email: { type: "string", description: "Admin's email address" }
},
required: ["message", "adminId", "email"]
},
example: {
message:
"Admin account created successfully. Please check your email for confirmation.",
adminId: "01234567-89ab-cdef-0123-456789abcdef",
email: "admin@example.com"
}
}
}
},
"400": {
description: "Invalid input data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "Invalid admin data" }
}
}
},
"409": {
description: "Admin with this email already exists",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "An admin with this email already exists" }
}
}
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "Internal server error" }
}
}
}
}
});
export const POST: RequestHandler = async ({ request }) => {
const log = logger.setContext("API");
try {
const body = await request.json();
log.debug("Creating admin account with passkey", {
email: body.email,
passkeyId: body.passkey?.id,
deviceName: body.passkey?.deviceName
});
// Create admin account
const admin = await AdminAccountService.createAdmin({
name: body.name,
email: body.email
});
// Add the passkey to the admin account
await AdminAccountService.addPasskey(admin.id, {
id: body.passkey.id,
publicKey: body.passkey.publicKey,
counter: body.passkey.counter || 0,
deviceName: body.passkey.deviceName || "Unknown Device"
});
log.debug("Admin account and passkey created successfully", {
adminId: admin.id,
email: admin.email,
passkeyId: body.passkey.id
});
return json(
{
message: "Admin account created successfully. Please check your email for confirmation.",
adminId: admin.id,
email: admin.email
},
{ status: 201 }
);
} catch (error) {
log.error("Admin registration error:", JSON.stringify(error || "?"));
if (error instanceof ValidationError) {
return json({ error: error.message }, { status: 400 });
}
// Handle unique constraint violation (email already exists)
if (error instanceof Error && error.message.includes("unique constraint")) {
return json({ error: "An admin with this email already exists" }, { status: 409 });
}
return json({ error: "Internal server error" }, { status: 500 });
}
};
+150
View File
@@ -0,0 +1,150 @@
import { json } from "@sveltejs/kit";
import { WebAuthnService } from "$lib/server/auth/webauthn-service";
import { UserService } from "$lib/server/services/user-service";
import { NotFoundError } from "$lib/server/utils/errors";
import type { RequestHandler } from "./$types";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import { UniversalLogger } from "$lib/logger";
const logger = new UniversalLogger().setContext("AuthChallengeAPI");
registerOpenAPIRoute("/auth/challenge", "POST", {
summary: "Generate WebAuthn authentication challenge",
description: "Generate a challenge for WebAuthn authentication and return registered passkeys",
tags: ["Authentication"],
requestBody: {
description: "User email to generate challenge for",
content: {
"application/json": {
schema: {
type: "object",
properties: {
email: {
type: "string",
format: "email",
description: "User's email address",
example: "admin@example.com"
}
},
required: ["email"]
}
}
}
},
responses: {
"200": {
description: "Challenge generated successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
challenge: {
type: "string",
description: "Base64url encoded challenge"
},
allowCredentials: {
type: "array",
description: "List of registered passkeys for this user",
items: {
type: "object",
properties: {
id: { type: "string", description: "Credential ID" },
type: { type: "string", enum: ["public-key"] },
transports: {
type: "array",
items: { type: "string" },
description: "Supported transports"
}
}
}
},
timeout: {
type: "number",
description: "Timeout in milliseconds"
}
},
required: ["challenge", "allowCredentials"]
}
}
}
},
"404": {
description: "User not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "User not found" }
}
}
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "Internal server error" }
}
}
}
}
});
export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
logger.debug("Generating WebAuthn challenge", { email: body.email });
// Get user by email
let user;
try {
user = await UserService.getUserByEmail(body.email);
} catch (error) {
if (error instanceof NotFoundError) {
return json({ error: "User not found" }, { status: 404 });
}
throw error;
}
// Generate challenge
const challenge = WebAuthnService.generateChallenge();
// Store challenge in session cookie (in production, use proper session storage)
cookies.set("webauthn-challenge", challenge, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/",
maxAge: 60 * 5 // 5 minutes
});
// Get user's registered passkeys
const passkeys = await WebAuthnService.getUserPasskeys(user.id);
// Format passkeys for WebAuthn API
const allowCredentials = passkeys.map((passkey) => ({
id: passkey.id,
type: "public-key" as const,
transports: ["usb", "nfc", "ble", "internal"] // All possible transports
}));
logger.debug("WebAuthn challenge generated", {
userId: user.id,
email: user.email,
passkeyCount: passkeys.length,
challenge: challenge.substring(0, 8) + "..."
});
return json({
challenge,
allowCredentials,
timeout: 60000, // 60 seconds
rpId: "localhost", // TODO: Configure for production
userVerification: "preferred"
});
} catch (error) {
logger.error("Challenge generation error", { error: String(error) });
return json({ error: "Internal server error" }, { status: 500 });
}
};
@@ -1,15 +1,15 @@
import { json } from "@sveltejs/kit";
import { AdminAccountService } from "$lib/server/services/admin-account-service";
import { UserService } from "$lib/server/services/user-service";
import { NotFoundError } from "$lib/server/utils/errors";
import type { RequestHandler } from "./$types";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
// Register OpenAPI documentation
registerOpenAPIRoute("/admin/confirm", "POST", {
summary: "Confirm admin account",
description: "Confirms an admin account using the email confirmation token",
tags: ["Admin"],
registerOpenAPIRoute("/auth/confirm", "POST", {
summary: "Confirm user account",
description: "Confirms a user account using the email confirmation token",
tags: ["Authentication"],
requestBody: {
description: "Confirmation token",
content: {
@@ -30,7 +30,7 @@ registerOpenAPIRoute("/admin/confirm", "POST", {
},
responses: {
"200": {
description: "Admin account confirmed successfully",
description: "User account confirmed successfully",
content: {
"application/json": {
schema: {
@@ -41,7 +41,7 @@ registerOpenAPIRoute("/admin/confirm", "POST", {
required: ["message"]
},
example: {
message: "Admin account confirmed successfully. You can now log in."
message: "User account confirmed successfully. You can now log in."
}
}
}
@@ -71,17 +71,23 @@ export const POST: RequestHandler = async ({ request }) => {
try {
const body = await request.json();
await AdminAccountService.confirm(body.token);
const confirmationResult = await UserService.confirm(body.token);
return json(
{
message: "Admin account confirmed successfully. You can now log in."
},
{ status: 200 }
);
const response: Record<string, string> = {
message: "User account confirmed successfully. You can now log in."
};
// Include recovery passphrase if it exists (for WebAuthn-only users)
if (confirmationResult.recoveryPassphrase) {
response.recoveryPassphrase = confirmationResult.recoveryPassphrase;
response.recoveryMessage =
"Please save this recovery passphrase in a secure location. It will not be shown again.";
}
return json(response, { status: 200 });
} catch (error) {
const log = logger.setContext("API");
log.error("Admin confirmation error:", JSON.stringify(error || "?"));
log.error("User confirmation error:", JSON.stringify(error || "?"));
if (error instanceof NotFoundError) {
return json({ error: "Invalid or expired confirmation token" }, { status: 404 });
+256
View File
@@ -0,0 +1,256 @@
import { json } from "@sveltejs/kit";
import { SessionService } from "$lib/server/auth/session-service";
import { UserService } from "$lib/server/services/user-service";
import { WebAuthnService } from "$lib/server/auth/webauthn-service";
import { ValidationError, NotFoundError } from "$lib/server/utils/errors";
import { verifyPassphrase } from "$lib/server/utils/passphrase";
import type { RequestHandler } from "./$types";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import { UniversalLogger } from "$lib/logger";
const logger = new UniversalLogger().setContext("AuthLoginAPI");
registerOpenAPIRoute("/auth/login", "POST", {
summary: "Login with WebAuthn passkey or passphrase",
description:
"Authenticate user with WebAuthn passkey or passphrase and create session. For WebAuthn, first call /auth/challenge to get a challenge.",
tags: ["Authentication"],
requestBody: {
description: "Authentication data (either WebAuthn credential or passphrase)",
content: {
"application/json": {
schema: {
type: "object",
properties: {
email: {
type: "string",
format: "email",
description: "User's email address",
example: "admin@example.com"
},
passphrase: {
type: "string",
description: "User's passphrase (alternative to WebAuthn)",
example: "my-secure-passphrase-123"
},
credential: {
type: "object",
description: "WebAuthn credential data (alternative to passphrase)",
properties: {
id: { type: "string", description: "Credential ID" },
response: {
type: "object",
description: "WebAuthn response data",
properties: {
authenticatorData: {
type: "string",
description: "Base64 encoded authenticator data"
},
signature: { type: "string", description: "Base64 encoded signature" },
userHandle: { type: "string", description: "User handle" },
clientDataJSON: {
type: "string",
description: "Base64 encoded client data JSON"
}
},
required: ["authenticatorData", "signature", "clientDataJSON"]
}
},
required: ["id", "response"]
}
},
required: ["email"]
}
}
}
},
responses: {
"200": {
description: "Login successful",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: { type: "string", description: "Success message" },
user: {
type: "object",
properties: {
id: { type: "string", description: "User ID" },
email: { type: "string", description: "User email" },
name: { type: "string", description: "User name" },
role: { type: "string", enum: ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] },
tenantId: { type: "string", description: "Tenant ID (if applicable)" }
},
required: ["id", "email", "name", "role"]
},
accessToken: { type: "string", description: "JWT access token" },
expiresAt: {
type: "string",
format: "date-time",
description: "Session expiration time"
}
},
required: ["message", "user", "accessToken", "expiresAt"]
}
}
}
},
"400": {
description: "Invalid credentials or request data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" }
}
}
},
"401": {
description: "Authentication failed",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" }
}
}
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" }
}
}
}
}
});
export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
try {
const body = await request.json();
const ipAddress = getClientAddress();
const userAgent = request.headers.get("user-agent");
logger.debug("Login attempt", {
email: body.email,
ipAddress,
authMethod: body.passphrase ? "passphrase" : "webauthn"
});
// Validate that either passphrase or credential is provided
if (!body.passphrase && !body.credential) {
return json(
{ error: "Either passphrase or WebAuthn credential must be provided" },
{ status: 400 }
);
}
// Get user by email
let user;
try {
user = await UserService.getUserByEmail(body.email);
} catch (error) {
if (error instanceof NotFoundError) {
return json({ error: "Invalid email or authentication method" }, { status: 401 });
}
throw error;
}
// Handle passphrase authentication
if (body.passphrase) {
if (!user.passphraseHash) {
return json(
{ error: "Passphrase authentication not enabled for this user" },
{ status: 401 }
);
}
const isPassphraseValid = await verifyPassphrase(user.passphraseHash, body.passphrase);
if (!isPassphraseValid) {
return json({ error: "Invalid passphrase" }, { status: 401 });
}
logger.debug("Passphrase authentication successful", { userId: user.id });
}
// Handle WebAuthn authentication
if (body.credential) {
// Get the challenge from the session
const challengeFromSession = cookies.get("webauthn-challenge");
if (!challengeFromSession) {
return json(
{ error: "No WebAuthn challenge found. Please request a new challenge." },
{ status: 400 }
);
}
// Clear the challenge cookie after use
cookies.delete("webauthn-challenge", { path: "/" });
const verificationResult = await WebAuthnService.verifyAuthentication(
body.credential,
challengeFromSession
);
if (!verificationResult.verified) {
return json({ error: "Invalid WebAuthn credential" }, { status: 401 });
}
// Verify that the credential belongs to the user
if (verificationResult.userId !== user.id) {
return json({ error: "WebAuthn credential does not belong to this user" }, { status: 401 });
}
// Update the counter to prevent replay attacks
if (verificationResult.newCounter && verificationResult.passkeyId) {
await WebAuthnService.updatePasskeyCounter(
verificationResult.passkeyId,
verificationResult.newCounter
);
}
logger.debug("WebAuthn authentication successful", { userId: user.id });
}
const sessionData = await SessionService.createSession(
user.id,
ipAddress,
userAgent || undefined
);
// Set HTTP-only cookie for session
cookies.set("session", sessionData.sessionToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/",
maxAge: 60 * 60 * 24 * 7 // 7 days
});
logger.info("Login successful", {
userId: sessionData.user.id,
email: sessionData.user.email,
role: sessionData.user.role,
authMethod: body.passphrase ? "passphrase" : "webauthn"
});
return json({
message: "Login successful",
user: {
id: sessionData.user.id,
email: sessionData.user.email,
name: sessionData.user.name,
role: sessionData.user.role,
tenantId: sessionData.user.tenantId
},
accessToken: sessionData.accessToken,
expiresAt: sessionData.expiresAt.toISOString()
});
} catch (error) {
logger.error("Login error:", { error: String(error) });
if (error instanceof ValidationError) {
return json({ error: error.message }, { status: 400 });
}
return json({ error: "Authentication failed" }, { status: 401 });
}
};
+71
View File
@@ -0,0 +1,71 @@
import { json } from "@sveltejs/kit";
import { SessionService } from "$lib/server/auth/session-service";
import type { RequestHandler } from "./$types";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import { UniversalLogger } from "$lib/logger";
const logger = new UniversalLogger().setContext("AuthLogoutAPI");
registerOpenAPIRoute("/auth/logout", "POST", {
summary: "Logout user session",
description: "Invalidate current user session and clear session cookie",
tags: ["Authentication"],
responses: {
"200": {
description: "Logout successful",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: { type: "string", description: "Success message" }
},
required: ["message"]
}
}
}
},
"400": {
description: "No active session found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" }
}
}
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" }
}
}
}
}
});
export const POST: RequestHandler = async ({ cookies }) => {
try {
const sessionToken = cookies.get("session");
if (!sessionToken) {
return json({ error: "No active session found" }, { status: 400 });
}
await SessionService.logout(sessionToken);
cookies.delete("session", {
path: "/",
httpOnly: true,
secure: true,
sameSite: "strict"
});
logger.info("Logout successful");
return json({ message: "Logout successful" });
} catch (error) {
logger.error("Logout error:", { error: String(error) });
return json({ error: "Internal server error" }, { status: 500 });
}
};
+157
View File
@@ -0,0 +1,157 @@
import { json } from "@sveltejs/kit";
import { UserService } from "$lib/server/services/user-service";
import { NotFoundError, ValidationError } from "$lib/server/utils/errors";
import type { RequestHandler } from "./$types";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
// Register OpenAPI documentation
registerOpenAPIRoute("/auth/passkeys", "POST", {
summary: "Add additional WebAuthn passkey to user account",
description: "Allows authenticated users to add additional WebAuthn keys to their accounts",
tags: ["Authentication"],
requestBody: {
description: "WebAuthn passkey data",
content: {
"application/json": {
schema: {
type: "object",
properties: {
userId: {
type: "string",
format: "uuid",
description: "User ID to add the passkey to",
example: "01234567-89ab-cdef-0123-456789abcdef"
},
passkey: {
type: "object",
description: "WebAuthn passkey data",
properties: {
id: { type: "string", description: "Credential ID from WebAuthn" },
publicKey: { type: "string", description: "Base64 encoded public key" },
counter: { type: "integer", description: "Signature counter", default: 0 },
deviceName: {
type: "string",
description: "Device name for identification",
example: "iPhone 15"
}
},
required: ["id", "publicKey"]
}
},
required: ["userId", "passkey"]
}
}
}
},
responses: {
"200": {
description: "WebAuthn passkey added successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: { type: "string", description: "Success message" },
passkeyId: { type: "string", description: "ID of the added passkey" }
},
required: ["message", "passkeyId"]
},
example: {
message: "WebAuthn passkey added successfully",
passkeyId: "credential_id_123"
}
}
}
},
"400": {
description: "Invalid input data or user account not confirmed",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "User account must be confirmed before adding additional passkeys" }
}
}
},
"404": {
description: "User not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "User not found" }
}
}
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "Internal server error" }
}
}
}
}
});
export const POST: RequestHandler = async ({ request }) => {
const log = logger.setContext("API");
try {
const body = await request.json();
// Validate required fields
if (!body.userId || !body.passkey) {
return json({ error: "userId and passkey are required" }, { status: 400 });
}
if (!body.passkey.id || !body.passkey.publicKey) {
return json({ error: "Passkey must include id and publicKey" }, { status: 400 });
}
log.debug("Adding additional passkey to user", {
userId: body.userId,
passkeyId: body.passkey.id,
deviceName: body.passkey.deviceName
});
// Add the passkey using the UserService
await UserService.addAdditionalPasskey(body.userId, {
id: body.passkey.id,
userId: body.userId,
publicKey: body.passkey.publicKey,
counter: body.passkey.counter || 0,
deviceName: body.passkey.deviceName || "Unknown Device"
});
log.debug("Additional passkey added successfully", {
userId: body.userId,
passkeyId: body.passkey.id
});
return json(
{
message: "WebAuthn passkey added successfully",
passkeyId: body.passkey.id
},
{ status: 200 }
);
} catch (error) {
log.error("Add passkey error:", JSON.stringify(error || "?"));
if (error instanceof NotFoundError) {
return json({ error: "User not found" }, { status: 404 });
}
if (error instanceof ValidationError) {
return json({ error: error.message }, { status: 400 });
}
// Handle unique constraint violation (passkey already exists)
if (error instanceof Error && error.message.includes("unique constraint")) {
return json({ error: "This passkey is already registered" }, { status: 409 });
}
return json({ error: "Internal server error" }, { status: 500 });
}
};
+102
View File
@@ -0,0 +1,102 @@
import { json } from "@sveltejs/kit";
import { SessionService } from "$lib/server/auth/session-service";
import type { RequestHandler } from "./$types";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import { UniversalLogger } from "$lib/logger";
const logger = new UniversalLogger().setContext("AuthRefreshAPI");
registerOpenAPIRoute("/auth/refresh", "POST", {
summary: "Refresh access token",
description: "Generate new access and refresh tokens using existing refresh token",
tags: ["Authentication"],
requestBody: {
description: "Refresh token data",
content: {
"application/json": {
schema: {
type: "object",
properties: {
refreshToken: {
type: "string",
description: "Current refresh token"
}
},
required: ["refreshToken"]
}
}
}
},
responses: {
"200": {
description: "Token refresh successful",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: { type: "string", description: "Success message" },
accessToken: { type: "string", description: "New JWT access token" },
refreshToken: { type: "string", description: "New refresh token" },
expiresAt: { type: "string", format: "date-time", description: "New expiration time" }
},
required: ["message", "accessToken", "refreshToken", "expiresAt"]
}
}
}
},
"400": {
description: "Invalid refresh token",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" }
}
}
},
"401": {
description: "Refresh token expired or invalid",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" }
}
}
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" }
}
}
}
}
});
export const POST: RequestHandler = async ({ request }) => {
try {
const body = await request.json();
const { refreshToken } = body;
if (!refreshToken) {
return json({ error: "Refresh token is required" }, { status: 400 });
}
const result = await SessionService.refreshSession(refreshToken);
if (!result) {
return json({ error: "Invalid or expired refresh token" }, { status: 401 });
}
logger.info("Token refresh successful");
return json({
message: "Token refresh successful",
accessToken: result.accessToken,
refreshToken: result.refreshToken,
expiresAt: result.expiresAt.toISOString()
});
} catch (error) {
logger.error("Token refresh error:", { error: String(error) });
return json({ error: "Internal server error" }, { status: 500 });
}
};
+187
View File
@@ -0,0 +1,187 @@
import { json } from "@sveltejs/kit";
import { UserService } from "$lib/server/services/user-service";
import { ValidationError } from "$lib/server/utils/errors";
import type { RequestHandler } from "./$types";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
// Register OpenAPI documentation
registerOpenAPIRoute("/auth/register", "POST", {
summary: "Register a new user account",
description: "Creates a new user account that requires email confirmation",
tags: ["Authentication"],
requestBody: {
description: "User registration data",
content: {
"application/json": {
schema: {
type: "object",
properties: {
name: { type: "string", description: "User's full name", example: "John Doe" },
email: {
type: "string",
format: "email",
description: "User's email address",
example: "user@example.com"
},
role: {
type: "string",
enum: ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"],
description: "User's role in the system",
example: "STAFF"
},
tenantId: {
type: "string",
format: "uuid",
description: "Tenant ID for TENANT_ADMIN and STAFF roles",
example: "01234567-89ab-cdef-0123-456789abcdef"
},
passphrase: {
type: "string",
minLength: 12,
description: "Optional passphrase for password authentication (min 12 chars)",
example: "my-secure-passphrase-123"
},
passkey: {
type: "object",
description: "WebAuthn passkey data",
properties: {
id: { type: "string", description: "Credential ID from WebAuthn" },
publicKey: { type: "string", description: "Base64 encoded public key" },
counter: { type: "integer", description: "Signature counter", default: 0 },
deviceName: {
type: "string",
description: "Device name for identification",
example: "MacBook Pro"
}
},
required: ["id", "publicKey"]
}
},
required: ["name", "email", "role"]
}
}
}
},
responses: {
"201": {
description: "User account created successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: { type: "string", description: "Success message" },
userId: { type: "string", description: "Generated user ID" },
email: { type: "string", description: "User's email address" }
},
required: ["message", "userId", "email"]
},
example: {
message: "User account created successfully. Please check your email for confirmation.",
userId: "01234567-89ab-cdef-0123-456789abcdef",
email: "user@example.com"
}
}
}
},
"400": {
description: "Invalid input data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "Invalid user data" }
}
}
},
"409": {
description: "User with this email already exists",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "A user with this email already exists" }
}
}
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "Internal server error" }
}
}
}
}
});
export const POST: RequestHandler = async ({ request }) => {
const log = logger.setContext("API");
try {
const body = await request.json();
// Validate that either passphrase or passkey is provided
if (!body.passphrase && !body.passkey) {
return json({ error: "Either passphrase or passkey must be provided" }, { status: 400 });
}
log.debug("Creating user account", {
email: body.email,
role: body.role,
tenantId: body.tenantId,
hasPassphrase: !!body.passphrase,
passkeyId: body.passkey?.id,
deviceName: body.passkey?.deviceName
});
// Create user account
const user = await UserService.createUser({
name: body.name,
email: body.email,
role: body.role,
tenantId: body.tenantId,
passphrase: body.passphrase
});
// Add the passkey to the user account if provided
if (body.passkey) {
await UserService.addPasskey(user.id, {
id: body.passkey.id,
publicKey: body.passkey.publicKey,
counter: body.passkey.counter || 0,
deviceName: body.passkey.deviceName || "Unknown Device"
});
}
log.debug("User account and passkey created successfully", {
userId: user.id,
email: user.email,
role: user.role,
tenantId: user.tenantId,
passkeyId: body.passkey.id
});
return json(
{
message: "User account created successfully. Please check your email for confirmation.",
userId: user.id,
email: user.email
},
{ status: 201 }
);
} catch (error) {
log.error("User registration error:", JSON.stringify(error || "?"));
if (error instanceof ValidationError) {
return json({ error: error.message }, { status: 400 });
}
// Handle unique constraint violation (email already exists)
if (error instanceof Error && error.message.includes("unique constraint")) {
return json({ error: "A user with this email already exists" }, { status: 409 });
}
return json({ error: "Internal server error" }, { status: 500 });
}
};
@@ -1,17 +1,17 @@
import { json } from "@sveltejs/kit";
import { AdminAccountService } from "$lib/server/services/admin-account-service";
import { UserService } from "$lib/server/services/user-service";
import { NotFoundError } from "$lib/server/utils/errors";
import type { RequestHandler } from "./$types";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
// Register OpenAPI documentation
registerOpenAPIRoute("/admin/resend-confirmation", "POST", {
registerOpenAPIRoute("/auth/resend-confirmation", "POST", {
summary: "Resend confirmation email",
description: "Resends the confirmation email to an admin account",
tags: ["Admin"],
description: "Resends the confirmation email to a user account",
tags: ["Authentication"],
requestBody: {
description: "Admin email address",
description: "User email address",
content: {
"application/json": {
schema: {
@@ -20,8 +20,8 @@ registerOpenAPIRoute("/admin/resend-confirmation", "POST", {
email: {
type: "string",
format: "email",
description: "Admin's email address",
example: "admin@example.com"
description: "User's email address",
example: "user@example.com"
}
},
required: ["email"]
@@ -48,11 +48,11 @@ registerOpenAPIRoute("/admin/resend-confirmation", "POST", {
}
},
"404": {
description: "No admin found with this email address",
description: "No user found with this email address",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
example: { error: "No admin found with this email address" }
example: { error: "No user found with this email address" }
}
}
},
@@ -73,7 +73,7 @@ export const POST: RequestHandler = async ({ request }) => {
const body = await request.json();
// Resend confirmation email
await AdminAccountService.resendConfirmationEmail(body.email);
await UserService.resendConfirmationEmail(body.email);
return json(
{
@@ -86,7 +86,7 @@ export const POST: RequestHandler = async ({ request }) => {
log.error("Resend confirmation error:", JSON.stringify(error || "?"));
if (error instanceof NotFoundError) {
return json({ error: "No admin found with this email address" }, { status: 404 });
return json({ error: "No user found with this email address" }, { status: 404 });
}
return json({ error: "Internal server error" }, { status: 500 });
+107
View File
@@ -0,0 +1,107 @@
import { json } from "@sveltejs/kit";
import { SessionService } from "$lib/server/auth/session-service";
import type { RequestHandler } from "./$types";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import { UniversalLogger } from "$lib/logger";
const logger = new UniversalLogger().setContext("AuthSessionAPI");
registerOpenAPIRoute("/auth/session", "GET", {
summary: "Get current session status",
description: "Retrieve current user session information and authentication status",
tags: ["Authentication"],
responses: {
"200": {
description: "Session information retrieved successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
authenticated: { type: "boolean", description: "Whether user is authenticated" },
user: {
type: "object",
properties: {
id: { type: "string", description: "User ID" },
email: { type: "string", description: "User email" },
name: { type: "string", description: "User name" },
role: { type: "string", enum: ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] },
tenantId: { type: "string", description: "Tenant ID (if applicable)" }
},
required: ["id", "email", "name", "role"]
},
expiresAt: {
type: "string",
format: "date-time",
description: "Session expiration time"
}
},
required: ["authenticated"]
}
}
}
},
"401": {
description: "Not authenticated",
content: {
"application/json": {
schema: {
type: "object",
properties: {
authenticated: { type: "boolean", example: false },
message: { type: "string", description: "Authentication status message" }
},
required: ["authenticated"]
}
}
}
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" }
}
}
}
}
});
export const GET: RequestHandler = async ({ cookies }) => {
try {
const sessionToken = cookies.get("session");
if (!sessionToken) {
return json({ authenticated: false, message: "No session found" }, { status: 401 });
}
const sessionData = await SessionService.validateSession(sessionToken);
if (!sessionData) {
cookies.delete("session", {
path: "/",
httpOnly: true,
secure: true,
sameSite: "strict"
});
return json({ authenticated: false, message: "Invalid or expired session" }, { status: 401 });
}
logger.debug("Session validated", { userId: sessionData.user.id });
return json({
authenticated: true,
user: {
id: sessionData.user.id,
email: sessionData.user.email,
name: sessionData.user.name,
role: sessionData.user.role,
tenantId: sessionData.user.tenantId
},
expiresAt: sessionData.expiresAt.toISOString()
});
} catch (error) {
logger.error("Session validation error:", { error: String(error) });
return json({ error: "Internal server error" }, { status: 500 });
}
};
+180
View File
@@ -0,0 +1,180 @@
import { json } from "@sveltejs/kit";
import { SessionService } from "$lib/server/auth/session-service";
import type { RequestHandler } from "./$types";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import { UniversalLogger } from "$lib/logger";
const logger = new UniversalLogger().setContext("AuthSessionsAPI");
registerOpenAPIRoute("/auth/sessions", "GET", {
summary: "Get all active sessions",
description: "Retrieve all active sessions for the current user",
tags: ["Authentication"],
responses: {
"200": {
description: "Active sessions retrieved successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
sessions: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string", description: "Session ID" },
ipAddress: { type: "string", description: "IP address of session" },
userAgent: { type: "string", description: "User agent string" },
createdAt: {
type: "string",
format: "date-time",
description: "Session creation time"
},
lastUsedAt: {
type: "string",
format: "date-time",
description: "Last activity time"
},
expiresAt: {
type: "string",
format: "date-time",
description: "Session expiration time"
},
current: { type: "boolean", description: "Whether this is the current session" }
},
required: ["id", "createdAt", "lastUsedAt", "expiresAt", "current"]
}
}
},
required: ["sessions"]
}
}
}
},
"401": {
description: "Not authenticated",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" }
}
}
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" }
}
}
}
}
});
registerOpenAPIRoute("/auth/sessions", "DELETE", {
summary: "Logout all sessions",
description: "Invalidate all active sessions for the current user",
tags: ["Authentication"],
responses: {
"200": {
description: "All sessions logged out successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: { type: "string", description: "Success message" }
},
required: ["message"]
}
}
}
},
"401": {
description: "Not authenticated",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" }
}
}
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" }
}
}
}
}
});
export const GET: RequestHandler = async ({ cookies }) => {
try {
const sessionToken = cookies.get("session");
if (!sessionToken) {
return json({ error: "Not authenticated" }, { status: 401 });
}
const sessionData = await SessionService.validateSession(sessionToken);
if (!sessionData) {
return json({ error: "Invalid session" }, { status: 401 });
}
const sessions = await SessionService.getActiveSessions(sessionData.user.id);
const formattedSessions = sessions.map((session) => ({
id: session.id,
ipAddress: session.ipAddress,
userAgent: session.userAgent,
createdAt: session.createdAt?.toISOString(),
lastUsedAt: session.lastUsedAt?.toISOString(),
expiresAt: session.expiresAt.toISOString(),
current: session.sessionToken === sessionToken
}));
logger.debug("Active sessions retrieved", {
userId: sessionData.user.id,
sessionCount: sessions.length
});
return json({ sessions: formattedSessions });
} catch (error) {
logger.error("Get sessions error:", { error: String(error) });
return json({ error: "Internal server error" }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ cookies }) => {
try {
const sessionToken = cookies.get("session");
if (!sessionToken) {
return json({ error: "Not authenticated" }, { status: 401 });
}
const sessionData = await SessionService.validateSession(sessionToken);
if (!sessionData) {
return json({ error: "Invalid session" }, { status: 401 });
}
await SessionService.logoutAllSessions(sessionData.user.id);
cookies.delete("session", {
path: "/",
httpOnly: true,
secure: true,
sameSite: "strict"
});
logger.info("All sessions logged out", { userId: sessionData.user.id });
return json({ message: "All sessions logged out successfully" });
} catch (error) {
logger.error("Logout all sessions error:", { error: String(error) });
return json({ error: "Internal server error" }, { status: 500 });
}
};
@@ -0,0 +1,119 @@
import { json } from "@sveltejs/kit";
import { SessionService } from "$lib/server/auth/session-service";
import type { RequestHandler } from "./$types";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import { UniversalLogger } from "$lib/logger";
const logger = new UniversalLogger().setContext("AuthSessionRevokeAPI");
registerOpenAPIRoute("/auth/sessions/{sessionId}", "DELETE", {
summary: "Revoke specific session",
description: "Invalidate a specific session by ID",
tags: ["Authentication"],
parameters: [
{
name: "sessionId",
in: "path",
description: "Session ID to revoke",
required: true,
schema: { type: "string" }
}
],
responses: {
"200": {
description: "Session revoked successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: { type: "string", description: "Success message" }
},
required: ["message"]
}
}
}
},
"401": {
description: "Not authenticated",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" }
}
}
},
"403": {
description: "Cannot revoke session of another user",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" }
}
}
},
"404": {
description: "Session not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" }
}
}
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" }
}
}
}
}
});
export const DELETE: RequestHandler = async ({ params, cookies }) => {
try {
const sessionToken = cookies.get("session");
const sessionIdToRevoke = params.sessionId;
if (!sessionToken) {
return json({ error: "Not authenticated" }, { status: 401 });
}
const sessionData = await SessionService.validateSession(sessionToken);
if (!sessionData) {
return json({ error: "Invalid session" }, { status: 401 });
}
// Get all user sessions to verify ownership
const userSessions = await SessionService.getActiveSessions(sessionData.user.id);
const targetSession = userSessions.find((session) => session.id === sessionIdToRevoke);
if (!targetSession) {
return json({ error: "Session not found" }, { status: 404 });
}
// Check if trying to revoke current session
if (targetSession.sessionToken === sessionToken) {
// Clear current session cookie
cookies.delete("session", {
path: "/",
httpOnly: true,
secure: true,
sameSite: "strict"
});
}
await SessionService.revokeSession(sessionIdToRevoke);
logger.info("Session revoked", {
userId: sessionData.user.id,
revokedSessionId: sessionIdToRevoke,
currentSession: targetSession.sessionToken === sessionToken
});
return json({ message: "Session revoked successfully" });
} catch (error) {
logger.error("Session revoke error:", { error: String(error) });
return json({ error: "Internal server error" }, { status: 500 });
}
};
+108
View File
@@ -0,0 +1,108 @@
import type { Handle } from "@sveltejs/kit";
import { SessionService } from "$lib/server/auth/session-service";
import { verifyAccessToken } from "$lib/server/auth/jwt-utils";
import { UniversalLogger } from "$lib/logger";
import type { JWTPayload } from "jose";
import { AuthorizationService } from "$lib/server/auth/authorization-service";
const logger = new UniversalLogger().setContext("AuthHandle");
const SESSION_COOKIE_NAME = "session";
const PROTECTED_PATHS = ["/api/admin", "/api/tenant-admin"];
const PUBLIC_PATHS = [
"/api/auth",
"/api/health",
"/api/docs",
"/api/admin/init",
"/api/admin/confirm",
"/api/admin/exists"
];
const GLOBAL_ADMIN_PATHS = ["/api/admin"];
const ADMIN_PATHS = ["/api/tenant-admin"];
export const authHandle: Handle = async ({ event, resolve }) => {
const { url, request } = event;
const path = url.pathname;
const isProtectedPath = PROTECTED_PATHS.some((protectedPath) => path.startsWith(protectedPath));
const isPublicPath = PUBLIC_PATHS.some((publicPath) => path.startsWith(publicPath));
const isGlobalAdminPath = GLOBAL_ADMIN_PATHS.some((gadPath) => path.startsWith(gadPath));
const isAdminPath = ADMIN_PATHS.some((gadPath) => path.startsWith(gadPath));
if (!isProtectedPath || isPublicPath) {
return resolve(event);
}
let sessionToken: string | null = null;
let accessToken: string | null = null;
const sessionCookie = event.cookies.get(SESSION_COOKIE_NAME);
if (sessionCookie) {
sessionToken = sessionCookie;
}
const authHeader = request.headers.get("authorization");
if (authHeader?.startsWith("Bearer ")) {
accessToken = authHeader.substring(7);
}
if (!sessionToken && !accessToken) {
logger.warn(`Authentication required for ${path}`);
return new Response(JSON.stringify({ error: "Authentication required" }), {
status: 401,
headers: { "Content-Type": "application/json" }
});
}
let user: JWTPayload | null = null;
if (accessToken) {
user = await verifyAccessToken(accessToken);
if (!user) {
logger.warn(`Invalid access token for ${path}`);
return new Response(JSON.stringify({ error: "Invalid access token" }), {
status: 401,
headers: { "Content-Type": "application/json" }
});
}
} else if (sessionToken) {
const sessionData = await SessionService.validateSession(sessionToken);
if (!sessionData) {
logger.warn(`Invalid session for ${path}`);
return new Response(JSON.stringify({ error: "Invalid session" }), {
status: 401,
headers: { "Content-Type": "application/json" }
});
}
user = await verifyAccessToken(sessionData.accessToken);
}
if (!user) {
logger.warn(`Authentication failed for ${path}`);
return new Response(JSON.stringify({ error: "Authentication failed" }), {
status: 401,
headers: { "Content-Type": "application/json" }
});
}
event.locals.user = user;
event.locals.sessionToken = sessionToken || undefined;
if (isGlobalAdminPath && !AuthorizationService.hasRole(user, "GLOBAL_ADMIN")) {
return new Response(JSON.stringify({ error: "Authentication failed" }), {
status: 401,
headers: { "Content-Type": "application/json" }
});
} else if (
isAdminPath &&
!AuthorizationService.hasAnyRole(user, ["GLOBAL_ADMIN", "TENANT_ADMIN"])
) {
return new Response(JSON.stringify({ error: "Authentication failed" }), {
status: 401,
headers: { "Content-Type": "application/json" }
});
}
logger.debug(`User authenticated: ${user.email} for ${path}`);
return resolve(event);
};
+34
View File
@@ -0,0 +1,34 @@
import type { Handle } from "@sveltejs/kit";
import { StartupService } from "$lib/server/services/startup-service";
import { UniversalLogger } from "$lib/logger";
const logger = new UniversalLogger().setContext("StartupHandle");
// Promise to ensure initialization happens only once
let initializationPromise: Promise<void> | null = null;
export const startupHandle: Handle = async ({ event, resolve }) => {
// Initialize the application on first request
if (!initializationPromise) {
logger.info("Initializing application on first request");
initializationPromise = StartupService.initialize();
}
// Wait for initialization to complete
try {
await initializationPromise;
} catch (error) {
logger.error("Application initialization failed", { error: String(error) });
// Return a 503 Service Unavailable response
return new Response("Service temporarily unavailable. Please try again later.", {
status: 503,
headers: {
"Content-Type": "text/plain",
"Retry-After": "30"
}
});
}
// Continue with the request
return resolve(event);
};