Skip to content

Commit 6789ad9

Browse files
committed
security: Move OAuth token exchange to backend to hide client secrets
1 parent fe1537c commit 6789ad9

File tree

3 files changed

+127
-35
lines changed

3 files changed

+127
-35
lines changed

server/src/index.ts

Lines changed: 101 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,99 @@ app.get("/api/health", (_req: Request, res: Response) => {
5858
res.json({ status: "ok", uptime: process.uptime() });
5959
});
6060

61+
// OAuth token exchange endpoint - proxies token requests to hide client secrets from browser
62+
app.post("/api/auth/exchange-token", express.json(), async (req: Request, res: Response) => {
63+
try {
64+
const { code, codeVerifier, environment } = req.body;
65+
66+
// Validate required parameters
67+
if (!code || !codeVerifier || !environment) {
68+
return res.status(400).json({
69+
error: 'missing_parameters',
70+
message: 'code, codeVerifier, and environment are required'
71+
});
72+
}
73+
74+
// Validate environment
75+
if (environment !== 'dev' && environment !== 'prod') {
76+
return res.status(400).json({
77+
error: 'invalid_environment',
78+
message: 'environment must be "dev" or "prod"'
79+
});
80+
}
81+
82+
// Get environment-specific configuration from server environment variables
83+
let loginBaseUrl: string;
84+
let clientId: string;
85+
let clientSecret: string;
86+
let redirectUri: string;
87+
88+
if (environment === 'dev') {
89+
loginBaseUrl = process.env.VITE_DEV_LOGIN_BASE_URL || '';
90+
clientId = process.env.VITE_DEV_OAUTH_WEB_CLIENT_ID || '';
91+
clientSecret = process.env.VITE_DEV_OAUTH_WEB_CLIENT_SECRET || '';
92+
} else {
93+
loginBaseUrl = process.env.VITE_PROD_LOGIN_BASE_URL || '';
94+
clientId = process.env.VITE_PROD_OAUTH_WEB_CLIENT_ID || '';
95+
clientSecret = process.env.VITE_PROD_OAUTH_WEB_CLIENT_SECRET || '';
96+
}
97+
98+
redirectUri = process.env.VITE_OAUTH_REDIRECT_URI ||
99+
`${req.protocol}://${req.get('host')}/account/callback`;
100+
101+
// Validate configuration
102+
if (!loginBaseUrl || !clientId || !clientSecret) {
103+
console.error(`❌ Missing OAuth configuration for environment: ${environment}`);
104+
return res.status(500).json({
105+
error: 'server_configuration_error',
106+
message: 'OAuth credentials not configured on server'
107+
});
108+
}
109+
110+
console.log(`🔑 [token-exchange] Exchanging token for environment: ${environment}`);
111+
console.log(`🔑 [token-exchange] OAuth server: ${loginBaseUrl}`);
112+
113+
// Build token exchange request
114+
const tokenUrl = `${loginBaseUrl}/connect/token`;
115+
const body = new URLSearchParams({
116+
grant_type: 'authorization_code',
117+
code: code,
118+
redirect_uri: redirectUri,
119+
code_verifier: codeVerifier,
120+
client_id: clientId,
121+
client_secret: clientSecret
122+
});
123+
124+
// Exchange code for token with OAuth server
125+
const response = await fetch(tokenUrl, {
126+
method: 'POST',
127+
headers: {
128+
'Content-Type': 'application/x-www-form-urlencoded',
129+
},
130+
body: body.toString()
131+
});
132+
133+
const data = await response.json();
134+
135+
if (!response.ok) {
136+
console.error(`❌ [token-exchange] OAuth server error:`, data);
137+
return res.status(response.status).json(data);
138+
}
139+
140+
console.log(`✅ [token-exchange] Token exchange successful for environment: ${environment}`);
141+
142+
// Return tokens to frontend
143+
res.json(data);
144+
145+
} catch (error) {
146+
console.error('❌ [token-exchange] Unexpected error:', error);
147+
res.status(500).json({
148+
error: 'server_error',
149+
message: 'An unexpected error occurred during token exchange'
150+
});
151+
}
152+
});
153+
61154

62155
// Register webhook-related routes (event store, SSE, diagnostics)
63156
registerWebhookRoutes(app);
@@ -110,26 +203,27 @@ if (fs.existsSync(staticPath)) {
110203
}
111204

