Skip to content

Commit

Permalink
Merge pull request #727 from logto-io/simeng-refind-svelet-sdk
Browse files Browse the repository at this point in the history
feat(sveltekit): add support for custom storage and getUserInfo error handling
  • Loading branch information
simeng-li authored May 29, 2024
2 parents 9108ac4 + 54e49a1 commit f74e6f8
Show file tree
Hide file tree
Showing 3 changed files with 119 additions and 44 deletions.
9 changes: 9 additions & 0 deletions .changeset/afraid-tables-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@logto/sveltekit": minor
---

introduce custom local storage support and error handling for getUserInfo

1. Introduce a new `storage` option in `hookConfig` that allows a custom local storage to be passed into `logtoClient`. This will supersede the default `CookieStorage` for storing session and token data. If a custom `storage` is provided, the `cookieConfig` settings can be disregarded.

2. Incorporate a new `onGetUserInfoError` callback in `hookConfig` for custom error handling when `getUserInfo` or `getIdTokenClaims` operations fail. By default, a 500 server error will be thrown.
29 changes: 28 additions & 1 deletion packages/sveltekit/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { CookieStorage } from '@logto/node';
import { type RequestEvent } from '@sveltejs/kit';
import { vi, describe, it, expect } from 'vitest';
import { describe, expect, it, vi } from 'vitest';

import { LogtoClient, handleLogto } from './index.js';

Expand Down Expand Up @@ -130,4 +130,31 @@ describe('handleLogto()', () => {
await handle({ resolve, event });
expect(event.locals.user).toEqual({ name: 'John Doe' });
});

it('should call onGetUserInfoError if an error occurs while fetching user info', async () => {
const event = createMockEvent();
const client = new LogtoClient(config, {
navigate: () => {
console.log('navigate');
},
storage: createCookieStorageFromEvent(event),
});

vi.spyOn(client, 'isAuthenticated').mockResolvedValueOnce(true);
vi.spyOn(client, 'getIdTokenClaims').mockRejectedValueOnce(new Error('User info error'));

const handle = handleLogto(config, cookieConfig, {
buildLogtoClient: () => client,
onGetUserInfoError: (error: unknown) => {
if (error instanceof Error) {
return new Response(error.message, { status: 400 });
}
return new Response('Unknown error', { status: 500 });
},
});

const response = await handle({ resolve, event });
expect(response.status).toBe(400);
expect(await response.text()).toBe('User info error');
});
});
125 changes: 82 additions & 43 deletions packages/sveltekit/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,43 @@
import LogtoClient, { type LogtoConfig, type CookieConfig, CookieStorage } from '@logto/node';
import { redirect, type Handle, type RequestEvent, isRedirect } from '@sveltejs/kit';
import LogtoClient, {
CookieStorage,
type CookieConfig,
type LogtoConfig,
type PersistKey,
type Storage,
} from '@logto/node';
import { isRedirect, redirect, type Handle, type RequestEvent } from '@sveltejs/kit';

export type {
AccessTokenClaims,
ClientAdapter,
CookieConfig,
IdTokenClaims,
LogtoErrorCode,
LogtoConfig,
InteractionMode,
JwtVerifier,
LogtoClientErrorCode,
LogtoConfig,
LogtoErrorCode,
Storage,
StorageKey,
InteractionMode,
ClientAdapter,
JwtVerifier,
UserInfoResponse,
CookieConfig,
} from '@logto/node';

export {
CookieStorage,
default as LogtoClient,
LogtoClientError,
LogtoError,
LogtoRequestError,
LogtoClientError,
OidcError,
PersistKey,
Prompt,
ReservedScope,
ReservedResource,
ReservedScope,
StandardLogtoClient,
UserScope,
organizationUrnPrefix,
buildOrganizationUrn,
getOrganizationIdFromUrn,
PersistKey,
CookieStorage,
StandardLogtoClient,
default as LogtoClient,
organizationUrnPrefix,
} from '@logto/node';

