From 6c94aa84038b8c8a7b6e88c69c34864234d215d7 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 28 Aug 2026 10:11:32 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=AA=AA=20fix:=20Reject=20ID=20Tokens=20as?= =?UTF-8?q?=20the=20OpenID=20Bearer=20Reuse=20Access=20Token=20(#15317)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The openidJwt reuse strategy substituted the raw incoming Authorization bearer whenever it found no stored access token in the session or cookies. Clearing this strategy's audience check does not make a token an access token: an OIDC ID token is minted for the client id and satisfies the very same check, so the fallback could store an ID token as federatedTokens.access_token. That value is used verbatim as the On-Behalf-Of assertion, and Entra rejects an ID token there with AADSTS240002. It is easy to reach whenever the OpenID session store is not persistent, since express-session falls back to MemoryStore and every restart wipes the stored access tokens for active sessions. Deleting the fallback outright would break the case it legitimately serves, so reuse is now gated on the bearer being identifiable as an access token. Detection rests on the two signals that separate the token types by specification rather than by provider convention: an RFC 9068 `at+jwt` header type, defined for access tokens alone, and an `aud` that omits the OIDC client id, which OIDC Core section 2 requires every ID token to carry. A scp/scope claim is only a supporting signal, applying once the audience has already ruled out an ID token, because providers add claims freely in both directions -- Keycloak has emitted nonce and auth_time in genuine access tokens and maps scope into its ID tokens. at_hash and c_hash veto regardless, since they exist only to bind an ID token to its companion access token or code. An unrecognised token is left unset rather than guessed at, so isOpenIDTokenValid fails closed and the OBO path raises its actionable error instead of attempting a Graph exchange with a token of the wrong type. Reuse now requires either an at+jwt provider or OPENID_AUDIENCE naming a resource distinct from the client id; a bearer audienced only to the client id cannot be told apart from an ID token and fails closed. That applies solely on the degraded path where nothing was stored to begin with. Separately, processSingleValue gated every credential placeholder on isOpenIDTokenValid, which reports on the access token alone, so leaving access_token unset made a header configured with only {{LIBRECHAT_OPENID_ID_TOKEN}} raise re-authentication despite a present, current ID token. Validity now depends on the credential actually requested: an access-token-specific pattern gates the raise, and the ID token resolves through processOpenIDPlaceholders, which already validates its own expiry. processOpenIDPlaceholders takes a fields allowlist so that path resolves the ID token alone and identity metadata keeps its literal-then-strip behaviour. --- api/strategies/openIdJwtStrategy.js | 42 ++++++++- api/strategies/openIdJwtStrategy.spec.js | 92 +++++++++++++++++++- packages/api/src/utils/env.spec.ts | 51 ++++++++++- packages/api/src/utils/env.ts | 17 +++- packages/api/src/utils/oidc.spec.ts | 85 ++++++++++++++++++ packages/api/src/utils/oidc.ts | 104 ++++++++++++++++++++++- 6 files changed, 380 insertions(+), 11 deletions(-) diff --git a/api/strategies/openIdJwtStrategy.js b/api/strategies/openIdJwtStrategy.js index 3aefa03cbb..7af8329e59 100644 --- a/api/strategies/openIdJwtStrategy.js +++ b/api/strategies/openIdJwtStrategy.js @@ -16,6 +16,7 @@ const { invalidateCachedAuthUserDoc, setCachedAuthUserDoc, getHttpsProxyAgent, + isAccessTokenJwt, math, } = require('@librechat/api'); const { updateUser, findUser, isAgentTriggerPrincipalActive } = require('~/models'); @@ -30,17 +31,28 @@ function decodeJwtExpiry(token) { } } -const getOpenIdJwtAudience = () => { - const parsedAudience = (process.env.OPENID_AUDIENCE ?? '') +const parseOpenIdAudiences = () => + (process.env.OPENID_AUDIENCE ?? '') .split(',') .map((value) => value.trim()) .filter(Boolean); - const audiences = [process.env.OPENID_CLIENT_ID, ...parsedAudience].filter(Boolean); + +const getOpenIdJwtAudience = () => { + const audiences = [process.env.OPENID_CLIENT_ID, ...parseOpenIdAudiences()].filter(Boolean); const uniqueAudiences = [...new Set(audiences)]; return uniqueAudiences.length > 1 ? uniqueAudiences : uniqueAudiences[0]; }; +/** The configured audiences a reused bearer is weighed against when deciding whether it is an access token */ +const getOpenIdAudienceConfig = () => { + const clientId = process.env.OPENID_CLIENT_ID; + return { + clientId, + resources: new Set(parseOpenIdAudiences().filter((audience) => audience !== clientId)), + }; +}; + const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const issuerMatchesTemplate = (expectedIssuer, actualIssuer) => { @@ -118,6 +130,8 @@ const openIdJwtLogin = (openIdConfig) => { jwksRsaOptions.requestAgent = requestAgent; } + const audienceConfig = getOpenIdAudienceConfig(); + return new JwtStrategy( { jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), @@ -233,7 +247,27 @@ const openIdJwtLogin = (openIdConfig) => { refreshToken = refreshToken || parsedCookies.refreshToken; } - const resolvedAccessToken = accessToken || rawToken; + /** + * The raw bearer only stands in for a missing stored access token when it is + * identifiable as one. It cleared this strategy's audience check, but an ID token + * clears the same check, and an ID token used as the OBO assertion is rejected by the + * IdP (Entra answers `AADSTS240002`). An unrecognised token is left unset so + * `isOpenIDTokenValid` fails closed with an actionable error instead. + */ + let reusableRawToken; + if (!accessToken) { + reusableRawToken = isAccessTokenJwt(rawToken, payload, audienceConfig) + ? rawToken + : undefined; + if (!reusableRawToken) { + /** Per-request on the reuse path, so the actionable warning is left to the consumer that actually needs the credential */ + logger.debug( + '[openIdJwtLogin] No stored OpenID access token, and the request bearer is not identifiable as one; leaving it unset', + ); + } + } + + const resolvedAccessToken = accessToken || reusableRawToken; user.federatedTokens = { access_token: resolvedAccessToken, id_token: idToken, diff --git a/api/strategies/openIdJwtStrategy.spec.js b/api/strategies/openIdJwtStrategy.spec.js index a9ade4b160..dd19c66a4e 100644 --- a/api/strategies/openIdJwtStrategy.spec.js +++ b/api/strategies/openIdJwtStrategy.spec.js @@ -45,6 +45,7 @@ jest.mock('@librechat/api', () => ({ invalidateCachedAuthUserDoc: jest.fn(), setCachedAuthUserDoc: jest.fn(), getHttpsProxyAgent: jest.fn(() => undefined), + isAccessTokenJwt: jest.requireActual('@librechat/api').isAccessTokenJwt, math: jest.fn((val, fallback) => fallback), })); jest.mock('~/models', () => ({ @@ -344,7 +345,13 @@ describe('openIdJwtStrategy – token source handling', () => { }); }); - it('should use raw Bearer token as access_token fallback when neither session nor cookie has one', async () => { + const encodeSegment = (value) => Buffer.from(JSON.stringify(value)).toString('base64'); + const makeJwt = (claims, header = { alg: 'RS256' }) => + `${encodeSegment(header)}.${encodeSegment(claims)}.signature`; + + const resourceEnv = { OPENID_CLIENT_ID: 'client-id', OPENID_AUDIENCE: 'api://resource-app' }; + + it('should decline the raw Bearer token when nothing identifies it as an access token', async () => { const req = { headers: { authorization: 'Bearer raw-bearer-token', @@ -354,12 +361,93 @@ describe('openIdJwtStrategy – token source handling', () => { const { user } = await invokeVerify(req, payload); - expect(user.federatedTokens.access_token).toBe('raw-bearer-token'); + expect(user.federatedTokens.access_token).toBeUndefined(); expect(user.federatedTokens.id_token).toBe('cookie-id'); expect(user.federatedTokens.refresh_token).toBe('cookie-refresh'); + expect(user.federatedTokens.expires_at).toBeUndefined(); + }); + + it('should decline an Entra-shaped ID token rather than reuse it as the OBO assertion', async () => { + withEnv(resourceEnv, () => openIdJwtLogin(mockOpenIdConfig)); + + const claims = { ...payload, aud: 'client-id', nonce: 'n-0S6_WzA2Mj', tid: 'tenant-1' }; + const req = { headers: { authorization: `Bearer ${makeJwt(claims)}` } }; + + const { user } = await invokeVerify(req, claims); + + expect(user.federatedTokens.access_token).toBeUndefined(); + }); + + it('should decline a multi-audience ID token that also names a configured resource', async () => { + withEnv(resourceEnv, () => openIdJwtLogin(mockOpenIdConfig)); + + const claims = { ...payload, aud: ['client-id', 'api://resource-app'], nonce: 'n-0S6' }; + const req = { headers: { authorization: `Bearer ${makeJwt(claims)}` } }; + + const { user } = await invokeVerify(req, claims); + + expect(user.federatedTokens.access_token).toBeUndefined(); + }); + + it('should decline an ID token carrying a provider-added scope claim', async () => { + withEnv(resourceEnv, () => openIdJwtLogin(mockOpenIdConfig)); + + const claims = { ...payload, aud: 'client-id', scope: 'openid email profile' }; + const req = { headers: { authorization: `Bearer ${makeJwt(claims)}` } }; + + const { user } = await invokeVerify(req, claims); + + expect(user.federatedTokens.access_token).toBeUndefined(); + }); + + it('should decline a raw Bearer token carrying the ID-token-only at_hash claim', async () => { + withEnv(resourceEnv, () => openIdJwtLogin(mockOpenIdConfig)); + + const claims = { ...payload, aud: 'api://resource-app', at_hash: 'HK6E_P6Dh8Y93mRN' }; + const req = { headers: { authorization: `Bearer ${makeJwt(claims)}` } }; + + const { user } = await invokeVerify(req, claims); + + expect(user.federatedTokens.access_token).toBeUndefined(); + }); + + it('should reuse a raw Bearer token whose audience names a configured resource', async () => { + withEnv(resourceEnv, () => openIdJwtLogin(mockOpenIdConfig)); + + const claims = { ...payload, aud: 'api://resource-app' }; + const rawToken = makeJwt(claims); + const req = { headers: { authorization: `Bearer ${rawToken}` } }; + + const { user } = await invokeVerify(req, claims); + + expect(user.federatedTokens.access_token).toBe(rawToken); expect(user.federatedTokens.expires_at).toBe(payload.exp); }); + it('should reuse a raw Bearer token declaring the RFC 9068 `at+jwt` header type', async () => { + withEnv(resourceEnv, () => openIdJwtLogin(mockOpenIdConfig)); + + const rawToken = makeJwt(payload, { alg: 'RS256', typ: 'at+JWT' }); + const req = { headers: { authorization: `Bearer ${rawToken}` } }; + + const { user } = await invokeVerify(req, payload); + + expect(user.federatedTokens.access_token).toBe(rawToken); + }); + + it('should decline a raw Bearer token whose only audience is the OIDC client id', async () => { + withEnv({ OPENID_CLIENT_ID: 'client-id', OPENID_AUDIENCE: 'client-id' }, () => { + openIdJwtLogin(mockOpenIdConfig); + }); + + const claims = { ...payload, aud: 'client-id', scp: 'User.Read' }; + const req = { headers: { authorization: `Bearer ${makeJwt(claims)}` } }; + + const { user } = await invokeVerify(req, claims); + + expect(user.federatedTokens.access_token).toBeUndefined(); + }); + it('should decode expires_at from a session access token that is itself a JWT', async () => { const sessionAccessExp = 1234567890; const sessionAccessToken = `header.${Buffer.from( diff --git a/packages/api/src/utils/env.spec.ts b/packages/api/src/utils/env.spec.ts index a34adb0fcb..f33f1645e4 100644 --- a/packages/api/src/utils/env.spec.ts +++ b/packages/api/src/utils/env.spec.ts @@ -2431,7 +2431,7 @@ describe('processMCPEnv OpenID re-authentication signalling', () => { } }); - it('should raise re-auth from resolveHeaders when the token set is expired', () => { + it('should raise re-auth from resolveHeaders when the ID token is expired', () => { expect(() => resolveHeaders({ headers: { Authorization: 'Bearer {{LIBRECHAT_OPENID_ID_TOKEN}}' }, @@ -2439,10 +2439,57 @@ describe('processMCPEnv OpenID re-authentication signalling', () => { stripUnresolved: true, }), ).toThrow( - 'OpenID token is expired or unavailable; re-authentication is required to resolve {{LIBRECHAT_OPENID_ID_TOKEN}}', + 'OpenID ID token is expired or unavailable; re-authentication is required to resolve {{LIBRECHAT_OPENID_ID_TOKEN}}', ); }); + /** The OpenID JWT strategy leaves `access_token` unset when it cannot identify the request bearer as one */ + function accessTokenlessOpenIDUser(idTokenExp: number): { user: IUser; idToken: string } { + const idToken = `header.${Buffer.from( + JSON.stringify({ sub: 'oidc-sub-456', exp: idTokenExp }), + ).toString('base64')}.signature`; + const user = { + ...createTestUser({ id: 'user-123', provider: 'openid' }), + openidId: 'oidc-sub-456', + federatedTokens: { id_token: idToken, refresh_token: 'stored-refresh-token' }, + } as IUser; + return { user, idToken }; + } + + it('should resolve an ID token placeholder while no access token is stored', () => { + const { user, idToken } = accessTokenlessOpenIDUser(validSeconds); + + const resolved = resolveHeaders({ + headers: { Authorization: 'Bearer {{LIBRECHAT_OPENID_ID_TOKEN}}' }, + user, + stripUnresolved: true, + }); + + expect(resolved.Authorization).toBe(`Bearer ${idToken}`); + }); + + it('should keep identity metadata literal while no access token is stored', () => { + const { user } = accessTokenlessOpenIDUser(validSeconds); + + const resolved = resolveHeaders({ + headers: { 'X-User-Id': '{{LIBRECHAT_OPENID_USER_ID}}' }, + user, + }); + + expect(resolved['X-User-Id']).toBe('{{LIBRECHAT_OPENID_USER_ID}}'); + }); + + it('should still raise re-auth for an access token placeholder while no access token is stored', () => { + const { user } = accessTokenlessOpenIDUser(validSeconds); + + expect(() => + resolveHeaders({ + headers: { Authorization: 'Bearer {{LIBRECHAT_OPENID_ACCESS_TOKEN}}' }, + user, + }), + ).toThrow('re-authentication is required to resolve {{LIBRECHAT_OPENID_ACCESS_TOKEN}}'); + }); + it('should leave identity metadata placeholders literal for an OpenID user with no stored tokens', () => { const options: MCPOptions = { type: 'streamable-http', diff --git a/packages/api/src/utils/env.ts b/packages/api/src/utils/env.ts index 39a044ed68..1966a1d984 100644 --- a/packages/api/src/utils/env.ts +++ b/packages/api/src/utils/env.ts @@ -177,6 +177,15 @@ const RESOLVABLE_PLACEHOLDER_PATTERN = new RegExp( const OPENID_CREDENTIAL_PLACEHOLDER_PATTERN = /\{\{LIBRECHAT_OPENID_(?:ACCESS_TOKEN|ID_TOKEN|TOKEN)\}\}/; +/** + * The credential placeholders that specifically need a usable *access* token, which is all + * `isOpenIDTokenValid` reports on. `ID_TOKEN` is absent because `processOpenIDPlaceholders` + * validates the ID token's own expiry, so an ID-token header still resolves while no access + * token is stored. Non-global so `exec` stays stateless. + */ +const OPENID_ACCESS_CREDENTIAL_PLACEHOLDER_PATTERN = + /\{\{LIBRECHAT_OPENID_(?:ACCESS_TOKEN|TOKEN)\}\}/; + /** * Replaces resolvable-but-unresolved placeholders with an empty string so * LibreChat's internal template syntax is never sent upstream as if it were @@ -336,7 +345,7 @@ function processSingleValue({ if (openidTokenInfo && isOpenIDTokenValid(openidTokenInfo)) { value = processOpenIDPlaceholders(value, openidTokenInfo); } else if (openidTokenInfo) { - const unresolvable = OPENID_CREDENTIAL_PLACEHOLDER_PATTERN.exec(value); + const unresolvable = OPENID_ACCESS_CREDENTIAL_PLACEHOLDER_PATTERN.exec(value); if (unresolvable) { logger.warn( `OpenID token is expired or unavailable; cannot resolve ${unresolvable[0]} for the current request`, @@ -345,6 +354,12 @@ function processSingleValue({ `OpenID token is expired or unavailable; re-authentication is required to resolve ${unresolvable[0]}`, ); } + /** + * `isOpenIDTokenValid` reports on the access token alone, so an ID token placeholder is not + * its to refuse: `processOpenIDPlaceholders` validates the ID token's own expiry and raises + * if it is stale. Every other placeholder keeps its literal-then-strip behaviour here. + */ + value = processOpenIDPlaceholders(value, openidTokenInfo, ['ID_TOKEN']); } if (body) { diff --git a/packages/api/src/utils/oidc.spec.ts b/packages/api/src/utils/oidc.spec.ts index 0cefdf6068..5091603f50 100644 --- a/packages/api/src/utils/oidc.spec.ts +++ b/packages/api/src/utils/oidc.spec.ts @@ -4,6 +4,7 @@ import { extractOpenIDTokenInfo, isOpenIDTokenValid, processOpenIDPlaceholders, + isAccessTokenJwt, } from './oidc'; describe('OpenID Token Utilities', () => { @@ -765,4 +766,88 @@ describe('OpenID Token Utilities', () => { expect(tokenInfo).toBeNull(); }); }); + + describe('isAccessTokenJwt', () => { + const encodeSegment = (value: object) => Buffer.from(JSON.stringify(value)).toString('base64'); + const makeJwt = (claims: object, header: object = { alg: 'RS256' }) => + `${encodeSegment(header)}.${encodeSegment(claims)}.signature`; + const audiences = { resources: new Set(['api://resource-app']), clientId: 'client-id' }; + + it('accepts an RFC 9068 `at+jwt` header type regardless of case', () => { + const claims = { aud: 'client-id' }; + expect(isAccessTokenJwt(makeJwt(claims, { alg: 'RS256', typ: 'at+JWT' }), claims)).toBe(true); + expect( + isAccessTokenJwt(makeJwt(claims, { alg: 'RS256', typ: 'application/at+jwt' }), claims), + ).toBe(true); + }); + + it('accepts a token whose audience names a configured resource', () => { + const claims = { aud: 'api://resource-app' }; + expect(isAccessTokenJwt(makeJwt(claims), claims, audiences)).toBe(true); + }); + + it('accepts a scope claim once the audience has ruled out an ID token', () => { + const scp = { aud: 'api://other-app', scp: 'User.Read Files.Read' }; + const scope = { aud: 'api://other-app', scope: 'openid api.read' }; + expect(isAccessTokenJwt(makeJwt(scp), scp, audiences)).toBe(true); + expect(isAccessTokenJwt(makeJwt(scope), scope, audiences)).toBe(true); + }); + + it('does not treat `nonce` or `auth_time` as disqualifying once the audience has ruled out an ID token', () => { + const claims = { + aud: 'api://resource-app', + scope: 'api.read', + nonce: 'abc', + auth_time: 1700000000, + }; + expect(isAccessTokenJwt(makeJwt(claims), claims, audiences)).toBe(true); + }); + + it('rejects a scope claim while the client id is still an audience', () => { + const claims = { aud: 'client-id', scp: 'User.Read' }; + expect(isAccessTokenJwt(makeJwt(claims), claims, audiences)).toBe(false); + }); + + it('rejects an ID token carrying a provider-added scope claim', () => { + const claims = { aud: 'client-id', scope: 'openid email profile', nonce: 'n-0S6_WzA2Mj' }; + expect(isAccessTokenJwt(makeJwt(claims), claims, audiences)).toBe(false); + }); + + it('rejects a multi-audience ID token that also names a configured resource', () => { + const claims = { aud: ['client-id', 'api://resource-app'], nonce: 'n-0S6_WzA2Mj' }; + expect(isAccessTokenJwt(makeJwt(claims), claims, audiences)).toBe(false); + }); + + it('rejects an Entra-shaped ID token', () => { + const claims = { aud: 'client-id', nonce: 'n-0S6_WzA2Mj', tid: 'tenant-1', oid: 'object-1' }; + expect(isAccessTokenJwt(makeJwt(claims), claims, audiences)).toBe(false); + }); + + it('rejects a token bearing the ID-token-only `at_hash` claim even when scoped', () => { + const claims = { aud: 'api://resource-app', scp: 'User.Read', at_hash: 'HK6E_P6Dh8Y93mRN' }; + expect(isAccessTokenJwt(makeJwt(claims), claims, audiences)).toBe(false); + }); + + it('rejects a token bearing the ID-token-only `c_hash` claim', () => { + const claims = { aud: 'api://resource-app', scope: 'api.read', c_hash: 'LDktKdoQak3Pk0cn' }; + expect(isAccessTokenJwt(makeJwt(claims), claims, audiences)).toBe(false); + }); + + it('rejects a token whose audience is only the OIDC client id', () => { + const claims = { aud: 'client-id' }; + expect(isAccessTokenJwt(makeJwt(claims), claims, audiences)).toBe(false); + }); + + it('qualifies nothing but `at+jwt` when no client id is configured', () => { + const claims = { aud: 'api://resource-app', scp: 'User.Read' }; + expect(isAccessTokenJwt(makeJwt(claims), claims)).toBe(false); + expect(isAccessTokenJwt(makeJwt(claims, { alg: 'RS256', typ: 'at+jwt' }), claims)).toBe(true); + }); + + it('rejects a missing token, missing claims, or an unparseable header', () => { + expect(isAccessTokenJwt(undefined, { aud: 'api://resource-app' }, audiences)).toBe(false); + expect(isAccessTokenJwt('a.b.c', undefined, audiences)).toBe(false); + expect(isAccessTokenJwt('not-a-jwt', { aud: 'client-id' }, audiences)).toBe(false); + }); + }); }); diff --git a/packages/api/src/utils/oidc.ts b/packages/api/src/utils/oidc.ts index 27b7dc5e4a..f03b0a54f8 100644 --- a/packages/api/src/utils/oidc.ts +++ b/packages/api/src/utils/oidc.ts @@ -28,6 +28,8 @@ export const OPENID_TOKEN_FIELDS = [ 'EXPIRES_AT', ] as const; +export type OpenIDTokenField = (typeof OPENID_TOKEN_FIELDS)[number]; + /** * Placeholder for Microsoft Graph API access token. * This placeholder is resolved asynchronously via OBO (On-Behalf-Of) flow @@ -44,6 +46,98 @@ export const DEFAULT_GRAPH_SCOPES = 'https://graph.microsoft.com/.default'; /** Shared with AuthController's OpenID session reuse check: a token within the buffer would expire in transit and 401 downstream */ export const OPENID_EXPIRY_BUFFER_SECONDS = 30; +/** Claims consulted when deciding whether a verified JWT is an access token rather than an ID token. */ +export interface JwtTypeClaims { + aud?: string | string[]; + scp?: unknown; + scope?: unknown; + at_hash?: unknown; + c_hash?: unknown; +} + +/** The configured audiences a verified JWT is weighed against. */ +export interface AccessTokenAudiences { + /** Audiences that name a protected resource rather than the OIDC client. */ + resources?: ReadonlySet; + /** The OIDC client id, so an `aud` that still names it is not read as resource-bound. */ + clientId?: string; +} + +/** RFC 9068 media type for a JWT access token, as it appears in the `typ` header (compared case-insensitively). */ +const ACCESS_TOKEN_JWT_TYPES = new Set(['at+jwt', 'application/at+jwt']); + +function decodeJwtHeaderType(token: string): string | undefined { + try { + const header = JSON.parse(Buffer.from(token.split('.')[0], 'base64').toString()); + return typeof header?.typ === 'string' ? header.typ.toLowerCase() : undefined; + } catch { + return undefined; + } +} + +function audienceList(aud: string | string[] | undefined): string[] { + if (typeof aud === 'string') { + return [aud]; + } + return Array.isArray(aud) ? aud : []; +} + +/** + * Decides whether a verified bearer JWT is an OAuth 2.0 access token, so it may stand in for a + * stored access token. Passing this strategy's audience check does not settle the question: an + * OIDC ID token is minted for the client id and satisfies the same check, and using one as the + * On-Behalf-Of assertion is rejected by the IdP (Entra answers `AADSTS240002`). + * + * Only two signals distinguish the two by specification rather than by provider convention: + * - an RFC 9068 `at+jwt` header type, which is defined for access tokens alone; + * - an `aud` that omits the OIDC client id, which OIDC Core §2 requires every ID token to carry, + * so a token without it cannot be an ID token for this deployment. + * + * Claim presence is deliberately not proof on its own. Providers add claims freely in both + * directions — Keycloak has emitted `nonce` and `auth_time` in access tokens, and maps `scope` + * into ID tokens — so `scp`/`scope` only qualifies a token whose audience has already ruled out + * an ID token. While the client id is in `aud` the token may be either, and it fails closed. + * + * `at_hash` and `c_hash` veto regardless, since they exist only to bind an ID token to its + * companion access token or code. + */ +export function isAccessTokenJwt( + token: string | undefined, + claims: JwtTypeClaims | undefined, + audiences?: AccessTokenAudiences, +): boolean { + if (!token || !claims) { + return false; + } + + if (claims.at_hash != null || claims.c_hash != null) { + return false; + } + + const headerType = decodeJwtHeaderType(token); + if (headerType != null && ACCESS_TOKEN_JWT_TYPES.has(headerType)) { + return true; + } + + /** Without a configured client id the audience can rule nothing out, so nothing but `at+jwt` qualifies */ + if (audiences?.clientId == null) { + return false; + } + + const tokenAudiences = audienceList(claims.aud); + if (tokenAudiences.includes(audiences.clientId)) { + return false; + } + + if (audiences.resources?.size) { + if (tokenAudiences.some((audience) => audiences.resources!.has(audience))) { + return true; + } + } + + return claims.scp != null || claims.scope != null; +} + /** * Signals that the stored OpenID credentials cannot satisfy a placeholder, so the user must * re-authenticate. `ErrorController` maps this to a 401, and `statusCode` additionally lets @@ -148,9 +242,15 @@ export function isOpenIDTokenValid(tokenInfo: OpenIDTokenInfo | null): boolean { return true; } +/** + * @param fields Restricts which placeholders may resolve. Callers holding a token set whose + * access token is unusable pass `['ID_TOKEN']`, so the ID token resolves on its own expiry while + * every other placeholder keeps its literal-then-strip behaviour. + */ export function processOpenIDPlaceholders( value: string, tokenInfo: OpenIDTokenInfo | null, + fields: readonly OpenIDTokenField[] = OPENID_TOKEN_FIELDS, ): string { if (!tokenInfo || typeof value !== 'string') { return value; @@ -158,7 +258,7 @@ export function processOpenIDPlaceholders( let processedValue = value; - for (const field of OPENID_TOKEN_FIELDS) { + for (const field of fields) { const placeholder = `{{LIBRECHAT_OPENID_${field}}}`; if (!processedValue.includes(placeholder)) { continue; @@ -198,7 +298,7 @@ export function processOpenIDPlaceholders( } const genericPlaceholder = '{{LIBRECHAT_OPENID_TOKEN}}'; - if (processedValue.includes(genericPlaceholder)) { + if (fields.includes('ACCESS_TOKEN') && processedValue.includes(genericPlaceholder)) { const replacementValue = tokenInfo.accessToken || ''; processedValue = processedValue.replace(new RegExp(genericPlaceholder, 'g'), replacementValue); }