From 0a7255b2349499481de1bd30fc5c81be91afcf82 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 11 May 2026 17:40:30 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=AD=20feat:=20Support=20OpenID=20Audie?= =?UTF-8?q?nce=20On=20Refresh=20Grants=20(#13077)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 + api/server/controllers/AuthController.js | 19 +- api/server/controllers/AuthController.spec.js | 120 ++++++++- api/server/routes/admin/auth.js | 13 +- api/server/routes/admin/auth.refresh.test.js | 250 ++++++++++++++++++ packages/api/src/auth/refresh.spec.ts | 58 +++- packages/api/src/auth/refresh.ts | 19 ++ 7 files changed, 478 insertions(+), 5 deletions(-) create mode 100644 api/server/routes/admin/auth.refresh.test.js diff --git a/.env.example b/.env.example index 00030c7187..5d7f69a9de 100644 --- a/.env.example +++ b/.env.example @@ -527,6 +527,10 @@ OPENID_NAME_CLAIM= OPENID_EMAIL_CLAIM= # Optional audience parameter for OpenID authorization requests OPENID_AUDIENCE= +# Optional audience parameter for OpenID refresh token requests. +# Some providers, such as Auth0 custom APIs, require this to preserve +# the intended access-token audience during refresh. Usually matches OPENID_AUDIENCE. +OPENID_REFRESH_AUDIENCE= OPENID_BUTTON_LABEL= OPENID_IMAGE_URL= diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 5a4ce8a10a..16532851b5 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -2,7 +2,12 @@ const cookies = require('cookie'); const jwt = require('jsonwebtoken'); const openIdClient = require('openid-client'); const { logger } = require('@librechat/data-schemas'); -const { isEnabled, findOpenIDUser, getOpenIdIssuer } = require('@librechat/api'); +const { + isEnabled, + findOpenIDUser, + getOpenIdIssuer, + buildOpenIDRefreshParams, +} = require('@librechat/api'); const { requestPasswordReset, setOpenIDAuthTokens, @@ -78,12 +83,22 @@ const refreshController = async (req, res) => { try { const openIdConfig = getOpenIdConfig(); - const refreshParams = process.env.OPENID_SCOPE ? { scope: process.env.OPENID_SCOPE } : {}; + const refreshParams = buildOpenIDRefreshParams(); + logger.debug('[refreshController] OpenID refresh params', { + has_scope: Boolean(process.env.OPENID_SCOPE), + has_refresh_audience: Boolean(process.env.OPENID_REFRESH_AUDIENCE), + }); const tokenset = await openIdClient.refreshTokenGrant( openIdConfig, refreshToken, refreshParams, ); + logger.debug('[refreshController] OpenID refresh succeeded', { + has_access_token: Boolean(tokenset.access_token), + has_id_token: Boolean(tokenset.id_token), + has_refresh_token: Boolean(tokenset.refresh_token), + expires_in: tokenset.expires_in, + }); const claims = tokenset.claims(); const openidIssuer = getOpenIdIssuer(claims, openIdConfig); const { user, error, migration } = await findOpenIDUser({ diff --git a/api/server/controllers/AuthController.spec.js b/api/server/controllers/AuthController.spec.js index 8b19c28f36..b8d0aa3570 100644 --- a/api/server/controllers/AuthController.spec.js +++ b/api/server/controllers/AuthController.spec.js @@ -24,16 +24,30 @@ jest.mock('@librechat/api', () => ({ isEnabled: jest.fn(), findOpenIDUser: jest.fn(), getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'), + buildOpenIDRefreshParams: jest.fn(() => { + const params = {}; + if (process.env.OPENID_SCOPE) { + params.scope = process.env.OPENID_SCOPE; + } + if (process.env.OPENID_REFRESH_AUDIENCE) { + params.audience = process.env.OPENID_REFRESH_AUDIENCE; + } + return params; + }), })); const openIdClient = require('openid-client'); -const { isEnabled, findOpenIDUser } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); +const { isEnabled, findOpenIDUser, buildOpenIDRefreshParams } = require('@librechat/api'); const { graphTokenController, refreshController } = require('./AuthController'); const { getGraphApiToken } = require('~/server/services/GraphTokenService'); const { setOpenIDAuthTokens } = require('~/server/services/AuthService'); const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies'); const { updateUser } = require('~/models'); +const ORIGINAL_OPENID_SCOPE = process.env.OPENID_SCOPE; +const ORIGINAL_OPENID_REFRESH_AUDIENCE = process.env.OPENID_REFRESH_AUDIENCE; + describe('graphTokenController', () => { let req, res; @@ -155,6 +169,7 @@ describe('refreshController – OpenID path', () => { access_token: 'new-access', id_token: 'new-id', refresh_token: 'new-refresh', + expires_in: 3600, }; const baseClaims = { @@ -179,6 +194,8 @@ describe('refreshController – OpenID path', () => { beforeEach(() => { jest.clearAllMocks(); + delete process.env.OPENID_SCOPE; + delete process.env.OPENID_REFRESH_AUDIENCE; isEnabled.mockReturnValue(true); getOpenIdConfig.mockReturnValue({ some: 'config' }); @@ -201,9 +218,24 @@ describe('refreshController – OpenID path', () => { }; }); + afterAll(() => { + if (ORIGINAL_OPENID_SCOPE === undefined) { + delete process.env.OPENID_SCOPE; + } else { + process.env.OPENID_SCOPE = ORIGINAL_OPENID_SCOPE; + } + + if (ORIGINAL_OPENID_REFRESH_AUDIENCE === undefined) { + delete process.env.OPENID_REFRESH_AUDIENCE; + } else { + process.env.OPENID_REFRESH_AUDIENCE = ORIGINAL_OPENID_REFRESH_AUDIENCE; + } + }); + it('should call getOpenIdEmail with token claims and use result for findOpenIDUser', async () => { await refreshController(req, res); + expect(buildOpenIDRefreshParams).toHaveBeenCalledTimes(1); expect(getOpenIdEmail).toHaveBeenCalledWith(baseClaims); expect(findOpenIDUser).toHaveBeenCalledWith( expect.objectContaining({ @@ -214,6 +246,83 @@ describe('refreshController – OpenID path', () => { expect(res.status).toHaveBeenCalledWith(200); }); + it('should pass scope-only OpenID refresh params when OPENID_SCOPE is set', async () => { + process.env.OPENID_SCOPE = 'openid profile email'; + + await refreshController(req, res); + + expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith( + { some: 'config' }, + 'stored-refresh', + { scope: 'openid profile email' }, + ); + }); + + it('should pass scope and audience OpenID refresh params when both are set', async () => { + process.env.OPENID_SCOPE = 'openid profile email'; + process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com'; + + await refreshController(req, res); + + expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith( + { some: 'config' }, + 'stored-refresh', + { + scope: 'openid profile email', + audience: 'https://api.example.com', + }, + ); + }); + + it('should pass audience-only OpenID refresh params when scope is unset', async () => { + process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com'; + + await refreshController(req, res); + + expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith( + { some: 'config' }, + 'stored-refresh', + { audience: 'https://api.example.com' }, + ); + }); + + it('should omit empty OpenID refresh audience', async () => { + process.env.OPENID_SCOPE = 'openid profile email'; + process.env.OPENID_REFRESH_AUDIENCE = ''; + + await refreshController(req, res); + + expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith( + { some: 'config' }, + 'stored-refresh', + { scope: 'openid profile email' }, + ); + }); + + it('should keep OpenID refresh diagnostics free of token and audience values', async () => { + process.env.OPENID_SCOPE = 'openid profile email'; + process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com'; + + await refreshController(req, res); + + expect(logger.debug).toHaveBeenCalledWith('[refreshController] OpenID refresh params', { + has_scope: true, + has_refresh_audience: true, + }); + expect(logger.debug).toHaveBeenCalledWith('[refreshController] OpenID refresh succeeded', { + has_access_token: true, + has_id_token: true, + has_refresh_token: true, + expires_in: 3600, + }); + const debugOutput = JSON.stringify(logger.debug.mock.calls); + expect(debugOutput).not.toContain('stored-refresh'); + expect(debugOutput).not.toContain('new-access'); + expect(debugOutput).not.toContain('new-id'); + expect(debugOutput).not.toContain('new-refresh'); + expect(debugOutput).not.toContain('https://api.example.com'); + }); + it('should use OPENID_EMAIL_CLAIM-resolved value when claim is present in token', async () => { const claimsWithUpn = { ...baseClaims, upn: 'user@corp.example.com' }; mockTokenset.claims.mockReturnValue(claimsWithUpn); @@ -305,6 +414,15 @@ describe('refreshController – OpenID path', () => { expect(res.redirect).toHaveBeenCalledWith('/login'); }); + it('should preserve invalid OpenID refresh token behavior', async () => { + openIdClient.refreshTokenGrant.mockRejectedValue(new Error('invalid_grant')); + + await refreshController(req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.send).toHaveBeenCalledWith('Invalid OpenID refresh token'); + }); + it('should skip OpenID path when token_provider is not openid', async () => { req.headers.cookie = 'token_provider=local; refreshToken=some-token'; diff --git a/api/server/routes/admin/auth.js b/api/server/routes/admin/auth.js index a385177e1f..47081232de 100644 --- a/api/server/routes/admin/auth.js +++ b/api/server/routes/admin/auth.js @@ -19,6 +19,7 @@ const { preAuthTenantMiddleware, applyAdminRefresh, AdminRefreshError, + buildOpenIDRefreshParams, } = require('@librechat/api'); const { loginController } = require('~/server/controllers/auth/LoginController'); const { hasCapability, requireCapability } = require('~/server/middleware/roles/capabilities'); @@ -538,10 +539,20 @@ router.post( }); } - const refreshParams = process.env.OPENID_SCOPE ? { scope: process.env.OPENID_SCOPE } : {}; + const refreshParams = buildOpenIDRefreshParams(); + logger.debug('[admin/oauth/refresh] OpenID refresh params', { + has_scope: Boolean(process.env.OPENID_SCOPE), + has_refresh_audience: Boolean(process.env.OPENID_REFRESH_AUDIENCE), + }); let tokenset; try { tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken, refreshParams); + logger.debug('[admin/oauth/refresh] OpenID refresh succeeded', { + has_access_token: Boolean(tokenset.access_token), + has_id_token: Boolean(tokenset.id_token), + has_refresh_token: Boolean(tokenset.refresh_token), + expires_in: tokenset.expires_in, + }); } catch (err) { logger.warn('[admin/oauth/refresh] IdP refresh grant failed', { code: err?.code, diff --git a/api/server/routes/admin/auth.refresh.test.js b/api/server/routes/admin/auth.refresh.test.js new file mode 100644 index 0000000000..d4fb59c569 --- /dev/null +++ b/api/server/routes/admin/auth.refresh.test.js @@ -0,0 +1,250 @@ +const express = require('express'); +const request = require('supertest'); + +jest.mock('passport', () => ({ + authenticate: jest.fn(() => (req, res, next) => next()), +})); + +jest.mock('openid-client', () => ({ + refreshTokenGrant: jest.fn(), +})); + +jest.mock('librechat-data-provider', () => ({ + CacheKeys: { ADMIN_OAUTH_EXCHANGE: 'admin-oauth-exchange' }, +})); + +jest.mock('@librechat/data-schemas', () => ({ + logger: { + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + }, + DEFAULT_SESSION_EXPIRY: 60000, + SystemCapabilities: { ACCESS_ADMIN: 'ACCESS_ADMIN' }, + getTenantId: jest.fn(() => undefined), +})); + +jest.mock('@librechat/api', () => { + class AdminRefreshError extends Error { + constructor(code, status, message) { + super(message); + this.name = 'AdminRefreshError'; + this.code = code; + this.status = status; + } + } + + return { + isEnabled: jest.fn(), + getAdminPanelUrl: jest.fn(() => 'http://admin.example.com'), + exchangeAdminCode: jest.fn(), + createSetBalanceConfig: jest.fn(() => (req, res, next) => next()), + storeAndStripChallenge: jest.fn(), + tenantContextMiddleware: jest.fn((req, res, next) => next()), + preAuthTenantMiddleware: jest.fn((req, res, next) => next()), + applyAdminRefresh: jest.fn(), + AdminRefreshError, + buildOpenIDRefreshParams: jest.fn(() => { + const params = {}; + if (process.env.OPENID_SCOPE) { + params.scope = process.env.OPENID_SCOPE; + } + if (process.env.OPENID_REFRESH_AUDIENCE) { + params.audience = process.env.OPENID_REFRESH_AUDIENCE; + } + return params; + }), + }; +}); + +jest.mock('~/server/controllers/auth/LoginController', () => ({ + loginController: jest.fn((req, res) => res.status(200).end()), +})); + +jest.mock('~/server/middleware/roles/capabilities', () => ({ + hasCapability: jest.fn(() => Promise.resolve(true)), + requireCapability: jest.fn(() => (req, res, next) => next()), +})); + +jest.mock('~/server/controllers/auth/oauth', () => ({ + createOAuthHandler: jest.fn(() => (req, res) => res.status(200).end()), +})); + +jest.mock('~/models', () => ({ + findBalanceByUser: jest.fn(), + findUsers: jest.fn(), + generateToken: jest.fn(() => Promise.resolve('minted-token')), + getUserById: jest.fn(), + upsertBalanceFields: jest.fn(), +})); + +jest.mock('~/server/services/Config', () => ({ + getAppConfig: jest.fn(), +})); + +jest.mock('~/cache/getLogStores', () => + jest.fn(() => ({ + get: jest.fn(), + delete: jest.fn(), + })), +); + +jest.mock('~/strategies', () => ({ + getOpenIdConfig: jest.fn(), +})); + +jest.mock('~/server/middleware', () => ({ + logHeaders: jest.fn((req, res, next) => next()), + loginLimiter: jest.fn((req, res, next) => next()), + checkBan: jest.fn((req, res, next) => next()), + requireLocalAuth: jest.fn((req, res, next) => next()), + requireJwtAuth: jest.fn((req, res, next) => next()), + checkDomainAllowed: jest.fn((req, res, next) => next()), +})); + +const openIdClient = require('openid-client'); +const { logger } = require('@librechat/data-schemas'); +const { isEnabled, applyAdminRefresh, buildOpenIDRefreshParams } = require('@librechat/api'); +const { getOpenIdConfig } = require('~/strategies'); +const adminAuthRouter = require('./auth'); + +const ORIGINAL_OPENID_SCOPE = process.env.OPENID_SCOPE; +const ORIGINAL_OPENID_REFRESH_AUDIENCE = process.env.OPENID_REFRESH_AUDIENCE; +const ORIGINAL_SESSION_EXPIRY = process.env.SESSION_EXPIRY; + +describe('admin auth OpenID refresh route', () => { + const openIdConfig = { + serverMetadata: jest.fn(() => ({ issuer: 'https://issuer.example.com' })), + }; + const tokenset = { + access_token: 'new-admin-access', + id_token: 'new-admin-id', + refresh_token: 'new-admin-refresh', + expires_in: 3600, + claims: jest.fn(() => ({ sub: 'admin-openid-id' })), + }; + + let app; + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.OPENID_SCOPE; + delete process.env.OPENID_REFRESH_AUDIENCE; + delete process.env.SESSION_EXPIRY; + + app = express(); + app.use(express.json()); + app.use('/api/admin', adminAuthRouter); + + isEnabled.mockReturnValue(true); + getOpenIdConfig.mockReturnValue(openIdConfig); + openIdClient.refreshTokenGrant.mockResolvedValue(tokenset); + applyAdminRefresh.mockResolvedValue({ + token: 'admin-jwt', + refreshToken: 'new-admin-refresh', + user: { id: 'user-id', email: 'admin@example.com' }, + expiresAt: 1234567890, + }); + }); + + afterAll(() => { + if (ORIGINAL_OPENID_SCOPE === undefined) { + delete process.env.OPENID_SCOPE; + } else { + process.env.OPENID_SCOPE = ORIGINAL_OPENID_SCOPE; + } + + if (ORIGINAL_OPENID_REFRESH_AUDIENCE === undefined) { + delete process.env.OPENID_REFRESH_AUDIENCE; + } else { + process.env.OPENID_REFRESH_AUDIENCE = ORIGINAL_OPENID_REFRESH_AUDIENCE; + } + + if (ORIGINAL_SESSION_EXPIRY === undefined) { + delete process.env.SESSION_EXPIRY; + } else { + process.env.SESSION_EXPIRY = ORIGINAL_SESSION_EXPIRY; + } + }); + + it.each([ + ['scope-only', { OPENID_SCOPE: 'openid profile email' }, { scope: 'openid profile email' }], + [ + 'scope and audience', + { + OPENID_SCOPE: 'openid profile email', + OPENID_REFRESH_AUDIENCE: 'https://api.example.com', + }, + { scope: 'openid profile email', audience: 'https://api.example.com' }, + ], + [ + 'audience-only', + { OPENID_REFRESH_AUDIENCE: 'https://api.example.com' }, + { audience: 'https://api.example.com' }, + ], + ['empty audience', { OPENID_REFRESH_AUDIENCE: '' }, {}], + ])('passes %s params to the OpenID refresh grant', async (_label, env, expectedParams) => { + Object.assign(process.env, env); + + const response = await request(app) + .post('/api/admin/oauth/refresh') + .send({ refresh_token: 'incoming-refresh-token' }); + + expect(response.status).toBe(200); + expect(buildOpenIDRefreshParams).toHaveBeenCalledTimes(1); + expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith( + openIdConfig, + 'incoming-refresh-token', + expectedParams, + ); + expect(applyAdminRefresh).toHaveBeenCalledWith( + tokenset, + expect.any(Object), + expect.objectContaining({ previousRefreshToken: 'incoming-refresh-token' }), + ); + }); + + it('returns the existing refresh failure response when the IdP rejects the grant', async () => { + openIdClient.refreshTokenGrant.mockRejectedValue({ + code: 'invalid_grant', + name: 'OAuthError', + }); + + const response = await request(app) + .post('/api/admin/oauth/refresh') + .send({ refresh_token: 'incoming-refresh-token' }); + + expect(response.status).toBe(401); + expect(response.body).toEqual({ + error: 'Refresh failed', + error_code: 'REFRESH_FAILED', + }); + expect(applyAdminRefresh).not.toHaveBeenCalled(); + }); + + it('keeps admin refresh diagnostics free of token and audience values', async () => { + process.env.OPENID_SCOPE = 'openid profile email'; + process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com'; + + await request(app) + .post('/api/admin/oauth/refresh') + .send({ refresh_token: 'incoming-refresh-token' }); + + expect(logger.debug).toHaveBeenCalledWith('[admin/oauth/refresh] OpenID refresh params', { + has_scope: true, + has_refresh_audience: true, + }); + expect(logger.debug).toHaveBeenCalledWith('[admin/oauth/refresh] OpenID refresh succeeded', { + has_access_token: true, + has_id_token: true, + has_refresh_token: true, + expires_in: 3600, + }); + const debugOutput = JSON.stringify(logger.debug.mock.calls); + expect(debugOutput).not.toContain('incoming-refresh-token'); + expect(debugOutput).not.toContain('new-admin-access'); + expect(debugOutput).not.toContain('new-admin-id'); + expect(debugOutput).not.toContain('new-admin-refresh'); + expect(debugOutput).not.toContain('https://api.example.com'); + }); +}); diff --git a/packages/api/src/auth/refresh.spec.ts b/packages/api/src/auth/refresh.spec.ts index 97cea77ee4..aa7ff8ebd9 100644 --- a/packages/api/src/auth/refresh.spec.ts +++ b/packages/api/src/auth/refresh.spec.ts @@ -4,7 +4,7 @@ import { logger } from '@librechat/data-schemas'; import type { IUser } from '@librechat/data-schemas'; import type { AdminRefreshDeps, RefreshTokenset } from './refresh'; -import { applyAdminRefresh, AdminRefreshError } from './refresh'; +import { applyAdminRefresh, AdminRefreshError, buildOpenIDRefreshParams } from './refresh'; jest.mock('@librechat/data-schemas', () => ({ ...jest.requireActual('@librechat/data-schemas'), @@ -17,6 +17,9 @@ jest.mock('@librechat/data-schemas', () => ({ const SUB = 'idp-sub-12345'; +const ORIGINAL_OPENID_SCOPE = process.env.OPENID_SCOPE; +const ORIGINAL_OPENID_REFRESH_AUDIENCE = process.env.OPENID_REFRESH_AUDIENCE; + function makeUser(overrides: Partial = {}): IUser { const _id = overrides._id ?? new Types.ObjectId(); return { @@ -54,6 +57,59 @@ function makeDeps(user: IUser | undefined, overrides: Partial }; } +describe('buildOpenIDRefreshParams', () => { + beforeEach(() => { + delete process.env.OPENID_SCOPE; + delete process.env.OPENID_REFRESH_AUDIENCE; + }); + + afterAll(() => { + if (ORIGINAL_OPENID_SCOPE === undefined) { + delete process.env.OPENID_SCOPE; + } else { + process.env.OPENID_SCOPE = ORIGINAL_OPENID_SCOPE; + } + + if (ORIGINAL_OPENID_REFRESH_AUDIENCE === undefined) { + delete process.env.OPENID_REFRESH_AUDIENCE; + } else { + process.env.OPENID_REFRESH_AUDIENCE = ORIGINAL_OPENID_REFRESH_AUDIENCE; + } + }); + + it('returns scope-only params when OPENID_SCOPE is set', () => { + process.env.OPENID_SCOPE = 'openid profile email'; + + expect(buildOpenIDRefreshParams()).toEqual({ scope: 'openid profile email' }); + }); + + it('returns scope and audience params when both refresh settings are set', () => { + process.env.OPENID_SCOPE = 'openid profile email'; + process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com'; + + expect(buildOpenIDRefreshParams()).toEqual({ + scope: 'openid profile email', + audience: 'https://api.example.com', + }); + }); + + it('returns audience-only params when OPENID_SCOPE is unset', () => { + process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com'; + + expect(buildOpenIDRefreshParams()).toEqual({ audience: 'https://api.example.com' }); + }); + + it('omits an empty refresh audience', () => { + process.env.OPENID_REFRESH_AUDIENCE = ''; + + expect(buildOpenIDRefreshParams()).toEqual({}); + }); + + it('returns no params when scope and refresh audience are unset', () => { + expect(buildOpenIDRefreshParams()).toEqual({}); + }); +}); + describe('applyAdminRefresh', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/packages/api/src/auth/refresh.ts b/packages/api/src/auth/refresh.ts index 9185998100..c1b2f101cf 100644 --- a/packages/api/src/auth/refresh.ts +++ b/packages/api/src/auth/refresh.ts @@ -29,6 +29,11 @@ export interface RefreshTokenset { claims: () => AdminRefreshClaims; } +export interface OpenIDRefreshParams { + scope?: string; + audience?: string; +} + export interface MintedToken { /** Bearer the admin panel will send on subsequent requests. */ token: string; @@ -114,6 +119,20 @@ export class AdminRefreshError extends Error { } } +export function buildOpenIDRefreshParams(): OpenIDRefreshParams { + const params: OpenIDRefreshParams = {}; + + if (process.env.OPENID_SCOPE) { + params.scope = process.env.OPENID_SCOPE; + } + + if (process.env.OPENID_REFRESH_AUDIENCE) { + params.audience = process.env.OPENID_REFRESH_AUDIENCE; + } + + return params; +} + async function resolveAdminUser( deps: AdminRefreshDeps, openidId: string,