export type HookConfig = {
Expand All @@ -42,6 +48,13 @@ export type HookConfig = {
* @param error The error that occurred.
*/
onCallbackError?: (error: unknown) => Response;
/**
* The error response factory when an error occurs during fetching user info or parsing the IdToken.
* If not provided, a 500 response will be returned.
*
* @param error
*/
onGetUserInfoError?: (error: unknown) => Response;
/**
* The path to the callback handler. Default to `/callback`.
*/
Expand All @@ -55,6 +68,11 @@ export type HookConfig = {
* created.
*/
buildLogtoClient?: (event: RequestEvent) => LogtoClient;
/**
* The custom persistent storage instance parsed to the `LogtoClient`. It will be used to store the session and tokens.
* If not provided, a default `CookieStorage` instance will be created.
*/
customStorage?: Storage<PersistKey>;
};

/**
Expand Down Expand Up @@ -96,22 +114,25 @@ export type HookConfig = {
* ```
*
* @param config The Logto configuration.
* @param cookieConfig The configuration object for the cookie storage.
* @param cookieConfig The configuration object for the cookie storage. Required if no custom storage is provided.
* @param hookConfig The configuration object for the hook itself.
* @returns The SvelteKit hook.
*/

export const handleLogto = (
config: LogtoConfig,
cookieConfig: Pick<CookieConfig, 'cookieKey' | 'encryptionKey'>,
cookieConfig?: Pick<CookieConfig, 'cookieKey' | 'encryptionKey'>,
hookConfig?: HookConfig
): Handle => {
const {
signInCallback = '/callback',
onCallbackError,
onGetUserInfoError,
fetchUserInfo = false,
buildLogtoClient,
} = hookConfig ?? {};

// eslint-disable-next-line complexity
return async ({ resolve, event }) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- sanity check
if (event.locals.logtoClient) {
Expand All @@ -121,17 +142,8 @@ export const handleLogto = (
return resolve(event);
}

const storage = new CookieStorage(
{
setCookie: (...args) => {
event.cookies.set(...args);
},
getCookie: (...args) => event.cookies.get(...args),
...cookieConfig,
},
event.request
);
await storage.init();
const storage =
hookConfig?.customStorage ?? (await buildCookieStorageFromEvent(event, cookieConfig));

const logtoClient =
buildLogtoClient?.(event) ??
Expand All @@ -154,29 +166,56 @@ export const handleLogto = (
throw error;
}

return (
onCallbackError?.(error) ??
new Response(
`Error: ${
error instanceof Error ? error.message : JSON.stringify(error, undefined, 2)
}`,
{
status: 400,
}
)
);
return onCallbackError?.(error) ?? defaultErrorHandler(error, 400);
}

return redirect(302, '/');
}

if (await logtoClient.isAuthenticated()) {
// eslint-disable-next-line @silverhand/fp/no-mutation
event.locals.user = await (fetchUserInfo
? logtoClient.fetchUserInfo()
: logtoClient.getIdTokenClaims());
try {
// eslint-disable-next-line @silverhand/fp/no-mutation
event.locals.user = await (fetchUserInfo
? logtoClient.fetchUserInfo()
: logtoClient.getIdTokenClaims());
} catch (error: unknown) {
return onGetUserInfoError?.(error) ?? defaultErrorHandler(error);
}
}

return resolve(event);
};
};

const defaultErrorHandler = (error: unknown, status = 500): Response => {
return new Response(
`Error: ${error instanceof Error ? error.message : JSON.stringify(error, undefined, 2)}`,
{
status,
}
);
};

const buildCookieStorageFromEvent = async (
event: RequestEvent,
cookieConfig?: Pick<CookieConfig, 'cookieKey' | 'encryptionKey'>
): Promise<CookieStorage> => {
if (!cookieConfig) {
throw new Error('Missing cookie configuration for the CookieStorage.');
}

const storage = new CookieStorage(
{
setCookie: (...args) => {
event.cookies.set(...args);
},
getCookie: (...args) => event.cookies.get(...args),
...cookieConfig,
},
event.request
);

await storage.init();

return storage;
};

0 comments on commit f74e6f8

Please sign in to comment.