112205
// Build runtime config from VITE_ env vars (fall back to empty strings)
206+
// Note: Client secrets are intentionally hidden - token exchange happens on backend
113207
const runtime = {
114208
// Legacy environment variables (default/current environment)
115209
VITE_BACKEND_BASE_URL: process.env.VITE_BACKEND_BASE_URL || '',
116210
VITE_LOGIN_BASE_URL: process.env.VITE_LOGIN_BASE_URL || '',
117211
VITE_NODE_ENV: process.env.VITE_NODE_ENV || 'production',
118212
VITE_OAUTH_WEB_CLIENT_ID: process.env.VITE_OAUTH_WEB_CLIENT_ID || '',
119-
VITE_OAUTH_WEB_CLIENT_SECRET: process.env.VITE_OAUTH_WEB_CLIENT_SECRET || '',
213+
VITE_OAUTH_WEB_CLIENT_SECRET: '', // Hidden - not needed in browser
120214
VITE_OAUTH_REDIRECT_URI: process.env.VITE_OAUTH_REDIRECT_URI || '',
121215

122216
// Development environment variables
123217
VITE_DEV_BACKEND_BASE_URL: process.env.VITE_DEV_BACKEND_BASE_URL || '',
124218
VITE_DEV_LOGIN_BASE_URL: process.env.VITE_DEV_LOGIN_BASE_URL || '',
125219
VITE_DEV_OAUTH_WEB_CLIENT_ID: process.env.VITE_DEV_OAUTH_WEB_CLIENT_ID || '',
126-
VITE_DEV_OAUTH_WEB_CLIENT_SECRET: process.env.VITE_DEV_OAUTH_WEB_CLIENT_SECRET || '',
220+
VITE_DEV_OAUTH_WEB_CLIENT_SECRET: '', // Hidden - not needed in browser
127221

128222
// Production environment variables
129223
VITE_PROD_BACKEND_BASE_URL: process.env.VITE_PROD_BACKEND_BASE_URL || '',
130224
VITE_PROD_LOGIN_BASE_URL: process.env.VITE_PROD_LOGIN_BASE_URL || '',
131225
VITE_PROD_OAUTH_WEB_CLIENT_ID: process.env.VITE_PROD_OAUTH_WEB_CLIENT_ID || '',
132-
VITE_PROD_OAUTH_WEB_CLIENT_SECRET: process.env.VITE_PROD_OAUTH_WEB_CLIENT_SECRET || '',
226+
VITE_PROD_OAUTH_WEB_CLIENT_SECRET: '', // Hidden - not needed in browser
133227

134228
_generated: new Date().toISOString(),
135229
} as Record<string, any>;
@@ -198,26 +292,27 @@ if (fs.existsSync(staticPath)) {
198292
// Provide the same runtime-config.js fallback as we do when static files exist
199293
app.get('/runtime-config.js', (_req: Request, res: Response) => {
200294
res.type('application/javascript');
295+
// Note: Client secrets are intentionally hidden - token exchange happens on backend
201296
const runtime = {
202297
// Legacy environment variables (default/current environment)
203298
VITE_BACKEND_BASE_URL: process.env.VITE_BACKEND_BASE_URL || '',
204299
VITE_LOGIN_BASE_URL: process.env.VITE_LOGIN_BASE_URL || '',
205300
VITE_NODE_ENV: process.env.VITE_NODE_ENV || 'production',
206301
VITE_OAUTH_WEB_CLIENT_ID: process.env.VITE_OAUTH_WEB_CLIENT_ID || '',
207-
VITE_OAUTH_WEB_CLIENT_SECRET: process.env.VITE_OAUTH_WEB_CLIENT_SECRET || '',
302+
VITE_OAUTH_WEB_CLIENT_SECRET: '', // Hidden - not needed in browser
208303
VITE_OAUTH_REDIRECT_URI: process.env.VITE_OAUTH_REDIRECT_URI || '',
209304

210305
// Development environment variables
211306
VITE_DEV_BACKEND_BASE_URL: process.env.VITE_DEV_BACKEND_BASE_URL || '',
212307
VITE_DEV_LOGIN_BASE_URL: process.env.VITE_DEV_LOGIN_BASE_URL || '',
213308
VITE_DEV_OAUTH_WEB_CLIENT_ID: process.env.VITE_DEV_OAUTH_WEB_CLIENT_ID || '',
214-
VITE_DEV_OAUTH_WEB_CLIENT_SECRET: process.env.VITE_DEV_OAUTH_WEB_CLIENT_SECRET || '',
309+
VITE_DEV_OAUTH_WEB_CLIENT_SECRET: '', // Hidden - not needed in browser
215310

216311
// Production environment variables
217312
VITE_PROD_BACKEND_BASE_URL: process.env.VITE_PROD_BACKEND_BASE_URL || '',
218313
VITE_PROD_LOGIN_BASE_URL: process.env.VITE_PROD_LOGIN_BASE_URL || '',
219314
VITE_PROD_OAUTH_WEB_CLIENT_ID: process.env.VITE_PROD_OAUTH_WEB_CLIENT_ID || '',
220-
VITE_PROD_OAUTH_WEB_CLIENT_SECRET: process.env.VITE_PROD_OAUTH_WEB_CLIENT_SECRET || '',
315+
VITE_PROD_OAUTH_WEB_CLIENT_SECRET: '', // Hidden - not needed in browser
221316

222317
_generated: new Date().toISOString(),
223318
} as Record<string, any>;

src/lib/auth-service.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getCurrentEnvironmentConfig } from './environment-switcher';
1+
import { getCurrentEnvironmentConfig, getActiveEnvironment } from './environment-switcher';
22

33
interface TokenResponse {
44
access_token: string;
@@ -306,12 +306,14 @@ class AuthService {
306306
try {
307307
// Get current environment configuration for token exchange
308308
const envConfig = getCurrentEnvironmentConfig();
309+
const activeEnvironment = getActiveEnvironment();
309310
console.log('🔑 [auth-service] Using environment config for token exchange:', {
311+
environment: activeEnvironment,
310312
loginBaseUrl: envConfig.loginBaseUrl,
311313
clientId: envConfig.oauthClientId
312314
});
313315

314-
// Exchange code for tokens
316+
// Exchange code for tokens via backend (keeps secrets secure)
315317
const tokenResponse = await exchangeCodeForToken(
316318
{
317319
clientId: envConfig.oauthClientId,
@@ -321,7 +323,8 @@ class AuthService {
321323
scopes: OAUTH_CONFIG.scopes
322324
},
323325
code,
324-
codeVerifier
326+
codeVerifier,
327+
activeEnvironment // Pass environment to backend
325328
);
326329

327330
// Convert to internal format

src/lib/oauth2-utils.ts

Lines changed: 20 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -104,53 +104,45 @@ export function parseAuthorizationCallback(url: string): {
104104
}
105105

106106
/**
107-
* Exchange authorization code for access token
107+
* Exchange authorization code for access token via backend proxy
108+
* This keeps client secrets on the server and never exposes them to the browser
108109
*/
109110
export async function exchangeCodeForToken(
110111
config: OAuth2Config,
111112
code: string,
112-
codeVerifier: string
113+
codeVerifier: string,
114+
environment: 'dev' | 'prod'
113115
): Promise<{
114116
access_token: string;
115117
token_type: string;
116118
expires_in: number;
117119
refresh_token?: string;
118120
scope?: string;
119121
}> {
120-
const tokenUrl = `${config.loginBaseUrl}/connect/token`;
122+
// Call our backend endpoint instead of OAuth server directly
123+
// This keeps client secrets secure on the server
124+
const backendUrl = `${window.location.origin}/api/auth/exchange-token`;
121125

122-
const body = new URLSearchParams({
123-
grant_type: 'authorization_code',
124-
code: code,
125-
redirect_uri: config.redirectUri,
126-
code_verifier: codeVerifier,
127-
});
128-
129-
// Always include client_id in body
130-
body.append('client_id', config.clientId);
131-
132-
// For confidential clients, include client_secret in body (like portal does)
133-
if (config.clientSecret) {
134-
body.append('client_secret', config.clientSecret);
135-
}
136-
137-
const headers: Record<string, string> = {
138-
'Content-Type': 'application/x-www-form-urlencoded',
139-
};
126+
console.log(`🔑 [oauth2-utils] Calling backend token exchange for environment: ${environment}`);
140127

141128
let response: Response;
142129

143130
try {
144-
response = await fetch(tokenUrl, {
131+
response = await fetch(backendUrl, {
145132
method: 'POST',
146-
headers,
147-
body: body.toString(),
133+
headers: {
134+
'Content-Type': 'application/json',
135+
},
136+
body: JSON.stringify({
137+
code,
138+
codeVerifier,
139+
environment
140+
})
148141
});
149142
} catch (error) {
150143
console.error('❌ Network error during token exchange:');
151144
console.error(' Error:', error);
152-
console.error(' Token URL:', tokenUrl);
153-
console.error(' This is likely a CORS issue or network connectivity problem');
145+
console.error(' Backend URL:', backendUrl);
154146
throw new Error(`Failed to fetch token: ${error instanceof Error ? error.message : 'Network error'}`);
155147
}
156148

@@ -164,5 +156,7 @@ export async function exchangeCodeForToken(
164156

165157
const tokenData = await response.json();
166158

159+
console.log(`✅ [oauth2-utils] Token exchange successful for environment: ${environment}`);
160+
167161
return tokenData;
168162
}

0 commit comments

Comments
 (0)