feat(auth): negotiate DICOMweb authorization per server at runtime - #427
Conversation
With an `oidc` block configured, Slim attached the access token to every DICOMweb request regardless of whether the server wanted one. That has two consequences: - The token is disclosed to servers that have no business seeing it, including any URL typed into the server-selection dialog. - `Authorization` is not a CORS-safelisted request header, so every request is preceded by an OPTIONS preflight. Public DICOMweb endpoints that do not answer preflights correctly then fail with an opaque CORS error even though plain GETs would have succeeded. Requests now start anonymous and escalate only when a server answers 401/403. A server listed in the config file is credentialed silently, since the operator vouched for it by putting it there. A server introduced at runtime — the selection dialog or the `?gcp=` parameter — requires explicit user consent first, because escalation is triggered by the server and an untrusted endpoint could otherwise harvest a live credential by replying 401. Decisions are remembered per origin in localStorage, so each server is negotiated once per browser. Because this is negotiated at runtime, adding or swapping endpoints needs no redeployment. That matters for the two paths that never appear in the config file at all. `servers[].sendAuthorization` overrides the negotiation: `false` never sends the token, `true` sends it from the first request. The latter is needed for a server that answers 200 with fewer results rather than 401 when unauthenticated, which runtime detection cannot distinguish from an open server. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
|
Overall Grade |
Security Reliability Complexity Hygiene |
Code Review Summary
| Analyzer | Status | Updated (UTC) | Details |
|---|---|---|---|
| JavaScript | Aug 19, 2026 4:53p.m. | Review ↗ |
Important
AI Review is run only on demand for your team. We're only showing results of static analysis review right now. To trigger AI Review, comment @deepsourcebot review on this thread.
📦 Firebase Preview - Using Published DMVThis preview is using the published
|
|
Visit the preview URL for this PR (updated for commit 98f8600): https://idc-external-006--pr427-oidc-selector-9d3osx74.web.app (expires Wed, 26 Aug 2026 16:56:10 GMT) 🔥 via Firebase Hosting GitHub Action 🌎 Sign: 88aacecd98ba54d2f9c8d201a9444e43d1ad8307 |
There was a problem hiding this comment.
Pull request overview
Implements runtime, per-origin negotiation of whether to send the user’s OIDC access token on DICOMweb requests, defaulting to anonymous requests and escalating only after a 401/403 challenge (with persisted per-origin decisions).
Changes:
- Added
authPolicyutilities to persist per-origin grant/deny decisions inlocalStorage. - Updated
DicomWebManagerto support tri-state auth mode per store (always/never/auto) and to perform challenge-and-retry with per-origin in-flight deduplication. - Integrated an app-level authorization policy + consent modal, and stopped forwarding
Authorizationto user-typed server URLs by default; updated docs and types accordingly.
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/utils/authPolicy.ts | Adds origin parsing and localStorage-backed persistence for per-origin auth disclosure decisions. |
| src/DicomWebManager.ts | Introduces per-store auth modes, challenge detection, consent-driven escalation, and header filtering. |
| src/AppConfig.d.ts | Documents servers[].sendAuthorization override semantics (always/never vs runtime negotiation). |
| src/App.tsx | Implements policy wiring + consent modal; prevents forwarding tokens to typed URLs; applies policy to managers. |
| src/tests/DicomWebManager.test.ts | Adds unit tests for escalation behavior, refusal behavior, overrides, and concurrency deduplication. |
| docs/CONFIGURATION.md | Documents the new runtime negotiation flow and sendAuthorization override in configuration docs. |
Suppressed comments (1)
src/DicomWebManager.ts:476
- After a grant, callStore sets the Authorization header only on the challenged store (store.client.headers.Authorization = ...). If multiple stores share the same origin (or other stores are already pre-authorized), they’ll remain anonymous until they individually 401 or until some later updateHeaders() call, causing extra round-trips/retries. Consider reusing updateHeaders({ Authorization }) here so all eligible stores pick up the token immediately (while still respecting per-store filtering).
store.authGranted = true
this.currentAuthorization = authorization
store.client.headers.Authorization = authorization
return await call(store.client)
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| this.configuredOrigins = new Set( | ||
| props.config.servers | ||
| .map((server) => (server.url != null ? getOrigin(server.url) : baseUri)) | ||
| .filter((origin): origin is string => origin !== undefined), | ||
| ) |
There was a problem hiding this comment.
Confirmed and fixed in dbcac10.
The rewrite is serverSettings.url = pathUrl in _createClientMapping (src/App.tsx:118), which mutates the config object in place, so the snapshot taken beforehand held the pre-rewrite origin. On a /projects/ route the effective origin comes from gcpBaseUrl, so any deployment whose configured URL had a different origin would classify its own server as runtime/untrusted and prompt for consent — for a server the operator had listed all along.
Took the approach you described: configuredServers now holds references (not copies) taken before addGcpSecondaryAnnotationServer, and the origins are read from them after _createClientMapping has run. Since the objects are shared, reading .url afterwards picks up the rewrite, and the ?gcp= server is still excluded because it is appended after the snapshot.
| if (authorization === undefined) { | ||
| /** | ||
| * Consent was refused or no token exists. Stop treating this store as a | ||
| * candidate so later failures are reported normally, and report the | ||
| * error the interceptor suppressed while escalation was pending. | ||
| */ | ||
| store.authRefused = true | ||
| this.handleError(error as dwc.api.DICOMwebClientError, store.settings) | ||
| throw error | ||
| } |
There was a problem hiding this comment.
Good catch — confirmed and fixed in dbcac10.
The path was real: handleDICOMwebError (src/App.tsx:203) calls ensureAuthorized() on any 401, which renews the session and can escalate to an interactive redirect to the IdP. So clicking "Don't send" would bounce the user through a sign-in that could not have helped — the request failed because the credential was withheld, not because it was stale.
Worse, the original guard suppressed the interceptor only while a store was still an escalation candidate. Once authRefused was set, every subsequent 401 from that server fell through to the general handler, so the redirect would fire repeatedly for the rest of the session.
Both are addressed by splitting the predicate:
isAnonymousChallenge— any 401/403 against a store we deliberately queried without credentials (autonot yet granted, already refused, orsendAuthorization: false). The error interceptor now suppresses all of these, so none reach the expired-token recovery.isAuthChallenge— the subset that can still be escalated from.
Withheld-credential failures get their own NotificationMiddleware message naming the origin, reported once per store so a refused server does not spam on every subsequent request. A 401 against a store that did send its token is untouched and still drives re-authentication as before.
Four regression tests added, including the sendAuthorization: false variant of the same path and an assertion that consent is requested only once no matter how many requests follow a refusal.
Addresses review feedback on #427. `configuredOrigins` was snapshotted before `_createClientMapping`, which rewrites `serverSettings.url` in place for `/projects/` routes. On those routes the effective origin is derived from `gcpBaseUrl`, so an operator-configured server whose configured URL had a different origin was classified as untrusted and prompted for consent unnecessarily. The configured servers are now held by reference and their origins read after the rewrite, which still excludes the `?gcp=` server because the snapshot is taken before it is appended. A refused escalation was reported through the general DICOMweb error handler, whose 401 branch treats the failure as an expired session and calls `ensureAuthorized()` — dragging the user into an interactive sign-in they had just declined, and which could not have helped, since the request failed because the credential was withheld rather than stale. Withheld-credential failures now get their own notification, reported once per store, and the error interceptor stays quiet for any 401/403 against a deliberately anonymous store rather than only for escalatable ones. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
📦 Firebase Preview - Using Published DMVThis preview is using the published
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (7)
src/tests/DicomWebManager.test.ts:627
- As above, this
onErrorassertion is vacuous because the replacement stub never calls the real client'serrorInterceptor. Please make the stub execute the interceptor for rejected requests so thesendAuthorization: falsesuppression path is actually covered.
expect(onError).not.toHaveBeenCalled()
src/DicomWebManager.ts:427
- This predicate uses the store's current grant state rather than the state of the failed request. If two calls start anonymously and one challenge grants access before a slower 401/403 reaches the interceptor,
authGrantedis already true: that anonymous failure is routed through expired-token recovery, andcallStorealso skips its authenticated retry. Capture whether each call actually dispatched with authorization and classify its failure from that request-local value; the interceptor and retry path need to share that classification.
status !== undefined &&
AUTH_CHALLENGE_STATUSES.has(status) &&
store.authMode !== 'always' &&
!store.authGranted
)
src/App.tsx:390
- The in-flight deduplication is local to each
DicomWebManager, but_createClientMappingcreates distinct managers per storage class containing the same runtime?gcp=origin. Concurrent challenges from two managers can both reach this prompt before either writes localStorage, producing multiple dialogs (and potentially racing opposite persisted answers). DeduplicaterequestAuthorizationin this shared application policy by origin so all managers share one pending decision.
if (remembered !== 'granted' && !this.configuredOrigins.has(origin)) {
const approved = await App.confirmAuthorizationDisclosure(origin)
writeAuthorizationDecision(origin, approved ? 'granted' : 'denied')
src/tests/DicomWebManager.test.ts:578
- This assertion does not exercise the configured
errorInterceptor:stubManagerClientsreplaces the real DICOMweb client with a plain stub, so rejectingsearchForStudiesnever invokes the interceptor oronError. The test would still pass if anonymous 401s incorrectly triggered reauthentication. Preserve or explicitly invoke the captured interceptor in the test double before asserting this behavior.
This issue also appears on line 627 of the same file.
expect(onError).not.toHaveBeenCalled()
src/App.tsx:478
- README.md:161 still states that custom server selections re-apply the current authorization when OIDC is enabled, which is now the opposite of this behavior. Update that runtime-selection documentation so users are not told that typed endpoints receive the token immediately.
* Carry over non-credential headers only. The token is deliberately not
* forwarded here: this URL was typed by the user and has not been vetted by
* anyone. If the server actually needs credentials it will answer 401, and
* the authorization policy will ask before anything is disclosed.
docs/CONFIGURATION.md:78
- Omitting
Authorizationdoes not guarantee a CORS-simple request or no preflight. For example, this project adds a non-safelistedContent-Security-Policyheader whenupgradeInsecureRequestsis enabled, and STOW uses a non-safelisted content type. Rephrase this as avoiding the preflight caused byAuthorizationwhen the remaining request is otherwise CORS-safelisted.
1. **Try anonymously.** The first request carries only safelisted headers, so
the browser sends no `OPTIONS` preflight. An open server answers `200` and
the exchange ends here — it never sees your token.
src/utils/authPolicy.ts:66
- The new persistent authorization boundary has no direct tests, although comparable utilities under
src/utilsare unit-tested. Add coverage for per-origin grant/denial round trips, malformed stored data, and localStorage read/write exceptions; regressions here can either disclose a token unexpectedly or repeatedly prompt users.
export const readAuthorizationDecision = (
origin: string,
): AuthorizationDecision | undefined => {
const decision = readPolicy()[origin]
return decision === 'granted' || decision === 'denied' ? decision : undefined
Code ReviewThe implementation correctly matches the PR description and is well-designed. The anonymous-first approach properly solves both the token disclosure and CORS preflight issues. Tests are comprehensive and the security model is sound. Potential Hardening OpportunitiesA few areas that could be improved in this PR or a follow-up: 1. HTTPS enforcement/warningCurrently no check if credentials are about to be sent over plain HTTP: // In confirmAuthorizationDisclosure or requestAuthorization
if (new URL(origin).protocol !== 'https:') {
// Warn or block - sending tokens over HTTP is risky
}2. Consent revocation UI
3. Session vs persistent storageDecisions persist in
4. More informative consent modalThe modal could be more specific about what access the token provides (e.g., naming the identity provider). 5. Audit loggingFor debugging/security review, log authorization decisions with timestamps. |
Addresses hardening review feedback on #427. Escalation to a non-HTTPS origin is now refused outright rather than prompted for. A bearer token sent in cleartext is readable by anything on the path, and no consent dialog makes that safe. Loopback hosts are exempt, matching the browser's definition of a secure context, and an operator with a reason to do it anyway can still force the token with `sendAuthorization: true`, which bypasses negotiation. Consent grants now expire after 30 days; denials are still kept indefinitely. Consent is durable authority rather than a credential, so on a shared computer a decision left behind by one user would otherwise apply to the next user's token. The asymmetry is deliberate: forgetting a grant costs one prompt, forgetting a denial silently widens disclosure. The consent modal now names the identity provider that issued the token and states that whoever holds it can act as the user against that provider. "Your access token" alone does not convey what is at stake, which differs a great deal between a hospital SSO and a personal Google account. Both decision paths log the origin and whether the approval came from the user or from the origin being present in the deployed configuration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks @igoroctaviano — three of these are in as of 27ea6a1, and I'd like to push back mildly on one of the framings for the other two. 1. HTTPS enforcement — done, as a hard refusalAgreed this was a real gap. I went further than a warning: escalation to a non-HTTPS origin is now refused outright, with no prompt offered. A bearer token in cleartext is readable by anything on the path, and a consent dialog doesn't change that — offering the choice mostly launders the risk onto the user. Loopback hosts ( Worth noting the blast radius is narrower than it first looks: a Slim instance served over HTTPS can't reach an An operator who needs it anyway can still force the token with 3. Storage lifetime — grants now expire, denials don'tGood catch on the shared-computer case. The precise risk is worth stating: consent isn't itself a credential, so a leftover decision grants nothing on its own. But the next user signs in as themselves, and the stale approval then silently applies to their token. So the exposure is real, just indirect. Grants now carry a 30-day expiry. Denials are kept indefinitely, and the asymmetry is deliberate — forgetting a grant costs one extra prompt, whereas forgetting a denial silently widens what the token is disclosed to. Fail-closed is the safe direction to be wrong in. I considered 4. Consent modal — doneFair point that "your access token" doesn't tell anyone what's at stake; the answer differs a lot between a hospital SSO and a personal Google account. The modal now names the issuer from 5. Audit logging — already there, now more specificBoth paths already logged. I've made the lines say why a decision was reached, which is the part that was actually missing: I left out explicit timestamps — devtools timestamps console output already, and the project's logger config gates verbosity — but say the word if you want these routed through 2. Consent revocation UI — deferring, and I think that's the right callThis is the one I'd rather not do here. Slim has no existing "site permissions" surface, so doing it properly means deciding where it lives (header menu? settings modal?), how the list of remembered origins is presented, and whether individual origins can be revoked or only the whole set. That's a UI design conversation, not a line of code, and folding it into an auth-negotiation PR would bury it. I've documented the manual path in Full suite: 129 passing across 13 suites. New |
📦 Firebase Preview - Using Published DMVThis preview is using the published
|
Addresses the suppressed review comment on #427. After a successful escalation, callStore wrote the Authorization header directly onto the challenged store's client. Any other store the policy already permitted — a sibling on the same origin, or an origin approved earlier in the session — stayed anonymous until it was individually refused, costing an avoidable round trip each. The grant is now applied through updateHeaders, which re-evaluates every store against the policy. Per-store filtering is unchanged, so a store on an unapproved origin, or one configured `sendAuthorization: false`, is still skipped even when it shares an origin with the store that was granted. The same gap existed one level up: each storage class gets its own manager, so a grant discovered by one left the others to rediscover it. The policy now pushes the token across every manager once an origin is approved. Each manager re-applies its own filtering, so this cannot widen disclosure beyond the recorded grants. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Picking up the suppressed comment on The observation was correct: after a grant, the header was written directly onto the challenged store's client, so any other store the policy already permitted stayed anonymous until it was individually refused. Applying through Per-store filtering is unchanged, and the two boundaries that matter are now covered by tests:
While fixing it I found the same gap one level up, which the comment didn't reach: each storage class gets its own Three tests added; full suite is 132 passing across 13 suites. |
📦 Firebase Preview - Using Published DMVThis preview is using the published
|
The consent prompt was deduplicated per DicomWebManager, but `_createClientMapping` builds a separate manager for each storage class whenever any server declares `storageClasses` — which the `?gcp=` secondary store always does. A single page load then challenged the same origin from several managers at once, and each opened its own modal, so the user faced a stack of identical prompts. Deduplication now happens in the authorization policy, which is shared by every manager, so concurrent challenges for one origin join a single negotiation regardless of which client raised them. The same collapse-by-key logic existed in DicomWebManager already, so it is extracted to `createSingleFlight` and used by both. That also makes it directly testable, including the case that caused the bug: a failed run must release its key rather than poison it. `negotiateDisclosure` no longer rejects. Callers are already inside a DICOMweb error path, and a rejection there would replace the underlying server error with a less useful one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
📦 Firebase Preview - Using Published DMVThis preview is using the published
|
Records the localStorage decision format as a supported contract, with a Playwright recipe, and covers it with tests so the key and shape are not changed casually. Notes that omitting `expiresAt` makes a seeded decision permanent, which matters for a long-lived browser profile that would otherwise begin prompting once the default 30-day grant lifetime elapsed part-way through a campaign. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
📦 Firebase Preview - Using Published DMVThis preview is using the published
|
|
@igoroctaviano I think it is ready for you - if looks good, please merge! |



dmv-branch:
Context
With an
oidcblock configured, Slim attached the OIDC access token as anAuthorizationheader to every DICOMweb request, regardless of whether theserver wanted one.
applyAuthorizationfanned the token out to every storeunconditionally.
Two consequences:
including any URL typed into the server-selection dialog, which previously
copied
Authorizationfrom the default client onto the new endpoint and thenre-applied a fresh token on top.
Authorizationis not a CORS-safelisted request header, so every requestis preceded by an
OPTIONSpreflight. Public DICOMweb endpoints that do notanswer preflights correctly then fail with an opaque CORS error, even though
plain GETs would have succeeded.
This surfaced while testing WG-26 connectathon endpoints, where most servers are
open and the app is pointed at new ones at runtime.
Changes & Results
Requests now start anonymous and escalate only when a server actually asks.
serversis credentialed silently (the operator vouched for it by putting it there). A server introduced at runtime — selection dialog or?gcp=— prompts the user first.localStorage, so each server is negotiated once per browser, not once per session.The consent prompt exists because escalation is triggered by the server.
Without it, any endpoint could obtain a live cloud credential simply by replying
401to an anonymous request — a realistic risk for a URL pasted into theselector.
Because this is negotiated at runtime, adding or swapping endpoints needs no
redeployment. That matters for the two paths that never appear in the config
file at all: the server-selection dialog and the
?gcp=parameter.Verified against the servers that matter:
401withaccess-control-allow-originset andwww-authenticate: Bearerlisted inaccess-control-expose-headers, so thechallenge is readable from JS and escalation works.
200anonymously and never reaches step 2.Override
servers[].sendAuthorizationbypasses the negotiation:false— never send the token, even if the server returns 401.true— send from the first request, skipping the anonymous attempt.trueis needed for a server that answers200with fewer results rather than401when unauthenticated. Runtime detection cannot distinguish that from anopen server, and Slim would otherwise silently under-report studies. This is the
one case the runtime approach cannot handle.
Files
src/utils/authPolicy.ts(new) — per-origin decisions inlocalStorage,guarded against private-browsing failures.
src/DicomWebManager.ts— tri-state auth mode per store;callStoreperformsthe challenge-and-retry; per-origin in-flight deduplication so N parallel 401s
produce one prompt rather than N; error-interceptor suppression so an expected
401 does not fire a spurious sign-in redirect.
src/App.tsx— policy implementation and consent modal; the selection dialogno longer forwards the token to typed URLs.
src/AppConfig.d.ts—sendAuthorizationdocumented as an override.docs/CONFIGURATION.md— new "How the access token is sent to servers" section.The retry is capped at one attempt and fires only after a 401/403, so nothing was
stored or returned on the first attempt — safe even for STOW-RS.
Testing
13 new unit tests in
src/__tests__/DicomWebManager.test.tscover: withholdinguntil challenged, escalation on 401 and on 403, refusal leaving the store
anonymous,
sendAuthorization: falsenever escalating, one prompt forconcurrent challenges, 404 not being treated as a challenge, and pre-authorized
origins being credentialed from the first request.
Manual verification:
REACT_APP_CONFIG=gcp pnpm start— sign in, confirm the worklist loads. Thefirst request 401s and escalates silently; subsequent loads carry the token
from the start.
enableServerSelection, paste an open DICOMweb URL — confirm inDevTools that no
OPTIONSpreflight is sent and noAuthorizationheaderappears.
appears once, and that declining leaves requests anonymous.
Checklist
PR
Code
pnpm run lint,pnpm run fmt)Docs
Tested environment
🤖 Generated with Claude Code