Skip to content

feat(apis-explorer): session-scoped credentials and a real log-in flow - #1641

Merged
dawsontoth merged 7 commits into
stagefrom
claude/apis-explorer-auth-flow-8b1bcf
Aug 21, 2026
Merged

feat(apis-explorer): session-scoped credentials and a real log-in flow#1641
dawsontoth merged 7 commits into
stagefrom
claude/apis-explorer-auth-flow-8b1bcf

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Builds out the APIs Explorer "Authorize" story so authorization is credible and safe in deployed
environments, where the default cookie flow silently fails.

What changed

  • Session-scoped credentials. Per-entity Authorize state moves from localStorage to
    sessionStorage (cleared on tab close), with a one-time scrub of the legacy plaintext
    localStorage secrets on first load. authStore sign-out now delegates to a shared
    forgetEntitySettings.
  • A real log-in that works cross-site. Cookie auth isn't sent when the instance is on a
    different site than Studio. The Authorize panel now mints a Bearer operation token — one click
    for your current Studio session, or a username/password fallback — sent as an explicit
    Authorization header, which crosses origins where cookies don't.
  • Docs + Try it out. The Authorize panel is now a Documentation / Try it out pair (mirroring
    operations): docs explain Log in / Basic / Bearer / Cookie; Try it out is where you authenticate,
    which flips the lock and unlocks authenticated requests. Default method is Log in.
  • Deep-links. Auth-required operations link to the Try-it-out log-in view; requiresAuth now
    honors OpenAPI optional-auth (security: [{}]) and explicit [] overrides.

For the human reviewer

Read src/features/instance/apis/APIDocs.tsx and
src/integrations/api/instance/auth/createInstanceAuthenticationTokens.ts first — the credential
boundary is the thing to scrutinize.

