Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/core/stream.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,18 @@ export function parseExceptionText(text) {
export function isAuthExpiredBody(text) {
return /token_verification_exception|token expired/i.test(text);
}

/**
* Build the login-screen message shown when ClickHouse rejects a *valid* login
* (HTTP 401/403 with a non-expired token) — an authorization/identity problem,
* not session expiry. `reason` is ClickHouse's own text (already run through
* parseExceptionText); it's trimmed/collapsed and appended only when present.
*/
export function authDeniedMessage(status, reason) {
const base =
'ClickHouse denied your account (HTTP ' + status + "). You're signed in, " +
'but this server is not authorizing you — your identity may have no ' +
'ClickHouse user or the required grants.';
const r = String(reason || '').replace(/\s+/g, ' ').trim();
return r ? base + ' Server: ' + r : base;
}
9 changes: 7 additions & 2 deletions src/net/ch-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// onSignedOut() }
// so the whole module is unit-testable with plain stubs.

import { parseExceptionText, isAuthExpiredBody } from '../core/stream.js';
import { parseExceptionText, isAuthExpiredBody, authDeniedMessage } from '../core/stream.js';

/** Build a ClickHouse HTTP URL with query-string options. Pure. */
export function chUrl(origin, opts = {}) {
Expand Down Expand Up @@ -56,7 +56,12 @@ export async function authedFetch(ctx, url, sql, signal) {
attempt++;
continue;
}
ctx.onSignedOut();
// 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.
const reason = parseExceptionText(await resp.clone().text());
ctx.onSignedOut(authDeniedMessage(resp.status, reason));
throw new Error('signed out');
}
return resp;
Expand Down
7 changes: 6 additions & 1 deletion src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,12 @@ export function createApp(env = {}) {
getToken,
refresh,
authHeader,
onSignedOut: () => { clearTokens(); renderLogin(app, 'Session expired'); },
// detail is set when CH rejects a *valid* login (authorization denial); the
// no-arg calls (no token / expired + refresh failed) fall back to expiry.
onSignedOut: (detail) => {
clearTokens();
renderLogin(app, detail || 'Your session expired — please sign in again.');
},
};
app.chCtx = chCtx;

Expand Down
10 changes: 10 additions & 0 deletions tests/unit/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,16 @@ describe('auth flows', () => {
const app = createApp(e);
expect(await app.chCtx.getToken()).toBeNull();
});
it('onSignedOut shows the given message, else a session-expired default', async () => {
const app = createApp(env());
app.renderApp();
// authorization denial: CH-supplied message is shown verbatim on the login screen
app.chCtx.onSignedOut('ClickHouse denied your account (HTTP 403). Server: nope');
expect(app.root.querySelector('.login-error').textContent).toContain('denied your account');
// genuine expiry: no detail → the reworded default
app.chCtx.onSignedOut();
expect(app.root.querySelector('.login-error').textContent).toContain('session expired');
});
});

describe('share + star + columns', () => {
Expand Down
13 changes: 10 additions & 3 deletions tests/unit/ch-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,17 @@ describe('authedFetch', () => {
expect(r.ok).toBe(true);
expect(ctx.refresh).toHaveBeenCalledTimes(1);
});
it('signs out when refresh fails on 403', async () => {
const ctx = ctxWith(async () => jsonResp({}, false, 403), { refresh: async () => false });
it('signs out with an authorization message + server reason when CH rejects a valid token (403)', async () => {
const ctx = ctxWith(
async () => textResp('Code: 516. DB::Exception: Authentication failed', false, 403),
{ refresh: async () => false },
);
await expect(authedFetch(ctx, 'u', 'sql')).rejects.toThrow('signed out');
expect(ctx.onSignedOut).toHaveBeenCalled();
expect(ctx.onSignedOut).toHaveBeenCalledTimes(1);
const msg = ctx.onSignedOut.mock.calls[0][0];
expect(msg).toContain('HTTP 403');
expect(msg).toContain('not authorizing you');
expect(msg).toContain('Server: Code: 516. DB::Exception: Authentication failed');
});
it('treats a token_verification body as auth-expired', async () => {
let n = 0;
Expand Down
18 changes: 18 additions & 0 deletions tests/unit/stream.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import {
newResult, applyStreamLine, splitBuffer, parseExceptionText, isAuthExpiredBody,
authDeniedMessage,
} from '../../src/core/stream.js';

describe('newResult', () => {
Expand Down Expand Up @@ -87,3 +88,20 @@ describe('isAuthExpiredBody', () => {
expect(isAuthExpiredBody('syntax error')).toBe(false);
});
});

describe('authDeniedMessage', () => {
it('interpolates the status and appends a collapsed server reason', () => {
const m = authDeniedMessage(403, ' Code: 516.\n DB::Exception: Authentication failed ');
expect(m).toContain('HTTP 403');
expect(m).toContain('not authorizing you');
expect(m).toContain('Server: Code: 516. DB::Exception: Authentication failed');
expect(m).not.toContain('\n');
});
it('omits the Server tail when there is no reason', () => {
const m = authDeniedMessage(401, '');
expect(m).toContain('HTTP 401');
expect(m).not.toContain('Server:');
expect(authDeniedMessage(401, ' ')).toBe(m); // whitespace-only is treated as empty
expect(authDeniedMessage(401)).toBe(m); // undefined reason
});
});
Loading