Skip to content
Open
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
90 changes: 90 additions & 0 deletions e2e/auth/session-expiry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { test, expect, type Page } from '@playwright/test';
import { waitForHydration } from '../support/helpers.js';

/** Issue a same-origin fetch from the page and wait for the session check it triggers. */
async function fetchAndAwaitSessionCheck(page: Page, url: string) {
const sessionCheck = page.waitForResponse((res) => res.url().includes('/api/auth/get-session'));
const status = await page.evaluate(async (u) => (await fetch(u)).status, url);
await sessionCheck;
return status;
}

test.describe('Session expiry', () => {
test.use({ locale: 'en-US' });

test.describe('without a session', () => {
test.use({ storageState: { cookies: [], origins: [] } });

test('API routes return a JSON 401', async ({ request }) => {
const res = await request.get('/api/trino/catalog?level=catalogs', { maxRedirects: 0 });
expect(res.status()).toBe(401);
expect(await res.json()).toEqual({ message: 'Authentication required' });
});
});

test('shows a blocking modal when an API call fails after the session is gone', async ({
page
}) => {
await page.goto('/trino?tab=1');
await waitForHydration(page);

const dialog = page.getByRole('dialog', { name: 'Session expired' });
await expect(dialog).toBeHidden();

await page.context().clearCookies();
const status = await fetchAndAwaitSessionCheck(page, '/api/trino/catalog?level=catalogs');
expect(status).toBe(401);

await expect(dialog).toBeVisible();
await expect(dialog.getByText('Your session has expired.')).toBeVisible();

const signIn = dialog.getByRole('link', { name: 'Sign in again' });

// The modal cannot be dismissed. Press Escape twice: browsers may skip the
// cancellable `cancel` event on a repeated Escape and close the dialog.
await page.keyboard.press('Escape');
await page.keyboard.press('Escape');
await expect(dialog).toBeVisible();
await expect(signIn).toBeFocused();

await expect(signIn).toHaveAttribute(
'href',
'/auth/login?redirectTo=' + encodeURIComponent('/trino?tab=1')
);

// Signing in again returns the user to where they were. This relies on the
// mock OIDC provider signing in without a login form.
await signIn.click();
await expect(page).toHaveURL(/\/auth\/login/);
await waitForHydration(page);
await page.getByRole('button', { name: /sign in with sso/i }).click();
await expect(page).toHaveURL('/trino?tab=1');
await expect(dialog).toBeHidden();
});

test('shows the modal when the user returns to the tab after the session is gone', async ({
page
}) => {
await page.goto('/');
await waitForHydration(page);

await page.context().clearCookies();
const sessionCheck = page.waitForResponse((res) => res.url().includes('/api/auth/get-session'));
await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')));
await sessionCheck;

await expect(page.getByRole('dialog', { name: 'Session expired' })).toBeVisible();
});

test('does not show the modal for a 401 while the session is valid', async ({ page }) => {
await page.goto('/');
await waitForHydration(page);

// The storage API answers 401 when no storage connection header is sent
// (requires the storage browser to be enabled, as in .env.test).
const status = await fetchAndAwaitSessionCheck(page, '/api/storage/buckets');
expect(status).toBe(401);

await expect(page.getByRole('dialog', { name: 'Session expired' })).toBeHidden();
});
});
3 changes: 3 additions & 0 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@
"auth_login_subtitle": "Verwenden Sie den SSO-Anbieter Ihrer Organisation, um auf die Plattform zuzugreifen.",
"auth_login_button": "Mit SSO anmelden",
"auth_login_error": "Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.",
"session_expired_title": "Sitzung abgelaufen",
"session_expired_message": "Ihre Sitzung ist abgelaufen. Melden Sie sich erneut an, um fortzufahren.",
"session_expired_sign_in": "Erneut anmelden",
"header_sign_out": "Abmelden",
"trino_results_empty": "Keine Ergebnisse",
"trino_query_error": "Abfragefehler",
Expand Down
3 changes: 3 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@
"auth_login_subtitle": "Use your organisation's SSO provider to access the platform.",
"auth_login_button": "Sign in with SSO",
"auth_login_error": "Sign-in failed. Please try again.",
"session_expired_title": "Session expired",
"session_expired_message": "Your session has expired. Sign in again to continue.",
"session_expired_sign_in": "Sign in again",
"header_sign_out": "Sign out",
"trino_results_empty": "No results",
"trino_query_error": "Query error",
Expand Down
28 changes: 28 additions & 0 deletions src/hooks.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { ClientInit } from '@sveltejs/kit';
import { checkSession } from '$lib/client/session.svelte';