Decisions a reviewer might reasonably question:

  • decision — credential-mint direct-URL source. The username/password fallback POSTs
    create_authentication_tokens to the operations client's own baseURL — the address Studio
    already uses to talk to this instance — and only when it passes isDirectOperationsUrl (rejects
    the Fabric Connect /HDBInstance/…//Cluster/… proxy paths). The check is enforced inside the
    mint helper, not just at the call site, so the boundary can't regress. When the only reachable URL
    is the proxy, the password fallback is withheld (fail-closed) and one-click session mint still
    works. Session mint intentionally uses the existing (possibly proxied) client because it authorizes
    as you and discloses no one else's credentials.
  • decision — token stored, password not (for Log in). The log-in flow stores only the minted
    short-lived token in sessionStorage, never the password. Basic auth still stores username+password
    (session-scoped); the docs say so per method. sessionStorage is not claimed as an XSS boundary —
    it's a persistence/lifetime choice.
  • decision — code sample shows the live credential. buildFetchSnippet embeds the real
    Authorization header in the copyable sample (as the prior Swagger UI did). Kept deliberately: it's
    the user's own credential in their own tab, and redacting would break the copy-paste-run purpose.
    Flagging in case exfil-via-clipboard is in scope for this surface.
  • verified — entity sign-out clears explorer creds. signOutOfInstancesignOutLocally
    forgetEntitySettings clears per-entity; full logout (clearAuthStateLocally, logoutOnSuccess)
    calls clearSessionStorage(). So a review note that "normal disconnect paths bypass cleanup" did
    not hold on trace.
  • hardening — the credential POST fails on redirect (redirect: 'error') so a 3xx can't replay
    it past the direct-URL check, and the log-in form drops the typed password from memory once a token
    is minted.

Verification

  • Full local gate on Node 24.19.0: tsc -b, oxlint, dprint check clean; vitest run — all
    2630 tests pass (added coverage for storage/scrub, isDirectOperationsUrl, requiresAuth, the
    mint helper incl. non-direct refusal + server-error surfacing, and the explorer's login flows,
    method-switch mint race, and deep-link). The suite's process exit is non-zero only from a
    pre-existing, unrelated undici-WebSocket unhandled error in a Chat test (reproduces without this
    diff).
  • Dev server smoke: the explorer builds and loads against the stage central manager.
  • Cross-origin: the cross-site Bearer flow was verified locally — a minted token is accepted on a
    cross-origin request (the instance's CORS allows the Authorization header on preflight), which is
    the behavior the cookie path couldn't provide and the core reason for this change.

Process

Planning review (step 6) cleared chosen-approach-sound after widening the option set. Step-10
cross-model review ran five rounds (codex + gemini + cursor-grok, independent). The Harper-domain
adjudication leg fails on this host (zero-byte log), so outside findings were hand-adjudicated.
Round 1 (BLOCK) → round 2 (CHANGES; blocker + majors fixed) → round 3 found no blockers or majors
round 4 (delta) covers a gemini bot finding — the credential direct-URL gate was case-sensitive, so
isDirectOperationsUrl now rejects proxy paths case-insensitively → round 5 is the Final-artifact
check after rebasing onto stage to integrate its API-explorer sidebar-resize feature, and found
no blockers or majors in the merge. The Human-Review-Need: 4 floor reflects the high-risk
auth/storage surface plus the failed adjudication leg (degraded coverage), not an open blocker.

Review response (kriszyp): all 8 inline findings are fixed and the threads resolved — per-entity
cleanup centralized in setUserForIdAndKey (closing a cross-user credential leak via the
ClusterHome/ClusterCard disconnect paths), an auth-epoch that invalidates in-flight mints and
propagates sign-out across tabs (including an always-on listener for unmounted tabs), credentials
stamped to the server they were authorized against, form drafts resynced on Clear, the legacy scrub
moved to bootstrap, and the "Authorized" indicator relabelled "Credential set".

Follow-up rounds fixed three further majors the reviews surfaced in my own hardening: minted tokens
are now stamped with Studio's computed URL for the entity (not the spec picker's selection, which had
recreated a narrower version of the leak the stamping exists to prevent);
signOutFromPotentiallyAuthenticatedInstances — the one sign-out path that flags sign-out directly
rather than via signOutLocally — also clears explorer state; and a mirrored cross-tab invalidation
advances this tab's in-memory epoch so an in-flight mint is caught by the epoch check too. Each has a
regression test.

A second review round from kriszyp found two more real gaps, both fixed in a439aad7: an event-only
cross-tab signal couldn't survive a reload (sessionStorage outlives one, so a restarted tab never saw
the invalidation and kept a revoked credential) — the sign-out generation is now durable and every
credential is stamped with the generation it was created under, reconciled at bootstrap; and the mint's
abort timer didn't cover body consumption, so a stalled body left the log-in pending forever.

Coverage gap, stated plainly: the last two commits (e832fca6, a439aad7) have no outside-model
review
. The run for the first produced nothing — Gemini hit an account quota and Codex hung and was
killed with no budget to retry — and I did not re-run for the second. That is an absent review, not a
clean one. What they do have: the full local gate (2661 unit tests, tsc, oxlint, dprint) and a
regression test per fix — including one that caught a bug in my own first attempt at the abort fix
(a blanket .catch was swallowing the abort). Everything up to 254c81ee was reviewed by
codex+gemini. Reviewers may reasonably want to look hardest at those two commits.

Known follow-up (not in this PR): the credential indicator reports that a credential is configured,
not that it satisfies the operation's specific security requirement (AND/OR alternatives against
referenced schemes). Happy to fold that in if wanted.

Complexity: moderate — new auth UI surface plus a narrow, well-bounded reach into the operations
client for token minting.

Review-Coverage: authored=unknown; ran=none; rounds=1 @ a439aad

