diff --git a/.changeset/afraid-tables-explain.md b/.changeset/afraid-tables-explain.md new file mode 100644 index 00000000..e093fd0c --- /dev/null +++ b/.changeset/afraid-tables-explain.md @@ -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. diff --git a/packages/sveltekit/src/index.test.ts b/packages/sveltekit/src/index.test.ts index ce592a4d..870dc80b 100644 --- a/packages/sveltekit/src/index.test.ts +++ b/packages/sveltekit/src/index.test.ts @@ -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'; @@ -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'); + }); }); diff --git a/packages/sveltekit/src/index.ts b/packages/sveltekit/src/index.ts index 6eb61097..0b8a9002 100644 --- a/packages/sveltekit/src/index.ts +++ b/packages/sveltekit/src/index.ts @@ -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 = { @@ -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`. */ @@ -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; }; /** @@ -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?: Pick, 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) { @@ -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) ?? @@ -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 +): Promise => { + 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; +};