/**
* Wrap `window.fetch` so that any same-origin 401 triggers a session check.
* A 401 does not always mean the session has expired (e.g. the storage API
* uses it for a missing connection), so the response itself is passed through
* unchanged and `checkSession` decides whether the session is really gone.
*/
export const init: ClientInit = () => {
const originalFetch = window.fetch;
window.fetch = async (input, requestInit) => {
const response = await originalFetch(input, requestInit);
if (response.status === 401 && isSameOrigin(response.url)) {
void checkSession();
}
return response;
};
};

function isSameOrigin(url: string): boolean {
if (!url) return false;
try {
return new URL(url).origin === window.location.origin;
} catch {
return false;
}
}
7 changes: 4 additions & 3 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { paraglideMiddleware } from '$lib/paraglide/server';
import { httpRequestDuration } from '$lib/server/metrics';
import { building, dev } from '$app/environment';
import { error, redirect, type Handle, type HandleServerError } from '@sveltejs/kit';
import { error, type Handle, type HandleServerError } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { svelteKitHandler } from 'better-auth/svelte-kit';
import { auth, oidcEnabled } from '$lib/server/auth';
import { unauthenticatedResponse } from '$lib/server/auth-guard';
import { requestLogger, logger } from '$lib/server/logging';
import { getConnectionFromHeader } from '$lib/server/storage/connection.js';
import { storageBrowserEnabled } from '$lib/server/feature-flags.js';
Expand Down Expand Up @@ -53,8 +54,8 @@ const handleAuthGuard: Handle = async ({ event, resolve }) => {

const isPublic = PUBLIC_PATHS.some((p) => event.url.pathname.startsWith(p));
if (!isPublic && !event.locals.user) {
const redirectTo = encodeURIComponent(event.url.pathname + event.url.search);
throw redirect(302, `/auth/login?redirectTo=${redirectTo}`);
event.locals.logger.debug({ path: event.url.pathname }, 'Rejecting unauthenticated request');
return unauthenticatedResponse(event.url);
}

return resolve(event);
Expand Down
66 changes: 66 additions & 0 deletions src/lib/client/session.svelte.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { checkSession, sessionState } from './session.svelte.js';

const { getSession } = vi.hoisted(() => ({ getSession: vi.fn() }));
vi.mock('$lib/auth-client', () => ({ authClient: { getSession } }));

describe('checkSession', () => {
beforeEach(() => {
getSession.mockReset();
sessionState.active = true;
sessionState.expired = false;
});

it('marks the session expired when the server reports no session', async () => {
getSession.mockResolvedValue({ data: null, error: null });

await checkSession();

expect(sessionState.expired).toBe(true);
expect(getSession).toHaveBeenCalledWith({ query: { disableRefresh: true } });
});

it('keeps the session when the server returns one', async () => {
getSession.mockResolvedValue({ data: { user: {}, session: {} }, error: null });

await checkSession();

expect(sessionState.expired).toBe(false);
});

it('clears the flag once the session is restored', async () => {
sessionState.expired = true;
getSession.mockResolvedValue({ data: { user: {}, session: {} }, error: null });

await checkSession();

expect(sessionState.expired).toBe(false);
});

it('ignores server and network errors', async () => {
getSession.mockResolvedValueOnce({ data: null, error: { status: 500 } });
getSession.mockRejectedValueOnce(new TypeError('Failed to fetch'));

await checkSession();
await checkSession();

expect(sessionState.expired).toBe(false);
});

it('shares one request between concurrent checks', async () => {
getSession.mockResolvedValue({ data: { user: {}, session: {} }, error: null });

await Promise.all([checkSession(), checkSession(), checkSession()]);

expect(getSession).toHaveBeenCalledTimes(1);
});

it('does nothing while inactive', async () => {
sessionState.active = false;

await checkSession();

expect(getSession).not.toHaveBeenCalled();
expect(sessionState.expired).toBe(false);
});
});
36 changes: 36 additions & 0 deletions src/lib/client/session.svelte.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { authClient } from '$lib/auth-client';

/**
* Client-side view of the user's session. `active` is set while a signed-in
* layout is mounted; without it (e.g. OIDC disabled) no checks are made.
*/
export const sessionState = $state({ active: false, expired: false });

let pending: Promise<void> | null = null;

/**
* Ask the server whether the current session is still valid and update
* `sessionState.expired`. Concurrent calls share one request. A session that
* is restored (e.g. by signing in again in another tab) clears the flag.
*
* Network and server errors are ignored: only an explicit "no session" answer
* marks the session as expired.
*
* Note: `disableRefresh` only prevents extending a database-backed session.
* In stateless mode (no database, as configured today) a check made shortly
* before the session cookie expires still renews it, like any other request.
*/
export function checkSession(): Promise<void> {
if (!sessionState.active) return Promise.resolve();
pending ??= authClient
.getSession({ query: { disableRefresh: true } })
.then(({ data, error }) => {
// Drop the result if the signed-in layout unmounted in the meantime.
if (!error && sessionState.active) sessionState.expired = !data;
})
.catch(() => {})
.finally(() => {
pending = null;
});
return pending;
}
34 changes: 30 additions & 4 deletions src/lib/components/Modal.svelte
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
<script lang="ts">
import type { HTMLDialogAttributes } from 'svelte/elements';

let {
open = $bindable(false),
children,
class: className = ''
}: {
class: className = '',
dismissible = true,
...rest
}: Omit<
HTMLDialogAttributes,
'open' | 'class' | 'children' | 'onclose' | 'oncancel' | 'onclick'
> & {
open: boolean;
children: import('svelte').Snippet;
class?: string;
/** When false, Escape and backdrop clicks do not close the modal. */
dismissible?: boolean;
} = $props();

let dialogEl = $state<HTMLDialogElement | undefined>(undefined);
Expand All @@ -22,19 +31,36 @@
});

