Skip to content

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

Open
dawsontoth wants to merge 2 commits into
stagefrom
claude/apis-explorer-auth-flow-8b1bcf
Open

feat(apis-explorer): session-scoped credentials and a real log-in flow#1641
dawsontoth wants to merge 2 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.
  • known limitation — same-entity sign-out with an in-flight mint. In-flight mints invalidate on
    unmount and on any explicit auth change, and the explorer is keyed by entityId (sign-out
    navigation remounts it). A same-entity signOutLocally with no navigation and a still-valid JWT is
    the residual gap; closing it fully would mean subscribing the presentational tree to authStore,
    which I judged over-coupled for this UI. Happy to add it if you'd prefer.
  • known limitation — no cross-tab logout propagation. This is the deliberate consequence of the
    session-storage choice: state is now per-tab, so signing out in one tab doesn't reach another tab's
    explorer credentials (the old localStorage design propagated via a storage event). Cookies are
    still shared, but the explorer's minted token / Basic creds are tab-local. Accepted as the point of
    session-scoping; flagging so it's a conscious call.
  • 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.

Remaining minors, left for follow-up / your call: re-authenticating while already authorized doesn't
re-clear the login form (the clear fires on the unauth→auth transition); Basic/Bearer form drafts
persist after Clear (the form is the credential editor by design); and the legacy-secret scrub runs
on first Explorer load rather than app startup (bounded — an already-open old tab can rewrite the key
until the next load).

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

Review-Coverage: authored=claude; ran=gemini,codex; blocked=domain(exit-1); declined=cursor-grok,cursor-composer; rounds=5 @ 9f34635

Human-Review-Need: 4 @ 9f34635

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 59.14% 7881 / 13325
🔵 Statements 59.63% 8456 / 14180
🔵 Functions 51.94% 1993 / 3837
🔵 Branches 53.08% 5664 / 10670
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/auth/store/authStore.ts 66.5% 51.75% 80% 66.19% 80-86, 102-111, 121, 142, 161-172, 179-188, 243-246, 390-401, 445-451, 483-496, 508, 521-549
src/features/instance/apis/explorer/ApiExplorer.tsx 88.17% 66.15% 92.3% 87.95% 94-96, 102, 121, 153-162, 165
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 86.79% 84.12% 84% 86.79% 38, 202, 302, 328-334, 351, 370-381
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 94.33% 88.88% 100% 94.33% 30, 47, 96
src/features/instance/apis/explorer/spec.ts 92.3% 82.09% 93.54% 92.56% 56, 262-270, 273, 287-289, 303, 318-320
src/integrations/api/instance/auth/createInstanceAuthenticationTokens.ts 45% 63.63% 25% 47.36% 22-30, 54, 80-90
src/lib/storage/localStorageKeys.ts 100% 100% 100% 100%
src/lib/urls/isDirectOperationsUrl.ts 100% 100% 100% 100%
Generated in workflow #1777 for commit 9f34635 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

this.flagKeyAsSignedOut(id);
this.updateConnectionIfChanged(id, false, null);
forgetApiExplorerSettings(id);
forgetEntitySettings(id);

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.

Could we centralize every per-entity disconnect/user transition through this cleanup? Explorer credentials are keyed only by entity, but src/features/cluster/ClusterHome.tsx:84, src/features/cluster/ClusterHome.tsx:308, and src/features/clusters/components/ClusterCard.tsx:117-120 clear users with setUserForEntity(..., null) instead of calling this method. User A can therefore authorize Explorer, disconnect, let user B sign into the same entity, and B’s Explorer will reload A’s stored Basic credentials or Bearer token. Please bind settings to the authenticated identity/auth epoch or make all disconnect and sign-in transitions clear Explorer state, with an A→logout→B regression test.

