Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 20 additions & 20 deletions packages/auth0-api-js/src/api-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,18 +214,18 @@ test('getAccessTokenForConnection - should throw when no clientId configured', a
});

test('getAccessTokenForConnection - should throw when no clientSecret configured', async () => {
const apiClient = new ApiClient({
domain,
audience: '<audience>',
clientId: 'my-client-id',
});

await expect(
apiClient.getAccessTokenForConnection({
await expect(async () => {
const apiClient = new ApiClient({
domain,
audience: '<audience>',
clientId: 'my-client-id',
});

await apiClient.getAccessTokenForConnection({
connection: 'my-connection',
accessToken: 'my-access-token',
})
).rejects.toThrow(MissingClientAuthError);
});
}).rejects.toThrow(MissingClientAuthError);
});

test('getAccessTokenForConnection - should return a token set when the exchange is successful', async () => {
Expand Down Expand Up @@ -297,18 +297,18 @@ test('getTokenByExchangeProfile - should throw when no clientId configured', asy
});

test('getTokenByExchangeProfile - should throw when no clientSecret configured', async () => {
const apiClient = new ApiClient({
domain,
audience: '<audience>',
clientId: 'my-client-id',
});

await expect(
apiClient.getTokenByExchangeProfile('my-subject-token', {
await expect(async () => {
const apiClient = new ApiClient({
domain,
audience: '<audience>',
clientId: 'my-client-id',
});

await apiClient.getTokenByExchangeProfile('my-subject-token', {
subjectTokenType: 'urn:my-company:mcp-token',
audience: 'https://api.backend.com',
})
).rejects.toThrow(MissingClientAuthError);
});
}).rejects.toThrow(MissingClientAuthError);
});

test('getTokenByExchangeProfile - should return tokens when exchange succeeds', async () => {
Expand Down
21 changes: 21 additions & 0 deletions packages/auth0-auth-js/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,27 @@ const authClient = new AuthClient({

The `AUTH0_DOMAIN`, `AUTH0_CLIENT_ID`, and `AUTH0_CLIENT_SECRET` can be obtained from the [Auth0 Dashboard](https://manage.auth0.com) once you've created an application.

#### Environment Variable Support

The SDK supports automatic fallback to environment variables if configuration options are not provided:

```ts
// If AUTH0_DOMAIN, AUTH0_CLIENT_ID, and AUTH0_CLIENT_SECRET are set in your environment
const authClient = new AuthClient();

// Or mix and match
const authClient = new AuthClient({
domain: 'custom-domain.auth0.com', // Override env var
// clientId and clientSecret will fall back to AUTH0_CLIENT_ID and AUTH0_CLIENT_SECRET
});
```

Supported environment variables:
- `AUTH0_DOMAIN` - Your Auth0 tenant domain
- `AUTH0_CLIENT_ID` - Your application's client ID
- `AUTH0_CLIENT_SECRET` - Your application's client secret (optional - depending on the authentication method you want to use)
- `AUTH0_CLIENT_ASSERTION_SIGNING_KEY` - Your application's client assertion signing key (optional - depending on the authentication method you want to use)

### 3. Build the Authorization URL

Build the URL to redirect the user-agent to to request authorization at Auth0.
Expand Down
173 changes: 173 additions & 0 deletions packages/auth0-auth-js/src/auth-client-env.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { expect, test, describe, beforeEach, afterEach } from 'vitest';
import { AuthClient } from './auth-client.js';
import { MissingRequiredArgumentError } from './errors.js';

describe('AuthClient - Environment Variable Support', () => {
const originalEnv = process.env;

beforeEach(() => {
// Reset process.env before each test
process.env = { ...originalEnv };
});

afterEach(() => {
// Restore original environment
process.env = originalEnv;
});

describe('Environment variable usage', () => {
test('should use all configuration from environment variables', () => {
process.env.AUTH0_DOMAIN = 'env-domain.auth0.com';
process.env.AUTH0_CLIENT_ID = 'env-client-id';
process.env.AUTH0_CLIENT_SECRET = 'env-client-secret';

const authClient = new AuthClient({});

// Verify the client was created successfully with env vars
expect(authClient).toBeDefined();
expect(authClient.mfa).toBeDefined();
});

test('should use mixed environment variables and explicit options', () => {
process.env.AUTH0_DOMAIN = 'env-domain.auth0.com';
// No CLIENT_ID in env

const authClient = new AuthClient({
clientId: 'explicit-client-id',
clientSecret: 'explicit-client-secret',
});

expect(authClient).toBeDefined();
});
});

describe('Explicit options override environment variables', () => {
test('should override AUTH0_DOMAIN with explicit domain option', () => {
// Set env var to empty string which would normally fail
process.env.AUTH0_DOMAIN = '';
process.env.AUTH0_CLIENT_ID = 'client-id';
process.env.AUTH0_CLIENT_SECRET = 'client-secret';

// But providing explicit domain should work, proving override is working
const authClient = new AuthClient({
domain: 'correct-domain.auth0.com',
});

expect(authClient).toBeDefined();
});

test('should override AUTH0_CLIENT_ID with explicit clientId option', () => {
// Set env var to empty string which would normally fail
process.env.AUTH0_DOMAIN = 'domain.auth0.com';
process.env.AUTH0_CLIENT_ID = '';
process.env.AUTH0_CLIENT_SECRET = 'client-secret';

// But providing explicit clientId should work, proving override is working
const authClient = new AuthClient({
clientId: 'correct-client-id',
});

expect(authClient).toBeDefined();
});

test('should override AUTH0_CLIENT_SECRET with explicit clientSecret option', () => {
process.env.AUTH0_DOMAIN = 'domain.auth0.com';
process.env.AUTH0_CLIENT_ID = 'client-id';
// No CLIENT_SECRET in env, would normally require requireClientAuth: false

// But providing explicit clientSecret should allow requireClientAuth: true
const authClient = new AuthClient({
clientSecret: 'explicit-secret',
});

expect(authClient).toBeDefined();
});

test('should treat undefined options as "use environment variable"', () => {
process.env.AUTH0_DOMAIN = 'env-domain.auth0.com';
process.env.AUTH0_CLIENT_ID = 'env-client-id';
process.env.AUTH0_CLIENT_SECRET = 'env-client-secret';

const authClient = new AuthClient({
domain: undefined,
clientId: undefined,
clientSecret: undefined,
});

expect(authClient).toBeDefined();
});
});

describe('Required field validation', () => {
test('should throw MissingRequiredArgumentError when domain is missing from both options and environment', () => {
process.env.AUTH0_CLIENT_ID = 'client-id';
delete process.env.AUTH0_DOMAIN;

try {
new AuthClient({});
expect.fail('Should have thrown MissingRequiredArgumentError');
} catch (error) {
expect(error).toBeInstanceOf(MissingRequiredArgumentError);
expect((error as MissingRequiredArgumentError).code).toBe('missing_required_argument_error');
expect((error as MissingRequiredArgumentError).message).toBe(
'The argument \'domain\' is required but was not provided.'
);
}
});

test('should throw MissingRequiredArgumentError when clientId is missing from both options and environment', () => {
process.env.AUTH0_DOMAIN = 'domain.auth0.com';
delete process.env.AUTH0_CLIENT_ID;

try {
new AuthClient({});
expect.fail('Should have thrown MissingRequiredArgumentError');
} catch (error) {
expect(error).toBeInstanceOf(MissingRequiredArgumentError);
expect((error as MissingRequiredArgumentError).code).toBe('missing_required_argument_error');
expect((error as MissingRequiredArgumentError).message).toBe(
'The argument \'clientId\' is required but was not provided.'
);
}
});

test('should treat empty string as missing value (not fall back to env)', () => {
process.env.AUTH0_DOMAIN = 'env-domain.auth0.com';
process.env.AUTH0_CLIENT_ID = 'env-client-id';

try {
new AuthClient({
domain: '',
clientId: '',
});
expect.fail('Should have thrown MissingRequiredArgumentError');
} catch (error) {
expect(error).toBeInstanceOf(MissingRequiredArgumentError);
expect((error as MissingRequiredArgumentError).code).toBe('missing_required_argument_error');
expect((error as MissingRequiredArgumentError).message).toContain('required');
}
});
});

describe('Client authentication', () => {
test('should use AUTH0_CLIENT_SECRET from environment when available', () => {
process.env.AUTH0_DOMAIN = 'domain.auth0.com';
process.env.AUTH0_CLIENT_ID = 'client-id';
process.env.AUTH0_CLIENT_SECRET = 'client-secret';

const authClient = new AuthClient({});

expect(authClient).toBeDefined();
});

test('should use AUTH0_CLIENT_ASSERTION_SIGNING_KEY from environment when available', () => {
process.env.AUTH0_DOMAIN = 'domain.auth0.com';
process.env.AUTH0_CLIENT_ID = 'client-id';
process.env.AUTH0_CLIENT_ASSERTION_SIGNING_KEY = '-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----';

const authClient = new AuthClient();

expect(authClient).toBeDefined();
});
});
});
42 changes: 25 additions & 17 deletions packages/auth0-auth-js/src/auth-client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,13 +373,21 @@ test('configuration - should use private key JWT when passed as CryptoKey', asyn
test('configuration - should throw when no key configured', async () => {
const mockFetch = vi.fn().mockImplementation(fetch);

const authClient = new AuthClient({
domain,
clientId: '<client_id>',
customFetch: mockFetch,
});

await expect(authClient.buildAuthorizationUrl()).rejects.toThrowError('The client secret or client assertion signing key must be provided.');
expect(
() =>
new AuthClient({
domain,
clientId: '<client_id>',
customFetch: mockFetch,
}),
).toThrowError(
expect.objectContaining({
name: 'MissingClientAuthError',
code: 'missing_client_auth_error',
message:
'The client secret or client assertion signing key must be provided.',
}),
);
});

test('configuration - should use mTLS when useMtls is true', async () => {
Expand Down Expand Up @@ -2332,23 +2340,23 @@ ca/T0LLtgmbMmxSv/MmzIg==
});

test('should fail Custom Token Exchange when no client credentials provided', async () => {
const authClient = new AuthClient({
domain,
clientId: '<client_id>',
// No clientSecret or clientAssertionSigningKey
});
await expect(async () => {
const authClient = new AuthClient({
domain,
clientId: '<client_id>',
// No clientSecret or clientAssertionSigningKey
});

await expect(
authClient.exchangeToken({
await authClient.exchangeToken({
subjectTokenType: 'urn:test:mcp-token',
subjectToken: 'test-token',
audience: 'https://api.example.com',
})
).rejects.toThrowError(
});
}).rejects.toThrowError(
expect.objectContaining({
name: 'MissingClientAuthError',
code: 'missing_client_auth_error',
})
}),
);
});
});
Expand Down
43 changes: 37 additions & 6 deletions packages/auth0-auth-js/src/auth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
TokenByRefreshTokenError,
TokenForConnectionError,
VerifyLogoutTokenError,
MissingRequiredArgumentError,
} from './errors.js';
import { stripUndefinedProperties } from './utils.js';
import { MfaClient } from './mfa/mfa-client.js';
Expand Down Expand Up @@ -207,6 +208,11 @@ const SUBJECT_TYPE_ACCESS_TOKEN =
const REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN =
'http://auth0.com/oauth/token-type/federated-connection-access-token';

/**
* Internal type for resolved AuthClientOptions with required fields guaranteed to be present.
*/
type InternalAuthClientOptions = Required<Pick<AuthClientOptions, 'domain' | 'clientId'>> & Omit<AuthClientOptions, 'domain' | 'clientId'>;

/**
* Auth0 authentication client for handling OAuth 2.0 and OIDC flows.
*
Expand All @@ -217,25 +223,50 @@ const REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN =
export class AuthClient {
#configuration: client.Configuration | undefined;
#serverMetadata: client.ServerMetadata | undefined;
readonly #options: AuthClientOptions;
readonly #options: InternalAuthClientOptions;
readonly #customFetch: typeof fetch;
#jwks?: ReturnType<typeof createRemoteJWKSet>;
public mfa: MfaClient;

constructor(options: AuthClientOptions) {
this.#options = options;
constructor(options: AuthClientOptions = {}) {
// Resolve configuration with environment variable fallbacks
const domain = options?.domain ?? process.env.AUTH0_DOMAIN;
const clientId = options?.clientId ?? process.env.AUTH0_CLIENT_ID;
const clientSecret = options?.clientSecret ?? process.env.AUTH0_CLIENT_SECRET;
const clientAssertionSigningKey = options?.clientAssertionSigningKey ?? process.env.AUTH0_CLIENT_ASSERTION_SIGNING_KEY;

// Validate required fields
if (!domain) {
throw new MissingRequiredArgumentError('domain');
}
if (!clientId) {
throw new MissingRequiredArgumentError('clientId');
}

if (!clientSecret && !clientAssertionSigningKey && !options.useMtls) {
throw new MissingClientAuthError();
}

// Store resolved options
this.#options = {
...options,
domain,
clientId,
clientSecret,
clientAssertionSigningKey,
};

// When mTLS is being used, a custom fetch implementation is required.
if (options.useMtls && !options.customFetch) {
if (this.#options.useMtls && !this.#options.customFetch) {
throw new NotSupportedError(
NotSupportedErrorCode.MTLS_WITHOUT_CUSTOMFETCH_NOT_SUPPORT,
'Using mTLS without a custom fetch implementation is not supported'
);
}

this.#customFetch = createTelemetryFetch(
options.customFetch ?? ((...args) => fetch(...args)),
getTelemetryConfig(options.telemetry),
this.#options.customFetch ?? ((...args) => fetch(...args)),
getTelemetryConfig(this.#options.telemetry),
);

this.mfa = new MfaClient({
Expand Down
Loading