Human-Review-Need: 4 @ a439aad

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 59.34% 7957 / 13407
🔵 Statements 59.82% 8534 / 14265
🔵 Functions 52.13% 2011 / 3857
🔵 Branches 53.24% 5713 / 10730
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/auth/store/authStore.ts 67.2% 55.72% 75.47% 67.34% 96, 154-175, 191-200, 210, 231, 255-266, 273-282, 337-340, 484-497, 545-551, 585, 589-596, 608, 621-649
src/features/instance/apis/explorer/ApiExplorer.tsx 93.57% 70% 96.42% 93.93% 125-127, 141, 160, 173, 213
src/features/instance/apis/explorer/EndpointList.tsx 61.9% 68.18% 69.23% 57.89% 53-60, 90, 112
src/features/instance/apis/explorer/OperationDetail.tsx 100% 70.21% 100% 100%
src/features/instance/apis/explorer/SettingsPanel.tsx 93.33% 83.09% 92.59% 93.33% 38, 202, 342-348, 365
src/features/instance/apis/explorer/TryItOut.tsx 67.5% 61.29% 56.25% 68.57% 26, 30, 83-84, 90-94, 145, 234-261
src/features/instance/apis/explorer/request.ts 97.64% 89.09% 92.85% 97.64% 218-219
src/features/instance/apis/explorer/settings.ts 90.62% 89.83% 90.9% 90.62% 38, 55, 104, 153, 167-168
src/features/instance/apis/explorer/spec.ts 92.3% 82.71% 93.54% 92.56% 56, 262-270, 273, 287-289, 303, 318-320
src/integrations/api/instance/auth/createInstanceAuthenticationTokens.ts 60.71% 57.14% 60% 59.25% 22-30, 68, 103-113
src/lib/storage/localStorageKeys.ts 100% 100% 100% 100%
src/lib/urls/isDirectOperationsUrl.ts 100% 100% 100% 100%
Generated in workflow #1783 for commit a439aad by the Vitest Coverage Report Action

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request refactors the API Explorer's authorization flow, migrating credential storage from localStorage to sessionStorage and introducing a secure "Log in" method to mint short-lived Bearer tokens directly against Harper instances. It also refactors proxy URL detection to prevent credentials from reaching the central manager. The review feedback highlights a critical security improvement to make the proxy URL check case-insensitive to prevent bypasses, along with corresponding test updates, and suggests using a more idiomatic Object.keys check instead of a for...in loop when validating security requirements.

Comment thread src/lib/urls/isDirectOperationsUrl.ts
Comment thread src/lib/urls/isDirectOperationsUrl.test.ts Outdated
Comment thread src/features/instance/apis/explorer/spec.ts
@dawsontoth

Copy link
Copy Markdown
Contributor Author

