Skip to content

fix: Throw ConfigurationError when invalid Auth0Client configuration #2026

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Apr 3, 2025
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,20 @@ You can customize the client by using the options below:
| httpTimeout | `number` | Integer value for the HTTP timeout in milliseconds for authentication requests. Defaults to `5000` milliseconds |
| enableTelemetry | `boolean` | Boolean value to opt-out of sending the library name and version to your authorization server via the `Auth0-Client` header. Defaults to `true`. |

## Configuration Validation

The SDK performs validation of required configuration options when initializing the `Auth0Client`. The following options are mandatory and must be provided either through constructor options or environment variables:

- `domain` (or `AUTH0_DOMAIN` environment variable)
- `clientId` (or `AUTH0_CLIENT_ID` environment variable)
- `appBaseUrl` (or `APP_BASE_URL` environment variable)
- `secret` (or `AUTH0_SECRET` environment variable)
- Either:
- `clientSecret` (or `AUTH0_CLIENT_SECRET` environment variable), OR
- `clientAssertionSigningKey` (or `AUTH0_CLIENT_ASSERTION_SIGNING_KEY` environment variable)

If any of these required options are missing, the SDK will throw a `ConfigurationError` with the code `MISSING_REQUIRED_OPTIONS` and a detailed error message explaining which options are missing and how to provide them.

## Routes

The SDK mounts 6 routes:
Expand Down
55 changes: 55 additions & 0 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,58 @@ export class AccessTokenForConnectionError extends SdkError {
this.cause = cause;
}
}

/**
* Enum representing error codes related to configuration.
*/
export enum ConfigurationErrorCode {
/**
* Missing required configuration options.
*/
MISSING_REQUIRED_OPTIONS = "missing_required_options"
}

/**
* Error class representing a configuration error.
* Extends the `SdkError` class.
*/
export class ConfigurationError extends SdkError {
/**
* The error code associated with the configuration error.
*/
public code: string;
public missingOptions?: string[];

/**
* Constructs a new `ConfigurationError` instance.
*
* @param code - The error code.
* @param missingOptions - Array of missing configuration option names.
* @param envVarMapping - Optional mapping of option names to their environment variable names.
*/
constructor(
code: string,
missingOptions: string[] = [],
envVarMapping: Record<string, string> = {}
) {
// Standard intro message explaining the issue
let errorMessage =
"Not all required options where provided when creating an instance of Auth0Client. Ensure to provide all missing options, either by passing it to the Auth0Client constructor, or by setting the corresponding environment variable.\n\n";

// Add specific details for each missing option
missingOptions.forEach((key) => {
if (key === "clientAuthentication") {
errorMessage += `Missing: clientAuthentication: Set either AUTH0_CLIENT_SECRET env var or AUTH0_CLIENT_ASSERTION_SIGNING_KEY env var, or pass clientSecret or clientAssertionSigningKey in options\n`;
} else if (envVarMapping[key]) {
errorMessage += `Missing: ${key}: Set ${envVarMapping[key]} env var or pass ${key} in options\n`;
} else {
errorMessage += `Missing: ${key}\n`;
}
});

super(errorMessage.trim());
this.name = "ConfigurationError";
this.code = code;
this.missingOptions = missingOptions;
}
}
182 changes: 182 additions & 0 deletions src/server/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { ConfigurationError, ConfigurationErrorCode } from "../errors/index.js";
import { Auth0Client } from "./client.js";

