Skip to content

Ignore OIDC scopes in client-credentials .default resolution - #29

Open
benrobot wants to merge 2 commits into
cmaneu:mainfrom
benrobot:fix/client-credentials-oidc-scopes
Open

benrobot wants to merge 2 commits into
cmaneu:mainfrom
benrobot:fix/client-credentials-oidc-scopes

Conversation

@benrobot

@benrobot benrobot commented Aug 30, 2026

Copy link
Copy Markdown

Problem

The client-credentials grant rejected any scope containing more than one token, including the
OIDC companion scopes (openid, profile, offline_access) that MSAL/Azure Identity always
appends to a client-credentials request. Real Entra ID accepts this request. As shipped, this made
the emulator unusable with the MSAL client-credentials flow in any language.

This is the defect reported in #28. The maintainer has since added an accepted spec —
specs/2026-08-30_28-client-credentials-additional-scopes.md
which this PR implements in full.

What the official docs actually require

The client credentials flow docs
describe the scope requirement as being about the resource, not about excluding OIDC scopes:

scope ... All scopes included must be for a single resource. Including scopes for multiple
resources will result in an error.

The scopes and permissions docs
spell out the actual restriction on mixing .default with other scopes, and it's about resource
scopes, not OIDC scopes:

Clients can't combine static (.default) consent and dynamic consent in a single request. So
scope=https://graph.microsoft.com/.default Mail.Read results in an error because it combines
scope types.

That example combines .default with another resource-scoped permission (Mail.Read), not
with openid/profile/offline_access. In practice OIDC companion scopes can't be rejected here,
since Azure Identity/MSAL (Java, .NET, Node, Python) always appends them for this grant — if real
Entra ID rejected that, no MSAL-based daemon app could authenticate.

Fix (per the accepted spec)

resolveClientCredentialScope (src/identity/clientCredentials.ts):

  • Accepts exactly one resolvable <resource>/.default scope plus zero or more of a narrow
    companion-scope allowlist — openid, profile, offline_access — matching exactly what Azure
    Identity sends for this grant. This is deliberately not the more lenient OIDC_SCOPES set
    already used by /authorize//devicecode (which also admits email): email isn't one of the
    scopes MSAL appends here, so it's still rejected as an unrecognized extra, per the spec's
    contract table.
  • Rejects a duplicated companion scope (e.g. openid openid <default>).
  • Returns an effectiveScope on the resolved result. handleClientCredentials (token.ts) now
    uses it to set the response scope, so a successful response always normalizes to the effective
    <resource>/.default only — it never echoes back the OIDC companion scopes the caller sent
    (they weren't granted as delegated permissions in this app-only flow).

Errors preserved/added per the spec's contract table:

  • no tokens at all → invalid_request
  • OIDC scopes only, no resource scope → invalid_scope
  • more than one non-companion scope (incl. email, User.Read, or two .defaults) → invalid_scope
  • a duplicated companion scope → invalid_scope
  • a single non-companion scope that isn't <resource>/.defaultinvalid_scope (unchanged)

Java Azure Identity sample (samples/java-client-credentials/)

A standalone Maven sample using the official com.azure:azure-identity (MSAL4J) library's
ClientSecretCredential — the exact library/scenario reported in #28:

  • Requests only https://graph.microsoft.com/.default; Azure Identity appends the OIDC companion
    scopes itself (the sample never adds them).
  • Decodes and prints the resulting token's claims (app-only: aud, appid/azp, roles, no
    scp/user claims) and calls GET /graph/v1.0/users.
  • --smoke mode for CI (this flow has no human interaction to begin with, so it's the same flow,
    just a single machine-readable status line).
  • Dev-only TLS trust (Trust.java): trusts the emulator's own data/tls/cert.pem when present,
    else falls back to trust-all with a loud warning — never for anything but a local emulator.
  • Verified locally end-to-end against a live emulator build from this branch: real Azure Identity
    request with openid profile offline_access https://graph.microsoft.com/.default succeeds,
    decodes to aud=https://graph.microsoft.com, roles=[], no id_token/refresh_token, and
    GET /graph/v1.0/users returns 200.
  • Added samples/README.md index entry and a java-client-credentials-sample CI job (JDK 17 +
    Maven, mirrors the existing node-cli-sample job) that builds/starts the emulator and asserts
    the smoke output.

Tests

  • test/unit/client-credentials-helpers.test.ts / test/integration/client-credentials.test.ts:
    added cases for the full companion-scope allowlist (any order), email rejection, duplicate
    companion-scope rejection, and response-scope normalization; existing cases updated to match.
  • memory/decisions.md: recorded the decision per the repo's append-only convention.
  • Ran all 5 quality gates locally: lint, typecheck, build, test (412 passed / 6 skipped),
    test:e2e (including the real msal-node client-credentials e2e).

Closes #28

MSAL always appends openid/profile/offline_access to a client-credentials
token request, and real Entra ID accepts that request. The emulator's
strict token-count check rejected it outright, breaking every MSAL-based
client-credentials client (Java, and likely others).

Filter OIDC scopes (reusing the existing OIDC_SCOPES set from scopes.ts)
before applying the exactly-one-resource-scope rule, so the rule now
targets only non-OIDC scopes as originally intended.

Refs cmaneu#28
@benrobot

Copy link
Copy Markdown
Author

I just noticed there was a spec already in place for this. I'm closing this PR until the implementation matches the existing spec.

@benrobot benrobot closed this Aug 30, 2026
The maintainer accepted issue cmaneu#28 and added
specs/2026-08-30_28-client-credentials-additional-scopes.md, which is
stricter/more complete than the original fix on this branch. Bring the
implementation in line:

- resolveClientCredentialScope: narrow the companion-scope allowlist
  to exactly openid/profile/offline_access (matching what Azure
  Identity/MSAL actually sends for this grant), rather than reusing
  scopes.ts's more lenient OIDC_SCOPES (which also admits email).
  email now correctly stays rejected as an unrecognized extra.
- Reject a duplicated companion scope (e.g. 'openid openid ...').
- Add an effectiveScope to the resolved result; token.ts now uses it
  to normalize the response scope to the effective <resource>/.default
  only, never echoing OIDC companions the caller sent.
- Extend unit + integration coverage for the above.
- Add samples/java-client-credentials/: a real com.azure:azure-identity
  (MSAL4J) daemon reproducing the exact issue cmaneu#28 request shape against
  a live emulator, plus a CI smoke job.
- memory/decisions.md: record the decision per project convention.

Closes cmaneu#28
@benrobot benrobot reopened this Aug 30, 2026
benrobot added a commit to benrobot/entra-local that referenced this pull request Aug 30, 2026
Issue cmaneu#30 has been filed
upstream for the delegated-token roles feature; rename the spec file
and update its issue references from the placeholder cmaneu#29 to cmaneu#30.
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.

Client credentials additional scopes

1 participant