Cross-origin verified: a locally-run test confirms a minted operation token is accepted on a cross-site request (the instance's CORS allows the Authorization header on preflight) — the behavior the cookie path couldn't provide and the core reason for this change. That was the last open verification gate, so moving this out of draft.

CI is green and all bot review threads are resolved. Reviewers: please look hardest at the credential boundary in APIDocs.tsx + createInstanceAuthenticationTokens.ts (direct-URL-only credential mint) and the two documented lifecycle trade-offs of session-scoped storage noted in the description.

🤖 Generated with Claude Code

@dawsontoth
dawsontoth marked this pull request as ready for review August 20, 2026 21:24
@dawsontoth
dawsontoth requested a review from a team as a code owner August 20, 2026 21:24
dawsontoth and others added 2 commits August 20, 2026 17:32
Move the explorer's per-entity Authorize state from localStorage to
sessionStorage (cleared on tab close) and scrub the legacy plaintext
localStorage secrets on first load. Add a log-in flow that mints a Bearer
operation token — one click for the current Studio session, or a
username/password fallback POSTed directly to the instance's own operations URL
(never through the Fabric Connect proxy; enforced by isDirectOperationsUrl in the
mint helper, which also fails on redirect so the POST can't be replayed past the
check) — so authenticated "Try it out" requests work across sites where the
session cookie isn't sent.

Restructure the Authorize panel into Documentation + Try it out tabs offering
Log in / Basic / Bearer / Cookie (default Log in), modeled with a UI method
distinct from the wire ApiAuth so Login is a representable default. In-flight
mints are invalidated on any explicit auth change or unmount, and the log-in form
drops the typed password once a token is minted. Auth-required operations
deep-link to the Try-it-out log-in view, and requiresAuth now honors OpenAPI
optional-auth (security: [{}]) and explicit [] overrides.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
isDirectOperationsUrl now matches the Fabric Connect proxy path segments
(/HDBInstance/, /Cluster/) case-insensitively, so a differently-cased path can't
slip typed credentials or a Bearer token past the direct-URL gate. Addresses a
review finding; adds lowercase coverage in the util and mint-helper tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dawsontoth
dawsontoth force-pushed the claude/apis-explorer-auth-flow-8b1bcf branch from a3aa3d2 to 9f34635 Compare August 20, 2026 21:34
@dawsontoth

Copy link
Copy Markdown
Contributor Author

Rebased onto stage to resolve merge conflicts. stage had landed the API-explorer sidebar resize + independent scrolling (ddd5819, 7ada669); the conflicts were in ApiExplorer.tsx (that new resizable-aside layout vs. this PR's auth rewrite) and localStorageKeys.ts (its new ApiExplorerSidebarWidth key vs. this PR removing ApiExplorerSettings). Both features are preserved: the resizable/scrolling sidebar now renders the auth-aware EndpointList (method + authorized props), and the enum keeps ApiExplorerSidebarWidth without ApiExplorerSettings.

Verified after rebase: tsc -b, oxlint, dprint clean and the full unit suite green (2647 passed). Worth a fresh look at the merged ApiExplorer.tsx layout.

🤖 Generated with Claude Code

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this all seems like a good approach. There some suggestions for further hardening, but I think this generally correct.
🤖 Reviewed with Codex

Comment thread src/features/auth/store/authStore.ts
Comment thread src/features/instance/apis/APIDocs.tsx
Comment thread src/features/instance/apis/explorer/ApiExplorer.tsx
Comment thread src/features/instance/apis/explorer/SettingsPanel.tsx
Comment thread src/features/instance/apis/explorer/ApiExplorer.tsx
Comment thread src/features/instance/apis/explorer/settings.ts Outdated
Comment thread src/features/instance/apis/explorer/request.ts
Comment thread src/features/instance/apis/explorer/SettingsPanel.tsx
dawsontoth and others added 3 commits August 21, 2026 11:04
Addresses kriszyp's review of the Authorize story:

- Centralize per-entity credential cleanup: setUserForIdAndKey now clears the
  explorer's stored settings on sign-out, so the ClusterHome/ClusterCard
  disconnect paths (setUserForEntity(entity, null)) can't leave one user's Basic
  password or Bearer token for the next user on the same entity.
- Add a per-entity auth epoch (bumped on sign-out, mirrored to localStorage): an
  in-flight token mint that resolves after a sign-out is discarded rather than
  written back, and another tab signing the entity out clears this tab's
  sessionStorage credential and auth state (restores cross-tab logout propagation
  lost when the secret moved to per-tab sessionStorage).
- Clear credentials when the selected server changes, so a token minted for one
  origin is never sent to another declared server.
- Sync the Basic/Bearer forms when the credential is cleared, and clear the login
  password once a mint from it succeeds (covering re-auth while already authorized).
- Run the legacy-secret scrub at app bootstrap (not only on explorer open) and make
  it re-runnable so a pre-upgrade tab that rewrites the key is scrubbed again.
- Relabel the "Authorized" indicators to "Credential set" — a credential is
  configured, not verified against the operation's specific security scheme.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up hardening after the first review response surfaced further gaps:

- Cross-tab / unmounted-tab clearing: an always-on app-level listener
  (installExplorerCrossTabCleanup, wired at bootstrap) clears a tab's stored
  explorer credential when another tab signs the entity out, even while this
  tab's explorer is unmounted — so a tab that navigated away can't later restore
  a revoked token. The component listener still resets live state when mounted.
- Global logout: signOutAllLocally now broadcasts a '*' invalidation so every
  tab's explorer clears, covering entities not in this tab's authenticated set.
- Server scoping: the credential is sent only when the active server shares the
  trusted instance origin (originOf(baseURL)), so an implicit active-server change
  — not just an explicit picker change — can't send a token to another origin.
- Proxy-path gate: isDirectOperationsUrl matches the segment on a word boundary
  (no trailing slash required), rejecting a bare `/HDBInstance` or query-string
  proxy URL that previously slipped through.
- Direct credential mint is bounded by a 30s AbortController timeout.
- Legacy scrub no longer runs on every settings read (bootstrap + storage-event
  driven), and forgetAllEntitySettings backs the global clear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ath gate

Final hardening pass on the auth lifecycle:

- Stamp each credential with the server it was authorized against (authServer)
  and send it only when the active server still matches. Replaces the previous
  origin comparison, which parsed URLs on every render and silently stripped
  valid credentials when the base URL was relative (e.g. behind a reverse proxy).
  Plain string compare, no URL parsing on the render path.
- isDirectOperationsUrl now tests the parsed pathname, so a proxy path is rejected
  bare, cased, concatenated (/HDBInstance123), or with a query string, while a host
  merely named like the segment stays direct. Fails closed on a relative or
  unparseable URL.
- A global logout now advances every entity's epoch (getExplorerAuthEpoch folds in
  the '*' slot), so a same-tab full logout invalidates an in-flight mint even
  though localStorage fires no storage event in the originating tab.
- runMint clears the pending status when a sign-out invalidates it, instead of
  leaving the log-in button stuck on "Authorizing…".
- Clearing auth or switching to a logged-out method drops the authServer stamp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dawsontoth

Copy link
Copy Markdown
Contributor Author

@kriszyp thanks — this was a genuinely valuable review; the first finding was a real cross-user credential leak. All 8 inline findings are fixed and their threads resolved (individual replies on each). Pushed as 3268990ba9ef962c254c81ee:

  • Cross-user leak (authStore:418). setUserForIdAndKey now clears explorer settings on every sign-out, so the ClusterHome/ClusterCard setUserForEntity(entity, null) disconnect paths are covered. A→disconnect→B regression test added.
  • Token bound to the wrong server (APIDocs:50). Took your "bind minted auth to a validated server target" option: credentials are stamped with the server they were authorized against and only sent when the active server still matches — covering implicit activeServer recomputes, not just picker changes.
  • Cross-tab logout (ApiExplorer:59). Per-entity auth epoch mirrored through localStorage: the mounted explorer resets live state, and an always-on bootstrap listener scrubs the stored credential even when the explorer is unmounted. signOutAllLocally broadcasts a global clear.
  • Mint after logout (ApiExplorer:84). Mints are stamped with the epoch and refuse to apply if it moved; the global slot folds into the per-entity value so a same-tab full logout also invalidates (no storage event fires in the originating tab).
  • Form drafts / re-auth (SettingsPanel:275, :213). Forms resync from the applied credential on Clear; the login password clears on the mint status transition, so re-authenticating while already authorized clears it too.
  • Legacy scrub (settings.ts:75). Now an exported scrubLegacySettings() run at app bootstrap, re-runnable, and off the settings read path.
  • "Authorized" (request.ts:28). Relabelled "Credential set" — as you said, it only means a credential is configured. Evaluating full AND/OR alternatives against referenced schemes is noted as a follow-up; say the word and I'll fold it in here.

Also hardened while in here: the proxy-path gate now tests the parsed pathname (rejects bare/cased/concatenated /HDBInstance123 and query-string forms, while a host merely named like the segment stays direct, failing closed on relative URLs), and the direct credential mint has a 30s abort timeout.

Full unit suite green (2655) with regression tests for each fix above; tsc/oxlint/dprint clean.

🤖 Generated with Claude Code

…ed server

- A minted token is stamped with Studio's computed URL for the entity (the
  instance the mint client actually talks to), not the spec picker's current
  selection. Selecting a foreign declared server now withholds the token instead
  of marking it authorized for that origin. Regression test added.
