mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-03 22:32:42 +00:00
🔄 feat: Cross-Origin Admin OAuth Refresh (#13007)
* feat(admin-panel): add /api/admin/oauth/refresh endpoint for cross-origin BFF refresh
The cookie-based /api/auth/refresh controller can't be reached cross-origin
from a separately-hosted admin panel because the refresh-token cookie isn't
sent on cross-origin fetches. Add a dedicated POST /api/admin/oauth/refresh
endpoint that accepts the refresh token in the request body, exchanges it
at the IdP via openid-client refreshTokenGrant, and returns the same
response shape as /api/admin/oauth/exchange.
Implementation lives in packages/api/src/auth/refresh.ts as the
applyAdminRefresh helper. It validates the refreshed tokenset, looks up the
admin user by openidId (with optional user_id disambiguation when multiple
user docs share an openidId), mints the bearer via an injected mintToken
hook, and runs an optional onRefreshSuccess hook for downstream forks that
need to update server-side session state.
The default mintToken passed by the OSS route signs an HS256 LibreChat JWT
via generateToken so admin panel callers continue to use the existing local
JWT strategy. Forks that prefer to hand back an IdP-signed token (e.g. for
deployments where the JWT auth gate is JWKS-only) override mintToken
without changing the helper or the route.
Also threads expiresAt through AdminExchangeData and AdminExchangeResponse
so admin panel clients can drive proactive refresh before the bearer
expires. Defaults the OSS exchange flow to Date.now() + sessionExpiry.
* fix(admin-panel): address review feedback on /api/admin/oauth/refresh
mintToken now returns {token, expiresAt} so the minter is authoritative
for the bearer's lifetime instead of deriving it from the IdP `exp` claim.
The refresh response would otherwise lie to the admin panel and trigger
premature or late refresh cycles.
The helper now falls back to the inbound refresh_token when the IdP omits
one on rotation (Auth0 with rotation off, Microsoft personal accounts).
Without this the admin panel loses its refresh capability after one cycle.
Other hardening:
resolveAdminUser validates user_id with Types.ObjectId.isValid before
hitting Mongoose, avoiding a CastError that would surface as a generic
500 with no useful information for the client.
If user_id resolves to a user whose openidId does not match the refreshed
sub, throw USER_ID_MISMATCH (401) instead of silently swapping in a
different user matching the sub.
Wrap tokenset.claims() in readClaims so an IdP that returns a tokenset
without a usable id_token gets mapped to CLAIMS_INCOMPLETE (502) rather
than bubbling a raw exception.
findUsers now uses the same SAFE_USER_PROJECTION as getUserById so the
fallback path no longer pulls password/totpSecret/backupCodes into memory.
Removed dead fields (email on AdminRefreshClaims, id_token on
RefreshTokenset) and fixed import ordering per AGENTS.md.
Adds packages/api/src/auth/refresh.spec.ts: 18 tests covering the happy
path, userId disambiguation (match, invalid ObjectId, null, mismatch),
all error branches (IDP_INCOMPLETE, CLAIMS_INCOMPLETE for both throw and
missing sub, USER_NOT_FOUND, mintToken/onRefreshSuccess propagation), and
refresh-token preservation under rotation/no-rotation.
* chore(admin-panel): polish per re-review on /api/admin/oauth/refresh
readClaims now logs the original error name/message at warn before mapping
to CLAIMS_INCOMPLETE so a programming bug doesn't get silently rebadged
as an IdP problem in production logs.
The route handler's JSDoc now enumerates every error response (status +
error_code) so admin-panel implementors can plan for each branch without
reading the source.
Tightens the helper's surface: removed the now-dead `exp` field from
`AdminRefreshClaims` (only `sub` is read since the v2 mintToken refactor),
and tightened `AdminRefreshDeps.findUsers`'s projection parameter from
`string | null` to `string` so the contract matches actual usage.
Test polish: the userId-resolves-to-null fallthrough test now asserts the
exact `findUsers` and `getUserById` call arguments so a regression in the
fallthrough query shape is caught. The "skips onRefreshSuccess" test now
asserts a populated response shape rather than just `toBeDefined`.
Declined per prior triage and re-confirmed: a role guard inside
`applyAdminRefresh` (downstream `/api/admin/*` already enforces
ACCESS_ADMIN via requireCapability) and moving the IdP grant call out of
the JS route into TypeScript (matches existing oauth.js / openidStrategy
pattern; package-boundary refactor belongs in a separate PR).
* fix(admin-panel): reject /api/admin/oauth/refresh tokensets from foreign issuers
When the route handler can resolve the configured OpenID issuer, it now
threads it into applyAdminRefresh as expectedIssuer. The helper compares
that against the tokenset claims iss (after normalizeOpenIdIssuer on
both sides to absorb trailing-slash differences) and throws
ISSUER_MISMATCH (401) on mismatch.
The check is skipped when either side is unset so behavior is unchanged
for IdPs that don't return iss on a refresh-grant id_token, and for
older deployments where the OpenID config doesn't expose serverMetadata.
This is a defense-in-depth measure for the refresh path only. The
deeper OIDC posture fix (binding IUser lookup to (sub, iss) as a pair)
is pre-existing debt across openidStrategy.js and the regular exchange
flow as well, and belongs in a separate PR with the schema change and
backfill migration.
* fix(admin-panel): bind refresh user lookup to (sub, iss) and handle getOpenIdConfig throw
Two fixes raised on the PR thread that I previously misdescribed:
The user lookup in resolveAdminUser was keyed on openidId alone, so a
tokenset from a different issuer that happened to share the same sub
could resolve to a local user from a different IdP. Now exports
getIssuerBoundConditions and isUserIssuerAllowed from openid.ts (the
helpers findOpenIDUser already uses) and reuses them. The findUsers
filter becomes ($or of getIssuerBoundConditions for openidId) when an
expectedIssuer is provided, with the same legacy backward-compat
clause for users whose openidIssuer field was never populated. The
direct user_id path now also checks isUserIssuerAllowed and throws
USER_ID_MISMATCH if the stored openidIssuer disagrees with the
configured issuer.
The route's getOpenIdConfig() call was previously documented as
returning null when uninitialized; the actual implementation throws.
That made the if (!openIdConfig) guard unreachable, and an unconfigured
server would surface as 500 INTERNAL_ERROR rather than 503
OPENID_NOT_CONFIGURED. Wraps the call in try/catch so the documented
503 response is what callers actually receive.
Adds 4 tests covering the new lookup binding behavior.
* fix(admin-panel): re-check ACCESS_ADMIN on /api/admin/oauth/refresh
The IdP refresh token can outlive a capability/role change, so the
initial requireAdminAccess on the OAuth callback isn't sufficient.
Inject canAccessAdmin via the existing capability model
(hasCapability with SystemCapabilities.ACCESS_ADMIN, matching
requireAdminAccess so custom roles and user grants are honored)
and gate token minting on it. Capability backend errors are
warn-and-denied to keep the bearer-mint path fail-closed.
* fix(admin-panel): scope /api/admin/oauth/refresh to the request tenant
The same (openidId, openidIssuer) pair is allowed across tenants by
the user schema's unique index. The refresh helper was wrapping both
the direct getUserById and the fallback findUsers in runAsSystem,
bypassing tenant isolation, so an IdP identity that exists in two
tenants could resolve to the wrong tenant's user and mint a JWT
bound to that tenant.
Drop the runAsSystem wrappers, add a trusted tenantId option to
applyAdminRefresh, AND it into the fallback findUsers filter, and
assert it against the direct getUserById result. Mount
preAuthTenantMiddleware on the refresh route so the deployment's
X-Tenant-Id header drives the trusted tenant via ALS. Single-tenant
deploys (no header) keep the existing openidId-only behaviour.
Adds TENANT_MISMATCH (401) and a regression covering duplicate
(sub, iss) across tenants plus the direct-userId tenant assertion.
* fix(admin-panel): gate /api/admin/oauth/refresh on OPENID_REUSE_TOKENS
The OSS refreshController only refreshes OpenID tokensets when
OPENID_REUSE_TOKENS is enabled. The body-based admin variant was
unconditionally calling refreshTokenGrant, which made the flag
ineffective for the admin OAuth flow and let admin sessions keep
renewing in deployments that explicitly turned token reuse off.
Add the same isEnabled(process.env.OPENID_REUSE_TOKENS) check up
front and return 403 TOKEN_REUSE_DISABLED so the admin panel BFF
can surface the configuration mismatch instead of silently churning
through retries.
This commit is contained in:
parent
22890771cf
commit
e262219c8f
7 changed files with 958 additions and 6 deletions
|
|
@ -46,6 +46,7 @@ function createOAuthHandler(redirectUri = domains.client) {
|
|||
/** Get refresh token from tokenset for OpenID users */
|
||||
const refreshToken =
|
||||
req.user.tokenset?.refresh_token || req.user.federatedTokens?.refresh_token;
|
||||
const expiresAt = Date.now() + sessionExpiry;
|
||||
|
||||
const callbackUrl = new URL(redirectUri);
|
||||
const exchangeCode = await generateAdminExchangeCode(
|
||||
|
|
@ -55,6 +56,7 @@ function createOAuthHandler(redirectUri = domains.client) {
|
|||
refreshToken,
|
||||
callbackUrl.origin,
|
||||
req.pkceChallenge,
|
||||
expiresAt,
|
||||
);
|
||||
callbackUrl.searchParams.set('code', exchangeCode);
|
||||
logger.info(`[OAuth] Admin panel redirect with exchange code for user: ${req.user.email}`);
|
||||
|
|
|
|||
|
|
@ -1,19 +1,35 @@
|
|||
const express = require('express');
|
||||
const passport = require('passport');
|
||||
const crypto = require('node:crypto');
|
||||
const openIdClient = require('openid-client');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { logger, SystemCapabilities } = require('@librechat/data-schemas');
|
||||
const {
|
||||
logger,
|
||||
DEFAULT_SESSION_EXPIRY,
|
||||
SystemCapabilities,
|
||||
getTenantId,
|
||||
} = require('@librechat/data-schemas');
|
||||
const {
|
||||
isEnabled,
|
||||
getAdminPanelUrl,
|
||||
exchangeAdminCode,
|
||||
createSetBalanceConfig,
|
||||
storeAndStripChallenge,
|
||||
tenantContextMiddleware,
|
||||
preAuthTenantMiddleware,
|
||||
applyAdminRefresh,
|
||||
AdminRefreshError,
|
||||
} = require('@librechat/api');
|
||||
const { loginController } = require('~/server/controllers/auth/LoginController');
|
||||
const { requireCapability } = require('~/server/middleware/roles/capabilities');
|
||||
const { hasCapability, requireCapability } = require('~/server/middleware/roles/capabilities');
|
||||
const { createOAuthHandler } = require('~/server/controllers/auth/oauth');
|
||||
const { findBalanceByUser, upsertBalanceFields } = require('~/models');
|
||||
const {
|
||||
findBalanceByUser,
|
||||
findUsers,
|
||||
generateToken,
|
||||
getUserById,
|
||||
upsertBalanceFields,
|
||||
} = require('~/models');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { getOpenIdConfig } = require('~/strategies');
|
||||
|
|
@ -464,4 +480,132 @@ router.post('/oauth/exchange', middleware.loginLimiter, async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Admin-panel-shaped token refresh.
|
||||
*
|
||||
* The standard `/api/auth/refresh` controller reads the refresh token from
|
||||
* cookies, which a cross-origin admin panel can't set. This endpoint accepts
|
||||
* the refresh token in the request body, exchanges it at the IdP, mints a
|
||||
* fresh LibreChat JWT, and returns the same response shape as
|
||||
* `/api/admin/oauth/exchange`.
|
||||
*
|
||||
* POST /api/admin/oauth/refresh
|
||||
* Body: { refresh_token: string, user_id?: string }
|
||||
* Response: { token: string, refreshToken?: string, user: object, expiresAt: number }
|
||||
*
|
||||
* Errors (all responses are `{ error: string, error_code: string }`):
|
||||
* 400 MISSING_REFRESH_TOKEN — refresh_token absent or empty
|
||||
* 401 REFRESH_FAILED — IdP rejected the refresh grant
|
||||
* 401 USER_NOT_FOUND — no LibreChat user matches the refreshed sub
|
||||
* 401 USER_ID_MISMATCH — supplied user_id resolves to a user with a different openidId
|
||||
* 401 ISSUER_MISMATCH — refreshed tokenset was issued by an unexpected issuer
|
||||
* 401 TENANT_MISMATCH — resolved user belongs to a different tenant than the request
|
||||
* 403 FORBIDDEN — resolved user no longer holds ACCESS_ADMIN
|
||||
* 403 TOKEN_REUSE_DISABLED — OPENID_REUSE_TOKENS is not enabled on the server
|
||||
* 502 IDP_INCOMPLETE — IdP returned a tokenset missing access_token
|
||||
* 502 CLAIMS_INCOMPLETE — IdP tokenset has no readable claims or no sub
|
||||
* 503 OPENID_NOT_CONFIGURED — OpenID is not configured on this server
|
||||
* 500 INTERNAL_ERROR — anything else (logged server-side)
|
||||
*/
|
||||
router.post(
|
||||
'/oauth/refresh',
|
||||
middleware.loginLimiter,
|
||||
preAuthTenantMiddleware,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { refresh_token: refreshToken, user_id: userId } = req.body ?? {};
|
||||
if (typeof refreshToken !== 'string' || refreshToken.length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Missing refresh_token',
|
||||
error_code: 'MISSING_REFRESH_TOKEN',
|
||||
});
|
||||
}
|
||||
|
||||
if (!isEnabled(process.env.OPENID_REUSE_TOKENS)) {
|
||||
return res.status(403).json({
|
||||
error: 'OpenID token reuse is not enabled',
|
||||
error_code: 'TOKEN_REUSE_DISABLED',
|
||||
});
|
||||
}
|
||||
|
||||
let openIdConfig;
|
||||
try {
|
||||
openIdConfig = getOpenIdConfig();
|
||||
} catch {
|
||||
return res.status(503).json({
|
||||
error: 'OpenID is not configured',
|
||||
error_code: 'OPENID_NOT_CONFIGURED',
|
||||
});
|
||||
}
|
||||
|
||||
const refreshParams = process.env.OPENID_SCOPE ? { scope: process.env.OPENID_SCOPE } : {};
|
||||
let tokenset;
|
||||
try {
|
||||
tokenset = await openIdClient.refreshTokenGrant(openIdConfig, refreshToken, refreshParams);
|
||||
} catch (err) {
|
||||
logger.warn('[admin/oauth/refresh] IdP refresh grant failed', {
|
||||
code: err?.code,
|
||||
name: err?.name,
|
||||
});
|
||||
return res.status(401).json({
|
||||
error: 'Refresh failed',
|
||||
error_code: 'REFRESH_FAILED',
|
||||
});
|
||||
}
|
||||
|
||||
const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY;
|
||||
const expectedIssuer = openIdConfig.serverMetadata?.()?.issuer;
|
||||
|
||||
try {
|
||||
const result = await applyAdminRefresh(
|
||||
tokenset,
|
||||
{
|
||||
findUsers,
|
||||
getUserById,
|
||||
canAccessAdmin: async (user) => {
|
||||
try {
|
||||
return await hasCapability(
|
||||
{
|
||||
id: user.id ?? user._id?.toString(),
|
||||
role: user.role ?? '',
|
||||
tenantId: user.tenantId,
|
||||
},
|
||||
SystemCapabilities.ACCESS_ADMIN,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`[admin/oauth/refresh] capability check failed, denying: ${err?.message}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
mintToken: async (user) => ({
|
||||
token: await generateToken(user, sessionExpiry),
|
||||
expiresAt: Date.now() + sessionExpiry,
|
||||
}),
|
||||
},
|
||||
{
|
||||
userId: typeof userId === 'string' && userId.length > 0 ? userId : undefined,
|
||||
previousRefreshToken: refreshToken,
|
||||
expectedIssuer,
|
||||
tenantId: getTenantId(),
|
||||
},
|
||||
);
|
||||
return res.json(result);
|
||||
} catch (err) {
|
||||
if (err instanceof AdminRefreshError) {
|
||||
return res.status(err.status).json({ error: err.message, error_code: err.code });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[admin/oauth/refresh] Error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Internal server error',
|
||||
error_code: 'INTERNAL_ERROR',
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,10 @@ export interface AdminExchangeUser {
|
|||
}
|
||||
|
||||
/**
|
||||
* Data stored in cache for admin OAuth exchange
|
||||
* Data stored in cache for admin OAuth exchange.
|
||||
*
|
||||
* `expiresAt` is the absolute expiry of `token` (ms epoch). Used by admin
|
||||
* panel clients to drive proactive refresh before the bearer expires.
|
||||
*/
|
||||
export interface AdminExchangeData {
|
||||
userId: string;
|
||||
|
|
@ -39,6 +42,7 @@ export interface AdminExchangeData {
|
|||
refreshToken?: string;
|
||||
origin?: string;
|
||||
codeChallenge?: string;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -48,6 +52,7 @@ export interface AdminExchangeResponse {
|
|||
token: string;
|
||||
refreshToken?: string;
|
||||
user: AdminExchangeUser;
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -94,6 +99,7 @@ export function verifyCodeChallenge(verifier: string, challenge: string): boolea
|
|||
* @param refreshToken - Optional refresh token for OpenID users
|
||||
* @param origin - The admin panel origin (scheme://host:port) for origin binding
|
||||
* @param codeChallenge - PKCE code_challenge (hex-encoded SHA-256 of code_verifier)
|
||||
* @param expiresAt - Optional absolute expiry of `token` (ms epoch); admin panel uses for proactive refresh
|
||||
* @returns The generated exchange code
|
||||
*/
|
||||
export async function generateAdminExchangeCode(
|
||||
|
|
@ -103,6 +109,7 @@ export async function generateAdminExchangeCode(
|
|||
refreshToken?: string,
|
||||
origin?: string,
|
||||
codeChallenge?: string,
|
||||
expiresAt?: number,
|
||||
): Promise<string> {
|
||||
const exchangeCode = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
|
|
@ -113,6 +120,7 @@ export async function generateAdminExchangeCode(
|
|||
refreshToken,
|
||||
origin,
|
||||
codeChallenge,
|
||||
expiresAt,
|
||||
};
|
||||
|
||||
await cache.set(exchangeCode, data);
|
||||
|
|
@ -165,6 +173,7 @@ export async function exchangeAdminCode(
|
|||
token: data.token,
|
||||
refreshToken: data.refreshToken,
|
||||
user: data.user,
|
||||
expiresAt: data.expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export * from './domain';
|
||||
export * from './openid';
|
||||
export * from './exchange';
|
||||
export * from './refresh';
|
||||
export * from './agent';
|
||||
export * from './password';
|
||||
export * from './invite';
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ function isLegacyOpenIdIssuer(openidIssuer: string | undefined): boolean {
|
|||
return openidIssuer != null && loginIssuer != null && openidIssuer === loginIssuer;
|
||||
}
|
||||
|
||||
function getIssuerBoundConditions(
|
||||
export function getIssuerBoundConditions(
|
||||
field: OpenIdLookupField,
|
||||
value: string | undefined,
|
||||
openidIssuer: string | undefined,
|
||||
|
|
@ -76,7 +76,7 @@ function getIssuerBoundConditions(
|
|||
return conditions;
|
||||
}
|
||||
|
||||
function isUserIssuerAllowed(user: IUser, openidIssuer: string | undefined): boolean {
|
||||
export function isUserIssuerAllowed(user: IUser, openidIssuer: string | undefined): boolean {
|
||||
if (!openidIssuer) return true;
|
||||
|
||||
const userIssuer = normalizeOpenIdIssuer(user.openidIssuer);
|
||||
|
|
|
|||
542
packages/api/src/auth/refresh.spec.ts
Normal file
542
packages/api/src/auth/refresh.spec.ts
Normal file
|
|
@ -0,0 +1,542 @@
|
|||
import { Types } from 'mongoose';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
|
||||
import type { IUser } from '@librechat/data-schemas';
|
||||
import type { AdminRefreshDeps, RefreshTokenset } from './refresh';
|
||||
|
||||
import { applyAdminRefresh, AdminRefreshError } from './refresh';
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
...jest.requireActual('@librechat/data-schemas'),
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const SUB = 'idp-sub-12345';
|
||||
|
||||
function makeUser(overrides: Partial<IUser> = {}): IUser {
|
||||
const _id = overrides._id ?? new Types.ObjectId();
|
||||
return {
|
||||
_id,
|
||||
email: 'admin@example.com',
|
||||
name: 'Admin User',
|
||||
username: 'admin',
|
||||
role: 'ADMIN',
|
||||
provider: 'openid',
|
||||
openidId: SUB,
|
||||
...overrides,
|
||||
} as unknown as IUser;
|
||||
}
|
||||
|
||||
function makeTokenset(overrides: Partial<RefreshTokenset> = {}): RefreshTokenset {
|
||||
return {
|
||||
access_token: 'new-access',
|
||||
refresh_token: 'new-refresh',
|
||||
claims: () => ({ sub: SUB }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(user: IUser | undefined, overrides: Partial<AdminRefreshDeps> = {}) {
|
||||
const findUsers = jest.fn().mockResolvedValue(user ? [user] : []);
|
||||
const getUserById = jest.fn().mockResolvedValue(user ?? null);
|
||||
const mintToken = jest.fn().mockResolvedValue({ token: 'minted-jwt', expiresAt: 1234567890 });
|
||||
const onRefreshSuccess = jest.fn().mockResolvedValue(undefined);
|
||||
return {
|
||||
findUsers,
|
||||
getUserById,
|
||||
mintToken,
|
||||
onRefreshSuccess,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('applyAdminRefresh', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('happy path', () => {
|
||||
it('returns a fully-populated AdminExchangeResponse', async () => {
|
||||
const user = makeUser();
|
||||
const deps = makeDeps(user);
|
||||
const tokenset = makeTokenset();
|
||||
|
||||
const result = await applyAdminRefresh(tokenset, deps);
|
||||
|
||||
expect(result).toEqual({
|
||||
token: 'minted-jwt',
|
||||
refreshToken: 'new-refresh',
|
||||
user: expect.objectContaining({
|
||||
email: 'admin@example.com',
|
||||
openidId: SUB,
|
||||
}),
|
||||
expiresAt: 1234567890,
|
||||
});
|
||||
expect(deps.mintToken).toHaveBeenCalledWith(user, tokenset);
|
||||
expect(deps.onRefreshSuccess).toHaveBeenCalledWith(user, tokenset);
|
||||
});
|
||||
|
||||
it('uses findUsers when no userId is supplied', async () => {
|
||||
const user = makeUser();
|
||||
const deps = makeDeps(user);
|
||||
|
||||
await applyAdminRefresh(makeTokenset(), deps);
|
||||
|
||||
expect(deps.findUsers).toHaveBeenCalledWith(
|
||||
{ openidId: SUB },
|
||||
'-password -__v -totpSecret -backupCodes',
|
||||
{ sort: { updatedAt: -1 }, limit: 1 },
|
||||
);
|
||||
expect(deps.getUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips onRefreshSuccess when not provided', async () => {
|
||||
const user = makeUser();
|
||||
const { onRefreshSuccess: _omit, ...rest } = makeDeps(user);
|
||||
const deps: AdminRefreshDeps = rest;
|
||||
|
||||
const result = await applyAdminRefresh(makeTokenset(), deps);
|
||||
expect(result.token).toBe('minted-jwt');
|
||||
expect(result.user.email).toBe('admin@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('userId disambiguation', () => {
|
||||
it('returns the direct user when userId resolves and openidId matches', async () => {
|
||||
const id = new Types.ObjectId();
|
||||
const user = makeUser({ _id: id });
|
||||
const deps = makeDeps(user);
|
||||
|
||||
await applyAdminRefresh(makeTokenset(), deps, { userId: id.toString() });
|
||||
|
||||
expect(deps.getUserById).toHaveBeenCalledWith(
|
||||
id.toString(),
|
||||
'-password -__v -totpSecret -backupCodes',
|
||||
);
|
||||
expect(deps.findUsers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls through to findUsers when userId is not a valid ObjectId', async () => {
|
||||
const user = makeUser();
|
||||
const deps = makeDeps(user);
|
||||
|
||||
await applyAdminRefresh(makeTokenset(), deps, { userId: 'not-a-valid-id' });
|
||||
|
||||
expect(deps.getUserById).not.toHaveBeenCalled();
|
||||
expect(deps.findUsers).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls through to findUsers when userId resolves to null', async () => {
|
||||
const user = makeUser();
|
||||
const id = new Types.ObjectId().toString();
|
||||
const deps = makeDeps(user, { getUserById: jest.fn().mockResolvedValue(null) });
|
||||
|
||||
await applyAdminRefresh(makeTokenset(), deps, { userId: id });
|
||||
|
||||
expect(deps.getUserById).toHaveBeenCalledWith(id, '-password -__v -totpSecret -backupCodes');
|
||||
expect(deps.findUsers).toHaveBeenCalledWith(
|
||||
{ openidId: SUB },
|
||||
'-password -__v -totpSecret -backupCodes',
|
||||
{ sort: { updatedAt: -1 }, limit: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
it('throws USER_ID_MISMATCH when the resolved user has a different openidId', async () => {
|
||||
const id = new Types.ObjectId();
|
||||
const user = makeUser({ _id: id, openidId: 'different-sub' });
|
||||
const deps = makeDeps(user, {
|
||||
getUserById: jest.fn().mockResolvedValue(user),
|
||||
findUsers: jest.fn(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
applyAdminRefresh(makeTokenset(), deps, { userId: id.toString() }),
|
||||
).rejects.toMatchObject({
|
||||
name: 'AdminRefreshError',
|
||||
code: 'USER_ID_MISMATCH',
|
||||
status: 401,
|
||||
});
|
||||
expect(deps.findUsers).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('error branches', () => {
|
||||
it('throws IDP_INCOMPLETE when access_token is missing', async () => {
|
||||
const deps = makeDeps(makeUser());
|
||||
const tokenset = makeTokenset({ access_token: undefined });
|
||||
|
||||
await expect(applyAdminRefresh(tokenset, deps)).rejects.toMatchObject({
|
||||
code: 'IDP_INCOMPLETE',
|
||||
status: 502,
|
||||
});
|
||||
expect(deps.mintToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws CLAIMS_INCOMPLETE when claims() throws', async () => {
|
||||
const deps = makeDeps(makeUser());
|
||||
const tokenset = makeTokenset({
|
||||
claims: () => {
|
||||
throw new Error('id_token missing');
|
||||
},
|
||||
});
|
||||
|
||||
await expect(applyAdminRefresh(tokenset, deps)).rejects.toMatchObject({
|
||||
code: 'CLAIMS_INCOMPLETE',
|
||||
status: 502,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws CLAIMS_INCOMPLETE when sub is missing', async () => {
|
||||
const deps = makeDeps(makeUser());
|
||||
const tokenset = makeTokenset({ claims: () => ({}) });
|
||||
|
||||
await expect(applyAdminRefresh(tokenset, deps)).rejects.toMatchObject({
|
||||
code: 'CLAIMS_INCOMPLETE',
|
||||
status: 502,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws USER_NOT_FOUND when no user matches the refreshed identity', async () => {
|
||||
const deps = makeDeps(undefined);
|
||||
|
||||
await expect(applyAdminRefresh(makeTokenset(), deps)).rejects.toMatchObject({
|
||||
code: 'USER_NOT_FOUND',
|
||||
status: 401,
|
||||
});
|
||||
expect(deps.mintToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('propagates errors thrown from mintToken', async () => {
|
||||
const deps = makeDeps(makeUser(), {
|
||||
mintToken: jest.fn().mockRejectedValue(new Error('signing failure')),
|
||||
});
|
||||
|
||||
await expect(applyAdminRefresh(makeTokenset(), deps)).rejects.toThrow('signing failure');
|
||||
});
|
||||
|
||||
it('propagates errors thrown from onRefreshSuccess', async () => {
|
||||
const deps = makeDeps(makeUser(), {
|
||||
onRefreshSuccess: jest.fn().mockRejectedValue(new Error('cache write failed')),
|
||||
});
|
||||
|
||||
await expect(applyAdminRefresh(makeTokenset(), deps)).rejects.toThrow('cache write failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('issuer-bound user lookup', () => {
|
||||
it('binds findUsers to (openidId, openidIssuer) when expectedIssuer is provided', async () => {
|
||||
const user = makeUser();
|
||||
const deps = makeDeps(user);
|
||||
const tokenset = makeTokenset({
|
||||
claims: () => ({ sub: SUB, iss: 'https://issuer.example.com' }),
|
||||
});
|
||||
|
||||
await applyAdminRefresh(tokenset, deps, {
|
||||
expectedIssuer: 'https://issuer.example.com',
|
||||
});
|
||||
|
||||
const [filter] = (deps.findUsers as jest.Mock).mock.calls[0];
|
||||
expect(filter).toMatchObject({
|
||||
$or: expect.arrayContaining([
|
||||
{ openidId: SUB, openidIssuer: 'https://issuer.example.com' },
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to openidId-only lookup when expectedIssuer is not provided', async () => {
|
||||
const user = makeUser();
|
||||
const deps = makeDeps(user);
|
||||
|
||||
await applyAdminRefresh(makeTokenset(), deps);
|
||||
|
||||
expect(deps.findUsers).toHaveBeenCalledWith(
|
||||
{ openidId: SUB },
|
||||
'-password -__v -totpSecret -backupCodes',
|
||||
{ sort: { updatedAt: -1 }, limit: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
it('throws USER_ID_MISMATCH when direct user_id resolves but issuer differs', async () => {
|
||||
const id = new Types.ObjectId();
|
||||
const user = makeUser({ _id: id, openidIssuer: 'https://other-issuer.example.com' });
|
||||
const deps = makeDeps(user, {
|
||||
getUserById: jest.fn().mockResolvedValue(user),
|
||||
findUsers: jest.fn(),
|
||||
});
|
||||
const tokenset = makeTokenset({
|
||||
claims: () => ({ sub: SUB, iss: 'https://issuer.example.com' }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
applyAdminRefresh(tokenset, deps, {
|
||||
userId: id.toString(),
|
||||
expectedIssuer: 'https://issuer.example.com',
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'USER_ID_MISMATCH', status: 401 });
|
||||
expect(deps.findUsers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts direct user_id when stored openidIssuer matches the expected issuer', async () => {
|
||||
const id = new Types.ObjectId();
|
||||
const user = makeUser({ _id: id, openidIssuer: 'https://issuer.example.com' });
|
||||
const deps = makeDeps(user, { getUserById: jest.fn().mockResolvedValue(user) });
|
||||
const tokenset = makeTokenset({
|
||||
claims: () => ({ sub: SUB, iss: 'https://issuer.example.com' }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
applyAdminRefresh(tokenset, deps, {
|
||||
userId: id.toString(),
|
||||
expectedIssuer: 'https://issuer.example.com',
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('issuer guard', () => {
|
||||
it('passes when expected and actual issuers match', async () => {
|
||||
const deps = makeDeps(makeUser());
|
||||
const tokenset = makeTokenset({
|
||||
claims: () => ({ sub: SUB, iss: 'https://issuer.example.com' }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
applyAdminRefresh(tokenset, deps, { expectedIssuer: 'https://issuer.example.com' }),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('passes when expected and actual issuers match modulo trailing slash', async () => {
|
||||
const deps = makeDeps(makeUser());
|
||||
const tokenset = makeTokenset({
|
||||
claims: () => ({ sub: SUB, iss: 'https://issuer.example.com/' }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
applyAdminRefresh(tokenset, deps, { expectedIssuer: 'https://issuer.example.com' }),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('throws ISSUER_MISMATCH when issuers differ', async () => {
|
||||
const deps = makeDeps(makeUser());
|
||||
const tokenset = makeTokenset({
|
||||
claims: () => ({ sub: SUB, iss: 'https://attacker.example.com' }),
|
||||
});
|
||||
|
||||
await expect(
|
||||
applyAdminRefresh(tokenset, deps, { expectedIssuer: 'https://issuer.example.com' }),
|
||||
).rejects.toMatchObject({ code: 'ISSUER_MISMATCH', status: 401 });
|
||||
expect(deps.findUsers).not.toHaveBeenCalled();
|
||||
expect(deps.mintToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips the check when expectedIssuer is not provided', async () => {
|
||||
const deps = makeDeps(makeUser());
|
||||
const tokenset = makeTokenset({
|
||||
claims: () => ({ sub: SUB, iss: 'https://attacker.example.com' }),
|
||||
});
|
||||
|
||||
await expect(applyAdminRefresh(tokenset, deps)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('skips the check when the tokenset has no iss claim', async () => {
|
||||
const deps = makeDeps(makeUser());
|
||||
|
||||
await expect(
|
||||
applyAdminRefresh(makeTokenset(), deps, {
|
||||
expectedIssuer: 'https://issuer.example.com',
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh-token preservation', () => {
|
||||
it('preserves the inbound refresh token when the IdP does not rotate', async () => {
|
||||
const deps = makeDeps(makeUser());
|
||||
const tokenset = makeTokenset({ refresh_token: undefined });
|
||||
|
||||
const result = await applyAdminRefresh(tokenset, deps, {
|
||||
previousRefreshToken: 'inbound-refresh',
|
||||
});
|
||||
|
||||
expect(result.refreshToken).toBe('inbound-refresh');
|
||||
});
|
||||
|
||||
it('prefers the rotated refresh token over the previousRefreshToken fallback', async () => {
|
||||
const deps = makeDeps(makeUser());
|
||||
const tokenset = makeTokenset({ refresh_token: 'rotated-refresh' });
|
||||
|
||||
const result = await applyAdminRefresh(tokenset, deps, {
|
||||
previousRefreshToken: 'inbound-refresh',
|
||||
});
|
||||
|
||||
expect(result.refreshToken).toBe('rotated-refresh');
|
||||
});
|
||||
|
||||
it('returns undefined refreshToken when neither rotation nor fallback is available', async () => {
|
||||
const deps = makeDeps(makeUser());
|
||||
const tokenset = makeTokenset({ refresh_token: undefined });
|
||||
|
||||
const result = await applyAdminRefresh(tokenset, deps);
|
||||
|
||||
expect(result.refreshToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tenant scoping', () => {
|
||||
it('constrains the fallback findUsers filter to options.tenantId', async () => {
|
||||
const user = makeUser({ tenantId: 'tenant-a' } as Partial<IUser>);
|
||||
const deps = makeDeps(user);
|
||||
|
||||
await applyAdminRefresh(makeTokenset(), deps, { tenantId: 'tenant-a' });
|
||||
|
||||
const [filter] = (deps.findUsers as jest.Mock).mock.calls[0];
|
||||
expect(filter).toMatchObject({ openidId: SUB, tenantId: 'tenant-a' });
|
||||
});
|
||||
|
||||
it('returns the tenant-A user when two users share (sub, iss) across tenants', async () => {
|
||||
const userA = makeUser({ tenantId: 'tenant-a', email: 'a@example.com' } as Partial<IUser>);
|
||||
const userB = makeUser({ tenantId: 'tenant-b', email: 'b@example.com' } as Partial<IUser>);
|
||||
|
||||
const findUsers = jest.fn().mockImplementation(async (filter: { tenantId?: string }) => {
|
||||
if (filter.tenantId === 'tenant-a') return [userA];
|
||||
if (filter.tenantId === 'tenant-b') return [userB];
|
||||
return [userA, userB];
|
||||
});
|
||||
const deps = makeDeps(userA, { findUsers });
|
||||
|
||||
const result = await applyAdminRefresh(makeTokenset(), deps, { tenantId: 'tenant-a' });
|
||||
|
||||
expect(result.user.email).toBe('a@example.com');
|
||||
expect(findUsers).toHaveBeenCalledWith(
|
||||
{ openidId: SUB, tenantId: 'tenant-a' },
|
||||
'-password -__v -totpSecret -backupCodes',
|
||||
{ sort: { updatedAt: -1 }, limit: 1 },
|
||||
);
|
||||
});
|
||||
|
||||
it('throws TENANT_MISMATCH when direct user_id resolves to a different tenant', async () => {
|
||||
const id = new Types.ObjectId();
|
||||
const user = makeUser({ _id: id, tenantId: 'tenant-b' } as Partial<IUser>);
|
||||
const deps = makeDeps(user, {
|
||||
getUserById: jest.fn().mockResolvedValue(user),
|
||||
findUsers: jest.fn(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
applyAdminRefresh(makeTokenset(), deps, {
|
||||
userId: id.toString(),
|
||||
tenantId: 'tenant-a',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: 'AdminRefreshError',
|
||||
code: 'TENANT_MISMATCH',
|
||||
status: 401,
|
||||
});
|
||||
expect(deps.findUsers).not.toHaveBeenCalled();
|
||||
expect(deps.mintToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('accepts direct user_id when stored tenantId matches options.tenantId', async () => {
|
||||
const id = new Types.ObjectId();
|
||||
const user = makeUser({ _id: id, tenantId: 'tenant-a' } as Partial<IUser>);
|
||||
const deps = makeDeps(user, {
|
||||
getUserById: jest.fn().mockResolvedValue(user),
|
||||
findUsers: jest.fn(),
|
||||
});
|
||||
|
||||
const result = await applyAdminRefresh(makeTokenset(), deps, {
|
||||
userId: id.toString(),
|
||||
tenantId: 'tenant-a',
|
||||
});
|
||||
|
||||
expect(result.token).toBe('minted-jwt');
|
||||
expect(deps.findUsers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to openidId-only filter when options.tenantId is omitted', async () => {
|
||||
const user = makeUser();
|
||||
const deps = makeDeps(user);
|
||||
|
||||
await applyAdminRefresh(makeTokenset(), deps);
|
||||
|
||||
const [filter] = (deps.findUsers as jest.Mock).mock.calls[0];
|
||||
expect(filter).toEqual({ openidId: SUB });
|
||||
expect(filter).not.toHaveProperty('tenantId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('admin-access guard', () => {
|
||||
it('throws FORBIDDEN and does not mint when canAccessAdmin returns false', async () => {
|
||||
const user = makeUser();
|
||||
const canAccessAdmin = jest.fn().mockResolvedValue(false);
|
||||
const deps = makeDeps(user, { canAccessAdmin });
|
||||
|
||||
await expect(applyAdminRefresh(makeTokenset(), deps)).rejects.toMatchObject({
|
||||
name: 'AdminRefreshError',
|
||||
code: 'FORBIDDEN',
|
||||
status: 403,
|
||||
});
|
||||
|
||||
expect(canAccessAdmin).toHaveBeenCalledWith(user);
|
||||
expect(deps.mintToken).not.toHaveBeenCalled();
|
||||
expect(deps.onRefreshSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mints when canAccessAdmin returns true', async () => {
|
||||
const user = makeUser();
|
||||
const canAccessAdmin = jest.fn().mockResolvedValue(true);
|
||||
const deps = makeDeps(user, { canAccessAdmin });
|
||||
|
||||
const result = await applyAdminRefresh(makeTokenset(), deps);
|
||||
|
||||
expect(canAccessAdmin).toHaveBeenCalledWith(user);
|
||||
expect(deps.mintToken).toHaveBeenCalledWith(user, expect.any(Object));
|
||||
expect(result.token).toBe('minted-jwt');
|
||||
});
|
||||
|
||||
it('skips the check when canAccessAdmin is not provided', async () => {
|
||||
const user = makeUser();
|
||||
const { canAccessAdmin: _omit, ...rest } = makeDeps(user);
|
||||
const deps: AdminRefreshDeps = rest;
|
||||
|
||||
const result = await applyAdminRefresh(makeTokenset(), deps);
|
||||
expect(result.token).toBe('minted-jwt');
|
||||
});
|
||||
|
||||
it('propagates errors thrown by canAccessAdmin without minting', async () => {
|
||||
const user = makeUser();
|
||||
const boom = new Error('capability backend exploded');
|
||||
const canAccessAdmin = jest.fn().mockRejectedValue(boom);
|
||||
const deps = makeDeps(user, { canAccessAdmin });
|
||||
|
||||
await expect(applyAdminRefresh(makeTokenset(), deps)).rejects.toBe(boom);
|
||||
expect(deps.mintToken).not.toHaveBeenCalled();
|
||||
expect(deps.onRefreshSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('logging', () => {
|
||||
it('logs the refreshed user email at debug', async () => {
|
||||
const deps = makeDeps(makeUser());
|
||||
|
||||
await applyAdminRefresh(makeTokenset(), deps);
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining('admin@example.com'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminRefreshError', () => {
|
||||
it('captures code, status, and message', () => {
|
||||
const err = new AdminRefreshError('SOME_CODE', 418, 'teapot');
|
||||
expect(err.name).toBe('AdminRefreshError');
|
||||
expect(err.code).toBe('SOME_CODE');
|
||||
expect(err.status).toBe(418);
|
||||
expect(err.message).toBe('teapot');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
254
packages/api/src/auth/refresh.ts
Normal file
254
packages/api/src/auth/refresh.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { Types } from 'mongoose';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
|
||||
import type { IUser } from '@librechat/data-schemas';
|
||||
import type { FilterQuery } from 'mongoose';
|
||||
import type { AdminExchangeResponse } from '~/auth/exchange';
|
||||
|
||||
import {
|
||||
isUserIssuerAllowed,
|
||||
normalizeOpenIdIssuer,
|
||||
getIssuerBoundConditions,
|
||||
} from '~/auth/openid';
|
||||
import { serializeUserForExchange } from '~/auth/exchange';
|
||||
|
||||
const SAFE_USER_PROJECTION = '-password -__v -totpSecret -backupCodes';
|
||||
|
||||
interface AdminRefreshClaims {
|
||||
sub?: string;
|
||||
iss?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The minimal shape this module needs from an `openid-client` tokenset.
|
||||
* Avoids a hard import dependency on `openid-client` types in this package.
|
||||
*/
|
||||
export interface RefreshTokenset {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
claims: () => AdminRefreshClaims;
|
||||
}
|
||||
|
||||
export interface MintedToken {
|
||||
/** Bearer the admin panel will send on subsequent requests. */
|
||||
token: string;
|
||||
/** Absolute expiry of `token` (ms epoch). Drives the admin panel's proactive refresh. */
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface AdminRefreshDeps {
|
||||
findUsers: (
|
||||
filter: FilterQuery<IUser>,
|
||||
projection: string,
|
||||
options: { sort: Record<string, 1 | -1>; limit: number },
|
||||
) => Promise<IUser[]>;
|
||||
getUserById: (id: string, projection: string) => Promise<IUser | null>;
|
||||
/**
|
||||
* Mints the bearer the admin panel will send on subsequent requests, and
|
||||
* reports its absolute expiry. The minter is authoritative for the bearer's
|
||||
* lifetime — the helper does not attempt to derive expiry from the IdP's
|
||||
* `exp` claim. Default OSS callers pass `generateToken(user, sessionExpiry)`
|
||||
* and `Date.now() + sessionExpiry`.
|
||||
*/
|
||||
mintToken: (user: IUser, tokenset: RefreshTokenset) => Promise<MintedToken>;
|
||||
/**
|
||||
* Authorizes the resolved user against the same admin-access invariant
|
||||
* the initial OAuth callback enforces. The IdP refresh token can outlive
|
||||
* a capability/role change, so the bearer must not be reissued for a
|
||||
* user who no longer holds `ACCESS_ADMIN`. Optional only for backward
|
||||
* compatibility with existing callers; route handlers that mint admin
|
||||
* bearers should always inject this.
|
||||
*/
|
||||
canAccessAdmin?: (user: IUser) => Promise<boolean>;
|
||||
/**
|
||||
* Optional post-success hook for forks that need to do additional work
|
||||
* with the refreshed tokenset and resolved user (e.g. update a server-side
|
||||
* token cache or reconcile downstream session state). Errors thrown here
|
||||
* propagate to the route handler.
|
||||
*/
|
||||
onRefreshSuccess?: (user: IUser, tokenset: RefreshTokenset) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface AdminRefreshOptions {
|
||||
/**
|
||||
* Optional user `_id` from the previous admin-panel session. When provided,
|
||||
* the resolved user must have a matching `openidId` — otherwise the refresh
|
||||
* is rejected as a possible identity-swap attempt.
|
||||
*/
|
||||
userId?: string;
|
||||
/**
|
||||
* The refresh token the admin panel sent in. Preserved as the response
|
||||
* `refreshToken` when the IdP doesn't rotate (Auth0 with rotation off,
|
||||
* Microsoft personal accounts in some flows). Without this fallback, the
|
||||
* admin panel would receive `undefined` and lose its refresh capability.
|
||||
*/
|
||||
previousRefreshToken?: string;
|
||||
/**
|
||||
* Issuer URL of the OpenID provider this server trusts. When provided
|
||||
* alongside an `iss` claim on the refreshed tokenset, the helper rejects
|
||||
* the refresh if the two don't match. Defends against tokensets emitted
|
||||
* by an unexpected issuer, even though `IUser` lookup is still keyed by
|
||||
* `openidId` alone.
|
||||
*/
|
||||
expectedIssuer?: string;
|
||||
/**
|
||||
* Trusted tenant id resolved by the route layer (e.g. `getTenantId()` from
|
||||
* the pre-auth tenant ALS scope). When provided, the helper:
|
||||
* - constrains the fallback `findUsers` lookup with `tenantId`, and
|
||||
* - asserts a direct `getUserById` result has a matching `tenantId`,
|
||||
* so the same `(sub, iss)` existing in another tenant can never resolve
|
||||
* here. Multi-tenant deployments MUST pass this. Single-tenant deployments
|
||||
* may omit it.
|
||||
*/
|
||||
tenantId?: string;
|
||||
}
|
||||
|
||||
export class AdminRefreshError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AdminRefreshError';
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveAdminUser(
|
||||
deps: AdminRefreshDeps,
|
||||
openidId: string,
|
||||
normalizedIssuer: string | undefined,
|
||||
options: AdminRefreshOptions,
|
||||
): Promise<IUser | undefined> {
|
||||
const expectedTenantId = options.tenantId;
|
||||
|
||||
if (options.userId && Types.ObjectId.isValid(options.userId)) {
|
||||
const direct = await deps.getUserById(options.userId as string, SAFE_USER_PROJECTION);
|
||||
if (direct) {
|
||||
if (direct.openidId !== openidId) {
|
||||
throw new AdminRefreshError(
|
||||
'USER_ID_MISMATCH',
|
||||
401,
|
||||
'Provided user_id does not match the refreshed identity',
|
||||
);
|
||||
}
|
||||
if (!isUserIssuerAllowed(direct, normalizedIssuer)) {
|
||||
throw new AdminRefreshError(
|
||||
'USER_ID_MISMATCH',
|
||||
401,
|
||||
'Provided user_id matches sub but issuer differs from the refreshed identity',
|
||||
);
|
||||
}
|
||||
if (expectedTenantId && direct.tenantId !== expectedTenantId) {
|
||||
throw new AdminRefreshError(
|
||||
'TENANT_MISMATCH',
|
||||
401,
|
||||
'Provided user_id resolves outside the request tenant',
|
||||
);
|
||||
}
|
||||
return direct;
|
||||
}
|
||||
logger.debug(
|
||||
`[adminRefresh] user_id ${options.userId} not found; falling through to openidId lookup`,
|
||||
);
|
||||
}
|
||||
|
||||
const issuerBound = getIssuerBoundConditions('openidId', openidId, normalizedIssuer);
|
||||
const baseFilter: FilterQuery<IUser> =
|
||||
issuerBound.length > 0 ? { $or: issuerBound } : ({ openidId } as FilterQuery<IUser>);
|
||||
const filter: FilterQuery<IUser> = expectedTenantId
|
||||
? ({ ...baseFilter, tenantId: expectedTenantId } as FilterQuery<IUser>)
|
||||
: baseFilter;
|
||||
|
||||
const [user] = await deps.findUsers(filter, SAFE_USER_PROJECTION, {
|
||||
sort: { updatedAt: -1 },
|
||||
limit: 1,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
function readClaims(tokenset: RefreshTokenset): AdminRefreshClaims {
|
||||
try {
|
||||
return tokenset.claims();
|
||||
} catch (err) {
|
||||
const error = err as { name?: string; message?: string };
|
||||
logger.warn('[adminRefresh] tokenset.claims() threw', {
|
||||
name: error?.name,
|
||||
message: error?.message,
|
||||
});
|
||||
throw new AdminRefreshError(
|
||||
'CLAIMS_INCOMPLETE',
|
||||
502,
|
||||
'IdP returned a tokenset whose claims could not be read (no id_token?)',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up the active admin user for a freshly-refreshed OpenID tokenset,
|
||||
* mints the bearer the admin panel should send on subsequent requests, and
|
||||
* returns the response shape used by `/api/admin/oauth/exchange`.
|
||||
*
|
||||
* The route handler is responsible for the IdP `refreshTokenGrant` call;
|
||||
* this helper takes the resulting tokenset and turns it into an admin-panel
|
||||
* exchange response.
|
||||
*/
|
||||
export async function applyAdminRefresh(
|
||||
tokenset: RefreshTokenset,
|
||||
deps: AdminRefreshDeps,
|
||||
options: AdminRefreshOptions = {},
|
||||
): Promise<AdminExchangeResponse> {
|
||||
if (!tokenset.access_token) {
|
||||
throw new AdminRefreshError(
|
||||
'IDP_INCOMPLETE',
|
||||
502,
|
||||
'IdP returned an incomplete tokenset (missing access_token)',
|
||||
);
|
||||
}
|
||||
|
||||
const claims = readClaims(tokenset);
|
||||
const openidId = claims.sub;
|
||||
if (!openidId) {
|
||||
throw new AdminRefreshError(
|
||||
'CLAIMS_INCOMPLETE',
|
||||
502,
|
||||
'IdP returned a tokenset missing the required `sub` claim',
|
||||
);
|
||||
}
|
||||
|
||||
const expected = normalizeOpenIdIssuer(options.expectedIssuer);
|
||||
const actual = normalizeOpenIdIssuer(claims.iss);
|
||||
if (expected && actual && expected !== actual) {
|
||||
throw new AdminRefreshError(
|
||||
'ISSUER_MISMATCH',
|
||||
401,
|
||||
'Refreshed tokenset was issued by an unexpected issuer',
|
||||
);
|
||||
}
|
||||
|
||||
const user = await resolveAdminUser(deps, openidId, expected, options);
|
||||
if (!user) {
|
||||
throw new AdminRefreshError('USER_NOT_FOUND', 401, 'No user found for the refreshed identity');
|
||||
}
|
||||
|
||||
if (deps.canAccessAdmin && !(await deps.canAccessAdmin(user))) {
|
||||
throw new AdminRefreshError('FORBIDDEN', 403, 'User does not have admin access');
|
||||
}
|
||||
|
||||
const minted = await deps.mintToken(user, tokenset);
|
||||
|
||||
if (deps.onRefreshSuccess) {
|
||||
await deps.onRefreshSuccess(user, tokenset);
|
||||
}
|
||||
|
||||
const responseUser = serializeUserForExchange(user);
|
||||
|
||||
logger.debug(`[adminRefresh] Refreshed tokens for user: ${responseUser.email}`);
|
||||
|
||||
return {
|
||||
token: minted.token,
|
||||
refreshToken: tokenset.refresh_token ?? options.previousRefreshToken,
|
||||
user: responseUser,
|
||||
expiresAt: minted.expiresAt,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue