kit icon indicating copy to clipboard operation
kit copied to clipboard

Hook to protect routes

Open jerrythomas opened this issue 3 years ago • 13 comments

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.

jerrythomas avatar Feb 15 '22 03:02 jerrythomas

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...

thenbe avatar Feb 15 '22 03:02 thenbe

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.

jerrythomas avatar Feb 15 '22 04:02 jerrythomas

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.

netaisllc avatar Feb 16 '22 16:02 netaisllc

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.

yousufiqbal avatar Feb 20 '22 15:02 yousufiqbal

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.

chrislentz avatar Mar 01 '22 02:03 chrislentz

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 !

0gust1 avatar Mar 21 '22 14:03 0gust1

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.

williamviktorsson avatar Jun 18 '22 21:06 williamviktorsson

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.

maninalift avatar Jun 29 '22 21:06 maninalift

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.

maninalift avatar Jun 29 '22 22:06 maninalift

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');
}

rmunn avatar Aug 05 '22 03:08 rmunn

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?

jerrythomas avatar Aug 06 '22 11:08 jerrythomas

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.

rmunn avatar Aug 08 '22 04:08 rmunn

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);
};

tbdrz avatar Aug 11 '22 13:08 tbdrz

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.

williamviktorsson avatar Aug 11 '22 17:08 williamviktorsson

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. .

moisesbites avatar Aug 20 '22 21:08 moisesbites

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
  };
};

0gust1 avatar Aug 23 '22 22:08 0gust1

  • 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.

dummdidumm avatar Aug 30 '22 13:08 dummdidumm

Hi, This workflow is a bit complex to me. May I ask for a tiny example of the logic in the documentation?

borisdayma avatar Aug 30 '22 14:08 borisdayma

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

jdgamble555 avatar Oct 28 '22 02:10 jdgamble555

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:

  1. when ssr=true and csr=false the load function of protected and un-protected both run every time when switch route, which is expected;
  2. when ssr=true and csr=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.

ccll avatar Oct 30 '22 15:10 ccll

@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.

ccll avatar Oct 30 '22 15:10 ccll

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

kostoman69 avatar Mar 19 '23 17:03 kostoman69