diff --git a/src/net/ch-client.js b/src/net/ch-client.js index 4cd0da2..81a4ec0 100644 --- a/src/net/ch-client.js +++ b/src/net/ch-client.js @@ -45,21 +45,28 @@ export async function authedFetch(ctx, url, sql, signal) { headers: { Authorization: authHeader(bearer) }, signal, }); + // A 2xx confirms the credentials are good for the rest of the session. + if (resp.ok) ctx.authConfirmed = true; let authExpired = resp.status === 401 || resp.status === 403; if (!authExpired && !resp.ok) { const peek = await resp.clone().text(); if (isAuthExpiredBody(peek)) authExpired = true; } if (authExpired) { + // Once this session has authenticated successfully, the same credentials + // are still valid — so a later 401/403 is a *query-level* error ClickHouse + // maps to that HTTP status (ACCESS_DENIED, or UNKNOWN_USER from e.g. + // `SHOW CREATE USER `), not a sign-in problem. Return it so the + // caller shows it as a normal query error instead of force-logging-out. + if (ctx.authConfirmed) return resp; if (attempt === 0 && (await ctx.refresh())) { bearer = await ctx.getToken(); attempt++; continue; } - // getToken() already guaranteed a non-expired token above, so a 401/403 - // that survives the one refresh-retry means CH rejected a *valid* login — - // an authorization/identity problem, not session expiry. Surface CH's own - // reason so it's diagnosable. + // First-contact 401/403 with a non-expired token: CH rejected the login + // itself — an authorization/identity problem, not session expiry. Surface + // CH's own reason so it's diagnosable. const reason = parseExceptionText(await resp.clone().text()); ctx.onSignedOut(authDeniedMessage(resp.status, reason)); throw new Error('signed out'); diff --git a/src/ui/app.js b/src/ui/app.js index b002eaf..ece15c2 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -117,6 +117,7 @@ export function createApp(env = {}) { app.idpId = null; app.authMode = 'oauth'; chCtx.origin = loc.origin; + chCtx.authConfirmed = false; // a fresh sign-in starts unconfirmed again ['oauth_id_token', 'oauth_refresh_token', 'oauth_verifier', 'oauth_state', 'oauth_idp', 'ch_basic_auth', 'ch_basic_user', 'ch_basic_origin'].forEach((k) => ss.removeItem(k)); } @@ -184,6 +185,9 @@ export function createApp(env = {}) { // Where queries POST: the serving origin for OAuth, or the (possibly // cross-origin) target chosen at credential sign-in for basic mode. origin: app.authMode === 'basic' ? (ss.getItem('ch_basic_origin') || loc.origin) : loc.origin, + // Flips true after the first 2xx; gates whether a later 401/403 is treated + // as a sign-in failure (only before auth is confirmed) or a query error. + authConfirmed: false, getToken, refresh, authHeader, diff --git a/tests/unit/ch-client.test.js b/tests/unit/ch-client.test.js index 20b6234..f6e2738 100644 --- a/tests/unit/ch-client.test.js +++ b/tests/unit/ch-client.test.js @@ -90,6 +90,20 @@ describe('authedFetch', () => { expect(msg).toContain('not authorizing you'); expect(msg).toContain('Server: Code: 516. DB::Exception: Authentication failed'); }); + it('marks the ctx authenticated on a successful response', async () => { + const ctx = ctxWith(async () => jsonResp({ ok: 1 })); + await authedFetch(ctx, 'u', 'sql'); + expect(ctx.authConfirmed).toBe(true); + }); + it('once authenticated, a later 403 is returned as a query error (no sign-out)', async () => { + // e.g. SHOW CREATE USER → HTTP 403 / UNKNOWN_USER, mid-session. + const ctx = ctxWith(async () => textResp('Code: 192. DB::Exception: There is no user x', false, 403), + { authConfirmed: true }); + const resp = await authedFetch(ctx, 'u', 'sql'); + expect(resp.status).toBe(403); + expect(ctx.onSignedOut).not.toHaveBeenCalled(); + expect(ctx.refresh).not.toHaveBeenCalled(); + }); it('treats a token_verification body as auth-expired', async () => { let n = 0; const ctx = ctxWith(