Clean up auth

This commit is contained in:
Hendrik Belitz
2025-07-24 17:27:46 +02:00
parent bcbe2b4d3b
commit 58498e77c6
10 changed files with 128 additions and 277 deletions
@@ -21,7 +21,9 @@ const mockUser: SelectUser = {
isActive: true,
confirmed: true,
token: null,
tokenValidUntil: null
tokenValidUntil: null,
passphraseHash: null,
recoveryPassphrase: null
};
describe("JWT Utils", () => {
+10 -71
View File
@@ -1,12 +1,11 @@
import { error, json, type RequestHandler } from "@sveltejs/kit";
import { z } from "zod";
import { SessionService } from "$lib/server/auth/session-service";
import { centralDb } from "$lib/server/db";
import { tenant, user, userSession } from "$lib/server/db/central-schema";
import { tenant, user } from "$lib/server/db/central-schema";
import { eq } from "drizzle-orm";
import { ValidationError, NotFoundError } from "$lib/server/utils/errors";
import { UserService } from "$lib/server/services/user-service";
import { generateTokens } from "$lib/server/auth/jwt-utils";
import { generateAccessToken } from "$lib/server/auth/jwt-utils";
import { UniversalLogger } from "$lib/logger";
import { registerOpenAPIRoute } from "$lib/server/openapi";
@@ -59,86 +58,26 @@ export const POST: RequestHandler = async ({ request, locals, cookies }) => {
throw error(404, "Tenant not found");
}
// Get current session token from cookie
const sessionToken = cookies.get("session");
if (!sessionToken) {
logger.error("No session token found in cookies", { userId: locals.user.id });
throw error(401, "No valid session found");
}
// Get current session data
const sessionData = await SessionService.validateSession(sessionToken);
if (!sessionData) {
logger.error("Invalid session token", { userId: locals.user.id });
throw error(401, "Invalid session");
}
// Current user is already authenticated via authHandle
// Update user's active tenant in the database
const updatedUser = await UserService.updateUser(locals.user.id, {
const updatedUser = await UserService.updateUser(locals.user.userId as string, {
tenantId: tenantId || null
});
if (!updatedUser) {
logger.error("Failed to update user tenant", {
userId: locals.user.id,
userId: locals.user.userId,
tenantId
});
throw error(500, "Failed to update user data");
}
// Get the session ID from the database
const sessionInfo = await centralDb
.select({ id: userSession.id })
.from(userSession)
.where(eq(userSession.sessionToken, sessionData.sessionToken))
.limit(1);
// Generate new access token with updated tenant context
const newAccessToken = await generateAccessToken(updatedUser, (locals.user.sessionId as string) || "temp-session");
if (sessionInfo.length === 0) {
logger.error("Session not found in database", { userId: locals.user.id });
throw error(500, "Session not found");
}
// Generate new tokens with updated tenant context
const newTokens = await generateTokens(updatedUser, sessionInfo[0].id);
// Update session tokens in the database
await centralDb
.update(user)
.set({
tenantId: tenantId || null,
updatedAt: new Date()
})
.where(eq(user.id, locals.user.id));
// Update the user session with new tokens
await centralDb
.update(userSession)
.set({
accessToken: newTokens.accessToken,
refreshToken: newTokens.refreshToken,
lastUsedAt: new Date(),
updatedAt: new Date()
})
.where(eq(userSession.sessionToken, sessionData.sessionToken));
// Set new cookies with updated tokens
cookies.set("session", sessionData.sessionToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/",
maxAge: 60 * 60 * 24 * 7 // 7 days
});
cookies.set("accessToken", newTokens.accessToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/",
maxAge: 60 * 15 // 15 minutes
});
cookies.set("refreshToken", newTokens.refreshToken, {
// Set new access token cookie
cookies.set("access_token", newAccessToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
@@ -147,7 +86,7 @@ export const POST: RequestHandler = async ({ request, locals, cookies }) => {
});
logger.info("Tenant switched successfully", {
userId: locals.user.id,
userId: locals.user.userId,
fromTenant: locals.user.tenantId,
toTenant: tenantId
});
+9 -24
View File
@@ -53,7 +53,7 @@ vi.mock("$lib/server/services/user-service", () => ({
// Mock JWT utils
vi.mock("$lib/server/auth/jwt-utils", () => ({
generateTokens: vi.fn()
generateAccessToken: vi.fn()
}));
// Mock universal logger
@@ -92,7 +92,7 @@ vi.mock("$lib/server/utils/errors", () => ({
import { SessionService } from "$lib/server/auth/session-service";
import { centralDb } from "$lib/server/db";
import { UserService } from "$lib/server/services/user-service";
import { generateTokens } from "$lib/server/auth/jwt-utils";
import { generateAccessToken } from "$lib/server/auth/jwt-utils";
describe("POST /api/admin/tenant", () => {
const mockUser = {
@@ -107,7 +107,9 @@ describe("POST /api/admin/tenant", () => {
updatedAt: new Date(),
lastLoginAt: null,
token: null,
tokenValidUntil: null
tokenValidUntil: null,
passphraseHash: null,
recoveryPassphrase: null
};
const mockSessionData = {
@@ -130,7 +132,7 @@ describe("POST /api/admin/tenant", () => {
request: {
json: () => Promise.resolve(body)
} as Request,
locals: { user },
locals: { user: { ...user, sessionId: "session-123" } },
cookies: mockCookies,
params: {},
url: new URL("http://localhost/api/admin/tenant"),
@@ -159,25 +161,14 @@ describe("POST /api/admin/tenant", () => {
};
vi.mocked(centralDb.select).mockReturnValue(mockSelectQuery as any);
// Mock session cookie
mockCookies.get.mockReturnValue("session-123");
// Mock session validation
const mockValidateSession = vi.mocked(SessionService.validateSession);
mockValidateSession.mockResolvedValue(mockSessionData as any);
// Mock session ID lookup
mockSelectQuery.limit.mockResolvedValueOnce([{ id: "session-id-123" }]);
// No session handling needed anymore
// Mock user update
const updatedUser = { ...mockUser, tenantId };
vi.mocked(UserService.updateUser).mockResolvedValue(updatedUser as any);
// Mock token generation
vi.mocked(generateTokens).mockResolvedValue({
accessToken: "new-access-123",
refreshToken: "new-refresh-123"
});
vi.mocked(generateAccessToken).mockResolvedValue("new-access-123");
// Mock database update queries
const mockUpdateQuery = {
@@ -193,17 +184,11 @@ describe("POST /api/admin/tenant", () => {
expect(result.success).toBe(true);
expect(result.tenantId).toBe(tenantId);
expect(result.user.tenantId).toBe(tenantId);
expect(mockCookies.set).toHaveBeenCalledWith("session", "session-123", expect.any(Object));
expect(mockCookies.set).toHaveBeenCalledWith(
"accessToken",
"access_token",
"new-access-123",
expect.any(Object)
);
expect(mockCookies.set).toHaveBeenCalledWith(
"refreshToken",
"new-refresh-123",
expect.any(Object)
);
});
it("should return 401 when user is not authenticated", async () => {
+3 -5
View File
@@ -86,14 +86,13 @@ registerOpenAPIRoute("/auth/login", "POST", {
},
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"]
required: ["message", "user", "expiresAt"]
}
}
}
@@ -273,8 +272,8 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress,
userAgent || undefined
);
// Set HTTP-only cookie for session
cookies.set("session", sessionData.sessionToken, {
// Set HTTP-only cookie for access token
cookies.set("access_token", sessionData.accessToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
@@ -298,7 +297,6 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress,
role: sessionData.user.role,
tenantId: sessionData.user.tenantId
},
accessToken: sessionData.accessToken,
expiresAt: sessionData.expiresAt.toISOString()
});
} catch (error) {
+7 -9
View File
@@ -1,5 +1,4 @@
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";
@@ -8,7 +7,7 @@ const logger = new UniversalLogger().setContext("AuthLogoutAPI");
registerOpenAPIRoute("/auth/logout", "POST", {
summary: "Logout user session",
description: "Invalidate current user session and clear session cookie",
description: "Invalidate current user session and clear access token cookie",
tags: ["Authentication"],
responses: {
"200": {
@@ -26,7 +25,7 @@ registerOpenAPIRoute("/auth/logout", "POST", {
}
},
"400": {
description: "No active session found",
description: "No access token found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" }
@@ -46,15 +45,14 @@ registerOpenAPIRoute("/auth/logout", "POST", {
export const POST: RequestHandler = async ({ cookies }) => {
try {
const sessionToken = cookies.get("session");
const accessToken = cookies.get("access_token");
if (!sessionToken) {
return json({ error: "No active session found" }, { status: 400 });
if (!accessToken) {
return json({ error: "No access token found" }, { status: 400 });
}
await SessionService.logout(sessionToken);
cookies.delete("session", {
// Simply clear the access token cookie - no need to invalidate sessions
cookies.delete("access_token", {
path: "/",
httpOnly: true,
secure: true,
+28 -30
View File
@@ -8,25 +8,8 @@ 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",
description: "Generate new access and refresh tokens using the current session",
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",
@@ -36,17 +19,15 @@ registerOpenAPIRoute("/auth/refresh", "POST", {
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"]
required: ["message", "expiresAt"]
}
}
}
},
"400": {
description: "Invalid refresh token",
description: "No valid session found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" }
@@ -72,16 +53,26 @@ registerOpenAPIRoute("/auth/refresh", "POST", {
}
});
export const POST: RequestHandler = async ({ request }) => {
export const POST: RequestHandler = async ({ locals, cookies }) => {
try {
const body = await request.json();
const { refreshToken } = body;
// Get current access token from cookie to extract session info
const accessToken = cookies.get("access_token");
if (!refreshToken) {
return json({ error: "Refresh token is required" }, { status: 400 });
if (!accessToken) {
return json({ error: "Access token is required" }, { status: 400 });
}
const result = await SessionService.refreshSession(refreshToken);
// The user object is already available from authHandle,
// so we can create a new session based on that
if (!locals.user) {
return json({ error: "Invalid user context" }, { status: 400 });
}
const result = await SessionService.createSession(
locals.user.userId as string,
"", // IP address - could be extracted from request if needed
undefined // user agent
);
if (!result) {
return json({ error: "Invalid or expired refresh token" }, { status: 401 });
@@ -89,10 +80,17 @@ export const POST: RequestHandler = async ({ request }) => {
logger.info("Token refresh successful");
// Set HTTP-only cookie for new access token
cookies.set("access_token", result.accessToken, {
httpOnly: true,
secure: true,
sameSite: "strict",
path: "/",
maxAge: 60 * 60 * 24 * 7 // 7 days
});
return json({
message: "Token refresh successful",
accessToken: result.accessToken,
refreshToken: result.refreshToken,
expiresAt: result.expiresAt.toISOString()
});
} catch (error) {
+12 -26
View File
@@ -1,5 +1,4 @@
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";
@@ -67,41 +66,28 @@ registerOpenAPIRoute("/auth/session", "GET", {
}
});
export const GET: RequestHandler = async ({ cookies }) => {
export const GET: RequestHandler = async ({ locals }) => {
try {
const sessionToken = cookies.get("session");
if (!sessionToken) {
return json({ authenticated: false, message: "No session found" }, { status: 401 });
// User is already authenticated via authHandle
if (!locals.user) {
return json({ authenticated: false, message: "Not authenticated" }, { 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 });
logger.debug("Session check", { userId: locals.user.userId });
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
id: locals.user.userId,
email: locals.user.email,
name: locals.user.name,
role: locals.user.role,
tenantId: locals.user.tenantId
},
expiresAt: sessionData.expiresAt.toISOString()
expiresAt: new Date(locals.user.exp! * 1000).toISOString()
});
} catch (error) {
logger.error("Session validation error:", { error: String(error) });
logger.error("Session check error:", { error: String(error) });
return json({ error: "Internal server error" }, { status: 500 });
}
};
+23 -42
View File
@@ -1,5 +1,4 @@
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";
@@ -109,72 +108,54 @@ registerOpenAPIRoute("/auth/sessions", "DELETE", {
}
});
export const GET: RequestHandler = async ({ cookies }) => {
export const GET: RequestHandler = async ({ locals }) => {
try {
const sessionToken = cookies.get("session");
if (!sessionToken) {
// With access token-only approach, we don't track active sessions
// Return current token info only
if (!locals.user) {
return json({ error: "Not authenticated" }, { status: 401 });
}
const sessionData = await SessionService.validateSession(sessionToken);
const currentSession = {
id: locals.user.sessionId || "current",
ipAddress: "unknown", // Would need to be tracked separately
userAgent: "unknown", // Would need to be tracked separately
createdAt: new Date(locals.user.iat! * 1000).toISOString(),
lastUsedAt: new Date().toISOString(),
expiresAt: new Date(locals.user.exp! * 1000).toISOString(),
current: true
};
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
logger.debug("Current session info retrieved", {
userId: locals.user.userId
});
return json({ sessions: formattedSessions });
return json({ sessions: [currentSession] });
} catch (error) {
logger.error("Get sessions error:", { error: String(error) });
return json({ error: "Internal server error" }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ cookies }) => {
export const DELETE: RequestHandler = async ({ locals, cookies }) => {
try {
const sessionToken = cookies.get("session");
if (!sessionToken) {
// With access token-only approach, just clear the current token
if (!locals.user) {
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", {
cookies.delete("access_token", {
path: "/",
httpOnly: true,
secure: true,
sameSite: "strict"
});
logger.info("All sessions logged out", { userId: sessionData.user.id });
logger.info("Access token cleared", { userId: locals.user.userId });
return json({ message: "All sessions logged out successfully" });
return json({ message: "Logged out successfully" });
} catch (error) {
logger.error("Logout all sessions error:", { error: String(error) });
logger.error("Logout error:", { error: String(error) });
return json({ error: "Internal server error" }, { status: 500 });
}
};
@@ -1,5 +1,4 @@
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";
@@ -69,46 +68,32 @@ registerOpenAPIRoute("/auth/sessions/{sessionId}", "DELETE", {
}
});
export const DELETE: RequestHandler = async ({ params, cookies }) => {
export const DELETE: RequestHandler = async ({ params, locals, cookies }) => {
try {
const sessionToken = cookies.get("session");
const sessionIdToRevoke = params.sessionId;
if (!sessionToken) {
if (!locals.user) {
return json({ error: "Not authenticated" }, { status: 401 });
}
const sessionData = await SessionService.validateSession(sessionToken);
if (!sessionData) {
return json({ error: "Invalid session" }, { status: 401 });
// With access token-only approach, we can only revoke the current token
const currentSessionId = locals.user.sessionId || "current";
if (sessionIdToRevoke !== currentSessionId) {
return json({ error: "Can only revoke current session" }, { status: 404 });
}
// Get all user sessions to verify ownership
const userSessions = await SessionService.getActiveSessions(sessionData.user.id);
const targetSession = userSessions.find((session) => session.id === sessionIdToRevoke);
// Clear access token cookie
cookies.delete("access_token", {
path: "/",
httpOnly: true,
secure: true,
sameSite: "strict"
});
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
logger.info("Access token revoked", {
userId: locals.user.userId,
revokedSessionId: sessionIdToRevoke
});
return json({ message: "Session revoked successfully" });
+16 -37
View File
@@ -1,13 +1,11 @@
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 ACCESS_TOKEN_COOKIE_NAME = "access_token";
const PROTECTED_PATHS = ["/api/admin", "/api/tenant-admin", "/api/tenants", "/api/auth/register"];
const PUBLIC_PATHS = [
"/api/auth/challenge",
@@ -60,20 +58,23 @@ export const authHandle: Handle = async ({ event, resolve }) => {
);
}
let sessionToken: string | null = null;
let accessToken: string | null = null;
const sessionCookie = event.cookies.get(SESSION_COOKIE_NAME);
if (sessionCookie) {
sessionToken = sessionCookie;
// Get access token from cookie
const accessTokenCookie = event.cookies.get(ACCESS_TOKEN_COOKIE_NAME);
if (accessTokenCookie) {
accessToken = accessTokenCookie;
}
const authHeader = request.headers.get("authorization");
if (authHeader?.startsWith("Bearer ")) {
accessToken = authHeader.substring(7);
// Fallback: check Authorization header
if (!accessToken) {
const authHeader = request.headers.get("authorization");
if (authHeader?.startsWith("Bearer ")) {
accessToken = authHeader.substring(7);
}
}
if (!sessionToken && !accessToken) {
if (!accessToken) {
logger.warn(`Authentication required for ${path}`);
return new Response(JSON.stringify({ error: "Authentication required" }), {
status: 401,
@@ -81,39 +82,17 @@ export const authHandle: Handle = async ({ event, resolve }) => {
});
}
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);
}
// Verify access token
const user = await verifyAccessToken(accessToken);
if (!user) {
logger.warn(`Authentication failed for ${path}`);
return new Response(JSON.stringify({ error: "Authentication failed" }), {
logger.warn(`Invalid access token for ${path}`);
return new Response(JSON.stringify({ error: "Invalid access token" }), {
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" }), {