describe("Auth0Client", () => {
// Store original env vars
const originalEnv = { ...process.env };

// Define correct environment variable names
const ENV_VARS = {
DOMAIN: "AUTH0_DOMAIN",
CLIENT_ID: "AUTH0_CLIENT_ID",
CLIENT_SECRET: "AUTH0_CLIENT_SECRET",
CLIENT_ASSERTION_SIGNING_KEY: "AUTH0_CLIENT_ASSERTION_SIGNING_KEY",
APP_BASE_URL: "APP_BASE_URL",
SECRET: "AUTH0_SECRET",
SCOPE: "AUTH0_SCOPE"
};

// Clear env vars before each test
beforeEach(() => {
vi.resetModules();
// Clear all environment variables that might affect the tests
delete process.env[ENV_VARS.DOMAIN];
delete process.env[ENV_VARS.CLIENT_ID];
delete process.env[ENV_VARS.CLIENT_SECRET];
delete process.env[ENV_VARS.CLIENT_ASSERTION_SIGNING_KEY];
delete process.env[ENV_VARS.APP_BASE_URL];
delete process.env[ENV_VARS.SECRET];
delete process.env[ENV_VARS.SCOPE];
});

// Restore env vars after each test
afterEach(() => {
process.env = { ...originalEnv };
});

describe("constructor validation", () => {
it("should throw ConfigurationError when all required options are missing", () => {
expect(() => new Auth0Client()).toThrow(ConfigurationError);

try {
new Auth0Client();
} catch (error) {
const configError = error as ConfigurationError;
expect(configError).toBeInstanceOf(ConfigurationError);
expect(configError.code).toBe(
ConfigurationErrorCode.MISSING_REQUIRED_OPTIONS
);
expect(configError.missingOptions).toContain("domain");
expect(configError.missingOptions).toContain("clientId");
expect(configError.missingOptions).toContain("clientAuthentication");
expect(configError.missingOptions).toContain("appBaseUrl");
expect(configError.missingOptions).toContain("secret");

// Check that error message contains specific text
expect(configError.message).toContain(
"Not all required options where provided"
);
expect(configError.message).toContain(ENV_VARS.DOMAIN);
expect(configError.message).toContain(ENV_VARS.CLIENT_ID);
expect(configError.message).toContain(ENV_VARS.CLIENT_SECRET);
expect(configError.message).toContain(
ENV_VARS.CLIENT_ASSERTION_SIGNING_KEY
);
expect(configError.message).toContain(ENV_VARS.APP_BASE_URL);
expect(configError.message).toContain(ENV_VARS.SECRET);
}
});

it("should throw ConfigurationError when some required options are missing", () => {
// Provide some but not all required options
const options = {
domain: "example.auth0.com",
clientId: "client_123"
};

try {
new Auth0Client(options);
} catch (error) {
const configError = error as ConfigurationError;
expect(configError).toBeInstanceOf(ConfigurationError);
expect(configError.code).toBe(
ConfigurationErrorCode.MISSING_REQUIRED_OPTIONS
);
// These should be missing
expect(configError.missingOptions).toContain("clientAuthentication");
expect(configError.missingOptions).toContain("appBaseUrl");
expect(configError.missingOptions).toContain("secret");
// These should not be in the missing list
expect(configError.missingOptions).not.toContain("domain");
expect(configError.missingOptions).not.toContain("clientId");

// Error message should only contain instructions for missing options
expect(configError.message).toContain(ENV_VARS.CLIENT_SECRET);
expect(configError.message).toContain(
ENV_VARS.CLIENT_ASSERTION_SIGNING_KEY
);
expect(configError.message).toContain(ENV_VARS.APP_BASE_URL);
expect(configError.message).toContain(ENV_VARS.SECRET);
expect(configError.message).not.toContain(`Set ${ENV_VARS.DOMAIN}`);
expect(configError.message).not.toContain(`Set ${ENV_VARS.CLIENT_ID}`);
}
});

it("should accept clientSecret as authentication method", () => {
// Set required environment variables with clientSecret
process.env[ENV_VARS.DOMAIN] = "env.auth0.com";
process.env[ENV_VARS.CLIENT_ID] = "env_client_id";
process.env[ENV_VARS.CLIENT_SECRET] = "env_client_secret";
process.env[ENV_VARS.APP_BASE_URL] = "https://myapp.com";
process.env[ENV_VARS.SECRET] = "env_secret";

// Should not throw
const client = new Auth0Client();

// The client should be instantiated successfully
expect(client).toBeInstanceOf(Auth0Client);
});

it("should accept clientAssertionSigningKey as authentication method", () => {
// Set required environment variables with clientAssertionSigningKey instead of clientSecret
process.env[ENV_VARS.DOMAIN] = "env.auth0.com";
process.env[ENV_VARS.CLIENT_ID] = "env_client_id";
process.env[ENV_VARS.CLIENT_ASSERTION_SIGNING_KEY] = "some-signing-key";
process.env[ENV_VARS.APP_BASE_URL] = "https://myapp.com";
process.env[ENV_VARS.SECRET] = "env_secret";

// Should not throw
const client = new Auth0Client();

// The client should be instantiated successfully
expect(client).toBeInstanceOf(Auth0Client);
});

it("should prioritize options over environment variables", () => {
// Set environment variables
process.env[ENV_VARS.DOMAIN] = "env.auth0.com";
process.env[ENV_VARS.CLIENT_ID] = "env_client_id";
process.env[ENV_VARS.CLIENT_SECRET] = "env_client_secret";
process.env[ENV_VARS.APP_BASE_URL] = "https://myapp.com";
process.env[ENV_VARS.SECRET] = "env_secret";

// Provide conflicting options
const options = {
domain: "options.auth0.com",
clientId: "options_client_id",
clientSecret: "options_client_secret",
appBaseUrl: "https://options-app.com",
secret: "options_secret"
};

// Mock the validateAndExtractRequiredOptions to verify which values are used
const mockValidateAndExtractRequiredOptions = vi
.fn()
.mockReturnValue(options);
const originalValidateAndExtractRequiredOptions =
Auth0Client.prototype["validateAndExtractRequiredOptions"];
Auth0Client.prototype["validateAndExtractRequiredOptions"] =
mockValidateAndExtractRequiredOptions;

try {
new Auth0Client(options);

// Check that validateAndExtractRequiredOptions was called with our options
expect(mockValidateAndExtractRequiredOptions).toHaveBeenCalledWith(
options
);
// The first argument of the first call should be our options object
const passedOptions =
mockValidateAndExtractRequiredOptions.mock.calls[0][0];
expect(passedOptions.domain).toBe("options.auth0.com");
expect(passedOptions.clientId).toBe("options_client_id");
} finally {
// Restore the original method
Auth0Client.prototype["validateAndExtractRequiredOptions"] =
originalValidateAndExtractRequiredOptions;
}
});
});
});
95 changes: 77 additions & 18 deletions src/server/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import {
AccessTokenErrorCode,
AccessTokenForConnectionError,
AccessTokenForConnectionErrorCode,
ConfigurationError,
ConfigurationErrorCode
} from "../errors/index.js";
import {
AuthorizationParameters,
AccessTokenForConnectionOptions,
AuthorizationParameters,
SessionData,
SessionDataStore,
StartInteractiveLoginOptions
Expand Down Expand Up @@ -181,19 +183,16 @@ export class Auth0Client {
private authClient: AuthClient;

constructor(options: Auth0ClientOptions = {}) {
const domain = (options.domain || process.env.AUTH0_DOMAIN) as string;
const clientId = (options.clientId ||
process.env.AUTH0_CLIENT_ID) as string;
const clientSecret = (options.clientSecret ||
process.env.AUTH0_CLIENT_SECRET) as string;

const appBaseUrl = (options.appBaseUrl ||
process.env.APP_BASE_URL) as string;
const secret = (options.secret || process.env.AUTH0_SECRET) as string;
// Extract and validate required options
const {
domain,
clientId,
clientSecret,
appBaseUrl,
secret,
clientAssertionSigningKey
} = this.validateAndExtractRequiredOptions(options);

const clientAssertionSigningKey =
options.clientAssertionSigningKey ||
process.env.AUTH0_CLIENT_ASSERTION_SIGNING_KEY;
const clientAssertionSigningAlg =
options.clientAssertionSigningAlg ||
process.env.AUTH0_CLIENT_ASSERTION_SIGNING_ALG;
Expand Down Expand Up @@ -261,7 +260,7 @@ export class Auth0Client {
allowInsecureRequests: options.allowInsecureRequests,
httpTimeout: options.httpTimeout,
enableTelemetry: options.enableTelemetry,
enableAccessTokenEndpoint: options.enableAccessTokenEndpoint,
enableAccessTokenEndpoint: options.enableAccessTokenEndpoint
});
}

Expand Down Expand Up @@ -473,10 +472,7 @@ export class Auth0Client {
: tokenSet
);
} else {
tokenSets = [
...(session.connectionTokenSets || []),
retrievedTokenSet
];
tokenSets = [...(session.connectionTokenSets || []), retrievedTokenSet];
}

