Conversation
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>
"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())) { |
There was a problem hiding this comment.
The identity chain here is email → sub, 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:
UserRepository.updateUsermutates onlyname,activeandexternalId— email is immutable. Entra exposes three similar-looking GUIDs (app object id, application/client id, enterprise-application object id) and only the last appears asoid/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.- 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 — email → preferred_username → upn 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() { |
There was a problem hiding this comment.
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:
- A second tenant has to be listed by hand in
server.allowed-issuers, whichAuthServicecompares by exact string equality. Workable, but it bypasses the derivation this PR adds and has to be maintained separately. - The multi-tenant authorities (
common,organizations) cannot work at all today. Their OIDC discovery document returns the issuer as a literal template —https://login.microsoftonline.com/{tenantid}/v2.0— while the token carries the real tenant GUID. The discovery path compares the document'sissueragainst 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); |
There was a problem hiding this comment.
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()); |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
| 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= |
There was a problem hiding this comment.
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:
- the file needs restrictive permissions wherever CLI login is enabled, since the secret is at rest in the server's config directory;
- browser sign-in is not covered by this arrangement. A server-hosted
/login+/callbackpair 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) { |
There was a problem hiding this comment.
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.
|
Doc follow-up, not part of the diff: unitycatalog/docs/server/auth.md Lines 130 to 132 in 2aa698c Both sample responses read
|
| @@ -0,0 +1,1300 @@ | |||
| # Microsoft Entra ID Server Trust Implementation Plan | |||
There was a problem hiding this comment.
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:
- 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 sayUser not allowed: ...") that are no longer true of the final diff. - If they stay,
docs/superpowers/is a new top-level area underdocs/. 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.
PR Checklist
docsis updatedDescription 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.loadJwkProviderconsulted the static JWKS file whenever the file existed, which made the OIDC-discovery branch below it unreachable. Every deployment rendered bydeploy-uc.shsets 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
issuermember). 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).UC_ENTRA_TENANT_ID,UC_CLIENT_ID,UC_CLIENT_SECRET. The issuer and audience are derived inServerPropertiesand unioned into the configured lists, soUC_ALLOWED_ISSUERSneeds no Entra entry. The OAuth URLs are derived at render time indeploy-uc.sh, because their only consumer (Oauth2CliExchange) reads the rendered file directly and never goes throughServerProperties.JwkProvider(24h), rate limiting, and single-flight so concurrent callers share one in-flight fetch. Failures are never cached.jwks_urivalidation. 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.kidabsent from a healthy key set is 401. Classification happens inJwksOperations, 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.emailclaim says so and points at the Entra app registration's optional claims, instead of failing asUser not allowed: <guid>. A startup log records the derived issuer and audience so the effective trust set is auditable.deploy/README.md, mirrored intodeploy/README.zh-CN.md.Effect on existing deployments
None by default. Every derivation is gated on
server.entra.tenant-id; with no tenant configured,unionWithDerivedreturns the caller's list object unchanged, so there is no new code path. The routing change is safe becauseIssuerScopedJwkProvideralready required every key to carry anissuermember, so any working deployment necessarily has a non-emptyknownIssuers().One intended behavior change: an issuer listed in
server.allowed-issuersbut 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 blankUC_ALLOWED_ISSUERSthis 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;NetworkExceptionused as a proxy for "remote", so a missing localcerts.jsonreturned 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,
javafmtCheckclean. 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.ymltriggers only onmainandbranch-*, 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
UrlJwkProviderfollows same-protocol redirects, so an allow-listed identity provider whosejwks_urireturns a 302 to an internalhttpsaddress bypasses thejwks_urivalidation, 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
IssuerScopedJwkProviderfilters after selection, so akidcollision between two issuers in one JWKS file permanently rejects the later-listed one.kids, so a flood can still degrade sign-in (recovery ~6s per token).helm/does not support the new keys, so Entra is inert on a Helm deploy.docs/server/auth.mdstill documents a superseded error string, and both READMEs describe a warning that is now suppressed.--render-onlyaborts if the server binary is absent, which is the case it is most useful in.login.microsoftonline.comand 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
emailclaim, which is the assumption the whole provisioning model rests on.🤖 Generated with Claude Code