- signOutFromPotentiallyAuthenticatedInstances also clears explorer settings and
  bumps the auth epoch; it flags sign-out directly rather than via signOutLocally,
  so it was the one remaining sign-out path that skipped the cleanup.
- A mirrored cross-tab invalidation now advances this tab's in-memory epoch, so an
  in-flight mint here is invalidated by another tab's sign-out through the epoch
  check as well as the attempt guard.
- Abort/network failures from the direct mint surface a stable message ("did not
  respond within 30 seconds" / "could not reach the instance") instead of the
  browser's opaque abort text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dawsontoth
dawsontoth requested a review from kriszyp August 21, 2026 15:55
@dawsontoth

Copy link
Copy Markdown
Contributor Author

Pushed e832fca6 with three more fixes that came out of reviewing my own hardening:

  • Minted tokens are stamped with Studio's computed URL for the entity, not the spec picker's current selection. The previous commit stamped the selection, which recreated a narrower version of the exact leak the stamping exists to prevent — selecting a foreign declared server and then minting would have marked an instance token valid for that origin. Regression test added.
  • signOutFromPotentiallyAuthenticatedInstances now clears explorer state too — it flags sign-out directly rather than through signOutLocally, so it was the last sign-out path skipping the cleanup.
  • A mirrored cross-tab invalidation advances this tab's in-memory epoch, so an in-flight mint is caught by the epoch check as well as the attempt guard. Mint abort/network failures also now surface stable messages instead of the browser's opaque abort text.

One thing to be upfront about: this last commit has no outside-model review coverage. The run I kicked off for it produced nothing — Gemini hit an account quota (resets in ~5h) and Codex hung and was killed with no budget left to retry — so the Review-Coverage: ran=none footer is accurate rather than an oversight. Its parent 254c81ee and everything before were reviewed by codex+gemini. What e832fca6 does have is the full local gate (2656 unit tests, tsc, oxlint, dprint) plus a regression test per fix.

Given the last few rounds of findings were in my own patches on a security-sensitive surface, I've stopped self-iterating here — @kriszyp @kylebernhardy @DavidCockerill, the credential-to-server stamping model in ApiExplorer.tsx and the epoch logic in authStore.ts are where a human read is worth the most. CI is green and the branch is mergeable.

🤖 Generated with Claude Code

@kriszyp kriszyp left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nice work, a couple of follow-up suggestions, but I don't consider them blockers.
🤖 Reviewed with Codex

Comment thread src/features/auth/store/authStore.ts
An event-only cross-tab signal was not enough: sessionStorage survives a reload,
so a tab that restarted (or had not started) when another tab signed the entity
out would never observe the invalidation and could keep using a revoked
credential.

- The sign-out generation now lives in localStorage (durable, shared) instead of
  an in-memory map, and every stored credential records the generation it was
  created under. A credential whose stamp no longer matches is not sent, so the
  check works by comparison at read time rather than depending on a live event.
- pruneStaleEntitySettings strips such credentials (keeping non-secret server and
  method selections) and runs immediately when the app-level cleanup is installed
  at bootstrap, covering an invalidation that happened before any listener existed.
- The cross-tab listener now re-reads state rather than assuming the event names
  this entity, so another entity's sign-out no longer clears this one.
- The mint's abort timer now covers body consumption: an instance that sends
  headers and then stalls the body previously left the log-in pending forever. An
  abort during body read propagates instead of reading as "no token returned".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@dawsontoth
dawsontoth added this pull request to the merge queue Aug 21, 2026
Merged via the queue into stage with commit 806406d Aug 21, 2026
3 checks passed
@dawsontoth
dawsontoth deleted the claude/apis-explorer-auth-flow-8b1bcf branch August 21, 2026 17:32
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