diff --git a/src/index.ts b/src/index.ts index e122787..5ba87d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ * Supports any standard OAuth 2.0 provider through configuration. */ -import { initializeProviders } from './lib/config.ts'; +import { initializeProviders, expandEnvVar } from './lib/config.ts'; import { createOAuthResource } from './lib/resource.ts'; import { validateAndRefreshSession } from './lib/sessionValidator.ts'; import { clearOAuthSession } from './lib/handlers.ts'; @@ -83,11 +83,7 @@ export async function handleApplication(scope: Scope): Promise { const rawOptions = (scope.options.getAll() || {}) as OAuthPluginConfig; // Expand environment variables in plugin-level options - let debugValue = rawOptions.debug; - if (typeof debugValue === 'string' && debugValue.startsWith('${') && debugValue.endsWith('}')) { - const envVar = debugValue.slice(2, -1); - debugValue = process.env[envVar] || debugValue; - } + const debugValue = expandEnvVar(rawOptions.debug); const options = { ...rawOptions, debug: debugValue }; const previousDebugMode = debugMode; diff --git a/src/lib/config.ts b/src/lib/config.ts index cbb2bb4..72f948c 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -8,6 +8,29 @@ import { OAuthProvider } from './OAuthProvider.ts'; import { getProvider } from './providers/index.ts'; import type { OAuthProviderConfig, OAuthPluginConfig, ProviderRegistry, Logger } from '../types.ts'; +/** + * Expand environment variable in a string value + * + * If the value is a string in the format `${VAR_NAME}`, it will be replaced + * with the value of the environment variable. Non-string values are returned unchanged. + * + * @example + * expandEnvVar('${MY_VAR}') // Returns process.env.MY_VAR or '${MY_VAR}' if undefined + * expandEnvVar('literal') // Returns 'literal' + * expandEnvVar(123) // Returns 123 + * expandEnvVar(true) // Returns true + */ +export function expandEnvVar(value: any): any { + if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) { + // Extract environment variable name + const envVar = value.slice(2, -1); + const envValue = process.env[envVar]; + // Only use env value if it exists (even if empty string) + return envValue !== undefined ? envValue : value; + } + return value; +} + /** * Build configuration for a specific provider */ @@ -21,13 +44,7 @@ export function buildProviderConfig( // Expand environment variables in config values const expandedOptions: Record = {}; for (const [key, value] of Object.entries(options)) { - if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) { - // Extract environment variable name - const envVar = value.slice(2, -1); - expandedOptions[key] = process.env[envVar] || value; - } else { - expandedOptions[key] = value; - } + expandedOptions[key] = expandEnvVar(value); } // Check for known provider presets @@ -99,10 +116,10 @@ export function buildProviderConfig( export function extractPluginDefaults(options: OAuthPluginConfig): Partial { const pluginDefaults: Partial = {}; - // Copy all non-provider config to defaults + // Copy all non-provider config to defaults, expanding environment variables for (const [key, value] of Object.entries(options)) { if (key !== 'providers' && key !== 'debug') { - pluginDefaults[key as keyof OAuthProviderConfig] = value as any; + pluginDefaults[key as keyof OAuthProviderConfig] = expandEnvVar(value); } } diff --git a/test/lib/config.test.js b/test/lib/config.test.js index 1c1fea2..19844ec 100644 --- a/test/lib/config.test.js +++ b/test/lib/config.test.js @@ -4,7 +4,12 @@ import { describe, it, before, after, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; -import { buildProviderConfig, extractPluginDefaults, initializeProviders } from '../../dist/lib/config.js'; +import { + buildProviderConfig, + extractPluginDefaults, + initializeProviders, + expandEnvVar, +} from '../../dist/lib/config.js'; describe('OAuth Configuration', () => { let originalEnv; @@ -23,7 +28,7 @@ describe('OAuth Configuration', () => { beforeEach(() => { // Reset environment for each test Object.keys(process.env).forEach((key) => { - if (key.startsWith('OAUTH_')) { + if (key.startsWith('OAUTH_') || key.startsWith('TEST_')) { delete process.env[key]; } }); @@ -36,6 +41,46 @@ describe('OAuth Configuration', () => { }; }); + describe('expandEnvVar', () => { + it('should expand environment variable references', () => { + process.env.TEST_VAR = 'test-value'; + const result = expandEnvVar('${TEST_VAR}'); + assert.equal(result, 'test-value'); + }); + + it('should return original value when env var does not exist', () => { + const result = expandEnvVar('${NONEXISTENT_VAR}'); + assert.equal(result, '${NONEXISTENT_VAR}'); + }); + + it('should return non-string values unchanged', () => { + assert.equal(expandEnvVar(123), 123); + assert.equal(expandEnvVar(true), true); + assert.equal(expandEnvVar(null), null); + assert.deepEqual(expandEnvVar({ key: 'value' }), { key: 'value' }); + }); + + it('should return literal strings unchanged', () => { + const result = expandEnvVar('literal-string'); + assert.equal(result, 'literal-string'); + }); + + it('should not expand partial matches', () => { + const result1 = expandEnvVar('${MISSING_CLOSE'); + const result2 = expandEnvVar('MISSING_OPEN}'); + const result3 = expandEnvVar('text ${VAR} text'); + assert.equal(result1, '${MISSING_CLOSE'); + assert.equal(result2, 'MISSING_OPEN}'); + assert.equal(result3, 'text ${VAR} text'); + }); + + it('should handle empty environment variable values', () => { + process.env.EMPTY_VAR = ''; + const result = expandEnvVar('${EMPTY_VAR}'); + assert.equal(result, ''); + }); + }); + describe('buildProviderConfig', () => { it('should build basic provider config', () => { const providerConfig = { @@ -318,6 +363,62 @@ describe('OAuth Configuration', () => { assert.equal(defaults.providers, undefined); assert.equal(defaults.debug, undefined); }); + + it('should expand environment variables in plugin defaults', () => { + process.env.TEST_REDIRECT_URI = 'https://example.com/oauth/callback'; + process.env.TEST_DEFAULT_ROLE = 'admin'; + + const options = { + redirectUri: '${TEST_REDIRECT_URI}', + defaultRole: '${TEST_DEFAULT_ROLE}', + scope: 'openid profile', + providers: { github: {} }, + }; + + const defaults = extractPluginDefaults(options); + + assert.equal(defaults.redirectUri, 'https://example.com/oauth/callback'); + assert.equal(defaults.defaultRole, 'admin'); + assert.equal(defaults.scope, 'openid profile'); + }); + + it('should preserve literal values when not env vars', () => { + const options = { + redirectUri: 'https://literal.com/oauth', + scope: 'openid profile email', + }; + + const defaults = extractPluginDefaults(options); + + assert.equal(defaults.redirectUri, 'https://literal.com/oauth'); + assert.equal(defaults.scope, 'openid profile email'); + }); + + it('should handle missing environment variables in defaults', () => { + const options = { + redirectUri: '${NONEXISTENT_REDIRECT_URI}', + postLoginRedirect: '/home', + }; + + const defaults = extractPluginDefaults(options); + + // Should preserve the original value when env var doesn't exist + assert.equal(defaults.redirectUri, '${NONEXISTENT_REDIRECT_URI}'); + assert.equal(defaults.postLoginRedirect, '/home'); + }); + + it('should handle non-string values in defaults', () => { + const options = { + redirectUri: 'https://example.com/oauth', + timeout: 5000, + enabled: true, + }; + + const defaults = extractPluginDefaults(options); + + assert.equal(defaults.timeout, 5000); + assert.equal(defaults.enabled, true); + }); }); describe('initializeProviders', () => { @@ -453,5 +554,32 @@ describe('OAuth Configuration', () => { const providers = initializeProviders(options, mockLogger); assert.ok(providers.bad || !providers.bad); // Either initialized or skipped }); + + it('should expand environment variables in plugin-level redirectUri', () => { + process.env.TEST_OAUTH_REDIRECT = 'https://test.com/oauth'; + process.env.TEST_GOOGLE_CLIENT_ID = 'google-client-123'; + process.env.TEST_GOOGLE_SECRET = 'google-secret-456'; + + const options = { + redirectUri: '${TEST_OAUTH_REDIRECT}', + defaultRole: 'user', + providers: { + google: { + provider: 'google', + clientId: '${TEST_GOOGLE_CLIENT_ID}', + clientSecret: '${TEST_GOOGLE_SECRET}', + }, + }, + }; + + const providers = initializeProviders(options, mockLogger); + + assert.ok(providers.google); + assert.equal(providers.google.config.clientId, 'google-client-123'); + assert.equal(providers.google.config.clientSecret, 'google-secret-456'); + // The redirectUri should use the expanded value from plugin defaults + assert.ok(providers.google.config.redirectUri.startsWith('https://test.com/oauth')); + assert.ok(providers.google.config.redirectUri.includes('google')); + }); }); });