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
7 changes: 3 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@ jobs:
run: |
pnpm run build

# NOTE: uncomment when we have tests
# - name: Test
# run: |
# pnpm run test -- --coverage
- name: Test
run: |
pnpm run test -- --run --coverage
95 changes: 95 additions & 0 deletions example/src/hooks.server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, expect, it, vi } from 'vitest';
import type { RequestEvent } from '@sveltejs/kit';

vi.mock('$env/dynamic/private', () => ({
env: {
WORKOS_CLIENT_ID: 'client-id',
WORKOS_API_KEY: 'api-key',
WORKOS_REDIRECT_URI: 'https://example.test/callback',
WORKOS_COOKIE_PASSWORD: 'cookie-password',
},
}));

vi.mock('$app/environment', () => ({ dev: false }));

vi.mock('@workos/authkit-sveltekit', () => ({
configureAuthKit: vi.fn(),
authKitHandle: vi.fn(() => vi.fn()),
}));

import { protectedRoutesHandle } from './hooks.server.js';

function eventFor({
pathname,
routeId,
authenticated = false,
}: {
pathname: string;
routeId: string | null;
authenticated?: boolean;
}): RequestEvent {
const url = new URL(`https://example.test${pathname}`);

return {
url,
route: { id: routeId },
locals: authenticated ? { auth: { user: { id: 'user_01' } } } : {},
} as RequestEvent;
}

async function runRoute(options: Parameters<typeof eventFor>[0]) {
const event = eventFor(options);
const resolve = vi.fn(async () => new Response('resolved'));

const result = protectedRoutesHandle({ event, resolve } as Parameters<typeof protectedRoutesHandle>[0]);

return { result, resolve };
}

describe('example protectedRoutesHandle', () => {
it.each([
['/account', '/account'],
['/%61ccount', '/account'],
])('redirects unauthenticated account requests for %s', async (pathname, routeId) => {
const { result, resolve } = await runRoute({ pathname, routeId });

await expect(result).rejects.toMatchObject({
status: 302,
location: `/login?returnPathname=${encodeURIComponent(pathname)}`,
});
expect(resolve).not.toHaveBeenCalled();
});

it.each([
['/api/get-name', '/api/get-name'],
['/%61pi/get-name', '/api/get-name'],
])('returns 401 for unauthenticated API requests for %s', async (pathname, routeId) => {
const { result, resolve } = await runRoute({ pathname, routeId });
const response = await result;

expect(response.status).toBe(401);
await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' });
expect(resolve).not.toHaveBeenCalled();
});

it.each([
['/account', '/account'],
['/api/get-name', '/api/get-name'],
])('resolves authenticated protected requests for %s', async (pathname, routeId) => {
const { result, resolve } = await runRoute({ pathname, routeId, authenticated: true });

await expect(result).resolves.toMatchObject({ status: 200 });
expect(resolve).toHaveBeenCalledTimes(1);
});

it.each([
['/', '/'],
['/login', '/login'],
['/not-a-route', null],
])('resolves public and unmatched requests for %s', async (pathname, routeId) => {
const { result, resolve } = await runRoute({ pathname, routeId });

await expect(result).resolves.toMatchObject({ status: 200 });
expect(resolve).toHaveBeenCalledTimes(1);
});
});
15 changes: 12 additions & 3 deletions example/src/hooks.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,22 @@ const authHandle = authKitHandle({
});

// Create a custom handle for protected routes
const protectedRoutesHandle: Handle = async ({ event, resolve }) => {
export const protectedRoutesHandle: Handle = async ({ event, resolve }) => {
const protectedPaths = ['/account', '/api/'] as const;
const isProtectedRoute = protectedPaths.some((path) => event.url.pathname.startsWith(path));
// Guard on the resolved route id rather than the raw URL pathname.
// SvelteKit decodes the pathname before matching routes, so a check
// against event.url.pathname (which preserves percent-encoding) can be
// bypassed with an encoded path such as /%61ccount while the router still
// dispatches the protected /account route. event.route.id reflects the
// matched route and is immune to encoding tricks.
const routeId = event.route.id;
const isProtectedRoute =
routeId != null && protectedPaths.some((path) => routeId === path || routeId.startsWith(path));
const isApiRoute = routeId != null && routeId.startsWith('/api/');

if (isProtectedRoute && !event.locals.auth?.user) {
// API routes should return 401
if (event.url.pathname.startsWith('/api/')) {
if (isApiRoute) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
Expand Down
Loading