Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
51 changes: 51 additions & 0 deletions src/server/auth.oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,57 @@ 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('does not consume the one-time LibreChat exchange code when the PKCE verifier was lost', async () => {
sessionState.data = {};

Expand Down
22 changes: 12 additions & 10 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,25 @@ 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 own serving origin is
* always derivable from `host` plus the first `x-forwarded-proto` value.
*/
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');
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