Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion sake/src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ const authHandle: Handle = async ({ event, resolve }) => {

const hasStaleSessionCookie = Boolean(sessionToken) && !apiKey && !event.locals.auth;
if (hasStaleSessionCookie) {
clearSakeSessionCookie(cookies, url);
clearSakeSessionCookie(cookies, event);
event.locals.logger?.info(
{ event: 'auth.session_cookie.cleared', pathname, method },
'Cleared stale session cookie'
Expand Down
16 changes: 15 additions & 1 deletion sake/src/lib/client/stores/toastStore.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,27 @@ export interface Toast {
duration?: number;
}

function generateId(): string {
if (typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// crypto.randomUUID is only available in secure contexts (HTTPS/localhost).
// Fall back to getRandomValues which works over plain HTTP too.
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}

export class ToastStore {
toasts = $state<Toast[]>([]);

constructor() { }

add(message: string, type: ToastType = 'info', duration: number = 3000) {
const id = crypto.randomUUID();
const id = generateId();
const toast: Toast = { id, message, type, duration };
this.toasts.push(toast);

Expand Down
91 changes: 83 additions & 8 deletions sake/src/lib/server/auth/cookies.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,115 @@
import type { Cookies } from '@sveltejs/kit';
import { SAKE_SESSION_COOKIE_NAME } from '$lib/server/auth/constants';

function isSecureRequest(url: URL): boolean {
return url.protocol === 'https:';
interface CookieSecurityContext {
request: Request;
url: URL;
platform?: {
req?: {
socket?: {
encrypted?: boolean;
};
};
};
}

function getFirstHeaderToken(value: string | null): string | null {
if (!value) {
return null;
}

const [first] = value.split(',');
const normalized = first?.trim().toLowerCase();
return normalized ? normalized : null;
}

function getForwardedProto(request: Request): 'http' | 'https' | null {
const forwarded = request.headers.get('forwarded');
if (forwarded) {
const match = forwarded.match(/(?:^|[;,]\s*)proto="?([^;,\s"]+)"?/i);
const proto = match?.[1]?.trim().toLowerCase();
if (proto === 'http' || proto === 'https') {
return proto;
}
}

const xForwardedProto = getFirstHeaderToken(request.headers.get('x-forwarded-proto'));
if (xForwardedProto === 'http' || xForwardedProto === 'https') {
return xForwardedProto;
}

const xForwardedSsl = getFirstHeaderToken(request.headers.get('x-forwarded-ssl'));
if (xForwardedSsl === 'on') {
return 'https';
}
if (xForwardedSsl === 'off') {
return 'http';
}

const frontEndHttps = getFirstHeaderToken(request.headers.get('front-end-https'));
if (frontEndHttps === 'on') {
return 'https';
}
if (frontEndHttps === 'off') {
return 'http';
}

return null;
}

function getSocketEncryptionState(context: CookieSecurityContext): boolean | null {
const socket = context.platform?.req?.socket;
if (!socket) {
return null;
}

return socket.encrypted === true;
}

export function isSecureRequest(context: CookieSecurityContext): boolean {
const forwardedProto = getForwardedProto(context.request);
if (forwardedProto) {
return forwardedProto === 'https';
}

const socketEncrypted = getSocketEncryptionState(context);
if (socketEncrypted !== null) {
return socketEncrypted;
}

return context.url.protocol === 'https:';
}

export function setSakeSessionCookie(
cookies: Cookies,
url: URL,
context: CookieSecurityContext,
token: string,
expiresAt: string
): void {
cookies.set(SAKE_SESSION_COOKIE_NAME, token, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: isSecureRequest(url),
secure: isSecureRequest(context),
expires: new Date(expiresAt)
});
}

export function clearSakeSessionCookie(cookies: Cookies, url: URL): void {
export function clearSakeSessionCookie(cookies: Cookies, context: CookieSecurityContext): void {
cookies.delete(SAKE_SESSION_COOKIE_NAME, {
path: '/',
httpOnly: true,
sameSite: 'lax',
secure: isSecureRequest(url)
secure: isSecureRequest(context)
});
}

export function clearZlibraryCookies(cookies: Cookies, url: URL): void {
export function clearZlibraryCookies(cookies: Cookies, context: CookieSecurityContext): void {
const cookieOptions = {
path: '/',
httpOnly: true,
sameSite: 'lax' as const,
secure: isSecureRequest(url)
secure: isSecureRequest(context)
};

cookies.delete('userId', cookieOptions);
Expand Down
16 changes: 14 additions & 2 deletions sake/src/lib/server/auth/responseSignals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,30 @@ import { clearZlibraryCookies } from '$lib/server/auth/cookies';
import { errorResponse, withResponseHeader } from '$lib/server/http/api';
import type { Cookies } from '@sveltejs/kit';

interface CookieSecurityContext {
request: Request;
url: URL;
platform?: {
req?: {
socket?: {
encrypted?: boolean;
};
};
};
}

export function zlibraryAuthFailureResponse(
message: string,
status: number,
cookies: Cookies,
url: URL
context: CookieSecurityContext
): Response {
const response = errorResponse(message, status);

if (!isAuthenticationFailureStatus(status)) {
return response;
}

clearZlibraryCookies(cookies, url);
clearZlibraryCookies(cookies, context);
return withResponseHeader(response, SAKE_CLEAR_ZLIBRARY_AUTH_HEADER_NAME, 'true');
}
7 changes: 4 additions & 3 deletions sake/src/routes/api/auth/bootstrap/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { toLogError } from '$lib/server/infrastructure/logging/logger';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

export const POST: RequestHandler = async ({ request, locals, cookies, url }) => {
export const POST: RequestHandler = async (event) => {
const { request, locals, cookies } = event;
const requestLogger = getRequestLogger(locals);
const ipAddress = getRequestIp(request);
let body: {
Expand Down Expand Up @@ -63,8 +64,8 @@ export const POST: RequestHandler = async ({ request, locals, cookies, url }) =>
return errorResponse(result.error.message, result.error.status);
}

setSakeSessionCookie(cookies, url, result.value.sessionToken, result.value.sessionExpiresAt);
clearZlibraryCookies(cookies, url);
setSakeSessionCookie(cookies, event, result.value.sessionToken, result.value.sessionExpiresAt);
clearZlibraryCookies(cookies, event);

return json({
success: true,
Expand Down
7 changes: 4 additions & 3 deletions sake/src/routes/api/auth/login/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { toLogError } from '$lib/server/infrastructure/logging/logger';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

export const POST: RequestHandler = async ({ request, locals, cookies, url }) => {
export const POST: RequestHandler = async (event) => {
const { request, locals, cookies } = event;
const requestLogger = getRequestLogger(locals);
const ipAddress = getRequestIp(request);
let body: { username?: unknown; password?: unknown };
Expand Down Expand Up @@ -66,8 +67,8 @@ export const POST: RequestHandler = async ({ request, locals, cookies, url }) =>
return errorResponse(result.error.message, result.error.status);
}

setSakeSessionCookie(cookies, url, result.value.sessionToken, result.value.sessionExpiresAt);
clearZlibraryCookies(cookies, url);
setSakeSessionCookie(cookies, event, result.value.sessionToken, result.value.sessionExpiresAt);
clearZlibraryCookies(cookies, event);

return json({
success: true,
Expand Down
7 changes: 4 additions & 3 deletions sake/src/routes/api/auth/logout-all/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { toLogError } from '$lib/server/infrastructure/logging/logger';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

export const POST: RequestHandler = async ({ locals, cookies, url }) => {
export const POST: RequestHandler = async (event) => {
const { locals, cookies } = event;
const requestLogger = getRequestLogger(locals);

if (locals.auth?.type !== 'session') {
Expand All @@ -30,8 +31,8 @@ export const POST: RequestHandler = async ({ locals, cookies, url }) => {
return errorResponse(result.error.message, result.error.status);
}

clearSakeSessionCookie(cookies, url);
clearZlibraryCookies(cookies, url);
clearSakeSessionCookie(cookies, event);
clearZlibraryCookies(cookies, event);

return json({ success: true });
} catch (err: unknown) {
Expand Down
7 changes: 4 additions & 3 deletions sake/src/routes/api/auth/logout/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { toLogError } from '$lib/server/infrastructure/logging/logger';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

export const POST: RequestHandler = async ({ locals, cookies, url }) => {
export const POST: RequestHandler = async (event) => {
const { locals, cookies } = event;
const requestLogger = getRequestLogger(locals);
const sessionToken = cookies.get(SAKE_SESSION_COOKIE_NAME);

Expand All @@ -28,8 +29,8 @@ export const POST: RequestHandler = async ({ locals, cookies, url }) => {
return errorResponse(result.error.message, result.error.status);
}

clearSakeSessionCookie(cookies, url);
clearZlibraryCookies(cookies, url);
clearSakeSessionCookie(cookies, event);
clearZlibraryCookies(cookies, event);

return json({ success: true });
} catch (err: unknown) {
Expand Down
5 changes: 3 additions & 2 deletions sake/src/routes/api/search/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import type { SearchBooksRequest } from '$lib/types/Search/SearchBooksRequest';
import type { RequestHandler } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';

export const POST: RequestHandler = async ({ request, locals, cookies, url }) => {
export const POST: RequestHandler = async (event) => {
const { request, locals, cookies } = event;
const requestLogger = getRequestLogger(locals);

let parsedRequest: SearchBooksRequest;
Expand Down Expand Up @@ -43,7 +44,7 @@ export const POST: RequestHandler = async ({ request, locals, cookies, url }) =>
'Search rejected'
);
if (locals.zuser && isAuthenticationFailureStatus(result.error.status)) {
return zlibraryAuthFailureResponse(result.error.message, result.error.status, cookies, url);
return zlibraryAuthFailureResponse(result.error.message, result.error.status, cookies, event);
}
return errorResponse(result.error.message, result.error.status);
}
Expand Down
5 changes: 3 additions & 2 deletions sake/src/routes/api/zlibrary/download/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import type { ZDownloadBookRequest } from '$lib/types/ZLibrary/Requests/ZDownloa
import type { RequestHandler } from '@sveltejs/kit';
import { json } from '@sveltejs/kit';

export const POST: RequestHandler = async ({ request, locals, cookies, url }) => {
export const POST: RequestHandler = async (event) => {
const { request, locals, cookies } = event;
const requestLogger = getRequestLogger(locals);
let body: ZDownloadBookRequest;
try {
Expand Down Expand Up @@ -51,7 +52,7 @@ export const POST: RequestHandler = async ({ request, locals, cookies, url }) =>
},
'Download rejected'
);
return zlibraryAuthFailureResponse(result.error.message, result.error.status, cookies, url);
return zlibraryAuthFailureResponse(result.error.message, result.error.status, cookies, event);
}

if (body.downloadToDevice === false) {
Expand Down
5 changes: 3 additions & 2 deletions sake/src/routes/api/zlibrary/queue/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { json } from '@sveltejs/kit';
/**
* Queue a book for download to library (async, returns immediately)
*/
export const POST: RequestHandler = async ({ request, locals, cookies, url }) => {
export const POST: RequestHandler = async (event) => {
const { request, locals, cookies } = event;
const requestLogger = getRequestLogger(locals);
let body: ZDownloadBookRequest;
try {
Expand Down Expand Up @@ -54,7 +55,7 @@ export const POST: RequestHandler = async ({ request, locals, cookies, url }) =>
},
'Queue request rejected'
);
return zlibraryAuthFailureResponse(result.error.message, result.error.status, cookies, url);
return zlibraryAuthFailureResponse(result.error.message, result.error.status, cookies, event);
}

return json(result.value);
Expand Down
5 changes: 3 additions & 2 deletions sake/src/routes/api/zlibrary/search/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import { json } from '@sveltejs/kit';
// -------------------------------
// POST /api/zlibrary/search
// -------------------------------
export const POST: RequestHandler = async ({ request, locals, cookies, url }) => {
export const POST: RequestHandler = async (event) => {
const { request, locals, cookies } = event;
const requestLogger = getRequestLogger(locals);
requestLogger.warn(
{
Expand Down Expand Up @@ -48,7 +49,7 @@ export const POST: RequestHandler = async ({ request, locals, cookies, url }) =>
},
'Search rejected'
);
return zlibraryAuthFailureResponse(searchResult.error.message, searchResult.error.status, cookies, url);
return zlibraryAuthFailureResponse(searchResult.error.message, searchResult.error.status, cookies, event);
}

return json(searchResult.value, {
Expand Down
Loading
Loading