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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,11 @@ REFRESH_TOKEN_EXPIRY=(1000 * 60 * 60 * 24) * 7
JWT_SECRET=
JWT_REFRESH_SECRET=

# Optional: read the session JWT from this header before falling back to Authorization.
# Only needed behind a proxy that overwrites Authorization with its own token
# (e.g. oauth2-proxy with --set-authorization-header). Leave unset otherwise.
# JWT_AUTH_HEADER=x-original-authorization
Comment on lines +961 to +968

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Document the required raw-token header format

When a proxy copies LibreChat's original Authorization value verbatim into the suggested x-original-authorization header, the value is Bearer <jwt> because LibreChat emits session credentials in that form (for example, packages/data-provider/src/headers-helpers.ts:11). ExtractJwt.fromHeader does not remove the scheme, so authentication rejects the copied value, but this operator-facing documentation never says that the proxy must strip Bearer . State that requirement here or make the alternate extractor accept both forms.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Resolved by accepting both forms: the extractor strips an optional Bearer scheme (case-insensitive) before returning the token, so a proxy can copy Authorization verbatim. .env.example and librechat.example.yaml document it, and jwt.spec.ts covers raw, Bearer and bearer values (898cdeb).


# Discord
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
Expand Down
17 changes: 16 additions & 1 deletion api/strategies/jwtStrategy.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,26 @@ const { SystemRoles } = require('librechat-data-provider');
const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt');
const { getUserById, updateUser } = require('~/models');

/**
* A proxy in front of LibreChat may write the Authorization header itself (oauth2-proxy
* with --set-authorization-header, meshes and gateways doing credential injection), in
* which case the session token never arrives. JWT_AUTH_HEADER names a header to try
* first, holding the raw token with no scheme prefix. Unset, nothing changes.
*/
const jwtExtractor = () => {
const extractors = [];
if (process.env.JWT_AUTH_HEADER) {
extractors.push(ExtractJwt.fromHeader(process.env.JWT_AUTH_HEADER));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Normalize the configured header name before extraction

When an operator configures a conventionally capitalized name such as X-Original-Authorization, Node exposes the incoming key in req.headers as lowercase, while passport-jwt's fromHeader looks up the supplied name directly. Consequently, the custom token is never found and authentication still falls back to the proxy-overwritten Authorization header; lowercase JWT_AUTH_HEADER before constructing the extractor and cover mixed-case configuration in the test.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch. fromHeader looks the key up as-is and node lowercases incoming headers, so a capitalised JWT_AUTH_HEADER would have silently fallen through to Authorization β€” the exact failure the option exists to prevent. Now trimmed and lowercased, with tests for mixed case and for a whitespace-only value.

}
extractors.push(ExtractJwt.fromAuthHeaderAsBearerToken());
return extractors.length > 1 ? ExtractJwt.fromExtractors(extractors) : extractors[0];
};

// JWT strategy
const jwtLogin = () =>
new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
jwtFromRequest: jwtExtractor(),
secretOrKey: process.env.JWT_SECRET,
},
async (payload, done) => {
Expand Down
37 changes: 37 additions & 0 deletions api/strategies/jwtStrategy.spec.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
const { SystemRoles } = require('librechat-data-provider');

let capturedVerifyCallback;
let capturedStrategyOptions;
jest.mock('passport-jwt', () => ({
Strategy: jest.fn((opts, verifyCallback) => {
capturedStrategyOptions = opts;
capturedVerifyCallback = verifyCallback;
return { name: 'jwt' };
}),
ExtractJwt: {
fromAuthHeaderAsBearerToken: jest.fn(() => 'mock-extractor'),
fromHeader: jest.fn((name) => `mock-header-extractor:${name}`),
fromExtractors: jest.fn((extractors) => extractors),
},
}));

Expand Down Expand Up @@ -73,3 +77,36 @@ describe('jwtStrategy', () => {
expect(user).toBe(false);
});
});

describe('jwtStrategy token extraction', () => {
const originalHeader = process.env.JWT_AUTH_HEADER;

afterEach(() => {
if (originalHeader === undefined) {
delete process.env.JWT_AUTH_HEADER;
} else {
process.env.JWT_AUTH_HEADER = originalHeader;
}
});

it('reads the Authorization header when JWT_AUTH_HEADER is unset', () => {
delete process.env.JWT_AUTH_HEADER;
jwtLogin();
expect(capturedStrategyOptions.jwtFromRequest).toBe('mock-extractor');
});

it('ignores an empty JWT_AUTH_HEADER', () => {
process.env.JWT_AUTH_HEADER = '';
jwtLogin();
expect(capturedStrategyOptions.jwtFromRequest).toBe('mock-extractor');
});

it('prefers JWT_AUTH_HEADER and keeps Authorization as the fallback', () => {
process.env.JWT_AUTH_HEADER = 'x-original-authorization';
jwtLogin();
expect(capturedStrategyOptions.jwtFromRequest).toEqual([
'mock-header-extractor:x-original-authorization',
'mock-extractor',
]);
});
});