Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
99 changes: 99 additions & 0 deletions src/server/auth.oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,105 @@ describe('oauthExchangeFn', () => {
);
});

it('derives the exchange Origin from the serving host when the callback carries the IdP referer', async () => {
sessionState.data = { codeVerifier: 'verifier-123' };
requestHeaders.set('referer', 'https://login.microsoftonline.com/');
requestHeaders.set('host', 'example.com');
requestHeaders.set('x-forwarded-proto', 'https');
fetchMock.mockResolvedValueOnce(
jsonResponse(200, {
token: 'jwt-token',
user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' },
}),
);

const result = await oauthExchangeFn({ data: { code: 'c'.repeat(64) } });

expect(result).toEqual({
error: false,
user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' },
});
expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: 'https://example.com',
},
body: JSON.stringify({ code: 'c'.repeat(64), code_verifier: 'verifier-123' }),
});
});

it('uses the first x-forwarded-proto value when the proxy chain appends multiple', async () => {
sessionState.data = { codeVerifier: 'verifier-123' };
requestHeaders.set('host', 'example.com');
requestHeaders.set('x-forwarded-proto', 'https, http');
fetchMock.mockResolvedValueOnce(
jsonResponse(200, {
token: 'jwt-token',
user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' },
}),
);

await oauthExchangeFn({ data: { code: 'd'.repeat(64) } });

expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: 'https://example.com',
},
body: JSON.stringify({ code: 'd'.repeat(64), code_verifier: 'verifier-123' }),
});
});

it('prefers x-forwarded-host over a proxy-rewritten upstream host', async () => {
sessionState.data = { codeVerifier: 'verifier-123' };
requestHeaders.set('host', 'admin-panel:3000');
requestHeaders.set('x-forwarded-host', 'example.com');
requestHeaders.set('x-forwarded-proto', 'https');
fetchMock.mockResolvedValueOnce(
jsonResponse(200, {
token: 'jwt-token',
user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' },
}),
);

await oauthExchangeFn({ data: { code: 'e'.repeat(64) } });

expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: 'https://example.com',
},
body: JSON.stringify({ code: 'e'.repeat(64), code_verifier: 'verifier-123' }),
});
});

it('uses the first x-forwarded-host value when the proxy chain appends multiple', async () => {
sessionState.data = { codeVerifier: 'verifier-123' };
requestHeaders.set('host', 'admin-panel:3000');
requestHeaders.set('x-forwarded-host', 'example.com, internal-lb.local');
requestHeaders.set('x-forwarded-proto', 'https');
fetchMock.mockResolvedValueOnce(
jsonResponse(200, {
token: 'jwt-token',
user: { id: 'user-1', role: 'ADMIN', email: 'admin@example.com' },
}),
);

await oauthExchangeFn({ data: { code: 'f'.repeat(64) } });

expect(fetchMock).toHaveBeenCalledWith('http://librechat.test/api/admin/oauth/exchange', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: 'https://example.com',
},
body: JSON.stringify({ code: 'f'.repeat(64), code_verifier: 'verifier-123' }),
});
});

it('does not consume the one-time LibreChat exchange code when the PKCE verifier was lost', async () => {
sessionState.data = {};

Expand Down
27 changes: 16 additions & 11 deletions src/server/auth.ts
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -21,23 +21,28 @@ function extractCookieValue(response: Response, name: string): string | undefine
return undefined;
}

/**
* Resolves the admin panel's own origin for LibreChat's exchange-code origin binding.
*
* Never derived from the `referer` header: when a proxy or client drops the `Origin`
* header, the referer identifies whatever page or IdP initiated the request (e.g.
* Azure EntraID's login.microsoftonline.com on an IdP-initiated callback), not the
* panel itself. Forwarding a foreign origin makes LibreChat reject the exchange code
* as expired even though authentication succeeded. The panel's browser-visible origin
* is derived from forwarding metadata instead: the first `x-forwarded-host` value wins
* over `host` because Host-rewriting proxies replace `host` with the internal upstream
* authority, then the first `x-forwarded-proto` value supplies the scheme.
*/
function getRequestOrigin(): string | undefined {
const origin = getRequestHeader('origin');
if (origin) return origin;

const referer = getRequestHeader('referer');
if (referer) {
try {
return new URL(referer).origin;
} catch {
return undefined;
}
}

const host = getRequestHeader('host');
const forwardedHost = getRequestHeader('x-forwarded-host');
const host = forwardedHost?.split(',')[0]?.trim() || getRequestHeader('host');
if (!host) return undefined;

const proto = getRequestHeader('x-forwarded-proto') ?? 'http';
const forwardedProto = getRequestHeader('x-forwarded-proto');
const proto = forwardedProto?.split(',')[0]?.trim() || 'http';
Comment thread
dustinhealy marked this conversation as resolved.
Outdated
return `${proto}://${host}`;
}

Expand Down