await this.saveToSession(
Expand Down Expand Up @@ -652,4 +648,67 @@ export class Auth0Client {
}
}
}

/**
* Validates and extracts required configuration options.
* @param options The client options
* @returns The validated required options
* @throws ConfigurationError if any required option is missing
*/
private validateAndExtractRequiredOptions(options: Auth0ClientOptions) {
// Base required options that are always needed
const requiredOptions = {
domain: options.domain ?? process.env.AUTH0_DOMAIN,
clientId: options.clientId ?? process.env.AUTH0_CLIENT_ID,
appBaseUrl: options.appBaseUrl ?? process.env.APP_BASE_URL,
secret: options.secret ?? process.env.AUTH0_SECRET
};

// Check client authentication options - either clientSecret OR clientAssertionSigningKey must be provided
const clientSecret =
options.clientSecret ?? process.env.AUTH0_CLIENT_SECRET;
const clientAssertionSigningKey =
options.clientAssertionSigningKey ??
process.env.AUTH0_CLIENT_ASSERTION_SIGNING_KEY;
const hasClientAuthentication = !!(
clientSecret || clientAssertionSigningKey
);

const missing = Object.entries(requiredOptions)
.filter(([, value]) => !value)
.map(([key]) => key);

// Add client authentication error if neither option is provided
if (!hasClientAuthentication) {
missing.push("clientAuthentication");
}

if (missing.length) {
// Map of option keys to their exact environment variable names
const envVarNames: Record<string, string> = {
domain: "AUTH0_DOMAIN",
clientId: "AUTH0_CLIENT_ID",
appBaseUrl: "APP_BASE_URL",
secret: "AUTH0_SECRET"
};

throw new ConfigurationError(
ConfigurationErrorCode.MISSING_REQUIRED_OPTIONS,
missing,
envVarNames
);
}

// Prepare the result object with all validated options
const result = {
...requiredOptions,
clientSecret,
clientAssertionSigningKey
};

// Type-safe assignment after validation
return result as {
[K in keyof typeof result]: NonNullable<(typeof result)[K]>;
};
}
}