function handleClose() {
// Browsers may close a dialog despite a cancelled `cancel` event (e.g. a
// repeated Escape press), so reopen it if it must stay open.
if (!dismissible && open) {
dialogEl?.showModal();
return;
}
open = false;
}

function handleCancel(e: Event) {
if (!dismissible) e.preventDefault();
}

function handleBackdropClick(e: MouseEvent) {
// The click target is the <dialog> element itself only when the backdrop is
// clicked; clicks inside the content bubble up to child elements instead.
if (e.target === dialogEl) {
if (dismissible && e.target === dialogEl) {
open = false;
}
}
</script>

<dialog bind:this={dialogEl} class={className} onclose={handleClose} onclick={handleBackdropClick}>
<dialog
{...rest}
bind:this={dialogEl}
class={className}
onclose={handleClose}
oncancel={handleCancel}
onclick={handleBackdropClick}
>
{#if open}
{@render children()}
{/if}
Expand Down
54 changes: 54 additions & 0 deletions src/lib/components/layout/SessionExpiredModal.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import IconLogin from 'virtual:icons/material-symbols/login';
import * as m from '$lib/paraglide/messages.js';
import Modal from '$lib/components/Modal.svelte';
import { checkSession, sessionState } from '$lib/client/session.svelte';

const uid = $props.id();

let redirectTo = $derived(encodeURIComponent(page.url.pathname + page.url.search));

// Enable session checks while mounted, and check when the user returns to
// the tab so an expired session is noticed before the next API call. There
// is deliberately no periodic check: in stateless mode a check can renew
// the session, so polling would keep an idle tab signed in.
$effect(() => {
sessionState.active = true;
const checkIfVisible = () => {
if (document.visibilityState === 'visible') void checkSession();
};
document.addEventListener('visibilitychange', checkIfVisible);
return () => {
sessionState.active = false;
document.removeEventListener('visibilitychange', checkIfVisible);
};
});
</script>

<Modal
bind:open={sessionState.expired}
dismissible={false}
class="modal"
aria-labelledby="{uid}-title"
aria-describedby="{uid}-message"
>
<div class="modal-box max-w-md">
<h2 id="{uid}-title" class="text-lg font-bold">{m.session_expired_title()}</h2>
<p id="{uid}-message" class="text-base-content/70 py-4">{m.session_expired_message()}</p>
<div class="modal-action">
<!-- The path is resolved; the rule does not recognise the appended query string. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
href={`${resolve('/auth/login')}?redirectTo=${redirectTo}`}
class="btn btn-primary"
data-sveltekit-reload
>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
<IconLogin class="size-5" aria-hidden="true" />
{m.session_expired_sign_in()}
</a>
</div>
</div>
</Modal>
Loading
Loading