kit
kit copied to clipboard
Hook to protect routes
Describe the problem
I would like to propose the addition of a protect
function that can be used to redirect unauthenticated or unauthorized access to pages or endpoints.
Current State
- The
handle
is only executed during a fetch or a forced reload - The
load
function in__layout.svelte
is called only once for all routes using it - Adding
load
function purely for route protection is inefficient and error-prone - There is no
load
equivalent for endpoints and in some scenarios cannot be protected using the handle function
Describe the proposed solution
Proposal: Add a user-customizable protect
function that can be written in hooks
, where we can add our custom code for validating and identifying alternate fallback routes when the user is not allowed access.
- This function should be called prior to
handle
on fetch or forced reload of any URL - This function should be called prior to client-side navigation to any endpoint or route
- Since shadow endpoints are dependent on the page route, calling this function once prior to routing to the page
- This function should receive the session from getSession
// hooks.js
export async function protect({url, session}) {
// custom logic to identify allowed requests and rerout it
const {allowed, fallbackUrl} = isUserAllowed(url, session)
if (allowed) {
return { status: 200 }
} else {
return { status: 302, redirect: fallbackPath}
}
}
or
export async function protect({url, session}) {
// custom logic to identify aalternate URL when not authorized
const falbackUrl = getFallbackUrl(url, session)
return fallbackUrl || url.pathname
}
Alternatives considered
Writing the protect function in the handle and in multiple load functions.
Importance
it would make my life easier
Additional Information
Svelte-Kit really makes application development a breeze and I would really like to move our application development over to SvelteKit. Almost all our web applications require authentication and some form of route protection. This feature will make it a lot easier to incorporate authentication and role-based route protection.
You can implement an auth guard with a single load function by making it reactive. Take a look at this paragraph from the docs.
The load function is reactive, and will re-run when its parameters change, but only if they are used in the function. Specifically, if url, session or stuff are used in the function, they will be re-run whenever their value changes...
You can implement an auth guard with a single load function by making it reactive. Take a look at this paragraph from the docs.
The load function is reactive, and will re-run when its parameters change, but only if they are used in the function. Specifically, if url, session or stuff are used in the function, they will be re-run whenever their value changes...
Thank you. I thought I had tried it out and it didn't work for me. Will check it out once more. Wouldn't it be required to be repeated in every __layout.reset.svelte
if I use it in the __layout.svelte
. This does not help with endpoints though.
Exactly that re endpoints.
For people using Kit to build apps, there seems to be a conceptual mismatch between the (very common) guard
scenario and the newer concept of "automatic" endpoints.
In other words, conventional practice is to guard using a load function which seems to, at the same time, work against the newer notion of endpoints.
There should be some semantic way of protecting routes as well as endpoints. @Rich-Harris can better shed light on this. I welcome him to comment here directly and propose a better solution.
I like the idea of a "protect" hook, but I would possibly like to see it a little more generalized. Maybe like a "route" hook that runs the same as the OP described.
One particular area I would utilize this, would be a place to centralize logic based on route changes (client-side or server-side).
For instance, I have 5 different routes in my app, where if a user navigates away from these routes, it clears a "redirect" store. Right now I have to repeat the logic in the onDestroy
function of all 5 routes. It would be nice to be able to have that logic centralized to a single place that would have knowledge of where the user is coming from and where they are going. So I could match the "from" path against an array of routes where that store should be cleared.
We currently achieve that in hooks.ts
and a load function in the topmost __layout.svelte
file.
I would also add that it can depend on the authentication/authorization flow used.
On our side we used the "Authorization Code Flow" (in OAuth sense), because it's the most secure and useable for our context, but it won't be the case of everyone.
Finding a solution that fits all cases and plays well with sveltekit is not an easy problem !
I love this proposal. It would be great to have an official way of deciding server-side if a request for a route should be served or not regardless of how the request was sent.
After investigating what my options are there seems to be quite a lot of options to consider with little recommendations of how to properly implement this.
hooks, load functions, handling per page / in layout component, sveltekit:reload anchor on <a>
elements; all seem like they have their uses, nuances and edge cases.
Personally I have solved protected routes using a hook and adding the reload anchor to <a>
tags to force the hook to run for every route change. I feel like there could be better ways of doing this which does not include more precise code or additional verbosity.
A quick note: As sketched, this is impossible. protect
cannot run prior to handle
and also receive session
, as getSession
runs after handle
. Other than that, I like the idea of having authorization built in.
I was looking into a systematic way to protect my routes and I was hoping to find that I would be able to put further hooks
files inside my routes, and have the handle
function be called inside the any handle
function further up the chain:
-
/src/routes/beverages/pepsi.svelte
is resolved by -
resolve(event)
inside of/src/routes/beverages/__hooks.js:handle
if it exists, is called by -
resolve(event)
inside of/src/routes/__hooks.js:handle
if it exists, is called by -
resolve(event)
inside of/src/hooks.js:handle
if it exists
A outer handle
(usually the first on in src/hooks.js
can do the authentication and set event.locals
. An inner handle
can then be used to protect part of the site based on the authentication data in event.locals
.
This would allow protecting pages and endpoints to be protected, be very flexible (not limited to redirecting), and be less prone to error than the current available approaches described by others above.
However this does not address issues with protecting routes when a new request is not made, that's something I'm not qualified to talk about.
Whatever method is arrived at, it would be very nice if types could be propagated, so that I might set-up a guard that checks that I have an authenticated user and sets request.locals.user
and then in routes that are protected by that guard request.locals.user
is known not-null, and so on. I don't have the first clue whether or how this would be possible.
Once the changes discussed in https://github.com/sveltejs/kit/discussions/5748 go into Kit, it may be possible to implement this. #5748 mentions that there will be "layout endpoints" available in +layout.server.js
. It's not yet clear whether +layout.server.js
would run before +server.js
or not, but it would certainly run before +layout.js
. Furthermore, the handler functions in +server.js
will have the same ability to throw errors as the load()
functions in +layout.server.js
.
Which means you'll be able to write a protect()
handler more-or-less along the following lines:
/// authTools.js
export async function protect(authPredicate) {
const okay = await authPredicate();
if (!okay) throw error(403, 'Unauthorized');
}
export async function adminRequied(session) {
// In real code, this might look something up in a database
await protect(async () => session.username === 'admin');
}
export async function isLoggedIn(session) {
// In real code, this might look something up in a database
await protect(async () => session.username != null);
}
/// /src/routes/admin/+layout.server.js
import { adminRequired } from '$lib/authTools';
export async function load({ session }) {
await adminRequired(session);
// If we get to this point, user is allowed to be here
return { message: 'Hello, admin' };
}
Server-only endpoints in +server.js
could work very similarly. You'd simply need to write a helper function similar to protect
but looking at the request
object rather than the session
, since session
isn't available on server endpoints:
/// serverAuthTools.js
import { JWT_SECRET } from '$env/dynamic/private';
import { validateToken } from 'some_npm_library';
export function requireValidToken(request, authPredicate) {
const authHeader = request.headers.get('authorization');
if (!authHeader) throw error(401, 'Auth token required');
if (!authHeader.startsWith('Bearer ') throw error(401, 'Auth token required');
const token = authHeader.substring(7);
// Assume validateToken returns null on invalid tokens
const tokenDetails = await validateToken(token, JWT_SECRET);
if (!tokenDetails) throw error(403, 'Invalid token');
const isAllowed = await authPredicate(token);
if (!isAllowed) throw error (403, 'Unauthorized user');
// Fall through to return Promise<void> in success case
}
export function requireLoggedIn(request) {
return requireValidToken(request, () => true);
}
export function adminRequired(request) {
return requireValidToken(request, token => token.username === 'admin');
}
/// /src/routes/api/privileged/+server.js
import { adminRequired } from '$lib/serverAuthTools';
export async function GET({ request }) {
await adminRequired(request);
// If we reach this point, the JWT token had username 'admin', so proceed
return new Response('Privileged admin data here');
}
Will this also support redirection also? So that we can redirect unauthorized access to a login page or an alternative fallback page with error messages?
For clarity, I wasn't saying that the protect()
function I outlined above would go into SvelteKit, I was showing a way to implement it yourself once the changes from #5748 are released in an upcoming Kit version. Which means that you'll need to implement the redirects you want yourself, but it will be just as easy. In both load
and server handlers, you would do something like throw redirect(307, '/login')
to redirect. From the #5748 explanation:
This next one might feel weird but bear with me — redirects are also thrown. That's partly because a redirect result is semantically closer to an error result than to success, but partly because it's useful if await parent() throws in the redirect case as well as the error case
So in the example above, you could replace a throw error(403, 'Unauthorized')
with a throw redirect(307, '/login')
in the case where there are no user credentials present. (In which case my example really should have been returning a 401 anyway, not a 403). And throwing a redirect(code, message)
will work in both load()
functions and server-side handlers.
If all of your server-only endpoints live under a specific route, you could just protect it the handle
function as this runs on every server request.
export const handle = async ({ event, resolve }) => {
const user = await getUserByCookie(event.request);
if (!user && event.url.pathname.startsWith("/api")) {
return new Response("Redirect", {
status: 303,
headers: { Location: "/auth/login" },
});
}
if (!user) return await resolve(event);
if (user) event.locals.user = user;
return await resolve(event);
};
If all of your server-only endpoints live under a specific route, you could just protect it the
handle
function as this runs on every server request.export const handle = async ({ event, resolve }) => { const user = await getUserByCookie(event.request); if (!user && event.url.pathname.startsWith("/api")) { return new Response("Redirect", { status: 303, headers: { Location: "/auth/login" }, }); } if (!user) return await resolve(event); if (user) event.locals.user = user; return await resolve(event); };
AFAIK the handle hook currently does not run for routes navigated to using client side navigation i.e. clicking an <a/>
tag.
I used a /routes/+layout.server.ts and a protected as the first hook in a sequence of hooks. Inside the protect I implemented the logic of logged user (or not) and redirect to the appropriate path or page to login.
Inside load funcrion inside the +layout.ts of protected routes I propagate the user variable for authenticated user, and I set a user store writable to be used inside the pages where is necessary. .
To give some use-case info for the topic, and eventually to help others, here how we're currently doing it.
note: we just had migrated to svelteKit >v406 (v429 right now).
- the app is an SPA, most of its pages are under authentication
- auth is done server side with auth0, using OAuth2.0 authorization code flow
- we have also have admin pages, whose access is controlled by a claim in the auth0 payload
-
src/hooks.ts
is handling the main authentication work and the guarding of our data endpoints (who are in/api/...
routes) -
src/routes/+layout.server.ts
is handling the auth guard for pages
src/hooks.ts
:
import cookie from 'cookie';
import { isPublicPage, verifyToken, FULL_LOGOUT_URL } from '$lib/auth/auth0';
import { getBooleanEnv, getStringEnv } from '$lib/mab_environment'; //TODO: refactor env var handling
import { json } from '@sveltejs/kit';
import type { Handle, RequestEvent } from '@sveltejs/kit';
function setOrUnsetAuthCookies(locals: App.Locals) {
if (locals.user && locals.refreshToken && locals.idToken) {
return [
`user=${locals.user}; Path=/; HttpOnly; SameSite=Lax`,
`refreshToken=${locals.refreshToken}; Path=/; HttpOnly; SameSite=Lax`,
`idToken=${locals.idToken}; Path=/; HttpOnly; SameSite=Lax`
];
} else {
return [
`user=; Path=/; HttpOnly; SameSite=Lax; expires=Thu, 01 Jan 1970 00:00:00 GMT`,
`refreshToken=; Path=/; HttpOnly; SameSite=Lax; expires=Thu, 01 Jan 1970 00:00:00 GMT`,
`idToken=; Path=/; HttpOnly; SameSite=Lax; expires=Thu, 01 Jan 1970 00:00:00 GMT`
];
}
}
export const handle: Handle = async ({ event, resolve }) => {
// --------------------------
// code below happens before the endpoint or page is called
const request = event.request;
const cookies = cookie.parse(request.headers.get('cookie') || '');
event.locals.user = cookies.user;
event.locals.refreshToken = cookies.refreshToken;
event.locals.idToken = cookies.idToken;
event.locals.isLoggedIn = !!cookies.user;
const { isOK, newIdToken, isAdmin } = await verifyToken(cookies.idToken, cookies.refreshToken);
if (!isPublicPage(request.url)) {
//console.log('Private url / page');
if (!isOK) {
event.locals.user = null;
event.locals.refreshToken = null;
event.locals.idToken = null;
// protect data endpoints
if (request.url.includes('/api')) {
return json('unauthorized', {
status: 401,
headers: {
'WWW-Authenticate': 'Bearer'
}
});
}
}
if (newIdToken) event.locals.idToken = newIdToken;
}
event.locals.isAdmin = isAdmin;
event = setEnvVarsOnLocals(event);
// now, let sveltekit resolve the call
// note: we disable server-side page rendering (our app is mostly a SPA)
// ref: https://kit.svelte.dev/docs#hooks-handle
const response = await resolve(event, { ssr: false });
// --------------------------
// code below happens after the endpoint or page is called
// if cookies are set on the called endpoint, merge them with auth cookies
setOrUnsetAuthCookies(event.locals).forEach((setCookieDir) => {
response.headers.append('set-cookie', setCookieDir);
});
return response;
};
// TODO: consider using the new way to get env vars instead of stuffing everything in locals
const setEnvVarsOnLocals = (event:RequestEvent) => {
event.locals.ENV_NAME = getStringEnv('ENV_NAME');
event.locals.CLIENT_ID = getStringEnv('CLIENT_ID');
event.locals.FULL_LOGOUT_URL = FULL_LOGOUT_URL;
event.locals.ENABLE_FEATURE1 = getBooleanEnv('ENABLE_FEATURE1');
event.locals.ENABLE_FEATURE2 = getBooleanEnv('ENABLE_FEATURE2');
event.locals.IS_ENV_READ_ONLY = getBooleanEnv('IS_ENV_READ_ONLY');
return event;
};
src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';
import { redirect, error } from '@sveltejs/kit';
import { isPublicPage, isAdminPage, isAuthenticated } from '$lib/auth/auth0_front';
// This server-side load function is the auth guard for pages
export const load: LayoutServerLoad = async ({ locals, url }) => {
if (!isPublicPage(url.pathname) && !isAuthenticated(locals)) {
throw redirect(302, `/login?redirect=${url.pathname}`);
}
if (isAdminPage(url.pathname) && !locals.isAdmin) {
throw error(403, 'you need admin rights');
}
return {
...locals
};
};
- session was removed, so that's out of date in the OP. This in itself should make some code flows clearer and less error prone
-
+layout.server.js
is a thing now, you can put server-side guard logic there - if you need that logic in the client as well (since you're building an SPA), you can put that in
+layout.js
- your app-wide guards can go into the root layout, since that's now always guaranteed to exist and be loaded
To summarize, what the OP wanted is now possible with new routing API. Others have expressed related feature requests, but since it's all a little out of date and confusing, I'll close this issue. Feel free to open a new issue with updated requirements, if you feel the new API doesn't fit your needs. Also thanks to everyone who provided code snippets on how to achieve this with the new API.
Hi, This workflow is a bit complex to me. May I ask for a tiny example of the logic in the documentation?
Here is a simple example:
import { browser } from '$app/environment';
import { auth } from '$lib/database';
import { get } from 'svelte/store';
import type { PageLoad } from './$types';
const { user } = auth;
export const load: PageLoad = () => {
if (browser) {
if (get(user)) {
console.log('logged in');
// redirect somewhere ?
}
}
return;
}
However, since something like a login on a page can change dynamically, and the load function is meant to run before page render, it will not automatically update. This is why observables or readables are better suited for an auth guard in my opinion. They dynamically change.
J
To summarize, what the OP wanted is now possible with new routing API.
Just did some experiments and the behaviour is confusing, I have a public route and a protected route, and I'm trying to load the public route first then click the link to navigate to protected route, implemented some basic logging in +layout.ts and watch the behaviour:
- when
ssr=true
andcsr=false
the load function of protected and un-protected both run every time when switch route, which is expected; - when
ssr=true
andcsr=true
which I assume we're using client side hydration and routing after the initial page load, then only the public route load function will run once (at the initial page load), following client navigation won't trigger both load functions. Is this expected or not?
And what about the API endpoints in protected route, is there any way to protect them with a unified way together with the layout/page routes?
Would your mind clarify a bit, that would be awesome.
@dummdidumm Sorry my bad, further test show that client side navigation called both load
function correctly, it's just my browser dev-tools ill behaved.
So indeed we can protect route with +layout.js load functions.
And I'll try @0gust1 's comment about using hooks to protect API endpoints.
Is it possible to have an extra file for every route, let's name it +authorization.svelte that will include annotations like @Unprotected() @Roles('admin','powerusers') @AllowAnyRole() and if the loggin user has the appropriate role, will have access to the route. Also, there must be some method to set the users roles, after the users logs in