Skip to content

Commit

Permalink
Lazy instantiate WorkOS instance on first use (#201)
Browse files Browse the repository at this point in the history
  • Loading branch information
nicknisi authored Feb 20, 2025
1 parent 0417d6e commit 69f378a
Show file tree
Hide file tree
Showing 12 changed files with 87 additions and 45 deletions.
14 changes: 8 additions & 6 deletions __tests__/actions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,20 @@ import {
refreshAuthAction,
} from '../src/actions.js';
import { signOut } from '../src/auth.js';
import { workos } from '../src/workos.js';
import { getWorkOS } from '../src/workos.js';
import { withAuth, refreshSession } from '../src/session.js';

jest.mock('../src/auth.js', () => ({
signOut: jest.fn().mockResolvedValue(true),
}));

jest.mock('../src/workos.js', () => ({
workos: {
organizations: {
getOrganization: jest.fn().mockResolvedValue({ id: 'org_123', name: 'Test Org' }),
},
const fakeWorkosInstance = {
organizations: {
getOrganization: jest.fn().mockResolvedValue({ id: 'org_123', name: 'Test Org' }),
},
};
jest.mock('../src/workos.js', () => ({
getWorkOS: jest.fn(() => fakeWorkosInstance),
}));

jest.mock('../src/session.js', () => ({
Expand All @@ -27,6 +28,7 @@ jest.mock('../src/session.js', () => ({
}));

describe('actions', () => {
const workos = getWorkOS();
describe('checkSessionAction', () => {
it('should return true for authenticated users', async () => {
const result = await checkSessionAction();
Expand Down
17 changes: 10 additions & 7 deletions __tests__/authkit-callback-route.spec.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import { workos } from '../src/workos.js';
import { getWorkOS } from '../src/workos.js';
import { handleAuth } from '../src/authkit-callback-route.js';
import { NextRequest, NextResponse } from 'next/server';

// Mocked in jest.setup.ts
import { cookies, headers } from 'next/headers';

// Mock dependencies
jest.mock('../src/workos', () => ({
workos: {
userManagement: {
authenticateWithCode: jest.fn(),
getJwksUrl: jest.fn(() => 'https://api.workos.com/sso/jwks/client_1234567890'),
},
const fakeWorkosInstance = {
userManagement: {
authenticateWithCode: jest.fn(),
getJwksUrl: jest.fn(() => 'https://api.workos.com/sso/jwks/client_1234567890'),
},
};

jest.mock('../src/workos', () => ({
getWorkOS: jest.fn(() => fakeWorkosInstance),
}));

describe('authkit-callback-route', () => {
const workos = getWorkOS();
const mockAuthResponse = {
accessToken: 'access123',
refreshToken: 'refresh123',
Expand Down
15 changes: 13 additions & 2 deletions __tests__/get-authorization-url.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
import { getAuthorizationUrl } from '../src/get-authorization-url.js';
import { headers } from 'next/headers';
import { workos } from '../src/workos.js';
import { getWorkOS } from '../src/workos.js';

jest.mock('next/headers');
jest.mock('../src/workos.js');

// Mock dependencies
const fakeWorkosInstance = {
userManagement: {
getAuthorizationUrl: jest.fn(),
},
};

jest.mock('../src/workos', () => ({
getWorkOS: jest.fn(() => fakeWorkosInstance),
}));

describe('getAuthorizationUrl', () => {
const workos = getWorkOS();
beforeEach(() => {
jest.clearAllMocks();
});
Expand Down
4 changes: 3 additions & 1 deletion __tests__/session.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { cookies, headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { generateTestToken } from './test-helpers.js';
import { withAuth, updateSession, refreshSession, terminateSession, updateSessionMiddleware } from '../src/session.js';
import { workos } from '../src/workos.js';
import { getWorkOS } from '../src/workos.js';
import * as envVariables from '../src/env-variables.js';

import { jwtVerify } from 'jose';
Expand All @@ -20,6 +20,8 @@ jest.mock('jose', () => ({
// logging is disabled by default, flip this to true to still have logs in the console
const DEBUG = false;

const workos = getWorkOS();

describe('session.ts', () => {
const mockSession = {
accessToken: 'access-token',
Expand Down
15 changes: 8 additions & 7 deletions __tests__/workos.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { WorkOS } from '@workos-inc/node';
import { workos, VERSION } from '../src/workos.js';
import { getWorkOS, VERSION } from '../src/workos.js';

describe('workos', () => {
const workos = getWorkOS();
beforeEach(() => {
jest.clearAllMocks();
});
Expand Down Expand Up @@ -44,23 +45,23 @@ describe('workos', () => {

it('uses custom API hostname when provided', async () => {
process.env.WORKOS_API_HOSTNAME = 'custom.workos.com';
const { workos: customWorkos } = await import('../src/workos.js');
const { getWorkOS: customWorkos } = await import('../src/workos.js');

expect(customWorkos.options.apiHostname).toEqual('custom.workos.com');
expect(customWorkos().options.apiHostname).toEqual('custom.workos.com');
});

it('uses custom HTTPS setting when provided', async () => {
process.env.WORKOS_API_HTTPS = 'false';
const { workos: customWorkos } = await import('../src/workos.js');
const { getWorkOS: customWorkos } = await import('../src/workos.js');

expect(customWorkos.options.https).toEqual(false);
expect(customWorkos().options.https).toEqual(false);
});

it('uses custom port when provided', async () => {
process.env.WORKOS_API_PORT = '8080';
const { workos: customWorkos } = await import('../src/workos.js');
const { getWorkOS: customWorkos } = await import('../src/workos.js');

expect(customWorkos.options.port).toEqual(8080);
expect(customWorkos().options.port).toEqual(8080);
});
});
});
4 changes: 2 additions & 2 deletions src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { signOut } from './auth.js';
import { refreshSession, withAuth } from './session.js';
import { workos } from './workos.js';
import { getWorkOS } from './workos.js';

/**
* This action is only accessible to authenticated users,
Expand All @@ -18,7 +18,7 @@ export const handleSignOutAction = async ({ returnTo }: { returnTo?: string } =
};

export const getOrganizationAction = async (organizationId: string) => {
return await workos.organizations.getOrganization(organizationId);
return await getWorkOS().organizations.getOrganization(organizationId);
};

export const getAuthAction = async (options?: { ensureSignedIn?: boolean }) => {
Expand Down
4 changes: 2 additions & 2 deletions src/authkit-callback-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { WORKOS_CLIENT_ID, WORKOS_COOKIE_NAME } from './env-variables.js';
import { HandleAuthOptions } from './interfaces.js';
import { encryptSession } from './session.js';
import { errorResponseWithFallback, redirectWithFallback } from './utils.js';
import { workos } from './workos.js';
import { getWorkOS } from './workos.js';

export function handleAuth(options: HandleAuthOptions = {}) {
const { returnPathname: returnPathnameOption = '/', baseURL, onSuccess, onError } = options;
Expand All @@ -28,7 +28,7 @@ export function handleAuth(options: HandleAuthOptions = {}) {
try {
// Use the code returned to us by AuthKit and authenticate the user with WorkOS
const { accessToken, refreshToken, user, impersonator, oauthTokens } =
await workos.userManagement.authenticateWithCode({
await getWorkOS().userManagement.authenticateWithCode({
clientId: WORKOS_CLIENT_ID,
code,
});
Expand Down
4 changes: 2 additions & 2 deletions src/get-authorization-url.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { workos } from './workos.js';
import { getWorkOS } from './workos.js';
import { WORKOS_CLIENT_ID, WORKOS_REDIRECT_URI } from './env-variables.js';
import { GetAuthURLOptions } from './interfaces.js';
import { headers } from 'next/headers';
Expand All @@ -7,7 +7,7 @@ async function getAuthorizationUrl(options: GetAuthURLOptions = {}) {
const headersList = await headers();
const { returnPathname, screenHint, organizationId, redirectUri = headersList.get('x-redirect-uri') } = options;

return workos.userManagement.getAuthorizationUrl({
return getWorkOS().userManagement.getAuthorizationUrl({
provider: 'authkit',
clientId: WORKOS_CLIENT_ID,
redirectUri: redirectUri ?? WORKOS_REDIRECT_URI,
Expand Down
2 changes: 1 addition & 1 deletion src/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { OauthTokens, User } from '@workos-inc/node';
import type { OauthTokens, User } from '@workos-inc/node';
import { type NextRequest } from 'next/server';

export interface HandleAuthOptions {
Expand Down
23 changes: 12 additions & 11 deletions src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify, createRemoteJWKSet, decodeJwt } from 'jose';
import { sealData, unsealData } from 'iron-session';
import { getCookieOptions } from './cookie.js';
import { workos } from './workos.js';
import { getWorkOS } from './workos.js';
import { WORKOS_CLIENT_ID, WORKOS_COOKIE_PASSWORD, WORKOS_COOKIE_NAME, WORKOS_REDIRECT_URI } from './env-variables.js';
import { getAuthorizationUrl } from './get-authorization-url.js';
import {
Expand All @@ -21,13 +21,13 @@ import {
} from './interfaces.js';

import { parse, tokensToRegexp } from 'path-to-regexp';
import { redirectWithFallback } from './utils.js';
import { lazy, redirectWithFallback } from './utils.js';

const sessionHeaderName = 'x-workos-session';
const middlewareHeaderName = 'x-workos-middleware';
const signUpPathsHeaderName = 'x-sign-up-paths';

const JWKS = createRemoteJWKSet(new URL(workos.userManagement.getJwksUrl(WORKOS_CLIENT_ID)));
const JWKS = lazy(() => createRemoteJWKSet(new URL(getWorkOS().userManagement.getJwksUrl(WORKOS_CLIENT_ID))));

async function encryptSession(session: Session) {
return sealData(session, {
Expand Down Expand Up @@ -184,11 +184,12 @@ async function updateSession(

const { org_id: organizationIdFromAccessToken } = decodeJwt<AccessToken>(session.accessToken);

const { accessToken, refreshToken, user, impersonator } = await workos.userManagement.authenticateWithRefreshToken({
clientId: WORKOS_CLIENT_ID,
refreshToken: session.refreshToken,
organizationId: organizationIdFromAccessToken,
});
const { accessToken, refreshToken, user, impersonator } =
await getWorkOS().userManagement.authenticateWithRefreshToken({
clientId: WORKOS_CLIENT_ID,
refreshToken: session.refreshToken,
organizationId: organizationIdFromAccessToken,
});

if (options.debug) {
console.log('Session successfully refreshed');
Expand Down Expand Up @@ -270,7 +271,7 @@ async function refreshSession({
let refreshResult;

try {
refreshResult = await workos.userManagement.authenticateWithRefreshToken({
refreshResult = await getWorkOS().userManagement.authenticateWithRefreshToken({
clientId: WORKOS_CLIENT_ID,
refreshToken: session.refreshToken,
organizationId: nextOrganizationId ?? organizationIdFromAccessToken,
Expand Down Expand Up @@ -389,15 +390,15 @@ async function withAuth(options?: { ensureSignedIn?: boolean }): Promise<UserInf
async function terminateSession({ returnTo }: { returnTo?: string } = {}) {
const { sessionId } = await withAuth();
if (sessionId) {
redirect(workos.userManagement.getLogoutUrl({ sessionId, returnTo }));
redirect(getWorkOS().userManagement.getLogoutUrl({ sessionId, returnTo }));
} else {
redirect(returnTo ?? '/');
}
}

async function verifyAccessToken(accessToken: string) {
try {
await jwtVerify(accessToken, JWKS);
await jwtVerify(accessToken, JWKS());
return true;
} catch {
return false;
Expand Down
19 changes: 19 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,22 @@ export function errorResponseWithFallback(errorBody: { error: { message: string;
headers: { 'Content-Type': 'application/json' },
});
}

/**
* Returns a function that can only be called once.
* Subsequent calls will return the result of the first call.
* This is useful for lazy initialization.
* @param fn - The function to be called once.
* @returns A function that can only be called once.
*/
export function lazy<T>(fn: () => T): () => T {
let called = false;
let result: T;
return () => {
if (!called) {
result = fn();
called = true;
}
return result;
};
}
11 changes: 7 additions & 4 deletions src/workos.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { WorkOS } from '@workos-inc/node';
import { WORKOS_API_HOSTNAME, WORKOS_API_KEY, WORKOS_API_HTTPS, WORKOS_API_PORT } from './env-variables.js';
import { lazy } from './utils.js';

export const VERSION = '1.4.0';

Expand All @@ -13,7 +14,9 @@ const options = {
},
};

// Initialize the WorkOS client
const workos = new WorkOS(WORKOS_API_KEY, options);

export { workos };
/**
* Create a WorkOS instance with the provided API key and options.
* If an instance already exists, it returns the existing instance.
* @returns The WorkOS instance.
*/
export const getWorkOS = lazy(() => new WorkOS(WORKOS_API_KEY, options));

0 comments on commit 69f378a

Please sign in to comment.