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
3 changes: 3 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ export async function bootstrap(app, env) {

if (app.token && !isTokenExpired(app.token, 0)) {
ss.removeItem('oauth_shared_sql'); // consumed
// Resolve config first so the header shows the real CH identity (the
// ch_auth=basic username, not the raw email claim) on first paint.
await app.ensureConfig();
app.renderApp();
} else {
app.showLogin(callbackError);
Expand Down
8 changes: 7 additions & 1 deletion src/net/oauth-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,15 @@ function normalizeEntry(e) {
// or 'access_token' (audience-gated CH).
bearer: e.bearer === 'access_token' ? 'access_token' : 'id_token',
// How the token reaches ClickHouse: 'bearer' (default; Authorization: Bearer
// <jwt>) or 'basic' (Authorization: Basic base64(email:jwt), for OSS CH
// <jwt>) or 'basic' (Authorization: Basic base64(user:jwt), for OSS CH
// behind a verifier such as ch-jwt-verify).
chAuth: e.ch_auth === 'basic' ? 'basic' : 'bearer',
// For ch_auth=basic, which JWT claim becomes the Basic username (= the CH
// user the verifier must return). Empty → default chain (email →
// preferred_username → sub). Set e.g. 'nickname' when an IdP must map to a
// CH username distinct from another IdP's (avoids same-name collisions —
// e.g. a token-directory Bearer user vs. a static http user on Antalya CH).
basicUserClaim: e.basic_user_claim || '',
// Extra params merged into /authorize (e.g. Auth0 { organization: 'org_…' }).
authorizeParams: e.authorize_params && typeof e.authorize_params === 'object'
? e.authorize_params
Expand Down
21 changes: 15 additions & 6 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,16 @@ export function createApp(env = {}) {
app.host = () => loc.host || 'clickhouse';
app.activeTab = () => activeTab(app.state);
app.isSignedIn = () => !!app.token && !isTokenExpired(app.token, 0);
app.email = () => {
const p = decodeJwtPayload(app.token);
return p.email || p.preferred_username || p.sub || '';
};
// The CH-facing identity for the current token — what currentUser() will be:
// for ch_auth=basic it's the Basic username (honouring basicUserClaim); for
// bearer it's the email the token-processor keys on. Shared by authHeader and
// the header display so the UI never shows a different claim than CH sees.
function chUsername(p) {
return (app.chAuth === 'basic' && app.basicUserClaim && p[app.basicUserClaim])
|| p.email || p.preferred_username || p.sub || '';
}
app.chUsername = chUsername;
app.email = () => chUsername(decodeJwtPayload(app.token));

function setTokens(id, refresh) {
app.token = id;
Expand Down Expand Up @@ -137,10 +143,12 @@ export function createApp(env = {}) {
// (OSS + a verifier like ch-jwt-verify, where the JWT is the Basic password
// and the username is the token's email). Resolved from config by ensureConfig.
app.chAuth = 'bearer';
// Which claim becomes the Basic username (per-IdP, from config). Empty → the
// default chain. Lets one IdP map to a CH username distinct from another's.
app.basicUserClaim = '';
function authHeader(token) {
if (app.chAuth !== 'basic') return 'Bearer ' + token;
const p = decodeJwtPayload(token);
const user = p.email || p.preferred_username || p.sub || '';
const user = chUsername(decodeJwtPayload(token));
return 'Basic ' + btoa(unescape(encodeURIComponent(user + ':' + token)));
}
const chCtx = {
Expand All @@ -160,6 +168,7 @@ export function createApp(env = {}) {
try {
const cfg = await resolveConfig();
app.chAuth = cfg.chAuth;
app.basicUserClaim = cfg.basicUserClaim || '';
return cfg;
} catch {
return null;
Expand Down
25 changes: 25 additions & 0 deletions tests/unit/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,31 @@ describe('exhaustive controller coverage', () => {
expect(decodeURIComponent(escape(atob(auth.slice(6))))).toMatch(/^me@example\.com:/);
});

it('ch_auth=basic with basic_user_claim maps the Basic username to that claim', async () => {
const tok = jwt({ email: 'me@example.com', nickname: 'BorisT', exp: Math.floor(Date.now() / 1000) + 3600 });
const e = env({
window: fakeWin(),
sessionStorage: memSession({ oauth_id_token: tok }),
fetch: makeFetch([
[(u) => /config\.json/.test(u), resp({ json: { issuer: 'https://accounts.google.com', client_id: 'cid', ch_auth: 'basic', basic_user_claim: 'nickname' } })],
[(u) => /openid-configuration/.test(u), resp({ json: { authorization_endpoint: 'https://a', token_endpoint: 'https://t' } })],
[(u, sql) => /SELECT 1/.test(sql), resp({ body: streamBody(['{"row":{}}\n']) })],
]),
});
const app = createApp(e);
app.renderApp();
await app.ensureConfig();
expect(app.basicUserClaim).toBe('nickname');
app.activeTab().sql = 'SELECT 1';
await app.actions.run();
const q = e.fetch.mock.calls.find((c) => c[1] && c[1].body === 'SELECT 1');
const auth = q[1].headers.Authorization;
// username segment is the nickname claim, not the email
expect(decodeURIComponent(escape(atob(auth.slice(6))))).toMatch(/^BorisT:/);
// the header identity matches the CH user (nickname), not the email claim
expect(app.email()).toBe('BorisT');
});

it('shows and dismisses the auth-failure banner', () => {
const app = createApp(env());
app.renderApp();
Expand Down
1 change: 1 addition & 0 deletions tests/unit/main.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ function fakeApp(over = {}) {
token: null,
state: { tabs: [{ id: 't1', sql: '', name: 'Untitled' }] },
loadConfig: vi.fn(async () => ({ clientId: 'c', tokenUri: 'https://t', clientSecret: '' })),
ensureConfig: vi.fn(async () => ({})),
setTokens: vi.fn(function (id) { this.token = id; }),
renderApp: vi.fn(),
showLogin: vi.fn(),
Expand Down
7 changes: 5 additions & 2 deletions tests/unit/oauth-config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ describe('loadConfigDoc', () => {
id: 'accounts.google.com', label: 'accounts.google.com',
issuer: 'https://accounts.google.com', clientId: 'cid', clientSecret: 'sek',
audience: 'aud', bearer: 'id_token', chAuth: 'bearer', authorizeParams: {},
basicUserClaim: '',
}]);
expect(f.mock.calls[0][0]).toBe('/sql/config.json');
});
Expand Down Expand Up @@ -59,14 +60,16 @@ describe('loadConfigDoc', () => {
expect(idp.bearer).toBe('id_token');
expect(idp.chAuth).toBe('bearer');
expect(idp.authorizeParams).toEqual({});
expect(idp.basicUserClaim).toBe('');
});
it('honours ch_auth=basic, bearer=access_token, and an authorize_params object', async () => {
it('honours ch_auth=basic, bearer=access_token, basic_user_claim, and an authorize_params object', async () => {
const [idp] = await docOf({
issuer: 'https://i', client_id: 'c', ch_auth: 'basic', bearer: 'access_token',
authorize_params: { organization: 'org_x' },
basic_user_claim: 'nickname', authorize_params: { organization: 'org_x' },
});
expect(idp.chAuth).toBe('basic');
expect(idp.bearer).toBe('access_token');
expect(idp.basicUserClaim).toBe('nickname');
expect(idp.authorizeParams).toEqual({ organization: 'org_x' });
});
it('ignores a non-object authorize_params and an unknown bearer', async () => {
Expand Down
Loading