diff --git a/src/main.js b/src/main.js index 49048f84..091279b4 100644 --- a/src/main.js +++ b/src/main.js @@ -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); diff --git a/src/net/oauth-config.js b/src/net/oauth-config.js index fd71532d..199efa86 100644 --- a/src/net/oauth-config.js +++ b/src/net/oauth-config.js @@ -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 - // ) or 'basic' (Authorization: Basic base64(email:jwt), for OSS CH + // ) 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 diff --git a/src/ui/app.js b/src/ui/app.js index 2bb21351..5ad4723e 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -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; @@ -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 = { @@ -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; diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index 5090f761..69d7739f 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -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(); diff --git a/tests/unit/main.test.js b/tests/unit/main.test.js index ed7318f2..61002c46 100644 --- a/tests/unit/main.test.js +++ b/tests/unit/main.test.js @@ -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(), diff --git a/tests/unit/oauth-config.test.js b/tests/unit/oauth-config.test.js index 7661dfee..11fc5865 100644 --- a/tests/unit/oauth-config.test.js +++ b/tests/unit/oauth-config.test.js @@ -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'); }); @@ -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 () => {