Skip to content

Commit eee9259

Browse files
authored
Add programmatic configuration API (#43)
* add configure configuration * replace env-variables with config * improve DX around getConfig, add defaults and required fields checking * updating types for config values * add getWorkOS to the export, exposing it to end users * fixing tests * fixing the rest of the tests * add additional test * README updates * add warning about non-node runtimes * add getFullConfig function and expose to end users * add note about getFullConfig to the README * lint fixes * remove getFullConfig function * export the `createWorkOSInstance` function to make it easier to work/test with internally * wrap config in a class and manage a lazily-instantiated singleton of it
1 parent afb35d7 commit eee9259

18 files changed

+586
-178
lines changed

README.md

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,36 +16,89 @@ or
1616
yarn add @workos-inc/authkit-remix
1717
```
1818

19-
## Pre-flight
19+
## Configuration
2020

21-
Make sure the following values are present in your `.env.local` environment variables file. The client ID and API key can be found in the [WorkOS dashboard](https://dashboard.workos.com), and the redirect URI can also be configured there.
21+
AuthKit for Remix offers a flexible configuration system that allows you to customize various settings. You can configure the library in three ways:
2222

23-
```sh
24-
WORKOS_CLIENT_ID="client_..." # retrieved from the WorkOS dashboard
25-
WORKOS_API_KEY="sk_test_..." # retrieved from the WorkOS dashboard
26-
WORKOS_REDIRECT_URI="http://localhost:5173/callback" # configured in the WorkOS dashboard
27-
WORKOS_COOKIE_PASSWORD="<your password>" # generate a secure password here
28-
```
23+
### 1. Environment Variables
2924

30-
`WORKOS_COOKIE_PASSWORD` is the private key used to encrypt the session cookie. It has to be at least 32 characters long. You can use the [1Password generator](https://1password.com/password-generator/) or the `openssl` library to generate a strong password via the command line:
25+
The simplest way is to set environment variables in your `.env.local` file:
3126

27+
```bash
28+
WORKOS_CLIENT_ID="client_..." # retrieved from the WorkOS dashboard
29+
WORKOS_API_KEY="sk_test_..." # retrieved from the WorkOS dashboard
30+
WORKOS_REDIRECT_URI="http://localhost:5173/callback" # configured in the WorkOS dashboard
31+
WORKOS_COOKIE_PASSWORD="<your password>" # generate a secure password here
3232
```
33-
openssl rand -base64 24
33+
34+
### 2. Programmatic Configuration
35+
36+
You can also configure AuthKit programmatically by importing the `configure` function:
37+
38+
```typescript
39+
import { configure } from '@workos-inc/authkit-remix';
40+
// In your root or entry file
41+
configure({
42+
clientId: 'client_1234567890',
43+
apiKey: 'sk_test_1234567890',
44+
redirectUri: 'http://localhost:5173/callback',
45+
cookiePassword: 'your-secure-cookie-password',
46+
// Optional settings
47+
cookieName: 'my-custom-cookie-name',
48+
apiHttps: true,
49+
cookieMaxAge: 60 * 60 * 24 * 30, // 30 days
50+
});
3451
```
3552

36-
To use the `signOut` method, you'll need to set your app's homepage in your WorkOS dashboard settings under "Redirects".
53+
### 3. Custom Environment Source
3754

38-
### Optional configuration
55+
For non-standard environments (like Deno or Edge functions), you can provide a custom environment variable source:
3956

40-
Certain environment variables are optional and can be used to debug or configure cookie settings.
57+
> [!Warning]
58+
>
59+
>While this library includes support for custom environment sources that could theoretically work in non-Node.js runtimes like Deno or Edge functions, this functionality has not been extensively tested (yet). If you're planning to use AuthKit in these environments, you may encounter unexpected issues. We welcome feedback and contributions from users who test in these environments.
4160
42-
```sh
43-
WORKOS_COOKIE_MAX_AGE='600' # maximum age of the cookie in seconds. Defaults to 400 days
44-
WORKOS_API_HOSTNAME='api.workos.com' # base WorkOS API URL
45-
WORKOS_API_HTTPS=true # whether to use HTTPS in API calls
46-
WORKOS_API_PORT=5173 # port to use for API calls
61+
```typescript
62+
import { configure } from '@workos-inc/authkit-remix';
63+
64+
configure(key => Deno.env.get(key));
65+
// Or combine with explicit values
66+
configure(
67+
{ clientId: 'client_1234567890' },
68+
key => Deno.env.get(key)
69+
);
4770
```
4871

72+
### Configuration Priority
73+
74+
When retrieving configuration values, AuthKit follows this priority order:
75+
76+
1. Programmatically provided values via `configure()`
77+
2. Environment variables (prefixed with `WORKOS_`)
78+
3. Default values for optional settings
79+
80+
### Available Configuration Options
81+
82+
>[!NOTE]
83+
>
84+
>To print out the entire config, a `getFullConfig` function is provided for debugging purposes.
85+
86+
| Option | Environment Variable | Default | Required | Description |
87+
| ---- | ---- | ---- | ---- | ---- |
88+
| `clientId` | `WORKOS_CLIENT_ID` | - | Yes | Your WorkOS Client ID |
89+
| `apiKey` | `WORKOS_API_KEY` | - | Yes | Your WorkOS API Key |
90+
| `redirectUri` | `WORKOS_REDIRECT_URI` | - | Yes | The callback URL configured in WorkOS |
91+
| `cookiePassword` | `WORKOS_COOKIE_PASSWORD` | - | Yes | Password for cookie encryption (min 32 chars) |
92+
| `cookieName` | `WORKOS_COOKIE_NAME` | `wos-session` | No | Name of the session cookie |
93+
| `apiHttps` | `WORKOS_API_HTTPS` | `true` | No | Whether to use HTTPS for API calls |
94+
| `cookieMaxAge` | `WORKOS_COOKIE_MAX_AGE` | `34560000` (400 days) | No | Maximum age of cookie in seconds |
95+
| `apiHostname` | `WORKOS_API_HOSTNAME` | `api.workos.com` | No | WorkOS API hostname |
96+
| `apiPort` | `WORKOS_API_PORT` | - | No | Port to use for API calls |
97+
98+
>[!NOTE]
99+
>
100+
>The `cookiePassword` must be at least 32 characters long for security reasons.
101+
49102
## Setup
50103

51104
### Callback route

jest.setup.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,5 @@ process.env.WORKOS_CLIENT_ID = 'client_1234567890';
33
process.env.WORKOS_COOKIE_PASSWORD = 'kR620keEzOIzPThfnMEAba8XYgKdQ5vg';
44
process.env.WORKOS_REDIRECT_URI = 'http://localhost:5173/callback';
55
process.env.WORKOS_COOKIE_DOMAIN = 'example.com';
6-
process.env.WORKOS_API_HOSTNAME = 'api.workos.com';
76

87
export {};

src/authkit-callback-route.spec.ts

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { LoaderFunction } from '@remix-run/node';
2-
import { workos as workosInstance } from '../src/workos.js';
2+
import { getWorkOS } from './workos.js';
33
import { authLoader } from './authkit-callback-route.js';
44
import {
55
createRequestWithSearchParams,
@@ -9,19 +9,22 @@ import {
99
import { configureSessionStorage } from './sessionStorage.js';
1010

1111
// Mock dependencies
12-
jest.mock('../src/workos.js', () => ({
13-
workos: {
14-
userManagement: {
15-
authenticateWithCode: jest.fn(),
16-
getJwksUrl: jest.fn(() => 'https://api.workos.com/sso/jwks/client_1234567890'),
17-
},
12+
const fakeWorkosInstance = {
13+
userManagement: {
14+
authenticateWithCode: jest.fn(),
15+
getJwksUrl: jest.fn(() => 'https://api.workos.com/sso/jwks/client_1234567890'),
1816
},
17+
};
18+
19+
jest.mock('./workos.js', () => ({
20+
getWorkOS: jest.fn(() => fakeWorkosInstance),
1921
}));
2022

2123
describe('authLoader', () => {
2224
let loader: LoaderFunction;
2325
let request: Request;
24-
const workos = jest.mocked(workosInstance);
26+
const workos = getWorkOS();
27+
const authenticateWithCode = jest.mocked(workos.userManagement.authenticateWithCode);
2528

2629
beforeAll(() => {
2730
// Silence console.error during tests
@@ -30,10 +33,8 @@ describe('authLoader', () => {
3033
});
3134

3235
beforeEach(async () => {
33-
jest.resetAllMocks();
34-
3536
const mockAuthResponse = createAuthWithCodeResponse();
36-
workos.userManagement.authenticateWithCode.mockResolvedValue(mockAuthResponse);
37+
authenticateWithCode.mockResolvedValue(mockAuthResponse);
3738

3839
loader = authLoader();
3940
const url = new URL('http://example.com/callback');
@@ -55,15 +56,15 @@ describe('authLoader', () => {
5556
});
5657

5758
it('should handle authentication failure', async () => {
58-
workos.userManagement.authenticateWithCode.mockRejectedValue(new Error('Auth failed'));
59+
authenticateWithCode.mockRejectedValue(new Error('Auth failed'));
5960
request = createRequestWithSearchParams(request, { code: 'invalid-code' });
6061
const response = (await loader({ request, params: {}, context: {} })) as Response;
6162

6263
expect(response.status).toBe(500);
6364
});
6465

6566
it('should handle authentication failure with string error', async () => {
66-
workos.userManagement.authenticateWithCode.mockRejectedValue('Auth failed');
67+
authenticateWithCode.mockRejectedValue('Auth failed');
6768
request = createRequestWithSearchParams(request, { code: 'invalid-code' });
6869
const response = (await loader({ request, params: {}, context: {} })) as Response;
6970

@@ -141,7 +142,7 @@ describe('authLoader', () => {
141142

142143
it('provides impersonator to onSuccess callback when provided', async () => {
143144
const onSuccess = jest.fn();
144-
workos.userManagement.authenticateWithCode.mockResolvedValue(
145+
authenticateWithCode.mockResolvedValue(
145146
createAuthWithCodeResponse({
146147
impersonator: {
147148
@@ -162,7 +163,7 @@ describe('authLoader', () => {
162163

163164
it('provides oauthTokens to onSuccess callback when provided', async () => {
164165
const onSuccess = jest.fn();
165-
workos.userManagement.authenticateWithCode.mockResolvedValue(
166+
authenticateWithCode.mockResolvedValue(
166167
createAuthWithCodeResponse({
167168
oauthTokens: {
168169
accessToken: 'access123',

src/authkit-callback-route.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1+
import { LoaderFunctionArgs, json, redirect } from '@remix-run/node';
2+
import { getConfig } from './config.js';
13
import { HandleAuthOptions } from './interfaces.js';
2-
import { WORKOS_CLIENT_ID } from './env-variables.js';
3-
import { workos } from './workos.js';
44
import { encryptSession } from './session.js';
55
import { getSessionStorage } from './sessionStorage.js';
6-
import { redirect, json, LoaderFunctionArgs } from '@remix-run/node';
6+
import { getWorkOS } from './workos.js';
77

88
export function authLoader(options: HandleAuthOptions = {}) {
99
return async function loader({ request }: LoaderFunctionArgs) {
@@ -19,8 +19,8 @@ export function authLoader(options: HandleAuthOptions = {}) {
1919
if (code) {
2020
try {
2121
const { accessToken, refreshToken, user, impersonator, oauthTokens } =
22-
await workos.userManagement.authenticateWithCode({
23-
clientId: WORKOS_CLIENT_ID,
22+
await getWorkOS().userManagement.authenticateWithCode({
23+
clientId: getConfig('clientId'),
2424
code,
2525
});
2626

src/config.spec.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import type { configure as ConfigureType, getConfig as GetConfigType } from './config.js';
2+
import { AuthKitConfig } from './interfaces.js';
3+
4+
describe('config', () => {
5+
let configure: typeof ConfigureType;
6+
let getConfig: typeof GetConfigType;
7+
8+
beforeEach(() => {
9+
jest.resetModules();
10+
({ configure, getConfig } = require('./config.js'));
11+
});
12+
13+
it('reads values from process.env with no configure call', () => {
14+
expect(getConfig('clientId')).toBe(process.env.WORKOS_CLIENT_ID);
15+
expect(getConfig('apiKey')).toBe(process.env.WORKOS_API_KEY);
16+
});
17+
18+
it('reads values from the provided config', () => {
19+
configure({
20+
clientId: 'client_1234567890',
21+
apiKey: 'sk_test_1234567890',
22+
});
23+
24+
expect(getConfig('clientId')).toBe('client_1234567890');
25+
expect(getConfig('apiKey')).toBe('sk_test_1234567890');
26+
});
27+
28+
it('reads env variables from the provided config', () => {
29+
configure(
30+
{},
31+
{
32+
WORKOS_CLIENT_ID: 'client_123456789',
33+
WORKOS_API_KEY: 'sk_test_123456789',
34+
},
35+
);
36+
37+
expect(getConfig('clientId')).toBe('client_123456789');
38+
expect(getConfig('apiKey')).toBe('sk_test_123456789');
39+
});
40+
41+
it('reads values from the provided config', () => {
42+
configure({
43+
clientId: 'client_1234567890',
44+
});
45+
46+
expect(getConfig('clientId')).toBe('client_1234567890');
47+
expect(getConfig('apiKey')).toBe(process.env.WORKOS_API_KEY);
48+
});
49+
50+
it('reads values from the provided source', () => {
51+
configure((key) => {
52+
if (key === 'WORKOS_CLIENT_ID') {
53+
return 'client_1234567890';
54+
} else if (key === 'WORKOS_API_KEY') {
55+
return 'sk_test_1234567890';
56+
}
57+
58+
return undefined;
59+
});
60+
61+
expect(getConfig('clientId')).toBe('client_1234567890');
62+
expect(getConfig('apiKey')).toBe('sk_test_1234567890');
63+
});
64+
65+
it('reads from provided config, falling back to provided source', () => {
66+
configure(
67+
{
68+
clientId: 'overridden client id',
69+
redirectUri: 'http://localhost:5173/callback',
70+
cookiePassword: 'a really long cookie password that is definitely more than 32 characters',
71+
},
72+
(key) => {
73+
if (key === 'WORKOS_API_KEY') {
74+
return 'overridden api key';
75+
}
76+
},
77+
);
78+
79+
expect(getConfig('clientId')).toBe('overridden client id');
80+
expect(getConfig('apiKey')).toBe('overridden api key');
81+
});
82+
83+
it('reads from defaults when no values are provided', () => {
84+
configure(() => undefined);
85+
86+
expect(getConfig('apiHttps')).toBe(true);
87+
expect(getConfig('apiHostname')).toBe('api.workos.com');
88+
});
89+
90+
it('returns undefined for unknown values', () => {
91+
expect(getConfig('unknown' as keyof AuthKitConfig)).toBeUndefined();
92+
});
93+
94+
it('converts strings to appropriate types', () => {
95+
configure((key) => {
96+
switch (key) {
97+
case 'WORKOS_API_PORT':
98+
return '3000';
99+
case 'WORKOS_COOKIE_MAX_AGE':
100+
return '3600';
101+
case 'WORKOS_API_HTTPS':
102+
return 'true';
103+
default:
104+
return undefined;
105+
}
106+
});
107+
expect(typeof getConfig('apiPort')).toBe('number');
108+
expect(typeof getConfig('cookieMaxAge')).toBe('number');
109+
expect(typeof getConfig('apiHttps')).toBe('boolean');
110+
});
111+
112+
it('throws an error if cookiePassword is too short', () => {
113+
expect(() => {
114+
configure({ cookiePassword: 'short' });
115+
}).toThrow('cookiePassword must be at least 32 characters long');
116+
});
117+
118+
it('throws an error if required values are missing', () => {
119+
expect(() => {
120+
configure(() => undefined);
121+
getConfig('apiKey');
122+
}).toThrow();
123+
});
124+
});

0 commit comments

Comments
 (0)