(await createInstanceAuthenticationTokens({ instanceClient: operationsParams.instanceClient })).operationToken,
[operationsParams.instanceClient],
);
const onCredentialMint = useMemo(

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.

This mint callback is permanently bound to operationsBaseURL, while ApiExplorer stores the resulting token only by entity and sends it to whichever independently selected activeServer is current. With a multi-server spec, selecting another declared server—or changing servers while minting is pending—attaches this entity’s token to that other origin and may also use the wrong credential for the target. Please bind minted auth to a validated server target and clear/refuse it when the target changes; one safe policy is to restrict one-click minted tokens to the trusted computed Studio server.

// Server + auth selections persist per entity in sessionStorage (this browser tab only), so
// credentials never cross entities and are cleared when the tab closes. Ephemeral navigation state
// (filter, selected endpoint, which pane is open) is deliberately not persisted.
const [entitySettings, setEntitySettings] = useState(() => readEntitySettings(entityId));

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.

Moving the secret to sessionStorage also removes the previous cross-tab logout propagation: separate top-level tabs have separate sessionStorage areas, and the old storage listener is gone. If two tabs hold Basic/Bearer auth, signing out in one leaves the other tab’s credential and in-memory state usable; cookie logout does not neutralize stored Basic credentials, and operation JWTs may remain valid. Please keep secrets tab-local but broadcast entity/all-auth invalidations through BroadcastChannel or a non-secret localStorage epoch, then clear both storage and component state in every tab.

);
}

function BasicForm({ auth, onApply }: { auth: ApiAuth; onApply: (username: string, password: string) => void }) {

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.

onClearAuth empties the parent credential, but these Basic/Bearer forms initialize their local state only on first mount. Clicking Clear therefore leaves the password/token visible in the mounted input, and submitting immediately restores the supposedly cleared credential. Please synchronize local form state when auth is cleared or key/remount the form on credential changes; add coverage for both Basic and Bearer Clear flows.

const [loginStatus, setLoginStatus] = useState<'idle' | 'pending' | 'error'>('idle');
const [loginError, setLoginError] = useState<string | null>(null);

const runMint = async (mint: () => Promise<string>) => {

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.

The attempt guard is invalidated by UI changes and unmount only, not by external logout/session invalidation. If clearAuthStateLocally() clears sessionStorage while this request is pending, a response arriving before router-driven unmount still passes these checks and line 92 writes the old user’s token back after logout. Please include a shared auth/logout generation or authenticated identity in the mint stamp, re-check it before committing, and test logout between mint start and resolution.

* write the key back — this runs on every explorer init, but not continuously.
*/
function scrubLegacySettings(): void {
if (legacyScrubbed) {

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.

The legacy secret cleanup is both lazy and one-shot. It runs only when readMap() is called, so upgraded users who never reopen Explorer retain the old localStorage password/token indefinitely. Once it does run, legacyScrubbed prevents the “every explorer init” retry described above; a concurrently open pre-upgrade tab can repopulate the key until a full reload. Please run this migration during application bootstrap and retry removal on relevant storage events (or at least remove the one-shot guard), with coverage independent of readEntitySettings().

* relies on an ambient session cookie that may not be sent cross-site. Branch-only, allocation-free:
* it runs on the sidebar's lock indicator during ordinary renders.
*/
export function isAuthorized(auth: ApiAuth): boolean {

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.

This predicate means “an explicit credential is configured,” not that the selected operation is authorized. A Login-minted Bearer token marks a Basic-only operation Authorized; one half of an OpenAPI AND requirement also marks it Authorized; valid cookie/API-key auth is always shown as unauthorized. Please either label this state “Credential configured” or evaluate the operation’s complete security alternatives against their referenced schemes before claiming Authorized.

const pending = login.status === 'pending';

// Don't keep the typed credentials in memory once a token has been minted from them.
useEffect(() => {

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.

This cleanup only runs when the boolean authorized changes. If a login token is already loaded, submitting different username/password credentials replaces it with another non-empty token, so authorized stays true and the submitted password remains in the mounted form. Please clear the credential fields on successful credential mint rather than relying on the generic authorization boolean, and add a regression test that starts already authorized before submitting alternate credentials.

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