Skip to content

feat(server): support Microsoft Entra ID as a token-exchange issuer - #15

Open
weiishann wants to merge 37 commits into
mainfrom
feature/m365-integration
Open

weiishann wants to merge 37 commits into
mainfrom
feature/m365-integration

Conversation

@weiishann

@weiishann weiishann commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

PR Checklist

  • A description of the changes is added to the description of this PR.
  • If there is a related issue, make sure it is linked to this PR.
  • If you've fixed a bug or added code that should be tested, add tests!
  • If you've added or modified a feature, documentation in docs is updated

Description of changes

Adds Microsoft Entra ID as a token-exchange issuer, alongside the existing DWSU static-JWKS trust path. Server side only — the UI sign-in flow is a separate, later change.

Why this was not possible before

JwksOperations.loadJwkProvider consulted the static JWKS file whenever the file existed, which made the OIDC-discovery branch below it unreachable. Every deployment rendered by deploy-uc.sh sets that file, so an Entra-signed token failed signature lookup with a 401. Entra's keys rotate and cannot live in a hand-maintained file, so the file-vs-discovery choice had to become per-issuer.

What changed

  • Per-issuer JWKS routing. The static file is authoritative only for the issuers it declares (each key's issuer member). Everything else resolves by OIDC discovery. Both trust sources coexist in one deployment, and DWSU hot-onboarding is untouched — the file path stays .cached(false).
  • Three-value Entra configuration. UC_ENTRA_TENANT_ID, UC_CLIENT_ID, UC_CLIENT_SECRET. The issuer and audience are derived in ServerProperties and unioned into the configured lists, so UC_ALLOWED_ISSUERS needs no Entra entry. The OAuth URLs are derived at render time in deploy-uc.sh, because their only consumer (Oauth2CliExchange) reads the rendered file directly and never goes through ServerProperties.
  • Discovery hardened for production. 5s timeout, response-status checking, per-issuer caching of the built JwkProvider (24h), rate limiting, and single-flight so concurrent callers share one in-flight fetch. Failures are never cached.
  • jwks_uri validation. Absolute-URL and scheme checks, and rejection of loopback, link-local, CGNAT, NAT64, multicast and RFC1918 targets. See the open item below for the known gap.
  • Failures classified by provenance, not by exception subtype. A local key file that cannot be read is a server-configuration fault; an unreachable identity provider is 503; a kid absent from a healthy key set is 401. Classification happens in JwksOperations, where the origin of the key set is known, rather than by pattern-matching a third-party exception hierarchy in a handler shared by every route.
  • Error responses no longer disclose internals. No stack traces, no raw cause messages, no provider URLs or filesystem paths in any client-facing message. All of that goes to the server log.
  • Diagnosable principal failures. A subject token missing a usable email claim says so and points at the Entra app registration's optional claims, instead of failing as User not allowed: <guid>. A startup log records the derived issuer and audience so the effective trust set is auditable.
  • Docs. New "Microsoft Entra ID sign-in" section in deploy/README.md, mirrored into deploy/README.zh-CN.md.

Effect on existing deployments

None by default. Every derivation is gated on server.entra.tenant-id; with no tenant configured, unionWithDerived returns the caller's list object unchanged, so there is no new code path. The routing change is safe because IssuerScopedJwkProvider already required every key to carry an issuer member, so any working deployment necessarily has a non-empty knownIssuers().

One intended behavior change: an issuer listed in server.allowed-issuers but not declared in the JWKS file now reaches OIDC discovery instead of returning 401. This only affects operator-configured values. Note the corollary — for such an issuer, removing its key from the JWKS file no longer fails closed; it routes to discovery. With the recommended blank UC_ALLOWED_ISSUERS this cannot arise.

Review history

Two rounds of adversarial review ran against this branch and found roughly thirty defects between them, including several introduced by the fixes for earlier ones. Fixed since the first draft: a rate limiter configured 100× tighter than documented (rateLimited's second argument is a refill period, not a count) which allowed ~10 unauthenticated requests to disable Entra sign-in for ~100 minutes; NetworkException used as a proxy for "remote", so a missing local certs.json returned 503 blaming Microsoft across the entire authenticated API surface; List.copyOf().contains(null) turning a 401 into a 500; three unauthenticated paths returning 500s with stack traces; an SSRF guard defeated by a trailing dot; and secret injection through the deploy renderer's sequential substitution.

Testing

265 tests, javafmtCheck clean. New tests drive real behavior rather than mocks: discovery runs over real HTTP against a local Armeria server, caching is asserted via JWKS-endpoint hit counts, and the redaction guard asserts the response body contains no path or URL. Every test added in the later waves was proved load-bearing by reverting the behavior and confirming it goes red.

CI will not verify this. unit-tests.yml triggers only on main and branch-*, so this PR runs lint and the Python client but never the server suite. All test results above are from local runs.

Open item needing a decision

UrlJwkProvider follows same-protocol redirects, so an allow-listed identity provider whose jwks_uri returns a 302 to an internal https address bypasses the jwks_uri validation, which only checks the initial URL. Closing this requires fetching the JWKS directly instead of delegating to the library.

Pending that, the limit is now documented rather than implied: the code javadoc and both deploy READMEs state that the check bounds the URL handed to the fetch and not every address the fetch can reach, so trusting an issuer reads as what it is — a decision about that issuer's operator.

Known follow-ups

  • 24-hour key cache means a signing key withdrawn at the identity provider keeps verifying tokens for up to a day, with no flush short of a restart.
  • IssuerScopedJwkProvider filters after selection, so a kid collision between two issuers in one JWKS file permanently rejects the later-listed one.
  • Single-flight serializes the failure path, so concurrent requests during an outage queue rather than failing in parallel.
  • No negative caching of unknown kids, so a flood can still degrade sign-in (recovery ~6s per token).
  • The discovery INFO line is emitted before any signing key is fetched, so it can report success while every key lookup fails.
  • helm/ does not support the new keys, so Entra is inert on a Helm deploy.
  • docs/server/auth.md still documents a superseded error string, and both READMEs describe a warning that is now suppressed.
  • --render-only aborts if the server binary is absent, which is the case it is most useful in.
  • A Helm-independent gap: a configured tenant's discovery path cannot be integration-tested, because the authority is fixed at login.microsoftonline.com and cannot be pointed at a test server.

Manual acceptance still required

Exchanging a real token from a real tenant. No test can prove a given app registration actually emits the email claim, which is the assumption the whole provisioning model rests on.

🤖 Generated with Claude Code

weishan and others added 30 commits September 19, 2026 13:52
Server-side only; the UI sign-in flow is a separate, later spec.

Records the three blockers found in the current code (all-or-nothing JWKS
resolution, the email-claim dependency in principal mapping, and an
uncached/untimed discovery path), the approved design, and the decisions
behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eight TDD tasks from the approved design: config derivation, per-issuer
JWKS routing, discovery hardening, JwkException status mapping, caching,
principal error split, deploy config, docs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… it declares

The static JWKS file previously swallowed every external issuer because the
branch tested whether the file existed rather than whether it declared the
requested issuer. Route per issuer instead: the file is authoritative only
for issuers present in knownIssuers(); anything else -- notably Microsoft
Entra ID, whose keys rotate and cannot live in a hand-maintained file --
falls through to OIDC discovery.

Adds DiscoveryTestServer, a local Armeria-backed OIDC provider for tests
(discovery + JWKS endpoints, hit counters, and knobs to fail or delay
discovery), reused by later tasks in this feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… as 503/504

The discovery call had no timeout and never checked the response status, so
a slow IdP could pin a request thread indefinitely and a 500 surfaced as a
confusing Jackson parse error instead of an honest failure. Add a fixed
5-second response timeout to the WebClient and check the discovery
response's status before parsing it: unreachable hosts, non-2xx responses,
and timeouts now map to ErrorCode.UNAVAILABLE (503) or
ErrorCode.DEADLINE_EXCEEDED (504) instead of leaking a raw exception. The
empty-configuration check moves from ABORTED to UNAVAILABLE as the same
class of upstream failure; the issuer-mismatch and missing-jwks_uri checks
stay on ABORTED since those are trust/configuration mismatches, not
outages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ch is bounded

Review caught that the comment (copied verbatim from the task brief) described
JwkProviderBuilder.timeouts as already applying to the JWKS fetch. Nothing calls
that method yet -- the JWKS fetch is unbounded until a later task wires it up.
Reword to future/conditional tense so the comment doesn't mislead a reader of
this commit alone. No behavior change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
NetworkException extends SigningKeyNotFoundException extends JwkException,
so a JWKS fetch failure was falling into the generic JwkException branch
and reporting an unreachable identity provider as a rejected token (401).
Add explicit branches for NetworkException and RateLimitReachedException
ahead of the generic JwkException check, mapping both to
ErrorCode.UNAVAILABLE (503). A genuinely unknown signing key
(SigningKeyNotFoundException without a network cause) still maps to 401.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cache the resolved jwks_uri per issuer for 24h so token exchange stops
re-running OIDC discovery on every call, and build the remote JWKS
provider with a 10-entry/24h key cache, a 10-per-minute rate limit, and
the existing HTTP_TIMEOUT applied to the JWKS fetch as well as
discovery. The static external JWKS file path is untouched and stays
uncached, so hot key onboarding still works without a restart. Only
successfully resolved jwks_uri values are cached; failures always
retry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing take effect

remoteProvider was rebuilt on every loadJwkProvider call, including on the
discovery-document cache hit path, so each new JwkProviderBuilder produced a
fresh GuavaCachedJwkProvider/RateLimitedJwkProvider with an empty key cache and
a full rate-limit bucket -- the JWKS endpoint was still hit on every token
exchange. CachedDiscovery now also holds the built JwkProvider, built exactly
once per fresh discovery and reused on every cache hit within the TTL; only a
successfully resolved provider is ever cached, after all validation.

Also fixed a read-before-mutation bug where the discovery cache's get and put
used different keys for a schemeless issuer (issuer was reassigned between
them). A single normalizedIssuer local is now computed once and used
consistently for the cache key and the rest of the discovery flow; the issuer
parameter itself is no longer mutated. Currently unreachable in practice since
every issuer that reaches this branch already carries a scheme, but the read
site no longer depends on that being true.

Added the regression assertion (jwksHits()) that would have caught the first
issue to discoveryDocumentIsFetchedOncePerIssuer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… user

An Entra token without an 'email' optional claim falls back to 'sub' -- an
opaque GUID -- and used to fail as "User not allowed: <guid>", which reads
like an authorization decision rather than a missing app-registration
setting. verifyPrincipal now reports the two causes separately: a missing
'email' claim names the app-registration fix, while a resolved-but-
unprovisioned subject keeps a distinct "User not provisioned" message. The
email -> sub fallback itself is unchanged; DWSU tokens still rely on it, and
the admin shortcut is untouched. AuthDecorator's own "User not allowed"
message for a separate code path is not touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uted placeholders

Three operator values (UC_ENTRA_TENANT_ID, UC_CLIENT_ID, UC_CLIENT_SECRET) now
flow from uc.env through deploy-uc.sh into server.properties, alongside two
optional URL overrides. The OAuth authorize/token URLs are derived from the
tenant id in the deploy script (not the server) because their only consumer,
the CLI, reads server.properties directly via java.util.Properties.

Also adds a render guard that fails the script if any ${VAR} placeholder
survives substitution in the rendered output — previously a key missing from
deploy-uc.sh's two hardcoded allow-lists would silently render as literal
text and be read by the server as a real value. A new --render-only flag
renders config and exits without starting the server, for safe verification.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds a "Microsoft Entra ID sign-in" section to deploy/README.md covering app
registration, the required email optional claim, SCIM-only provisioning (no
JIT), the three uc.env values, coexistence with the static JWKS file (the
remote path caches the built key provider per issuer, not just the discovery
document), and the outbound-HTTPS requirement with its 503/504 semantics.
Also records the feature and its two bugfixes in features.md as #15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review found that the Entra coexistence section's mention of the
"resolving keys by OIDC discovery" log line implied it was visible at
the shipped default. It is emitted at LOGGER.debug, while
etc/conf/server.log4j2.properties ships rootLogger.level=info -- an
operator at default log level would never see it and could wrongly
read that as caching being broken. State the prerequisite explicitly
and cross-reference the existing Logging section.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ed code

The spec said the discovery path should cache the resolved jwks_uri per
issuer while building the JwkProvider on every call. In jwks-rsa 0.22.1,
JwkProviderBuilder.build() constructs a fresh GuavaCachedJwkProvider,
RateLimitedJwkProvider and UrlJwkProvider on every call, each holding
its own per-instance cache/bucket state, so that design left the
configured caching and rate limiting inert and still hit the network
every exchange. The shipped code (JwksOperations.CachedDiscovery)
caches the built provider itself, per issuer, with a TTL instead.
Rewrite section 3 to describe what shipped and add a marked correction
note explaining why the original mechanism didn't work, so the goal
stays intact and future readers understand the design changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The discovery log line sat above the cache-hit return, so it fired on
every token exchange while deploy/README.md told operators it appears
once per issuer per cache window. Move it below the return, where it
fires only on an actual fetch, and promote it to INFO: at most one line
per issuer per 24h, so it is visible at the shipped info default rather
than invisible at debug. Add a WARN when an issuer falls through to
discovery while an external JWKS file is configured -- the typo'd
"issuer" member the spec asks to surface. Stay quiet when no file is
configured, which is a normal Entra-only deployment.

Also on the discovery path:

- A 200 with a non-JSON body threw a Jackson IOException out through
  @SneakyThrows, matched no GlobalExceptionHandler branch and surfaced
  as a bodyless 500 -- the exact confusing parse error the design set
  out to eliminate. Wrap the parse and report it as UNAVAILABLE (503)
  like the other upstream failures.
- A document with no "issuer" member NPE'd on the null cast result,
  while the adjacent jwks_uri was already null-checked. Null-check it.
- The DEADLINE_EXCEEDED branch dropped its cause while the UNAVAILABLE
  branch chained it, losing the stack trace on the one diagnostic path
  this work exists to improve. Pass it through.

Fold the two contradicting comment blocks into one, scoping the "bare
identifiers, not OIDC providers" claim to file-held issuers, and drop
the TODO asking for caching that has been in place since the provider
cache landed a few dozen lines above it.

DiscoveryTestServer can now serve an arbitrary 200 body; both new
failure modes are covered by tests that fail against the old code with
JsonParseException and NullPointerException respectively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The design requires a startup line naming the derived issuer and
audience so the effective trust set is auditable; it was never carried
into any task. The rendered server.properties holds only
server.entra.tenant-id, so today the issuer and audience actually
trusted appear in no file and no log.

Emit one INFO from initializeServer -- the path that runs exactly once
per server, rather than a getter called per request -- and only when a
tenant is configured, so non-Entra deployments are unchanged.

When server.client-id is unset the line says so explicitly instead of
just omitting the audience. That is the likeliest misconfiguration:
no audience is derived, every Entra token fails withAnyOfAudience, and
the operator is left with an opaque 401 about the 'aud' claim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two deploy READMEs are a maintained mirror with identical section
structure, but this branch added the Entra operator documentation to
the English one only. Translate the whole "Microsoft Entra ID sign-in"
section into deploy/README.zh-CN.md at the matching position, keeping
code identifiers, config keys, URLs and the verbatim server error
message in their original form.

Correct the logging guidance in both files: the discovery line now
fires only on an actual fetch and is logged at info, so the instruction
to raise rootLogger.level to debug is wrong and the line is visible at
the shipped default. Document the new warning, including that it is
expected for the Entra issuer in a deployment that also uses a JWKS
file.

Correct the design's failure table, which was right about the discovery
timeout and wrong about the JWKS one: a JWKS-fetch timeout is wrapped
by the auth0 library as NetworkException and maps to 503, not 504. The
README already said this; the spec did not get the correction when its
caching section did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JwkProviderBuilder.rateLimited(size, rate, unit) does not take a count
per unit. BucketImpl.getRatePerToken() returns unit.toMillis(rate) --
the refill period for ONE token -- so (10, 10, MINUTES) meant one token
every ten minutes, not ten per minute. Since /auth/tokens is excluded
from AuthDecorator and the Guava cache wraps outside the limiter without
caching failures, ~10 unauthenticated requests carrying unknown kids
drained the bucket and kept every uncached key lookup returning 503 for
100 minutes. Six seconds per token is the documented ten per minute; the
constant is renamed so it cannot be read as a count, and the trap is
spelled out where the next reader will hit it.

The key cache goes from 10 to 32 entries. Entra publishes around six
signing keys and rotates them, so 10 was tight enough that eviction cost
avoidable refetches.

jwks_uri is now validated before a provider is built or cached.
UrlJwkProvider hands the URL to URL.openConnection() with no scheme
restriction, so a discovery response could aim the fetch at file:///,
ftp://, or a private or metadata address, with the result observable
through the 401-vs-503 split, and the built provider was then cached for
24h. Only https to a public host is accepted, plus http to loopback so
the local test IdP keeps working; loopback, link-local, any-local,
private and unique-local literals are refused, including the decimal
spelling of 127.0.0.1. Hosts are classified from the literal only --
resolving a DNS name here would just open a rebinding race.

Malformed discovery documents now answer consistently: a member that is
present but not a string no longer reaches a (String) cast, and a
relative or otherwise unusable jwks_uri no longer escapes as
IllegalArgumentException or MalformedURLException. Both were bodyless
500s; both are UNAVAILABLE now. The attacker-supplied URL is logged at
debug only, and the messages name the scheme or the class of address.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
unionWithDerived returned List.copyOf(...) whenever a tenant was
configured, and List.copyOf(...).contains(null) throws where the
Stream.toList() it replaced returns false. AuthService consults the
allow-list with the token's issuer, which is null for a subject token
with no 'iss' claim, so on an unauthenticated endpoint anyone could turn
a 401 into a NullPointerException and a bodyless 500. Deriving a value
must not change that property, so the union is returned as an
unmodifiable view of an ArrayList: still immutable, still null-tolerant.

Also adds isEntraIssuer, so the one place that knows what an Entra
issuer looks like is the one that derives it. It grants no trust of its
own; the allow-list check is still what admits an issuer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… issuer

Three defects around who a subject token names.

AuthService and SecurityContext resolved the principal differently.
verifyPrincipal tested isMissing()/isNull() while createAccessToken used
getClaims().getOrDefault(EMAIL, sub), so a token carrying an explicit
"email": null was admitted on its 'sub' and then minted with a null
subject: HTTP 200 handing back a credential AuthDecorator rejects on
every later call. On main the same input failed loudly with an NPE. The
rule now lives once, in SecurityContext, and both callers use it.

That rule is also about usability, not presence. Claim.asString()
returns null -- it neither throws nor coerces -- for a claim that is a
number, array or object, so "email": 12345 was "present", the 'sub'
fallback was skipped, and a token with a well-provisioned 'sub' was
refused with "User not provisioned: null".

The Entra guidance fired for every sub-based token whose lookup missed,
including the DWSU tokens this fork's primary users present. Those
operators used to be told which principal was refused and are now sent
to edit an app registration they do not have, with no subject in the
message at all. The advice is gated on the token actually coming from
Entra, and every branch names the subject.

Finally, a subject token with no 'iss' claim is rejected as an unknown
issuer before the allow-list is consulted: 401 "Invalid issuer", which
is what it was before this branch, rather than a 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…secrets

--render-only was parsed into UC_RENDER_ONLY at the top and then
overwritten by `set -a; . uc.env`. A UC_RENDER_ONLY in uc.env -- which
the header comment invited -- therefore beat the command line: the flag
started a real server, or a stale =1 made every ordinary run exit 0
without starting anything. The flag now lives in a lowercase local that
sourcing cannot reach, and the env file is read back afterwards, so the
command line wins. Code and comment agree on the truthy forms (1, true,
yes, on, case-insensitive) and the flag still never reaches
start-uc-server.

The unsubstituted-placeholder guard grepped the RENDERED file for '${',
which now holds real secrets: a secret merely containing those two
characters failed the deploy and was printed in full to stderr, into
console output, CI logs and any deploy-log capture. The real check moves
to the template, before substitution, where there are no values to leak
and where a missing key is what actually goes wrong; the embedded
placeholder in hibernate.properties.template is covered too, which an
end-anchored scan of the rendered file would have missed. A backstop
over the rendered files stays, anchored to whole lines of the shape
key=${SOME_VAR} and printing only the key name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s class

GlobalExceptionHandler classified signing-key failures by matching auth0's
exception hierarchy: NetworkException -> 503, RateLimitReachedException -> 503,
any other JwkException -> 401. NetworkException does not mean "remote".
UrlJwkProvider.getJwks() fetches through a plain URLConnection under a blanket
catch (IOException) that constructs NetworkException, and BOTH local sources in
JwksOperations are UrlJwkProviders over file: URLs -- the INTERNAL certs file
and the static external JWKS file.

So deleting, truncating or mis-permissioning etc/conf/certs.json -- a volume
mount or umask mistake -- made AuthDecorator, which runs on every authenticated
API call, answer 503 "Could not reach the identity provider to fetch signing
keys." for the entire API surface, with the filename stripped out because that
branch used a fixed string. 503 also tells load balancers and clients to retry
a condition that never clears on its own.

The mirror defect came from the same root cause: an IdP serving 200 with
{"keys":[]} or an unparseable key entry throws a PLAIN SigningKeyNotFoundException
(auth0 reserves NetworkException for "cannot obtain jwks from url"), so it fell
to the generic branch and reported 401 -- telling the caller their token was
rejected when the identity provider is the thing that is broken.

Classify in JwksOperations instead, at the point provider.get(keyId) is called,
where loadJwkProvider's routing decision is still in hand. The provenance is
carried to the catch site in a ResolvedProvider record rather than re-derived,
so the routing decision exists once:

  - local source, key set unobtainable  -> ErrorCode.INTERNAL, message names
    the file; not 503, and the identity provider is not blamed
  - remote source, key set unobtainable -> ErrorCode.UNAVAILABLE (503)
  - key set obtained, kid not in it     -> ErrorCode.UNAUTHENTICATED (401)
  - rate limit reached                  -> ErrorCode.UNAVAILABLE (503), as before

Telling "kid not in the key set" from "key set could not be produced" needs
message matching, because auth0 throws a plain SigningKeyNotFoundException for
both. Option (a): match against jwks-rsa 0.22.1, pinned in build.sbt, with a
test that reads the wordings back out of the library and fails loudly if an
upgrade changes them. The match is fail-safe -- an unrecognised wording is read
as a kid miss, the 401 this code reported before, never a new 503 for a token
that is simply wrong -- and the severe case, a local file that cannot be read,
is typed and does not depend on the text at all.

GlobalExceptionHandler's NetworkException and RateLimitReachedException branches
are deleted as dead. The generic JwkException branch stays: com.auth0.jwk
exceptions are checked and from a sibling hierarchy of com.auth0.jwt's, so any
that still escaped would surface as a bodyless HTTP 500.

Tests drive the real provider chain rather than hand-built exceptions, which is
precisely what let the old classification pass review: GlobalExceptionHandlerJwkTest
constructed new NetworkException(...) itself, so it proved the branch ORDERING
and never touched which branch a real file error lands in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…path

ServerPropertiesEntraTest.blankTenantIsTreatedAsUnset built its properties from
a file, and readPropertiesFromFile strips every blank-valued entry -- which is
what lets etc/conf/server.properties document keys by leaving them empty. So
"server.entra.tenant-id=" left the property ABSENT, the test took
nothingIsDerivedWithoutATenant's path, and the isBlank() half of the guard in
getEntraIssuer() was never executed by any test.

The blank value is genuinely reachable: the ServerProperties(Properties)
constructor is a bare putAll with no stripping, and that is what BaseServerTest
-- and so every server-level test -- uses. Build it that way, assert the
property really is present and blank so the test cannot drift back onto the
null path, and cover a whitespace-only value too.

The guard itself is correct and needs no change: a blank or whitespace-only
tenant derives no issuer today. Removing the isBlank() half now fails both new
tests, where before it failed nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Relyt fork additions block documents server.external-jwks-file and
server.access-token-ttl, which is the convention for keys this fork added, but
server.entra.tenant-id never got a line -- it exists only in
deploy/server.properties.template. An operator working from the stock config
sees Entra mentioned next to server.allowed-issuers, hand-writes the full v2.0
issuer there, and silently gets no derived audience, because the client id only
becomes an accepted audience when a tenant is configured.

Add the key with a comment in the block's existing style, and point the
allowed-issuers Entra example at it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e positive principal

The Entra pieces were unit-tested in isolation -- ServerPropertiesEntraTest
derives the issuer and audience, JwksOperationsTest routes between the static
file and discovery -- but nothing drove AuthService with a tenant configured. So
nothing showed the derived issuer reaching the allow-list, the derived audience
reaching withAnyOfAudience, or a configured tenant's issuer resolving from the
static JWKS file. Each is one line from being silently inert.

AuthServiceEntraTest configures a tenant and a client id and nothing else -- no
server.allowed-issuers, no server.audiences -- and exchanges a real Entra-shaped
token end to end. It succeeds only if the audience is derived, and only if the
issuer resolves from the file rather than reaching login.microsoftonline.com. It
also pins that the derived audience is enforced and not merely present, and that
deriving one tenant does not admit the whole Entra authority.

That test has to declare the Entra issuer in the JWKS file to stay offline, and
that alone makes the issuer trusted via knownIssuers(), so it cannot show the
DERIVED issuer reaching the trust lists at all.
AuthServiceEntraTrustDerivationTest closes that gap with an empty key set: every
trusted issuer and accepted audience the server has is then derived from the
tenant, and which refusal comes back says whether the derivation happened. No
key lookup is reached, so both classes stay offline.

AuthServicePrincipalErrorsTest never provisioned a user, so "not provisioned"
was the only outcome it could observe and every assertion in it would still hold
if the lookup rejected everyone. Add the positive case for both token shapes,
asserting the issued token carries the email the principal was matched on.

BaseServerTest gains provisionUser(): the server keeps its own SessionFactory,
but under server.env=test both point at the same in-memory H2, and tearDown
already clears UserDAO.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ra's own issuer

Two log-and-fetch problems on the discovery path, both worst exactly when an
identity provider is unwell.

The fallthrough WARN exists to catch a typo'd "issuer" member in the external
JWKS file, which silently reroutes a file-held issuer to OIDC discovery. A
configured Entra tenant reaching discovery is not that mistake -- it is the only
path Entra has -- so in any mixed JWKS-file + Entra deployment the warning fired
for Entra every time and trained the operator to ignore it. The condition is now
a package-private predicate that skips the derived Entra issuer, and is unit
tested directly: asserting on log lines would pin the wording, not the rule.

Discovery failures are never cached, deliberately: /tokens is unauthenticated,
so negative caching would let anyone turn a momentary upstream blip into a local
outage lasting the whole discovery TTL. The cost is that during an outage every
exchange re-ran a 5-second fetch and re-emitted the same WARNs. Discovery is now
single-flight per issuer -- one thread fetches, the rest wait and re-check the
cache on entry -- so N concurrent cold-start callers make one fetch instead of
pinning N Armeria blocking threads. The lock object, not the fetch, is what
computeIfAbsent creates; a fetch inside computeIfAbsent would hold a
ConcurrentHashMap bin lock across a network call. On failure the lock is
released with nothing written, so the next request still retries.

The two failure-path WARNs are throttled to one line per issuer per minute by a
small monotonic-clock cooldown. The discovery INFO is left alone: it already
fires at most once per issuer per cache TTL for a healthy issuer, and it is what
an operator uses to confirm caching works.

Tests, via DiscoveryTestServer's hit counters and a new response gate, so
"a fetch is in flight" is a state the test controls rather than a sleep: eight
concurrent cold-start callers produce exactly one discovery fetch; a failed
discovery is retried on the next request rather than cached; and the cooldown
allows one line per key per window without ever becoming a one-shot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A local key file that cannot be read produced a 500 whose message interpolated
both the resolved file location and auth0's own cause message -- which, for a
file: URL, is "Cannot obtain jwks from url file:/opt/uc/etc/conf/certs.json".
AuthDecorator runs on every authenticated route and any garbage bearer token
reaches it, so that server filesystem path was disclosed to what is effectively
an unauthenticated caller.

Naming the file in the response was my own earlier request, and it was wrong.
The client-facing message is now generic: this is a server key-configuration
problem rather than a problem with the token, and the server logs have the
detail. ErrorCode.INTERNAL is unchanged, so the 500-not-503 classification
stands. The operator loses nothing: the ERROR log above it still carries the
issuer, the path and the cause exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three placeholder values shipped unquoted:

  ALIYUN_ACCESS_KEY=<your-aliyun-access-key-id>
  ALIYUN_SECRET_KEY=<your-aliyun-access-key-secret>
  ALIYUN_MASTER_ROLE_ARN=acs:ram::<account-id>:user/<master-ram-user>

In bash < and > are redirections. Sourcing a copy of the file confirms the
actual failure mode -- "syntax error near unexpected token `newline'" at the
first such line, exit 1, and no stray files created, because the trailing > has
no target and the parse fails before anything runs. It aborts mid-file, so every
variable after the first offending line is silently left unset, and deploy-uc.sh
sources uc.env under set -e and would exit there.

Single-quoting makes the characters literal. Verified by sourcing the fixed file
with set -a: exit 0, values intact, no files created. Checked the rest of the
file; nothing else needs quoting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…can work

Entra matches the redirect URI of a confidential client exactly, and
Oauth2CliExchange.findAvailablePort() falls back to a random port when
server.redirect-port is blank -- which it always was, because the deploy path
never rendered it. No registered URI can match a random port, so CLI login
against an Entra app registration could not work.

This is config only. The CLI already reads the property and honours it when it
is set (findAvailablePort():181-189), and helm/templates/server/_config.tpl
already renders it; deploy-uc.sh was the odd one out. So: UC_REDIRECT_PORT in
uc.env.example, server.redirect-port=${UC_REDIRECT_PORT} in the template, and
the key added to BOTH allow-lists in deploy-uc.sh -- the export line and the
python key list. A name in one but not the other is not harmless: missing from
the key list, the template's unknown-placeholder check fails the deploy (proved
against a scratch copy); missing from the export, it renders as empty, which is
indistinguishable from "deliberately blank".

Both READMEs document, in the Entra section, that CLI login needs a fixed port
plus the matching http://localhost:<port> registered on the app registration.

Also corrects two statements this PR's own docs got wrong: the spec's "the
server.redirect-port property exists but is unused" and the plan's claim that
the CLI "ignores" it. It does not; it reads it and falls back only when blank.

Verified with ./deploy-uc.sh --render-only against a scratch env file and a
scratch UC_HOME: server.redirect-port=8081 when set, blank when blank, and no
unsubstituted placeholder in either rendered file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…anch

The previous commit took the filesystem path out of the local-unreadable 500 and
left it in the 401 next to it: the kid-miss branch returned "Invalid signing
key: " + cause.getMessage(), and auth0's wording for that is "No key found in
file:/opt/uc/etc/conf/certs.json with kid ...". For a discovered key set the
same message carries the identity provider's jwks_uri. Both reach anyone who can
present a bearer token, since AuthDecorator resolves signing keys on every
authenticated route. Fixing one branch and not the other was incoherent.

So this is now a rule for the whole of keyLookupFailure, written into its
javadoc: a client-facing message carries no provider URL and no filesystem path.
The kid-miss 401 says that no key matching the token's 'kid' is registered for
the issuer -- the issuer having come from the caller's own token -- and
auth0's message goes to DEBUG, not ERROR, because this branch really is a client
error rather than a server fault. The remote branch already named only the
issuer and is unchanged.

GlobalExceptionHandler's unclassified-JwkException safety net did the same
interpolation, so it gets the same treatment: a generic 401 with auth0's message
logged at DEBUG. It is unreachable for key lookups in practice -- JwksOperations
classifies them all -- but a rule with an exception left in it is not a rule.

The regression guard asserts on the bytes a caller receives, not on an exception
message: responses rendered through the real GlobalExceptionHandler must contain
neither the key file's path, nor "file:", nor the jwks_uri, across the kid-miss
401, the unparseable-key-set 500 and the missing-certs-file 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
weishan and others added 7 commits September 19, 2026 23:56
"Issuer 'X': resolving keys by OIDC discovery" sat ahead of the fetch, and the
claim that it fires at most once per issuer per cache window is true only while
discovery SUCCEEDS. A failed discovery is deliberately never cached, so for the
whole duration of an identity-provider outage that line was emitted on every
single token exchange -- exactly the storm the single-flight and the WARN
cooldown were added to stop, one level quieter and so not covered by either.

Moved past the cache put and reworded to "resolved signing keys by OIDC
discovery": it now reports what happened rather than what is about to be
attempted, and is once per issuer per cache window in every condition. A failing
issuer is reported by the throttled WARN alone, at most once a minute.

Tested by attaching an appender to the JwksOperations logger: three failed
exchanges against a 503 identity provider produce no INFO at all, and the
subsequent successful resolution produces exactly one, with the second (cached)
call adding none. Log capture rather than a predicate because here the log line
IS the behaviour under test -- unlike the fallthrough rule, which is a decision
and stayed a plain testable predicate. Events are filtered by the test's own
issuer, whose port is unique to its DiscoveryTestServer, so a test class running
alongside cannot pollute the assertion. log4j-core is declared as a test
dependency for that: it was already on the test classpath transitively.

Both READMEs now say a *successful* discovery fetch is what gets logged, with
the reason; the plan's manual verification step names the new wording.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n error body

createErrorResponse put Arrays.toString(cause.getStackTrace()) into EVERY error
response, and the unclassified-RuntimeException branch returned cause.getMessage()
verbatim. Both go to whoever made the request: AuthDecorator runs on every
authenticated route and /tokens takes no credentials at all, so anyone able to
provoke an exception read back this server's class names, package layout, library
versions and the exact line reached -- and, through the message, whatever the
thrower had in scope: a filesystem path, a jdbc URL, a jwks_uri. It also quietly
undid the redaction added for key-set locations, since the frames name the classes
that failed to read them.

The trace now goes to the log, which is the only place it was ever useful:

  - a classified failure (BaseException and the two token branches) is logged at
    DEBUG, because the code that threw it already decided how loudly to report it.
    Logging those at ERROR from here would reopen, once per request, the exact
    log-volume hole the per-issuer cooldowns in JwksOperations exist to close.
  - an unclassified RuntimeException is logged at ERROR with its stack trace: it
    was classified by nobody, so this is the only record of it, and the caller
    gets a fixed message instead.

createErrorResponse no longer takes the throwable, so no future branch can start
returning one without that being visible. stack_trace stays in the body as an
explicit null: docs/server/auth.md documents that shape, and a client reading the
field should find it empty rather than missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…NPEing on it

AuthDecorator read the issuer out of an UNVERIFIED token and called
issuer.equals(INTERNAL) on it. A JWT is not required to carry an "iss": JWT.decode
accepts one that does not, and getIssuer() returns null, so that line threw
NullPointerException -- matching no GlobalExceptionHandler branch but the
RuntimeException one -- and answered 500 where the very next line would have
answered PERMISSION_DENIED. Reachable on every authenticated route by anyone who
can set a header.

This is the defect fixed in AuthService's token-exchange path in the previous
wave, in the sibling that was missed: both read a caller-supplied issuer, and only
one of them was guarded. Constant first is all it takes.

AuthDecoratorTest covers what the decorator does with a token BEFORE verifying it,
which is the part any caller controls: no iss (refused, and no user lookup
attempted), another issuer (still refused), and no alg at all against a one-key
certs.json -- UrlJwkProvider.get(null) returns the sole key of a one-key set, so a
missing kid is no obstacle and a missing alg reaches the algorithm switch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s_uri bypasses

Four findings on the one path, all reachable by an unauthenticated caller through
/tokens and by any bearer token through AuthDecorator.

1. A token naming no algorithm faulted the server. DecodedJWT.getAlgorithm()
   returns null for a header with no "alg", and a String switch on null throws
   NullPointerException: a RuntimeException, so neither catch in
   verifierForIssuerAndKey covered it and the answer was a 500 carrying the NPE.
   A null kid is no obstacle either -- UrlJwkProvider.get(null) returns the sole
   key of a one-key set, which is what certs.json holds. Naming no usable
   algorithm is a rejected token: 401, like an unsupported one, which also moves
   from ABORTED (409) to UNAUTHENTICATED.

2. A JWK whose material does not decode escaped the catches. Jwk.getPublicKey()
   declares only InvalidKeySpecException / NoSuchAlgorithmException /
   InvalidParameterSpecException, and for kty=RSA base64url-decodes "n" first: an
   entry with no "n" throws NullPointerException, a non-base64url "n" throws
   IllegalArgumentException, and a numeric "n" throws ClassCastException out of
   Jwk's own (String) cast. A key set holding an undecodable entry is an unusable
   key set, so it is classified by the provenance rule that already covers one
   that does not parse -- server-configuration fault for a local file, upstream
   fault for a discovered one. The catch is the whole of RuntimeException on
   purpose: what a third-party key decoder throws is not ours to keep in step
   with, and the property being defended is that NONE of it reaches a caller.

3. The jwks_uri guard was bypassable and too permissive.
   - A trailing root dot defeated it outright: https://localhost./keys has the
     host "localhost.", which matches neither "localhost" nor ".localhost" and
     which InetAddress resolves to 127.0.0.1. Hosts are now normalised before
     being classified.
   - Ranges nothing in InetAddress answers for are added: 100.64.0.0/10 (CGNAT,
     and most cloud-internal networks), 192.0.0.0/24, 198.18.0.0/15, multicast,
     and the IPv6 forms that embed an IPv4 address -- 64:ff9b::/96 (NAT64, so
     [64:ff9b::7f00:1] IS 127.0.0.1) and the IPv4-compatible ::/96.
   - Plain http to any loopback port was allowed in production, gated by nothing
     but a javadoc saying it was for local tests. That is a standing SSRF
     primitive: a trusted-but-compromised IdP publishes http://127.0.0.1:<port>/
     and reads this server's loopback surface off the 401-vs-503 split. It now
     requires io.unitycatalog.server.jwks.allowPlainHttpLoopback=true, which the
     build sets for the test JVM alone; the rule as it applies WITHOUT the flag
     is asserted by passing the flag's value in, so no global state decides it.

4. The local-key-file ERROR was unthrottled on the hottest path. Its remote twin
   has always been gated by a per-issuer cooldown; this one logged an ERROR with
   a stack trace per request, and AuthDecorator reaches it on every authenticated
   call, so a deleted certs.json let any caller hold the log open. Both halves of
   the unusable-key-set branch are now throttled the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two defects in the renderer, one of them introduced by the previous wave.

A value could inject a secret into an unrelated property. Substitution ran key by
key with str.replace, so text already inserted was rescanned: a value containing
another key's placeholder picked up that key's real value. With
UC_ALLOWED_ISSUERS='https://a.example/,${ALIYUN_SECRET_KEY}' the OSS secret was
rendered into server.allowed-issuers -- a secret moved into a property that is not
supposed to hold one, from a file nobody thinks of as executable. The template-side
placeholder check cannot catch it, because it scans the template, and the
placeholder is not in the template. One re.sub pass fixes it; the replacement is a
function, never a string, so a value containing \1 or \g<0> is not reinterpreted
either. A value that still looks like a placeholder now survives verbatim, and if
it occupies a whole property line the existing post-render backstop aborts the
deploy, naming the key and never the value.

An unedited uc.env deployed. Quoting the uc.env.example placeholders last wave
made the file sourceable, which is right, but '<your-aliyun-access-key-id>' is a
perfectly good non-empty string, so [ -z "${!v:-}" ] passed and the deploy went
ahead with literal placeholder credentials -- failing much later with an opaque
STS error instead of at deploy time. The required-variable check now also treats
a value still wearing angle brackets as not set. Only the variable NAME is ever
printed, since these are secrets and this output reaches CI logs.

Verified with --render-only against scratch env files and a scratch UC_HOME:
before, the injection env rendered the real secret into server.allowed-issuers and
the unedited example exited 0; after, the secret appears only in aliyun.secretKey
and the unedited example exits 1 having written nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e hot path

The sibling site of the local-key-file ERROR, found by asking where else the same
defect lives. knownIssuers() reads the external JWKS file to derive the trusted
issuers, and AuthService calls it on EVERY token exchange, before it will look at
the token at all -- so it sits on a path that needs no credentials to reach. Both
of its failure branches describe a state that persists until someone fixes the
deployment: the file is not there, or it does not parse. Ungated, each was one
WARN per request -- with a stack trace, in the unreadable case -- for as long as
the misconfiguration lasted, which is the same caller-driven log-volume hole the
key-lookup cooldowns exist to close.

Same LogCooldown, keyed on the file path, which is configuration and so bounded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… complete

The check bounds the URL handed to the fetch, but UrlJwkProvider delegates to
URL.openConnection(), which follows same-protocol redirects -- so an issuer that
is already allow-listed can redirect the JWKS request to an internal https
address without passing through the check again.

The javadoc specified the rule as though it were a complete SSRF control and the
deploy READMEs said nothing about the boundary at all. Both now say what the
check does and does not cover, so trusting an issuer reads as what it is: a
decision about that issuer's operator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
// is gated on the token actually coming from Entra: falling back to 'sub' is how DWSU
// token-exchange is designed to work, and those operators need the plain message. Every branch
// names the subject, because that is the principal the operator has to create.
if (!hasEmail && serverProperties.isEntraIssuer(decodedJWT.getIssuer())) {

@hujincalrin41 hujincalrin41 Sep 21, 2026

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.

The identity chain here is emailsub, which may not admit a service principal, depending on what the deployment actually sends. Flagging it as a question rather than a defect, because I have not verified it against a live tenant.

The concern: an Entra app-only token (client credentials) is documented as carrying neither email nor preferred_username, and its sub is the service principal's object id. If that holds, verifyPrincipal reaches getUserByEmail(<guid>), and the only way to provision such a caller is a UC user whose email field literally holds the GUID. validateUserEmail is lax enough to accept that, so it would function — but with two consequences that are verifiable in this repository rather than inferred:

  1. UserRepository.updateUser mutates only name, active and externalIdemail is immutable. Entra exposes three similar-looking GUIDs (app object id, application/client id, enterprise-application object id) and only the last appears as oid/sub, so picking the wrong one is an easy mistake. The only remedy would be deleting and recreating the user, which mints a new UUID and drops every grant, since authorization is keyed by the user UUID.
  2. The message this branch adds would not help in that case: with no user behind an app-only token, "add 'email' as an optional claim on the app registration" is not an action the operator can take.

Why this seems worth settling before merge: UC_ENTRA_TENANT_ID + UC_CLIENT_ID + UC_CLIENT_SECRET — the three values this PR introduces — is also the configuration shape of a service principal. The question that decides it is narrow: after those three are configured, is an interactive sign-in still required? If yes, this is a user-account flow and the current chain is fine (given email is configured as an optional claim). If no, it is a service principal and this path likely needs to resolve it.

If it turns out to be needed, one direction: resolve through an ordered chain rather than a single claim — emailpreferred_usernameupn looked up by email, then oid looked up by externalId, then sub. getUserByExternalId(Session, String) already exists in UserRepository (it backs the uniqueness check on insert); it would need a public overload, no schema change. Keeping the GUID in externalId also makes a mistyped id repairable in place, since that field is updatable.

Test coverage: AuthServicePrincipalErrorsTest covers the email/sub outcomes; there is no case for a token carrying only oid.

* The Entra v2.0 issuer derived from {@code server.entra.tenant-id}, or null when no tenant is
* configured. This is the exact string Entra puts in the {@code iss} claim of a v2.0 token.
*/
public String getEntraIssuer() {

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.

getEntraIssuer() derives exactly one issuer from a single-valued server.entra.tenant-id, which fixes this deployment to one Entra tenant. Worth deciding explicitly whether that is the intended scope.

Two things follow from the single value:

  1. A second tenant has to be listed by hand in server.allowed-issuers, which AuthService compares by exact string equality. Workable, but it bypasses the derivation this PR adds and has to be maintained separately.
  2. The multi-tenant authorities (common, organizations) cannot work at all today. Their OIDC discovery document returns the issuer as a literal templatehttps://login.microsoftonline.com/{tenantid}/v2.0 — while the token carries the real tenant GUID. The discovery path compares the document's issuer against the token's issuer for equality, so the check fails before any key is fetched. There is no template-aware comparison in this branch.

If single-tenant is the intended scope, it would help to say so in deploy/README.md next to UC_ENTRA_TENANT_ID, so an operator pointing it at common gets an answer from the docs rather than from a 401.

If multi-tenant is in scope, the comparison needs to treat the tenant path segment as a wildcard: match scheme, host and every other segment exactly, and allow that one segment to be any non-empty value. That keeps single-tenant behaviour bit-for-bit identical (an exact match still matches).

Test coverage: AuthServiceEntraTrustDerivationTest covers the single-tenant derivation; no case exercises a {tenantid} discovery document.

} catch (IOException e) {
LOGGER.warn("Failed to read external JWKS file '{}' for issuer discovery", jwksPath, e);
if (jwksFileWarnCooldown.allow(jwksPath.toString())) {
LOGGER.warn("Failed to read external JWKS file '{}' for issuer discovery", jwksPath, e);

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.

When the JWKS file exists but cannot be parsed, this catch returns Set.of(), and because loadJwkProvider routes on knownIssuers().contains(issuer), every locally-registered issuer stops being "known" and is sent to OIDC discovery instead.

For a DWSU issuer — a bare identifier, not a URL — that means "https://" + issuer, a DNS failure, and a 503 that reads identity provider unreachable or rate-limited. The operator is pointed at Microsoft for what is a local file problem. This also contradicts the classification principle this PR states in its own description: a local key file that cannot be read is a server-configuration fault; an unreachable identity provider is 503. Here an unreadable local file produces exactly the latter.

Before this change the routing did not depend on parsing: Files.exists(jwksPath) returned the file provider unconditionally, so a corrupt file surfaced as a file-provider failure at key-lookup time.

What makes this more than theoretical is the documented DWSU hot-onboarding procedure — an operator appends a key to this JSON file by hand, with no restart. One syntax error there does not just break the new key; it reroutes every issuer declared in that file to the public internet.

The javadoc above this method also describes the empty return as (fail-closed), which is not what it does at the call site: an empty set opens the discovery path rather than closing anything.

Suggested direction: distinguish "file parsed, issuer not declared in it" from "file could not be parsed". Only the first should fall through to discovery; the second is a server-configuration fault and should be reported as one.

Test coverage: JwksOperationsTest covers routing for a well-formed file; there is no case for a present-but-unparseable file.

// blocking unrelated issuers that happen to hash to the same bin and risking the map's own
// recursive-update failure. The map is keyed by normalized issuer and so is bounded by the
// allow-list that has already admitted this issuer upstream.
Object discoveryLock = discoveryLocks.computeIfAbsent(normalizedIssuer, key -> new Object());

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.

This lock is held across the full discovery fetch below it, and a failed discovery is deliberately never cached, so during an identity-provider outage the callers do not fail in parallel — they queue.

Each waiter re-checks the cache, finds nothing (the peer that just failed wrote nothing), and starts its own 5-second attempt. The Nth concurrent /tokens call therefore returns after roughly 5N seconds, and each one holds a thread from Armeria's shared blocking executor for the whole wait. The pool is shared with the rest of the server, so a sustained outage on one issuer can degrade unrelated routes.

Single-flight itself is the right call for the cold-start case the comment describes; the problem is only that failures serialize as well as successes.

Suggested direction: either give failures a short negative cache (a few seconds is enough to collapse a burst) so the queue fails fast, or have waiters share the in-flight attempt through a future rather than take the lock and retry in turn.

Test coverage: JwksOperationsTest asserts caching via endpoint hit counts on the success path; there is no concurrent-outage case.

int timeoutMillis = (int) HTTP_TIMEOUT.toMillis();
return new JwkProviderBuilder(validatedJwksUrl(jwksUri, issuer))
.cached(KEY_CACHE_SIZE, KEY_CACHE_TTL_HOURS, TimeUnit.HOURS)
.rateLimited(RATE_LIMIT_BUCKET, RATE_LIMIT_REFILL_PERIOD_SECONDS, TimeUnit.SECONDS)

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.

The bucket here is 10 tokens refilling one per 6 seconds, and auth0's Guava cache does not cache failures, so every lookup of a kid that is not already cached consumes a token and performs a real fetch against the identity provider.

/tokens needs no credentials, so ten unauthenticated requests carrying the Entra issuer and a random kid drain the bucket, and for roughly the next minute every legitimate cold lookup is refused by the limiter. It is worst exactly when it hurts most: right after a restart, or right after a key rotation, when nothing is cached yet. The ten requests also each cause a real JWKS fetch at Microsoft.

The refusal surfaces as identity provider unreachable or rate-limited, which points the operator at Microsoft rather than at the local limiter.

Suggested direction: at minimum separate the two conditions in the error text so the local limiter is diagnosable. Beyond that, the limiter is currently the only thing standing between an unauthenticated caller and an outbound fetch per request — a short negative cache keyed on (issuer, kid) would absorb the repeat-miss pattern without spending a token each time.

Test coverage: a test inspects the configured bucket; no case drives repeated unknown-kid lookups to observe the refusal.

// reaches that path on every authenticated request. Re-logging the same failure at ERROR
// from here, once per request, would reopen exactly the log-volume hole that cooldown
// exists to close.
LOGGER.debug("Answering a classified failure with {}", baseException.getErrorCode(), cause);

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.

Logging BaseException at DEBUG rests on the premise stated in the comment — that the code which threw it already reported the failure — and several throwers do not.

ModelRepository (around line 260) and AliyunCredentialGenerator (around line 146) both construct new BaseException(ErrorCode.INTERNAL, message, cause) and log nothing. Combined with the removal of stack_trace from the response body, a genuine 500 at the shipped info level now leaves no record on either side: the client gets a sanitised message, the server log gets nothing.

The premise does hold for the paths this PR owns — JwksOperations logs under a cooldown, and re-logging those at ERROR would undo the cooldown — but the handler is shared by every route, so the change applies well beyond them.

Suggested direction: gate the level on the status rather than on the exception type, e.g. server errors at ERROR (or WARN) and client errors at DEBUG. That keeps the cooldown intact for the 4xx classifications this PR adds, while a 500 stays visible.

Test coverage: GlobalExceptionHandlerErrorBodyTest asserts the response body; no case asserts what reaches the log.

Comment thread deploy/uc.env.example
UC_CLIENT_ID=
# Client secret. NOT used by the server, which only verifies signatures using public keys. It is
# used by clients that run the authorization-code flow (the CLI today, the UI later).
UC_CLIENT_SECRET=

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.

UC_CLIENT_SECRET is documented here as not used by the server, and that is accurate for this branch — but it is worth recording where it is used, because the answer affects who can read it.

Its only consumer is examples/cli/.../Oauth2CliExchange.java, which reads etc/conf/server.properties by relative path (Paths.get("etc/conf/server.properties")). The CLI therefore has to run from the UC installation directory, and anyone who can do that — or simply read that file — holds the client secret. The upstream source acknowledges the placement with its own // TODO: These properties, especially client-secret should probably be server side.

Not something this PR has to solve, but two consequences are worth stating in deploy/README.md next to these variables:

  1. the file needs restrictive permissions wherever CLI login is enabled, since the secret is at rest in the server's config directory;
  2. browser sign-in is not covered by this arrangement. A server-hosted /login + /callback pair would keep the secret inside the server process and let the UI degrade to a plain link, which is also the shape that avoids shipping a confidential client's secret to any browser later.

Worth confirming the intended sequencing here: is browser sign-in expected to reuse these same four OAuth settings server-side, or to stay a CLI-only flow?

* for anything else is what keeps this free of DNS lookups: {@code InetAddress.getByName} does
* not resolve a literal, and is never reached with a name.
*/
private static InetAddress literalAddress(String host) {

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.

NUMERIC_HOST is \d+(\.\d+)*, which matches strings that are not IPv4 literals — 1.2.3.4.5, 256.1.1.1 — so InetAddress.getByName treats them as names and performs a real DNS lookup, contrary to the javadoc directly above stating this is what keeps this free of DNS lookups.

The exposure is limited: jwks_uri comes from the discovery document of an issuer that already passed the allow-list, so this is not reachable by an unauthenticated caller. Two things still make it worth tightening. The invariant the comment asserts is relied on by the rest of the guard, and the lookup happens while the per-issuer discovery lock is held, so a slow resolver extends the serialization described in the other comment on this file.

Suggested direction: parse the literal instead of pattern-matching it — reject anything that is not a well-formed IPv4 (four octets, each 0-255) or bracketed IPv6 before calling getByName, so the "literal only" property is enforced rather than approximated.

Test coverage: the SSRF cases cover loopback, link-local, CGNAT, NAT64, multicast and RFC1918 literals; none covers a numeric-looking non-literal.

@hujincalrin41

Copy link
Copy Markdown
Contributor

Doc follow-up, not part of the diff: docs/server/auth.md lines 130 and 132 still show the pre-change wording for the token-exchange path.

Exception in thread "main" java.lang.RuntimeException: io.unitycatalog.client.ApiException: Error authenticating - {"error_code":"INVALID_ARGUMENT","details":[{"reason":"INVALID_ARGUMENT","metadata":{},"@type":"google.rpc.ErrorInfo"}],"stack_trace":null,"message":"User not allowed: bobbie@rocinante"}
at io.unitycatalog.cli.UnityCatalogCli.main(UnityCatalogCli.java:168)
Caused by: io.unitycatalog.client.ApiException: Error authenticating - {"error_code":"INVALID_ARGUMENT","details":[{"reason":"INVALID_ARGUMENT","metadata":{},"@type":"google.rpc.ErrorInfo"}],"stack_trace":null,"message":"User not allowed: bobbie@rocinante"}

Both sample responses read "message":"User not allowed: bobbie@rocinante", which this PR renamed to User not provisioned: ... in AuthService.verifyPrincipal. The same samples also show "stack_trace":null, a field this PR removes from the error body.

AuthDecorator keeps its own User not allowed message, so only the token-exchange samples in this file need updating.

@@ -0,0 +1,1300 @@
# Microsoft Entra ID Server Trust Implementation Plan

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.

This file and docs/superpowers/specs/2026-09-19-entra-idp-server-trust-design.md add roughly 1,600 lines of planning and design material to the repository. They are useful as review context, but worth a deliberate decision before merge rather than arriving with the code.

Two questions:

  1. Should they ship in the repository at all? This is a public fork; a dated implementation plan is a different kind of artifact from docs/server/*, and it will age out of step with the code it describes. Several passages already describe intermediate states ("Expected: FAIL — both responses currently say User not allowed: ...") that are no longer true of the final diff.
  2. If they stay, docs/superpowers/ is a new top-level area under docs/. Worth stating what belongs there, so the next feature knows whether to add to it.

Dropping them from the branch and keeping them in the PR description or a linked issue is the lower-maintenance option, and loses nothing for review.

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.

2 participants