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
72 changes: 35 additions & 37 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,23 @@ export async function handleApplication(scope: Scope): Promise<void> {
async function updateConfiguration() {
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;
// Expand environment variables in plugin-level options (lazily)
const options: OAuthPluginConfig = { ...rawOptions };

// Create lazy getters for all string values that look like env vars
for (const [key, value] of Object.entries(rawOptions)) {
if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) {
const envVar = value.slice(2, -1);
Comment on lines +89 to +91

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You no like regexes?

Suggested change
for (const [key, value] of Object.entries(rawOptions)) {
if (typeof value === 'string' && value.startsWith('${') && value.endsWith('}')) {
const envVar = value.slice(2, -1);
const re = /^\${(.+)}$/;
for (const [key, value] of Object.entries(rawOptions)) {
const m = typeof value === 'string' && value.match(re);
if (m) {
const envVar = m[1];

J/K, yours is more readable, though I do wonder which is faster...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could be an interesting test...might try something later

Object.defineProperty(options, key, {
get() {
const envValue = process.env[envVar];
return envValue !== undefined && envValue !== '' ? envValue : value;
},
enumerable: true,
configurable: true,
});
}
}

const options = { ...rawOptions, debug: debugValue };
const previousDebugMode = debugMode;
// Handle both boolean and string values (from environment variables)
debugMode = options.debug === true || options.debug === 'true';
Expand All @@ -98,43 +107,22 @@ export async function handleApplication(scope: Scope): Promise<void> {
if (isInitialized) {
logger?.info?.('OAuth configuration changed, updating providers...');
} else {
logger?.info?.('OAuth plugin loading with options:', JSON.stringify(options, null, 2));
// Log raw options (don't stringify to avoid triggering lazy getters prematurely)
logger?.info?.('OAuth plugin loading...');
isInitialized = true;
}

// Re-initialize providers from new configuration
providers = initializeProviders(options, logger);

// Update the resource with new providers
// Always register the OAuth resource with lazy initialization support
// Even if no providers are initially configured, they may become available later
scope.resources.set('oauth', createOAuthResource(providers, options, debugMode, hookManager, logger));

// Log configured providers
if (Object.keys(providers).length === 0) {
// No valid providers configured
scope.resources.set('oauth', {
async get() {
return {
status: 503,
body: {
error: 'No valid OAuth providers configured',
message: 'Please check your OAuth configuration',
example: options.providers
? 'Check that all required fields are provided'
: {
providers: {
github: {
provider: 'github',
clientId: '${OAUTH_GITHUB_CLIENT_ID}',
clientSecret: '${OAUTH_GITHUB_CLIENT_SECRET}',
},
},
},
},
};
},
});
logger?.warn?.('No OAuth providers configured yet - will attempt lazy initialization on first request');
} else {
// Register the OAuth resource with configured providers
scope.resources.set('oauth', createOAuthResource(providers, debugMode, hookManager, logger));

// Log all configured providers
logger?.info?.('OAuth plugin ready:', {
providers: Object.entries(providers).map(([name, data]) => ({
name,
Expand All @@ -160,7 +148,17 @@ export async function handleApplication(scope: Scope): Promise<void> {

// Get the provider for this OAuth session
const providerName = request.session.oauth.provider;
const providerData = providers[providerName];
let providerData = providers[providerName];

// Try lazy initialization if provider not found (handles race condition with env var loading)
if (!providerData) {
const rawOptions = (scope.options.getAll() || {}) as OAuthPluginConfig;
const { getOrInitializeProvider } = await import('./lib/config.ts');
const initialized = getOrInitializeProvider(providerName, providers, rawOptions, logger);
if (initialized) {
providerData = initialized;
}
}

if (!providerData) {
logger?.warn?.(`OAuth provider '${providerName}' not found, logging out user`);
Expand Down
98 changes: 88 additions & 10 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,18 @@

import { OAuthProvider } from './OAuthProvider.ts';
import { getProvider } from './providers/index.ts';
import type { OAuthProviderConfig, OAuthPluginConfig, ProviderRegistry, Logger } from '../types.ts';
import type {
OAuthProviderConfig,
OAuthPluginConfig,
ProviderRegistry,
ProviderRegistryEntry,
Logger,
} from '../types.ts';

/**
* Required fields for OAuth provider configuration
*/
const REQUIRED_PROVIDER_FIELDS = ['clientId', 'clientSecret', 'authorizationUrl', 'tokenUrl', 'userInfoUrl'] as const;

/**
* Build configuration for a specific provider
Expand All @@ -18,13 +29,24 @@ export function buildProviderConfig(
): OAuthProviderConfig {
const options = providerConfig || {};

// Expand environment variables in config values
// Create a proxy that lazily expands environment variables when accessed
// This solves race conditions where env vars are loaded after plugin initialization
const expandedOptions: Record<string, any> = {};
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;

// Use getter for lazy evaluation
Object.defineProperty(expandedOptions, key, {
get() {
const envValue = process.env[envVar];
// Only use the env value if it's defined and not empty
return envValue !== undefined && envValue !== '' ? envValue : value;
},
enumerable: true,
configurable: true,
});
} else {
expandedOptions[key] = value;
}
Expand Down Expand Up @@ -60,13 +82,18 @@ export function buildProviderConfig(

// Provider preset (if available)
...providerPreset,
};

// Provider-specific options (with expanded env vars)
...expandedOptions,
// Manually copy expandedOptions to preserve getters (spread operator would invoke them)
for (const key of Object.keys(expandedOptions)) {
const descriptor = Object.getOwnPropertyDescriptor(expandedOptions, key);
if (descriptor) {
Object.defineProperty(config, key, descriptor);
}
}

// Ensure redirect URI includes provider name (override any previous value)
redirectUri,
};
// Ensure redirect URI includes provider name (override any previous value)
config.redirectUri = redirectUri;

// Handle provider-specific configuration
if (providerPreset?.configure) {
Expand Down Expand Up @@ -129,8 +156,12 @@ export function initializeProviders(options: OAuthPluginConfig, logger?: Logger)
const config = buildProviderConfig(providerConfig, providerName, pluginDefaults);

// Check if this provider is properly configured
const requiredFields = ['clientId', 'clientSecret', 'authorizationUrl', 'tokenUrl', 'userInfoUrl'];
const missingFields = requiredFields.filter((key) => !config[key as keyof OAuthProviderConfig]);
// Note: This triggers lazy env var expansion via getters
const missingFields = REQUIRED_PROVIDER_FIELDS.filter((key) => {
const value = config[key as keyof OAuthProviderConfig];
// Consider env var placeholders as "missing" if they haven't been loaded yet
return !value || (typeof value === 'string' && value.startsWith('${') && value.endsWith('}'));

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!value will cause 0 and '' (empty strings) to be true. Maybe this should check if value !== undefined or maybe !Object.hasOwn(config, key)?

});

if (missingFields.length > 0) {
logger?.warn?.(`OAuth provider '${providerName}' not configured. Missing: ${missingFields.join(', ')}`);
Expand All @@ -148,3 +179,50 @@ export function initializeProviders(options: OAuthPluginConfig, logger?: Logger)

return providers;
}

/**
* Get or lazily initialize a single provider by name
* This handles race conditions where env vars are loaded after initial plugin initialization
*/
export function getOrInitializeProvider(
providerName: string,
providers: ProviderRegistry,
options: OAuthPluginConfig,
logger?: Logger
): ProviderRegistryEntry | null {
// Check if provider already exists
if (providers[providerName]) {
return providers[providerName];
}

// Provider not initialized yet - try to initialize it now
if (!options.providers || !options.providers[providerName]) {
logger?.debug?.(`OAuth provider '${providerName}' not defined in configuration`);
return null;
}

const pluginDefaults = extractPluginDefaults(options);
const config = buildProviderConfig(options.providers[providerName], providerName, pluginDefaults);

// Check if this provider is properly configured (with current env vars)
const missingFields = REQUIRED_PROVIDER_FIELDS.filter((key) => {
const value = config[key as keyof OAuthProviderConfig];
return !value || (typeof value === 'string' && value.startsWith('${') && value.endsWith('}'));
});
Comment on lines +208 to +211

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate code? Could this be handled by a function?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should totally be pulled out :) -> https://github.com/HarperFast/oauth/pull/11/files


if (missingFields.length > 0) {
logger?.debug?.(`OAuth provider '${providerName}' cannot be initialized yet. Missing: ${missingFields.join(', ')}`);
return null;
}

// Initialize the provider
try {
const provider = new OAuthProvider(config, logger);
providers[providerName] = { provider, config };
logger?.info?.(`OAuth provider '${providerName}' initialized on-demand (${config.provider})`);
return providers[providerName];
} catch (error) {
logger?.error?.(`Failed to initialize OAuth provider '${providerName}':`, error);
return null;
}
}
16 changes: 13 additions & 3 deletions src/lib/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
* Harper resource class for handling OAuth REST endpoints
*/

import type { Request, RequestTarget, Logger, ProviderRegistry } from '../types.ts';
import type { Request, RequestTarget, Logger, ProviderRegistry, OAuthPluginConfig } from '../types.ts';
import { handleLogin, handleCallback, handleLogout, handleUserInfo, handleTestPage } from './handlers.ts';
import { validateAndRefreshSession } from './sessionValidator.ts';
import { getOrInitializeProvider } from './config.ts';
import type { HookManager } from './hookManager.ts';

/**
Expand All @@ -15,6 +16,7 @@ import type { HookManager } from './hookManager.ts';
*/
export function createOAuthResource(
providers: ProviderRegistry,
options: OAuthPluginConfig,
debugMode: boolean,
hookManager: HookManager,
logger?: Logger
Expand Down Expand Up @@ -67,8 +69,16 @@ export function createOAuthResource(
};
}

// Check if provider exists
const providerData = providers[providerName];
// Check if provider exists, or try to initialize it lazily
let providerData = providers[providerName];
if (!providerData) {
// Try lazy initialization (handles race condition with env var loading)
const initialized = getOrInitializeProvider(providerName, providers, options, logger);
if (initialized) {
providerData = initialized;
}
}

if (!providerData) {
return {
status: 404,
Expand Down
Loading