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); }