Skip to content

Commit

Permalink
feat(next): external storage
Browse files Browse the repository at this point in the history
  • Loading branch information
wangsijie committed Dec 31, 2024
1 parent 176adcf commit 46ef94d
Show file tree
Hide file tree
Showing 8 changed files with 131 additions and 17 deletions.
45 changes: 45 additions & 0 deletions .changeset/wise-colts-guess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
"@logto/next": minor
---

support custom external session storage

Add `sessionWrapper` to the config, you can implement your own session wrapper to support custom external session storage.

Take a look at the example below:

```ts
import { MemorySessionWrapper } from './storage';

export const config = {
// ...
sessionWrapper: new MemorySessionWrapper(),
};
```

```ts
import { randomUUID } from 'node:crypto';

import { type SessionWrapper, type SessionData } from '@logto/next';

export class MemorySessionWrapper implements SessionWrapper {
private readonly storage = new Map<string, unknown>();

async wrap(data: unknown, _key: string): Promise<string> {
const sessionId = randomUUID();
this.storage.set(sessionId, data);
return sessionId;
}

async unwrap(value: string, _key: string): Promise<SessionData> {
if (!value) {
return {};
}

const data = this.storage.get(value);
return data ?? {};
}
}
```

You can implement your own session wrapper to support custom external session storage like Redis, Memcached, etc.
3 changes: 2 additions & 1 deletion packages/next/edge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ export default class LogtoClient extends BaseClient {
const responseCookies = new ResponseCookies(headers);

this.storage = new CookieStorage({
encryptionKey: this.config.cookieSecret,
encryptionKey: this.config.cookieSecret ?? '',
sessionWrapper: this.config.sessionWrapper,
cookieKey: `logto_${this.config.appId}`,
isSecure: this.config.cookieSecure,
getCookie: (name) => {
Expand Down
3 changes: 2 additions & 1 deletion packages/next/server-actions/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ export default class LogtoClient extends BaseClient {
async createNodeClient({ ignoreCookieChange }: { ignoreCookieChange?: boolean } = {}) {
const { cookies } = await import('next/headers');
this.storage = new CookieStorage({
encryptionKey: this.config.cookieSecret,
encryptionKey: this.config.cookieSecret ?? '',
sessionWrapper: this.config.sessionWrapper,
cookieKey: `logto_${this.config.appId}`,
isSecure: this.config.cookieSecure,
getCookie: async (...args) => {
Expand Down
1 change: 0 additions & 1 deletion packages/next/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ const configs: LogtoNextConfig = {
cookieSecure: process.env.NODE_ENV === 'production',
};

const save = vi.fn();
const signIn = vi.fn();
const handleSignInCallback = vi.fn();
const getIdTokenClaims = vi.fn(() => ({
Expand Down
10 changes: 9 additions & 1 deletion packages/next/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,19 @@ export type {
InteractionMode,
LogtoErrorCode,
UserInfoResponse,
SessionWrapper,
SessionData,
} from '@logto/node';

export default class LogtoClient extends LogtoNextBaseClient {
constructor(config: LogtoNextConfig) {
super(config, {
NodeClient,
});

if (!config.sessionWrapper && !config.cookieSecret) {
throw new Error('cookieSecret is required when using default session wrapper');
}
}

handleSignIn: (
Expand Down Expand Up @@ -204,7 +210,9 @@ export default class LogtoClient extends LogtoNextBaseClient {
response: ServerResponse
): Promise<NodeClient> {
this.storage = new CookieStorage({
encryptionKey: this.config.cookieSecret,
// The type checking is done in the constructor, encryptionKey is required when using default session wrapper
encryptionKey: this.config.cookieSecret ?? '',
sessionWrapper: this.config.sessionWrapper,
cookieKey: `logto_${this.config.appId}`,
isSecure: this.config.cookieSecure,
getCookie: (name) => {
Expand Down
11 changes: 9 additions & 2 deletions packages/next/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import type { LogtoConfig } from '@logto/node';
import type { LogtoConfig, SessionWrapper } from '@logto/node';
import type NodeClient from '@logto/node';
import { type NextApiRequest, type NextApiResponse } from 'next';

export type LogtoNextConfig = LogtoConfig & {
cookieSecret: string;
cookieSecure: boolean;
baseUrl: string;
/**
* Can be provided to use custom session wrapper,
* for example, to use external storage solutions,
* you can save the session data in external storage and return a key in the sessionWrapper.wrap method,
* then use the key to get the session data from external storage in the sessionWrapper.unwrap method.
*/
sessionWrapper?: SessionWrapper;
cookieSecret?: string;
};

export type Adapters = {
Expand Down
23 changes: 23 additions & 0 deletions packages/node/src/utils/cookie-storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,29 @@ describe('CookieStorage', () => {
const cookie = await storage.config.getCookie('foo');
expect(await unwrapSession(cookie ?? '', encryptionKey)).toEqual({});
});

it('should support custom sessionWrapper', async () => {
// Use JSON.stringify/parse to simulate the sessionWrapper
const mockSessionWrapper = {
wrap: vi.fn(async (data) => JSON.stringify(data)),
unwrap: vi.fn(async (data: string) => (data ? JSON.parse(data) : {})),
};

const storage = new TestCookieStorage({
...createCookieConfig(encryptionKey),
sessionWrapper: mockSessionWrapper,
});

await storage.init();
await storage.setItem(PersistKey.AccessToken, 'test-token');

expect(mockSessionWrapper.wrap).toHaveBeenCalled();
expect(mockSessionWrapper.unwrap).toHaveBeenCalled();
expect(storage.data).toEqual({ [PersistKey.AccessToken]: 'test-token' });

const cookie = await storage.config.getCookie('logtoCookies');
expect(cookie).toBe(JSON.stringify({ [PersistKey.AccessToken]: 'test-token' }));
});
});

describe('CookieStorage concurrency', () => {
Expand Down
52 changes: 41 additions & 11 deletions packages/node/src/utils/cookie-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,8 @@ import { wrapSession, unwrapSession, type SessionData } from './session.js';
// eslint-disable-next-line @typescript-eslint/ban-types
type Nullable<T> = T | null;

export type CookieConfig = {
/** The encryption key to encrypt the session data. It should be a random string. */
encryptionKey: string;
/** The name of the cookie key. Default to `logtoCookies`. */
export type CookieConfigBase = {
cookieKey?: string;
/** Set to true in https */
isSecure?: boolean;
getCookie: (name: string) => Promise<string | undefined> | string | undefined;
setCookie: (
Expand All @@ -22,6 +18,26 @@ export type CookieConfig = {
) => Promise<void> | void;
};

export type SessionWrapper = {
wrap: (data: SessionData, key: string) => Promise<string>;
unwrap: (value: string, key: string) => Promise<SessionData>;
};

export type CookieConfig = CookieConfigBase &
(
| {
/** Required when using default session wrapper */
encryptionKey: string;
sessionWrapper?: never;
}
| {
/** Optional when custom sessionWrapper is provided */
encryptionKey?: string;
/** Custom session wrapper can be used to implement external storage solutions */
sessionWrapper: SessionWrapper;
}
);

/**
* A storage that persists data in cookies with encryption.
*/
Expand All @@ -47,15 +63,29 @@ export class CookieStorage implements Storage<PersistKey> {
protected sessionData: SessionData = {};
protected saveQueue = new PromiseQueue();

/**
* Handles the wrapping and unwrapping of session data.
* Can be provided via config or defaults to using wrapSession/unwrapSession functions.
* Users can implement custom storage solutions by providing their own sessionWrapper.
*/
protected sessionWrapper: SessionWrapper;

constructor(public config: CookieConfig) {
if (!config.encryptionKey) {
throw new TypeError('The `encryptionKey` string is required for `CookieStorage`');
if (!config.sessionWrapper && !config.encryptionKey) {
throw new TypeError(
'Either `sessionWrapper` or `encryptionKey` must be provided for `CookieStorage`'
);
}

this.sessionWrapper = config.sessionWrapper ?? {
wrap: wrapSession,
unwrap: unwrapSession,
};
}

async init() {
const { encryptionKey } = this.config;
this.sessionData = await unwrapSession(
const { encryptionKey = '' } = this.config;
this.sessionData = await this.sessionWrapper.unwrap(
(await this.config.getCookie(this.cookieKey)) ?? '',
encryptionKey
);
Expand Down Expand Up @@ -86,8 +116,8 @@ export class CookieStorage implements Storage<PersistKey> {
}

protected async write(data = this.sessionData) {
const { encryptionKey } = this.config;
const value = await wrapSession(data, encryptionKey);
const { encryptionKey = '' } = this.config;
const value = await this.sessionWrapper.wrap(data, encryptionKey);
await this.config.setCookie(this.cookieKey, value, this.cookieOptions);
}
}

0 comments on commit 46ef94d

Please sign in to comment.