aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatarLarge Libravatar memdmp <memdmpestrogenzone>2025-08-20 13:39:01 +0200
committerLibravatarLarge Libravatar memdmp <memdmpestrogenzone>2025-08-20 13:39:01 +0200
commitdddef149aea597a145e3717b2c461b251e0f6a8d (patch)
tree0a38a8d48e2db2501caca6d66358a4f88c1b743f
parent7fdaea73c5c67565202e19d6182fc215427919c3 (diff)
downloadcrunched-dddef149aea597a145e3717b2c461b251e0f6a8d.tar.gz
crunched-dddef149aea597a145e3717b2c461b251e0f6a8d.tar.bz2
crunched-dddef149aea597a145e3717b2c461b251e0f6a8d.tar.lz
crunched-dddef149aea597a145e3717b2c461b251e0f6a8d.zip

feat: oidc attempt 82845345

-rw-r--r--src/app.d.ts14
-rw-r--r--src/hooks.server.ts118
-rw-r--r--src/lib/auth.server.ts36
-rw-r--r--src/lib/auth.ts37
-rw-r--r--src/lib/oncePromise.ts16
-rw-r--r--src/lib/util-types.ts4
-rw-r--r--src/routes/+error.svelte1
-rw-r--r--src/routes/+layout.server.ts4
-rw-r--r--src/routes/+layout.svelte12
-rw-r--r--src/routes/+page.svelte26
-rw-r--r--src/routes/+server.ts3
-rw-r--r--src/routes/api/v1/whoami/+server.ts9
-rw-r--r--src/routes/home/+page.svelte34
-rw-r--r--src/routes/login/+server.ts16
-rw-r--r--src/routes/login/callback/+server.ts79
-rw-r--r--src/routes/login/callback/ok/+page.svelte13
-rw-r--r--src/routes/login/undo/+server.ts16
-rw-r--r--src/routes/logout/+server.ts14
-rw-r--r--src/routes/vms/+page.svelte16
19 files changed, 356 insertions, 112 deletions
diff --git a/src/app.d.ts b/src/app.d.ts
index cfeb8a5..4f96a65 100644
--- a/src/app.d.ts
+++ b/src/app.d.ts
@@ -1,11 +1,21 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
+
+import type { authHandler } from './hooks.server';
+
// for information about these interfaces
-type Session = unknown;
declare global {
namespace App {
// interface Error {}
interface Locals {
- auth: () => Promise<Session | null>;
+ /**
+ * Undefined: not authorized
+ * Null: failed to validate/renew token
+ */
+ auth: () => Promise<Session | undefined | null>;
+ /**
+ * Removes Authentication Cookies
+ */
+ logout: () => void;
}
// interface PageData {}
// interface PageState {}
diff --git a/src/hooks.server.ts b/src/hooks.server.ts
index 2fe3744..05b8b18 100644
--- a/src/hooks.server.ts
+++ b/src/hooks.server.ts
@@ -1,32 +1,104 @@
+import oncePromise from '$lib/oncePromise';
+import type { Defined, Unpromise } from '$lib/util-types';
+import type { RequestEvent, Transport } from '@sveltejs/kit';
import * as auth from './lib/auth.server';
import * as client from 'openid-client';
// https://svelte.dev/docs/kit/hooks#Server-hooks-handle
-export const handle = ({ event, resolve }) => {
- event.locals.auth = async () => {
- const accessToken = event.cookies.get('oid__access_token');
- const sub = event.cookies.get('oid__sub');
- console.warn({ accessToken, sub });
- if (accessToken && sub) {
+export const authHandler = (
+ event: RequestEvent<Partial<Record<string, string>>, string | null>
+) =>
+ oncePromise(async () => {
+ let refreshToken = event.cookies.get('oid__refresh_token');
+ let accessToken = event.cookies.get('oid__access_token');
+ let expiry = Number(event.cookies.get('oid__expires_at'));
+ if (
+ refreshToken &&
+ (!accessToken || isNaN(expiry) || expiry - 60 * 1000 >= Date.now())
+ ) {
try {
- const userInfo = await client
- .fetchUserInfo(await auth.getConfig(), accessToken, sub)
- .catch((e) => {
- console.warn(e);
-
- return null;
- });
- console.warn({
- userInfo,
- accessToken,
- sub,
- });
- } catch (error) {}
- } else if (accessToken || sub) {
- event.cookies.delete('access-token', { path: '/' });
- event.cookies.delete('sub', { path: '/' });
+ const tokens = await client.refreshTokenGrant(
+ await auth.getConfig(),
+ refreshToken
+ );
+ auth.setCookies(event.cookies, tokens);
+ accessToken = tokens.access_token;
+ refreshToken = tokens.refresh_token ?? refreshToken;
+ expiry = tokens.expires_in
+ ? Date.now() + tokens.expires_in * 1000
+ : expiry;
+ } catch (error) {
+ return null;
+ }
}
- return null;
+ if (accessToken) {
+ try {
+ const introspectionResponse = await client.tokenIntrospection(
+ await auth.getConfig(),
+ accessToken
+ );
+ if (!introspectionResponse.active) {
+ auth.unsetCookies(event.cookies);
+ return null;
+ }
+ return {
+ tokens: {
+ scope: introspectionResponse.scope,
+ token_type: introspectionResponse.token_type as Lowercase<string>,
+ expires_at: expiry,
+ },
+ userInfo: await client
+ .fetchUserInfo(
+ await auth.getConfig(),
+ accessToken,
+ introspectionResponse.sub ?? ''
+ )
+ .catch((e) => {
+ auth.unsetCookies(event.cookies);
+ throw e;
+ }),
+ __is_session: 1,
+ };
+ } catch (error) {
+ return null;
+ }
+ } else auth.unsetCookies(event.cookies);
+ return undefined;
+ });
+export type Session = Defined<
+ Unpromise<ReturnType<ReturnType<typeof authHandler>>>
+>;
+export type ClientSession = Omit<Session, 'tokens'> & {
+ tokens: {
+ scope?: string;
+ token_type?: Lowercase<string>;
+ expires_at?: number;
+ };
+};
+() => {
+ // just a type sanity check to ensure ClientSession is always a subset of Session
+ let session!: Session;
+ let clientSession: ClientSession = session satisfies ClientSession;
+ void clientSession;
+};
+
+export const filterSession = <T extends Session | undefined | null>(
+ value: T
+): T extends Session ? ClientSession : T => {
+ type RT = T extends Session ? ClientSession : T;
+ if (value === null || value === undefined) return value as RT;
+ // clients probably shouldnt get tokens in js land (we trust the client with the token, but only over HTTP; we want to maximize the annoyance of CSRF successes)
+ const v = structuredClone(value) as ClientSession;
+ v.tokens = {
+ expires_at: value.tokens.expires_at,
+ scope: value.tokens.scope,
+ token_type: value.tokens.token_type,
};
+ return v as RT;
+};
+
+export const handle = ({ event, resolve }) => {
+ event.locals.auth = authHandler(event);
+ event.locals.logout = () => auth.unsetCookies(event.cookies);
return resolve(event);
};
diff --git a/src/lib/auth.server.ts b/src/lib/auth.server.ts
index 77e0dd7..f762cef 100644
--- a/src/lib/auth.server.ts
+++ b/src/lib/auth.server.ts
@@ -2,6 +2,7 @@ import { env as env_priv } from '$env/dynamic/private';
import { env } from '$env/dynamic/public';
import * as client from 'openid-client';
import oncePromise from './oncePromise';
+import type { Cookies } from '@sveltejs/kit';
const server = new URL(env.PUBLIC_AUTH_KEYCLOAK_ISSUER);
const clientId = env_priv.PRIVATE_AUTH_KEYCLOAK_ID;
@@ -10,7 +11,10 @@ const redirectPath = '/login/callback';
// Only trigger discovery on first client.discovery (resetting the function after a failed discovery)
export const getConfig = oncePromise(() =>
- client.discovery(server, clientId, clientSecret)
+ client.discovery(server, clientId, clientSecret).then((config) => {
+ client.useJwtResponseMode(config);
+ return config;
+ })
);
const codeVerifier = client.randomPKCECodeVerifier();
@@ -71,3 +75,33 @@ export const authorizeNewSession = async (
return tokens;
};
+
+export const unsetCookies = (cookies: Cookies) => {
+ for (const v of [
+ 'oid__access_token',
+ 'oid__refresh_token',
+ 'oid__token_type',
+ 'oid__expires_at',
+ 'oid__scopes',
+ ])
+ if (cookies.get(v)) cookies.delete(v, { path: '/' });
+};
+export const setCookies = (
+ cookies: Cookies,
+ tokens: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers
+) => {
+ for (const [k, v] of Object.entries({
+ oid__access_token: tokens.access_token,
+ oid__refresh_token: tokens.refresh_token,
+ oid__token_type: tokens.token_type,
+ oid__expires_at: '' + (Date.now() + (tokens.expiresIn() ?? 0) * 1000),
+ oid__scopes: tokens.scope,
+ }))
+ if (v)
+ cookies.set(k, v, {
+ path: '/',
+ secure: true,
+ httpOnly: true,
+ sameSite: true,
+ });
+};
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..dd6b043
--- /dev/null
+++ b/src/lib/auth.ts
@@ -0,0 +1,37 @@
+import { browser } from '$app/environment';
+import { base } from '$app/paths';
+import { redirect } from '@sveltejs/kit';
+import type { ClientSession } from '../hooks.server';
+import { goto } from '$app/navigation';
+
+/**
+ * Returns `true` if scopes are all included in session, otherwise either attempts to re-login with the new scope added (unless `getScopeOnFail` is false) and returns false
+ *
+ * Check the return value of this, even if getScopeOnFail is true; navigating client-side may not stop thread immediately!
+ */
+export const checkScope = (
+ session: ClientSession,
+ /** The scopes we want */
+ neededScopes: string[],
+ /** Redirect to login page if the scopes aren't found */
+ getScopeOnFail = false,
+ /** The target URL if redirecting */
+ next?: string
+) => {
+ const scopes = session.tokens.scope?.split(' ') ?? [];
+ if (!neededScopes.find((v) => !scopes.includes(v))) return true;
+ else if (getScopeOnFail) {
+ const targetUrl = `${base}/login?${
+ next || browser
+ ? `next=${next ?? encodeURIComponent(location.href)}&`
+ : ''
+ }scope=${encodeURIComponent(
+ [...scopes, ...neededScopes]
+ .filter((v, i, a) => a.indexOf(v) === i)
+ .join(' ')
+ )}`;
+ if (browser) goto(targetUrl);
+ else throw redirect(307, targetUrl);
+ }
+ return false;
+};
diff --git a/src/lib/oncePromise.ts b/src/lib/oncePromise.ts
index f6ce775..6ce6287 100644
--- a/src/lib/oncePromise.ts
+++ b/src/lib/oncePromise.ts
@@ -10,13 +10,19 @@ const ensurePromise = <T>(maybePromise: T | PromiseLike<T>): Promise<T> =>
? (maybePromise as Promise<T>)
: Promise.resolve(maybePromise);
/** Returns a function that caches successful promises until time runs out, and throws away unsuccessful ones */
-export const oncePromise = <T>(create: () => Promise<T>, timeout = -1) => {
+export const oncePromise = <T>(
+ create: () => Promise<T>,
+ retries = true,
+ timeout = -1
+) => {
let getPromise = (): Promise<T> => {
const oldGetPromise = getPromise,
- promise = ensurePromise(create()).catch((e) => {
- getPromise = oldGetPromise;
- throw e;
- }),
+ promise = retries
+ ? ensurePromise(create()).catch((e) => {
+ getPromise = oldGetPromise;
+ throw e;
+ })
+ : ensurePromise(create()),
expires = timeout > 0 ? performance.now() + timeout : 0;
return (getPromise = expires
? ((() =>
diff --git a/src/lib/util-types.ts b/src/lib/util-types.ts
new file mode 100644
index 0000000..861edcf
--- /dev/null
+++ b/src/lib/util-types.ts
@@ -0,0 +1,4 @@
+export type Unpromise<T extends Promise<any>> = T extends Promise<infer U>
+ ? U
+ : never;
+export type Defined<T> = T extends undefined | null ? never : T;
diff --git a/src/routes/+error.svelte b/src/routes/+error.svelte
index a19e467..c7cd13f 100644
--- a/src/routes/+error.svelte
+++ b/src/routes/+error.svelte
@@ -4,3 +4,4 @@
<h2 class="text-2xl">HTTP {page.status}</h2>
<p>{page.error?.message}</p>
+<p><a href="/home">Go Home</a></p>
diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts
index afdac71..c6f56da 100644
--- a/src/routes/+layout.server.ts
+++ b/src/routes/+layout.server.ts
@@ -1,5 +1,7 @@
+import { filterSession } from '../hooks.server';
+
export const load = async ({ locals }) => {
return {
- // session: await locals.auth(),
+ session: filterSession(await locals.auth()),
};
};
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 1980c3a..dc81706 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -13,8 +13,16 @@
<nav class="header">
<h1 class="text-4xl">crunched</h1>
<p>
- <a href="/">home</a> - {#if page.data.session}<a href="/vms">vms</a
- >{:else}<a href="/login?scope=profile%20vm-own-read">login</a>{/if}
+ <a href="/">home</a> - {#if page.data.session}<a href="/vms">vms</a> -
+ <a
+ href="/login/undo?next={encodeURIComponent(
+ page.url.pathname + page.url.search
+ )}">logout</a
+ >{:else}<a
+ href="/login?scope=default&next={encodeURIComponent(
+ page.url.pathname + page.url.search
+ )}">login</a
+ >{/if}
</p>
<div class="my-2">
<hr />
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
deleted file mode 100644
index 2634622..0000000
--- a/src/routes/+page.svelte
+++ /dev/null
@@ -1,26 +0,0 @@
-<script>
- import { page } from '$app/state';
- let s = $derived(page.data.session);
-</script>
-
-{@debug s}
-
-<h1>SvelteKit Auth Example</h1>
-<div>
- {#if page.data.session}
- {#if page.data.session.user?.image}
- <img
- src={page.data.session.user.image}
- class="avatar"
- alt="User Avatar"
- />
- {/if}
- <span>
- <small>Signed in as</small><br />
- <strong>{page.data.session.user?.name ?? 'User'}</strong>
- </span>
- <!-- <div slot="submitButton" class="buttonPrimary">Sign out</div> -->
- {:else}
- <span class="notSignedInText">You are not signed in</span>
- {/if}
-</div>
diff --git a/src/routes/+server.ts b/src/routes/+server.ts
new file mode 100644
index 0000000..c1ee9f0
--- /dev/null
+++ b/src/routes/+server.ts
@@ -0,0 +1,3 @@
+import { redirect } from '@sveltejs/kit';
+
+export const GET = () => redirect(308, '/home');
diff --git a/src/routes/api/v1/whoami/+server.ts b/src/routes/api/v1/whoami/+server.ts
new file mode 100644
index 0000000..98809a4
--- /dev/null
+++ b/src/routes/api/v1/whoami/+server.ts
@@ -0,0 +1,9 @@
+import { error, json } from '@sveltejs/kit';
+import { filterSession, type Session } from '../../../../hooks.server.js';
+
+export const GET = async ({ locals }) => {
+ const data = (await locals.auth()) as Session;
+ if (data === undefined) throw error(403, 'Unauthorized');
+ if (data === null) throw error(401, 'Session Expired');
+ return json(filterSession(data));
+};
diff --git a/src/routes/home/+page.svelte b/src/routes/home/+page.svelte
new file mode 100644
index 0000000..8fa9ddd
--- /dev/null
+++ b/src/routes/home/+page.svelte
@@ -0,0 +1,34 @@
+<script lang="ts">
+ import { page } from '$app/state';
+ import { checkScope } from '$lib/auth';
+ import type { Session } from '../../hooks.server';
+ let session = $derived(page.data.session as Session);
+</script>
+
+<svelte:head>
+ <title>Crunched - Home</title>
+</svelte:head>
+
+<article>
+ <h2 class="text-2xl">Home</h2>
+ <div>
+ {#if session}
+ <div>
+ <small>Signed in as</small><br />
+ <strong>{session.userInfo.preferred_username ?? 'User'}</strong>
+ </div>
+ <div>
+ <small>Scope</small><br />
+ <strong>{session.tokens.scope}</strong>
+ </div>
+ <button
+ onclick={() => {
+ alert(checkScope(session, ['vm-own-write'], true));
+ }}>need scope</button
+ >
+ <!-- <div slot="submitButton" class="buttonPrimary">Sign out</div> -->
+ {:else}
+ <span class="notSignedInText">You are not signed in</span>
+ {/if}
+ </div>
+</article>
diff --git a/src/routes/login/+server.ts b/src/routes/login/+server.ts
index 4a032d4..7313f13 100644
--- a/src/routes/login/+server.ts
+++ b/src/routes/login/+server.ts
@@ -1,10 +1,13 @@
import { getAuthorizeUrl } from '$lib/auth.server.js';
-import { error, redirect } from '@sveltejs/kit';
+import { redirect } from '@sveltejs/kit';
export const GET = async (event) => {
let target = event.url.searchParams.get('next') ?? '/';
- let desiredScopes =
- event.url.searchParams.get('scope') ?? 'profile vm-own-read';
+ let desiredScopes = event.url.searchParams.get('scope') ?? 'default';
+ desiredScopes = desiredScopes
+ .split(' ')
+ .flatMap((v) => (v === 'default' ? 'vm-own-read vm-own-write' : ''))
+ .join(' ');
if (new URL(target, event.url.href).host !== event.url.host) target = '/';
const existingScopes = (event.cookies.get('oid__scopes') ?? '').split(' ');
const authed = await event.locals.auth();
@@ -44,11 +47,14 @@ export const GET = async (event) => {
event.cookies.delete('pending-auth-nonces', {
path: '/',
});
- event.cookies.delete('next', {
- path: target,
+ event.cookies.set('next', target, {
+ path: '/',
});
throw redirect(303, redirectTo);
} else {
+ event.cookies.delete('next', {
+ path: '/',
+ });
throw redirect(303, target);
}
};
diff --git a/src/routes/login/callback/+server.ts b/src/routes/login/callback/+server.ts
index 32b1647..1de7811 100644
--- a/src/routes/login/callback/+server.ts
+++ b/src/routes/login/callback/+server.ts
@@ -1,5 +1,6 @@
+import { base } from '$app/paths';
import * as auth from '$lib/auth.server.js';
-import { error, json, redirect } from '@sveltejs/kit';
+import { error, isHttpError, isRedirect, json, redirect } from '@sveltejs/kit';
import * as client from 'openid-client';
// Pre-checker for nonce, not the primary implementation
@@ -18,63 +19,47 @@ const handleNonce = (nonce: string | null, nonceCookie: string | undefined) => {
};
export const GET = async (event) => {
const sp = event.url.searchParams;
- const params = {
- sessionState: sp.get('session_state'),
- iss: sp.get('iss'),
- code: sp.get('code'),
- nonce: sp.get('nonce'),
- };
- if (!params.sessionState || !params.iss || !params.code)
- throw error(400, 'Missing one of session_state, iss, code');
const remainingNonces = handleNonce(
- params.nonce,
+ sp.get('nonce'),
event.cookies.get('pending-auth-nonces')
);
try {
- const tk = await auth.authorizeNewSession(
+ const tokens = await auth.authorizeNewSession(
new URL(event.url.href),
- params.nonce ?? undefined
+ sp.get('nonce') ?? undefined
);
- for (const [k, v] of Object.entries({
- oid__access_token: tk.access_token,
- oid__token_type: tk.token_type,
- oid__expires_at: '' + (Date.now() + (tk.expiresIn() ?? 0) * 1000),
- oid__refresh_token: tk.refresh_token,
- oid__sub: tk.claims()!.sub,
- 'pending-auth-nonces': JSON.stringify(remainingNonces),
- }))
- if (v)
- event.cookies.set(k, v, {
- path: '/',
- secure: true,
- httpOnly: true,
- sameSite: true,
- });
- if (tk.scope)
- event.cookies.set('oid__scopes', tk.scope, {
- path: '/',
- secure: true,
- httpOnly: true,
- sameSite: true,
- });
-
- console.warn(
- 'New Session:',
- await client.fetchUserInfo(
- await auth.getConfig(),
- tk.access_token,
- tk.claims()!.sub
- )
- );
+ auth.setCookies(event.cookies, tokens);
+ event.cookies.set('pending-auth-nonces', JSON.stringify(remainingNonces), {
+ path: '/',
+ secure: true,
+ sameSite: true,
+ httpOnly: true,
+ });
- return json({
- sub: tk.claims()!.sub,
- at: tk.access_token,
+ let target = event.cookies.get('next') ?? '/';
+ if (new URL(target, event.url.href).host !== event.url.host) target = '/';
+ event.cookies.delete('next', {
+ path: '/',
});
+ throw redirect(
+ 303,
+ `${base}/login/callback/ok?next=${encodeURIComponent(target)}`
+ );
} catch (e) {
- throw redirect(307, '/login');
+ if (isRedirect(e) || isHttpError(e)) throw e;
+ // @ts-ignore
+ if (e?.cause?.error === 'invalid_grant')
+ throw error(
+ 403,
+ 'Invalid Grant Provided - Does your account have access to all requested resources?'
+ );
+ else
+ throw error(
+ 500,
+ `Could not authorize new session: ${JSON.stringify(e, null, 2)}`
+ );
}
};
diff --git a/src/routes/login/callback/ok/+page.svelte b/src/routes/login/callback/ok/+page.svelte
new file mode 100644
index 0000000..a71d962
--- /dev/null
+++ b/src/routes/login/callback/ok/+page.svelte
@@ -0,0 +1,13 @@
+<script lang="ts">
+ import { page } from '$app/state';
+
+ let target = page.url.searchParams.get('next') ?? '/';
+ if (new URL(target, page.url.href).host !== page.url.host) target = '/';
+</script>
+
+<svelte:head>
+ <meta http-equiv="refresh" content="0.1; url={target}" />
+</svelte:head>
+
+<h2 class="text-xl">Redirecting...</h2>
+<p>If nothing happens, click <a href={target}>here</a>.</p>
diff --git a/src/routes/login/undo/+server.ts b/src/routes/login/undo/+server.ts
new file mode 100644
index 0000000..a3559d6
--- /dev/null
+++ b/src/routes/login/undo/+server.ts
@@ -0,0 +1,16 @@
+import * as auth from '$lib/auth.server.js';
+import { error, redirect } from '@sveltejs/kit';
+import * as client from 'openid-client';
+export const GET = async (event) => {
+ const token = event.cookies.get('oid__access_token');
+ if (!token) throw error(403, 'Logout requires an access token!');
+ await client.tokenRevocation(await auth.getConfig(), token);
+ let target =
+ event.url.searchParams.get('next') ?? event.cookies.get('next') ?? '/';
+ if (new URL(target, event.url.href).host !== event.url.host) target = '/';
+ event.cookies.delete('next', {
+ path: '/',
+ });
+ auth.unsetCookies(event.cookies);
+ throw redirect(303, `/login/callback/ok?next=${encodeURIComponent(target)}`);
+};
diff --git a/src/routes/logout/+server.ts b/src/routes/logout/+server.ts
new file mode 100644
index 0000000..4880c2a
--- /dev/null
+++ b/src/routes/logout/+server.ts
@@ -0,0 +1,14 @@
+import { base } from '$app/paths';
+import { redirect } from '@sveltejs/kit';
+
+export const GET = (event) =>
+ redirect(
+ 307,
+ `${base}/login/undo${
+ event.url.searchParams.get('next')
+ ? `?next=${encodeURIComponent(
+ event.url.searchParams.get('next') ?? '/'
+ )}`
+ : ''
+ }`
+ );
diff --git a/src/routes/vms/+page.svelte b/src/routes/vms/+page.svelte
new file mode 100644
index 0000000..928b4f4
--- /dev/null
+++ b/src/routes/vms/+page.svelte
@@ -0,0 +1,16 @@
+<script lang="ts">
+ import { page } from '$app/state';
+ import { onDestroy, onMount } from 'svelte';
+
+ let mounted = $state(false);
+ onMount(() => (mounted = true));
+ onDestroy(() => (mounted = false));
+</script>
+
+<svelte:head>
+ {#if !mounted}
+ <noscript>
+ <meta http-equiv="refresh" content="5; url={page.url.href}" />
+ </noscript>
+ {/if}
+</svelte:head>