Skip to content

🪙 fix: Resolve Azure AD Group Overage via OBO Token Exchange for OpenID#12187

Merged
danny-avila merged 1 commit into
danny-avila:devfrom
Airamhh:fix/openid-group-overage-obo
Mar 15, 2026
Merged

🪙 fix: Resolve Azure AD Group Overage via OBO Token Exchange for OpenID#12187
danny-avila merged 1 commit into
danny-avila:devfrom
Airamhh:fix/openid-group-overage-obo

Conversation

@Airamhh

@Airamhh Airamhh commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Summary

When Azure AD users belong to 200+ groups, group claims are removed from the ID token (overage). Two bugs existed:

  1. resolveGroupsFromOverage() used the wrong token for Graph API it passed the raw tokenset.access_token (audience api://<client-id>) directly to Microsoft Graph, which rejected it with HTTP 401 because Graph requires aud: https://graph.microsoft.com.
  2. OPENID_ADMIN_ROLE had no overage handling the admin role block never checked for hasgroups or _claim_names/_claim_sources, so admins were silently demoted to USER during overage.

Fixes #12185 and #12186

Change Type

  • Bug fix (non-breaking change which fixes an issue)

Testing

Test Configuration

  • OPENID_REQUIRED_ROLE=<role-id>
  • OPENID_REQUIRED_ROLE_TOKEN_KIND=id
  • OPENID_REQUIRED_ROLE_PARAMETER_PATH=groups
  • OPENID_ADMIN_ROLE=<role-id>
  • OPENID_ADMIN_ROLE_TOKEN_KIND=id,
  • OPENID_ADMIN_ROLE_PARAMETER_PATH=groups
  • OPENID_SCOPE="api://<client-id>/.default openid profile email offline_access"

Checklist

  • My code adheres to this project's style guidelines
  • I have performed a self-review of my own code
  • I have commented in any complex areas of my code
  • My changes do not introduce new warnings
  • Local unit tests pass with my changes

Copilot AI review requested due to automatic review settings March 11, 2026 16:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes Azure Entra ID group overage handling in the OpenID strategy by exchanging the app-audience access token for a Microsoft Graph-audience token (OBO flow) before resolving groups, and extends overage handling to OPENID_ADMIN_ROLE checks.

Changes:

  • Added an OBO token exchange + caching path to obtain a Graph-scoped token for /me/getMemberObjects overage resolution.
  • Updated group overage resolution to use the exchanged Graph token instead of the raw tokenset.access_token.
  • Added admin-role overage detection and reuse of already-resolved overage groups to avoid duplicate Graph calls.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
api/strategies/openidStrategy.js Implements Graph OBO exchange + cache for overage resolution; reuses resolved groups for admin role checks.
api/strategies/openidStrategy.spec.js Updates existing Graph resolution assertions and adds tests for OBO exchange, caching, and admin overage behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread api/strategies/openidStrategy.js Outdated
Comment on lines +353 to +356
await tokensCache.set(
cacheKey,
{ access_token: grantResponse.access_token },
grantResponse.expires_in * 1000,

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

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

exchangeTokenForOverage() assumes grantResponse.expires_in is always present; if it’s missing, grantResponse.expires_in * 1000 becomes NaN and the cache set TTL may break or immediately expire. Consider defaulting to a sane value (e.g., (grantResponse.expires_in || 3600) * 1000) and/or skipping caching when expires_in is not a finite number.

Suggested change
await tokensCache.set(
cacheKey,
{ access_token: grantResponse.access_token },
grantResponse.expires_in * 1000,
const expiresInSeconds = Number(grantResponse.expires_in);
const ttlMs =
Number.isFinite(expiresInSeconds) && expiresInSeconds > 0
? expiresInSeconds * 1000
: 3600 * 1000;
await tokensCache.set(
cacheKey,
{ access_token: grantResponse.access_token },
ttlMs,

Copilot uses AI. Check for mistakes.
Comment thread api/strategies/openidStrategy.js Outdated
const cacheKey = `${sub}:overage`;

const cached = await tokensCache.get(cacheKey);
if (cached) {

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

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

The cache read path returns cached.access_token whenever cached is truthy, but doesn’t validate that access_token is actually present. If the cache contains an unexpected value (or a prior failed exchange cached an object without access_token), this would return undefined and lead to Authorization: Bearer undefined. Consider checking if (cached?.access_token) before returning and falling back to a fresh exchange otherwise.

Suggested change
if (cached) {
if (cached?.access_token) {

Copilot uses AI. Check for mistakes.
Comment thread api/strategies/openidStrategy.spec.js Outdated
Comment on lines +793 to +821
it('caches the exchanged token for subsequent calls', async () => {
const getLogStores = require('~/cache/getLogStores');
const mockSet = jest.fn();
getLogStores.mockReturnValue({ get: jest.fn().mockResolvedValue(undefined), set: mockSet });

process.env.OPENID_REQUIRED_ROLE = 'group-required';
process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';

jwtDecode.mockReturnValue({ hasgroups: true });

await setupOpenId();
verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');

undici.fetch.mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
json: async () => ({ value: ['group-required'] }),
});

await validate(tokenset);

expect(mockSet).toHaveBeenCalledWith(
'1234:overage',
{ access_token: 'exchanged_graph_token' },
3600000,
);
});

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

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

This test is named as if it verifies reuse of the cached exchanged token, but it only asserts that set() was called once. To actually test caching behavior, consider running the overage flow twice (or calling the exchange helper twice) and asserting genericGrantRequest is only called once and the second run uses the cached token.

Copilot uses AI. Check for mistakes.
@Airamhh
Airamhh force-pushed the fix/openid-group-overage-obo branch 2 times, most recently from ebb4dc8 to bc21856 Compare March 11, 2026 16:25
@Airamhh
Airamhh requested a review from Copilot March 11, 2026 16:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +343 to +350
const grantResponse = await client.genericGrantRequest(
openidConfig,
'urn:ietf:params:oauth:grant-type:jwt-bearer',
{
scope: 'https://graph.microsoft.com/User.Read',
assertion: accessToken,
requested_token_use: 'on_behalf_of',
},

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

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

exchangeTokenForOverage() hardcodes the OBO scope to https://graph.microsoft.com/User.Read. Elsewhere in the repo, Graph OBO scopes are configurable (e.g., OPENID_GRAPH_SCOPES in GraphApiService.exchangeTokenForGraphAccess), and different tenants may require additional Graph delegated permissions for membership resolution. Consider making the overage exchange scope configurable (new env var or reuse OPENID_GRAPH_SCOPES with a safe default) so deployments can adjust without code changes.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hmm... The /me/getMemberObjects endpoint only requires the User.Read delegated permission as it's the least privileged scope needed (link).

Making it configurable would add complexity for a case it's not so common, but it can be implemented if needed.

@danny-avila
danny-avila changed the base branch from main to dev March 12, 2026 01:30
@danny-avila

Copy link
Copy Markdown
Owner

Taking a look at this today. Thanks for your patience

@danny-avila

Copy link
Copy Markdown
Owner

Here's an automated review I run on all my PR's via LibreChat that has some findings. If you have the time to validate them, that would be great:


PASS 1: CORE LOGIC

[P1-A] Removal of !Array.isArray(roles) && typeof roles !== 'string' guards

The old code short-circuited overage resolution if a valid roles value was already present in the token. The new code always triggers Graph resolution whenever hasgroups or _claim_names/_claim_sources are present. Per Azure AD documentation, hasgroups: true means Azure explicitly removed all group claims — the in-token value of groups would be undefined anyway. This removal is correct and the commit message justifies it ("Always resolve via Graph when overage detected").

[P1-B] exchangeTokenForOverage captures module-level openidConfig implicitly

// ~line 346
const grantResponse = await client.genericGrantRequest(
  openidConfig,           // ← module-level singleton, no guard
  'urn:ietf:params:oauth:grant-type:jwt-bearer',
  { ... },
);

The sibling function exchangeAccessTokenIfNeeded(config, accessToken, sub) receives config explicitly as a parameter. This function breaks the pattern by depending on module state. If openidConfig is null (edge case: initialization partial failure), genericGrantRequest(null, ...) throws, which resolveGroupsFromOverage's catch swallows silently. This is an unguarded implicit dependency.

[P1-C] grantResponse.access_token is never validated before caching or returning

// ~line 360-362
await tokensCache.set(cacheKey, { access_token: grantResponse.access_token }, ttlMs);
return grantResponse.access_token;   // could be undefined

If for any reason the OBO response is structurally valid (HTTP 200, JSON parseable) but omits access_token, the function caches { access_token: undefined } and returns undefined. The caller then constructs Authorization: Bearer undefined, Graph responds with HTTP 401, resolveGroupsFromOverage logs "Failed to resolve groups via Microsoft Graph: HTTP 401 Unauthorized" — a deeply misleading error. The actual token exchange failure is invisible.

The cached { access_token: undefined } is recovered on next call because cached?.access_token is falsy (cache miss), but this means an infinite retry loop hitting a broken OBO endpoint on every login attempt.

[P1-D] sub parameter not validated before cache key construction

// ~line 336
const cacheKey = `${sub}:overage`;

sub comes from claims.sub, which is required by the OpenID Connect spec but can be absent from misconfigured or non-compliant IdPs. If sub is undefined, null, or an empty string, the cache key becomes undefined:overage, null:overage, or :overage, and all such users share the same cache entry. In the worst case this means User A is authenticated against User B's Graph token.

[P1-E] Dead assignment of resolvedOverageGroups in admin block

// ~lines 623-628
if (overageGroups) {
  adminRoles = overageGroups;
  if (!resolvedOverageGroups) {
    resolvedOverageGroups = overageGroups;  // ← dead write, never read again
  }
}

After the admin role block runs, resolvedOverageGroups is never used. This write has no effect. It misleads future readers into thinking there is a third consumer of this variable.

[P1-F] Correctness of resolvedOverageGroups reuse

Correctly works: required role block resolves via Graph first, stores in resolvedOverageGroups, admin role block reuses it. Eliminates a duplicate Graph call. Logic is sound.

[P1-G] Admin-only overage (no OPENID_REQUIRED_ROLE)

When OPENID_REQUIRED_ROLE is not configured, resolvedOverageGroups is null and the admin block's await resolveGroupsFromOverage(...) fires independently. adminRoleObject is decoded fresh via jwtDecode(tokenset.id_token). This code path is correct.


PASS 2: RELIABILITY & TESTING

[P2-A] No test for admin-only overage (OPENID_REQUIRED_ROLE absent)

The admin role overage test suite always configures OPENID_REQUIRED_ROLE. The independent code path where only OPENID_ADMIN_ROLE triggers a Graph call (with no shared resolvedOverageGroups) is never exercised. This is a security-sensitive path — admin escalation should be fully covered.

[P2-B] No test for OBO exchange failure (genericGrantRequest throws)

If Azure AD rejects the OBO exchange (wrong scope, insufficient app permissions, tenant policy), genericGrantRequest throws. This propagates through exchangeTokenForOverageresolveGroupsFromOverage's catch → returns null → login denied. There is no test that exercises this path. The error log message in this case would say "Error resolving groups via Microsoft Graph getMemberObjects" when the real cause is a failed OBO exchange, not a Graph API failure.

[P2-C] Cache test relies on brittle mock sequencing

// spec, caching test
const mockGet = jest.fn()
  .mockResolvedValueOnce(undefined)
  .mockResolvedValueOnce({ access_token: 'exchanged_graph_token' });

The test works, but it relies on mockGet being called exactly once per validate() invocation. If the cache is queried multiple times per auth flow (e.g., for other cache keys), the second value would be consumed prematurely and the second validate() call would incorrectly get a cache miss. This is currently safe but fragile.

[P2-D] Copilot comments 1-3 are outdated and resolved

  • Copilot comment on expires_in handling: The current code uses Number.isFinite(grantResponse.expires_in) && grantResponse.expires_in > 0, which is correct and handles the edge case better than Copilot's suggestion. Resolved.
  • Copilot comment on cached?.access_token: Current code already checks if (cached?.access_token). Resolved.
  • Copilot comment on cache test not testing reuse: The current test calls validate twice and asserts genericGrantRequest is not called the second time. Resolved.

[P2-E] Copilot comment 4 (hardcoded scope) — PR author's response is sound

Copilot flagged that https://graph.microsoft.com/User.Read is hardcoded. The PR author responded that /me/getMemberObjects only requires User.Read (least privileged). Per Microsoft docs, User.Read is the minimum delegated permission for the signed-in user endpoint. Making this configurable adds complexity with zero practical benefit for this specific API call. PR author's position is correct.

[P2-F] No test for missing grantResponse.access_token

There is no test verifying that a structurally valid but incomplete OBO response (no access_token) is handled gracefully. (See P1-C.)


PASS 3: CLEAN CAMPGROUND

[P3-A] Design inconsistency between exchangeTokenForOverage and exchangeAccessTokenIfNeeded

Property exchangeAccessTokenIfNeeded exchangeTokenForOverage
Config source Explicit parameter Module-level global
Trigger Feature flag Always
Cache key sub ${sub}:overage
TTL guard None (no isFinite check) Number.isFinite guard

The naming also differs: one is action+condition (exchangeAccessTokenIfNeeded), the other is action+purpose (exchangeTokenForOverage). Both functions do nearly the same thing. Per AGENTS.md: "Parameterized helpers instead of near-duplicate functions." These could be refactored into a single, parameterized OBO helper.

[P3-B] CRLF line endings in new test additions

The new test code in openidStrategy.spec.js uses CRLF line endings, visible as \r in the diff. The entire spec file uses CRLF. The implementation file uses LF. While consistent within the file, AGENTS.md says formatting lint errors must be fixed. This should be normalized to LF project-wide.

[P3-C] Inline comment violates the "no narrating comments" rule

// Handle Azure AD group overage for admin role when using ID token groups

AGENTS.md: "Write self-documenting code; no inline comments narrating what code does." This comment narrates mechanics. It's borderline acceptable since it names the business domain ("Azure AD group overage"), but the function names and conditions should speak for themselves.

[P3-D] resolvedOverageGroups pollution of function scope

The let resolvedOverageGroups = null variable is declared at the top of processOpenIDAuth and threaded through required-role and admin-role blocks. This is implicit shared mutable state within a function. Per AGENTS.md: "functional first, immutable data." A cleaner approach would extract the overage resolution into a helper that takes both config objects and returns a memoized resolver.


PASS 4: PERFORMANCE

[P4-A] Cache TTL not reduced by a clock skew safety buffer

const ttlMs =
  Number.isFinite(grantResponse.expires_in) && grantResponse.expires_in > 0
    ? grantResponse.expires_in * 1000
    : 3600 * 1000;

The cached Graph token's TTL is set to exactly expires_in seconds. If there's clock skew between the Azure AD server and the application server, or network/processing latency, a cached entry near its expiry could arrive as expired at the Microsoft Graph API, causing a 401 and silent login failure. The standard defense is subtracting a 60-second safety buffer (expires_in * 1000 - 60_000). Note: exchangeAccessTokenIfNeeded has the same omission. Fixing here without fixing there would be inconsistent.

[P4-B] resolvedOverageGroups in-memory reuse correctly avoids duplicate Graph call

When both requiredRole and adminRole use ID token groups with overage, the Graph call is made exactly once per auth flow (from the required role block), and resolvedOverageGroups is reused for the admin check. The OPENID_EXCHANGED_TOKENS cache further deduplicates the OBO exchange across requests. This is correct and efficient.

[P4-C] No pagination for /me/getMemberObjects

The Graph API call is not paginated. /me/getMemberObjects returns up to 1,000 group IDs but does not use @odata.nextLink pagination. For users in 1,000+ groups, the API returns an error rather than truncating. This is a pre-existing limitation, not introduced by this PR, and documented by Microsoft's 1,000-group hard cap for this endpoint.

[P4-D] Thundering herd on cold cache

Two concurrent login requests for the same user both trigger a cache miss and issue two parallel OBO exchanges. The second write overwrites the first. No correctness issue (both tokens are valid), but it is wasted work. This is inherent to the Keyv-based caching design throughout the module, not a regression.


STEP 4 & 5: SYNTHESIZED FINAL REPORT


1. Change Summary

When Azure AD users belong to more than 200 groups, Microsoft removes all group claims from the ID token ("group overage") and sets a marker (hasgroups: true or _claim_names/_claim_sources). This PR fixes two bugs: (1) resolveGroupsFromOverage() was calling Microsoft Graph with the app-audience access token, which Graph rejected with HTTP 401; it now exchanges the token via the OBO flow to get a Graph-scoped token first. (2) The OPENID_ADMIN_ROLE block never checked for group overage, silently failing to grant the admin role. The PR also adds shared caching of the resolved overage groups to avoid a redundant Graph call when both checks are active.


2. Files Modified

File Change
api/strategies/openidStrategy.js New exchangeTokenForOverage() OBO helper; updated resolveGroupsFromOverage() to use it; added admin-role overage detection with resolvedOverageGroups sharing
api/strategies/openidStrategy.spec.js Updated existing assertion for Graph token; added 8 new test cases covering OBO exchange, caching, and admin-role overage scenarios

3. Numbered Findings


Finding 1 — MAJOR | Confidence 75 | api/strategies/openidStrategy.js ~line 360

Problem: grantResponse.access_token is never validated before caching or returning

await tokensCache.set(cacheKey, { access_token: grantResponse.access_token }, ttlMs);
return grantResponse.access_token;   // ← could be undefined

If the OBO endpoint returns HTTP 200 with a valid JSON body that omits access_token (misbehaving IdP, or a future AAD policy change), the function caches { access_token: undefined } and returns undefined. resolveGroupsFromOverage then sends Authorization: Bearer undefined to Graph, gets HTTP 401, and logs a completely misleading "Failed to resolve groups via Microsoft Graph getMemberObjects: HTTP 401 Unauthorized" — the actual failure (broken OBO response) is invisible. Login is denied without any actionable diagnostic. The cache entry { access_token: undefined } is harmless long-term (cache miss on next check), but every subsequent login attempt for that user retries the broken OBO exchange.

Fix:

if (!grantResponse.access_token) {
  throw new Error(
    '[openidStrategy] OBO exchange succeeded but returned no access_token; cannot call Graph API',
  );
}
await tokensCache.set(cacheKey, { access_token: grantResponse.access_token }, ttlMs);
return grantResponse.access_token;

Question: Does the openid-client library guarantee access_token is present in all non-error grant responses, or can it return a partial token response?


Finding 2 — MINOR | Confidence 90 | api/strategies/openidStrategy.js ~line 346

Problem: exchangeTokenForOverage implicitly depends on module-level openidConfig with no guard

const grantResponse = await client.genericGrantRequest(
  openidConfig,   // ← module-level, could be null
  ...
);

The sibling function exchangeAccessTokenIfNeeded(config, accessToken, sub) receives config as an explicit parameter. This function breaks that pattern. The implicit module-level dependency makes the function impure, untestable in isolation, and inconsistent with the established design. If openidConfig is null for any reason (e.g., setupOpenId failed midway), genericGrantRequest(null, ...) throws, caught by resolveGroupsFromOverage's catch block, logging a misleading "Error resolving groups via Microsoft Graph" when the real problem is an uninitialized OpenID config.

Fix (minimal):

async function exchangeTokenForOverage(accessToken, sub) {
  if (!openidConfig) {
    throw new Error('[openidStrategy] OpenID config not initialized; cannot exchange OBO token');
  }
  ...
}

Fix (proper, aligned with existing design): Add config as the first parameter, consistent with exchangeAccessTokenIfNeeded.


Finding 3 — MINOR | Confidence 55 | api/strategies/openidStrategy.js ~line 336

Problem: sub is not validated before being used as a cache key

const cacheKey = `${sub}:overage`;

If claims.sub is undefined, null, or '' (possible with non-compliant IdPs, or if tokenset.claims() returns a malformed object), the cache key becomes undefined:overage, null:overage, or :overage. All such users share the same cache entry, meaning User A could receive User B's Graph token on a cache hit. While sub is required by the OpenID Connect spec and always present in Azure AD tokens, this is a defense-in-depth gap in security-sensitive authentication code.

Fix:

async function exchangeTokenForOverage(accessToken, sub) {
  if (!sub || typeof sub !== 'string') {
    throw new Error('[openidStrategy] Valid subject (sub) is required for OBO token cache keying');
  }
  ...
}

Finding 4 — MINOR | Confidence 95 | api/strategies/openidStrategy.js ~lines 624–628

Problem: Dead assignment — resolvedOverageGroups is updated inside the admin block but never read again

if (overageGroups) {
  adminRoles = overageGroups;
  if (!resolvedOverageGroups) {
    resolvedOverageGroups = overageGroups;  // ← never read after this point
  }
}

After the admin role block completes, processOpenIDAuth only deals with user persistence and avatar. resolvedOverageGroups has no more consumers. This dead write implies an anticipated future consumer that doesn't exist, misleading future maintainers.

Fix: Remove the dead update entirely:

if (overageGroups) {
  adminRoles = overageGroups;
}

Finding 5 — MINOR | Confidence 90 | api/strategies/openidStrategy.spec.js

Problem: No test for admin-only overage (OPENID_REQUIRED_ROLE absent or non-groups path)

Every test in the 'admin role group overage' suite configures both OPENID_REQUIRED_ROLE and OPENID_ADMIN_ROLE. The code path where resolvedOverageGroups is null (because the required role block didn't run or used a non-groups path) and the admin block independently calls resolveGroupsFromOverage is never exercised. Admin escalation is a security-sensitive decision; every code path leading to user.role = ADMIN needs direct test coverage.

Fix — add two tests:

it('resolves admin via Graph independently when OPENID_REQUIRED_ROLE is not configured', async () => {
  delete process.env.OPENID_REQUIRED_ROLE;
  process.env.OPENID_ADMIN_ROLE = 'admin-group-id';
  process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups';
  process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';

  jwtDecode.mockReturnValue({ hasgroups: true });
  await setupOpenId();
  verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');

  undici.fetch.mockResolvedValue({
    ok: true, status: 200, statusText: 'OK',
    json: async () => ({ value: ['admin-group-id'] }),
  });

  const { user } = await validate(tokenset);
  expect(user.role).toBe('ADMIN');
  expect(undici.fetch).toHaveBeenCalledTimes(1);
});

it('denies admin when OPENID_REQUIRED_ROLE is absent and Graph does not contain admin group', async () => {
  delete process.env.OPENID_REQUIRED_ROLE;
  process.env.OPENID_ADMIN_ROLE = 'admin-group-id';
  process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH = 'groups';
  process.env.OPENID_ADMIN_ROLE_TOKEN_KIND = 'id';

  jwtDecode.mockReturnValue({ hasgroups: true });
  await setupOpenId();
  verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');

  undici.fetch.mockResolvedValue({
    ok: true, status: 200, statusText: 'OK',
    json: async () => ({ value: ['other-group'] }),
  });

  const { user } = await validate(tokenset);
  expect(user.role).toBeUndefined();
});

Finding 6 — MINOR | Confidence 85 | api/strategies/openidStrategy.spec.js

Problem: No test for OBO exchange failure — genericGrantRequest throwing

All tests mock genericGrantRequest to succeed. If the OBO exchange itself fails (network error, Azure AD rejects the assertion with HTTP 400/401, tenant policy blocks OBO), genericGrantRequest throws. The exception propagates through exchangeTokenForOverage to resolveGroupsFromOverage's catch block, returning null. Login is denied. But the logged error says "Error resolving groups via Microsoft Graph getMemberObjects" — the error object contains the OBO failure, not a Graph API failure. There should be a test verifying this graceful degradation and that the error is logged correctly.

Fix — add a test:

it('denies login and logs error when OBO exchange throws', async () => {
  const openidClient = require('openid-client');
  process.env.OPENID_REQUIRED_ROLE = 'group-required';
  process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups';
  process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'id';

  jwtDecode.mockReturnValue({ hasgroups: true });
  openidClient.genericGrantRequest.mockRejectedValue(new Error('OBO exchange rejected'));

  await setupOpenId();
  verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid');

  const { user, details } = await validate(tokenset);
  expect(user).toBe(false);
  expect(details.message).toBe('You must have "group-required" role to log in.');
  expect(undici.fetch).not.toHaveBeenCalled();
});

Finding 7 — NIT | Confidence 65 | api/strategies/openidStrategy.js ~line 355

Problem: Cache TTL uses full expires_in without a clock-skew safety buffer

const ttlMs =
  Number.isFinite(grantResponse.expires_in) && grantResponse.expires_in > 0
    ? grantResponse.expires_in * 1000
    : 3600 * 1000;

A cached Graph token served near the end of its validity window could be expired by the time it reaches Microsoft's servers. Standard OAuth practice subtracts a small safety margin (60s). Note: exchangeAccessTokenIfNeeded has the same omission (no isFinite guard, and no clock-skew buffer). Fixing here without fixing there adds inconsistency. Worth addressing in both places together.

Fix:

const CLOCK_SKEW_BUFFER_MS = 60 * 1000;
const ttlMs =
  Number.isFinite(grantResponse.expires_in) && grantResponse.expires_in > 0
    ? Math.max(0, grantResponse.expires_in * 1000 - CLOCK_SKEW_BUFFER_MS)
    : 3600 * 1000;

Finding 8 — NIT | Confidence 85 | api/strategies/openidStrategy.js overall

Problem: Two near-duplicate OBO helper functions should be consolidated

exchangeAccessTokenIfNeeded and exchangeTokenForOverage both:

  1. Get the OPENID_EXCHANGED_TOKENS cache
  2. Check for a cached token
  3. Call client.genericGrantRequest with urn:ietf:params:oauth:grant-type:jwt-bearer
  4. Store the result and return the access token

The only differences are: the cache key suffix, the scope, whether a feature flag gates execution, and whether config is passed as a param. Per AGENTS.md: "Parameterized helpers instead of near-duplicate functions." These should be unified:

async function exchangeOBOToken(config, accessToken, sub, scope, cacheKeySuffix = '') {
  const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
  const cacheKey = cacheKeySuffix ? `${sub}:${cacheKeySuffix}` : sub;
  const cached = await tokensCache.get(cacheKey);
  if (cached?.access_token) { return cached.access_token; }
  const grantResponse = await client.genericGrantRequest(config, 'urn:ietf:params:oauth:grant-type:jwt-bearer', {
    scope,
    assertion: accessToken,
    requested_token_use: 'on_behalf_of',
  });
  if (!grantResponse.access_token) {
    throw new Error(`[openidStrategy] OBO exchange for scope "${scope}" returned no access_token`);
  }
  const ttlMs = Number.isFinite(grantResponse.expires_in) && grantResponse.expires_in > 0
    ? grantResponse.expires_in * 1000
    : 3600 * 1000;
  await tokensCache.set(cacheKey, { access_token: grantResponse.access_token }, ttlMs);
  return grantResponse.access_token;
}

4. Summary Table

# Severity Confidence Description
1 MAJOR 75 grantResponse.access_token not validated before cache/return — Authorization: Bearer undefined silently sent to Graph
2 MINOR 90 exchangeTokenForOverage relies on implicit module-level openidConfig instead of explicit parameter
3 MINOR 55 sub not validated before cache key construction — potential key collision if sub is absent
4 MINOR 95 Dead code: resolvedOverageGroups = overageGroups in admin block, variable never read again
5 MINOR 90 No test for admin-only overage when OPENID_REQUIRED_ROLE is not configured
6 MINOR 85 No test for OBO exchange throwing — genericGrantRequest error path untested
7 NIT 65 Cache TTL uses full expires_in with no clock-skew safety buffer (pre-existing pattern)
8 NIT 85 exchangeTokenForOverage and exchangeAccessTokenIfNeeded are near-duplicate OBO functions; should be consolidated

5. Verdict

The core bug fix is correct and necessary. The OBO token exchange is the right solution for the HTTP 401 problem. The overage detection logic (hasgroups / _claim_names/_claim_sources) is technically sound. The resolvedOverageGroups sharing is a real performance optimization. The tests added are meaningful and cover the primary happy and error paths. The existing Copilot review comments (1–3) were all already addressed in the final commit.

Must address before merge:

  • Finding 1 is the most actionable: a one-line guard on grantResponse.access_token before caching. The current code turns a broken OBO response into a misleading Graph 401 error. Easy fix, high value.

Should address before merge:

  • Finding 2 (null config guard) — a two-line defensive check that makes failures diagnosable.
  • Finding 4 (dead assignment) — remove two lines of dead code that mislead readers.
  • Finding 5 (missing admin-only test) — admin escalation paths require direct test coverage; adding it now is straightforward.
  • Finding 6 (missing OBO failure test) — error path test for the new function.

Can be follow-up work:

  • Finding 3 (sub validation), Finding 7 (clock skew), Finding 8 (consolidate OBO helpers) — these are quality improvements that belong in a broader refactor of the OBO exchange infrastructure rather than this focused bug fix PR.

@Airamhh

Airamhh commented Mar 14, 2026

Copy link
Copy Markdown
Contributor Author

Thanks!

I will work over them as soon as possible 😄

When Azure AD users belong to 200+ groups, group claims are moved out of
the ID token (overage). The existing resolveGroupsFromOverage() called
Microsoft Graph directly with the app-audience access token, which Graph
rejected (401/403).

Changes:
- Add exchangeTokenForOverage() dedicated OBO exchange with User.Read scope
- Update resolveGroupsFromOverage() to exchange token before Graph call
- Add overage handling to OPENID_ADMIN_ROLE block (was silently failing)
- Share resolved overage groups between required role and admin role checks
- Always resolve via Graph when overage detected (even with partial groups)
- Remove debug-only bypass that forced Graph resolution
- Add tests for OBO exchange, caching, and admin role overage scenarios
@Airamhh
Airamhh force-pushed the fix/openid-group-overage-obo branch from bc21856 to c9772a9 Compare March 14, 2026 18:44
@Airamhh

Airamhh commented Mar 14, 2026

Copy link
Copy Markdown
Contributor Author

I addressed some of the issues.

Implemented:

  • Finding 1 (MAJOR) — Added validation of grantResponse.access_token before caching/returning. Now throws an explicit error if the OBO exchange returns no access_token, preventing the silent Authorization: Bearer undefined → misleading Graph 401 scenario.
  • Finding 2 (MINOR) — Added a null guard for openidConfig at the top of exchangeTokenForOverage. Throws a descriptive error instead of letting genericGrantRequest(null, ...) fail with a confusing message.
  • Finding 4 (MINOR) — Removed the dead resolvedOverageGroups = overageGroups assignment in the admin block.
  • Finding 5 (MINOR) — Added two tests for admin-only overage when OPENID_REQUIRED_ROLE is not configured: one verifying admin is granted via independent Graph resolution, and one verifying admin is denied when the group isn't present.
  • Finding 6 (MINOR) — Added two tests: one for genericGrantRequest throwing (OBO exchange failure), and one for OBO returning a response without access_token (covers Finding 1's fix).

Deferred (as suggested in the verdict):

  • Finding 3 (sub validation) — sub is required by the OIDC spec and always present in Azure AD tokens so I didn't add it.
  • Finding 7 (clock-skew buffer) — exchangeAccessTokenIfNeeded has the same omission. Fixing only here would introduce inconsistency between the two functions; this should be addressed together with Finding 8.
  • Finding 8 (consolidate OBO helpers) — Refactoring exchangeAccessTokenIfNeeded is outside the scope of this PR, tho I agree that it be done in a follow-up PR that also addresses Finding 7 consistently across both helpers but I didn't want to touch it because it could mean changing existing tests, adding different env variables and make more complex the PR.

If there's something else you want to be implemented I can do it. 😄

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@danny-avila danny-avila changed the title fix: resolve OpenID group overage via OBO token exchange 🪙 fix: Resolve Azure AD Group Overage via OBO Token Exchange for OpenID Mar 15, 2026
@danny-avila
danny-avila merged commit aee1ced into danny-avila:dev Mar 15, 2026
12 of 13 checks passed
@Airamhh
Airamhh deleted the fix/openid-group-overage-obo branch March 27, 2026 16:33
jcbartle pushed a commit to jcbartle/LibreChat that referenced this pull request May 11, 2026
…ID (danny-avila#12187)

When Azure AD users belong to 200+ groups, group claims are moved out of
the ID token (overage). The existing resolveGroupsFromOverage() called
Microsoft Graph directly with the app-audience access token, which Graph
rejected (401/403).

Changes:
- Add exchangeTokenForOverage() dedicated OBO exchange with User.Read scope
- Update resolveGroupsFromOverage() to exchange token before Graph call
- Add overage handling to OPENID_ADMIN_ROLE block (was silently failing)
- Share resolved overage groups between required role and admin role checks
- Always resolve via Graph when overage detected (even with partial groups)
- Remove debug-only bypass that forced Graph resolution
- Add tests for OBO exchange, caching, and admin role overage scenarios

Co-authored-by: Airam Hernández Hernández <airam.hernandez@intelequia.com>
ThomasVuNguyen pushed a commit to ThomasVuNguyen/LibreChat that referenced this pull request Jul 15, 2026
…ID (danny-avila#12187)

When Azure AD users belong to 200+ groups, group claims are moved out of
the ID token (overage). The existing resolveGroupsFromOverage() called
Microsoft Graph directly with the app-audience access token, which Graph
rejected (401/403).

Changes:
- Add exchangeTokenForOverage() dedicated OBO exchange with User.Read scope
- Update resolveGroupsFromOverage() to exchange token before Graph call
- Add overage handling to OPENID_ADMIN_ROLE block (was silently failing)
- Share resolved overage groups between required role and admin role checks
- Always resolve via Graph when overage detected (even with partial groups)
- Remove debug-only bypass that forced Graph resolution
- Add tests for OBO exchange, caching, and admin role overage scenarios

Co-authored-by: Airam Hernández Hernández <airam.hernandez@intelequia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Azure EntraId group overage does not support OPENID_ADMIN_ROLE env variable [Bug]: Azure EntraId group overage to Graph resolution fails

3 participants