Skip to content

Commit a2a3dbd

Browse files
Isolator acmclaude
authored andcommitted
feat(login): add username/password + host:port credentials path alongside SSO
Adds a second sign-in path to the login screen: HTTP Basic credentials (ClickHouse user + password), optionally against another host:port, as an alternative to the OAuth/SSO flow. - src/ui/login.js: rebuilt to the Login.html design — SSO + credentials with a primary/secondary swap, an "Advanced" host disclosure, password show/hide, and a live "Target" summary row. Faithful to the handoff, reusing existing tokens. - src/ui/app.js: `authMode` ('oauth' | 'basic'); a `connect()` action that probes SELECT 1, commits the session, and enters the workbench; basic branches behind the existing chCtx seams (getToken/authHeader/refresh/isSignedIn/email/host), ensureConfig no-op, clearTokens resets. Basic session restored from sessionStorage (ch_basic_*); no ch-client changes needed. - src/core/target.js: pure resolveTarget() host→origin normalizer. - src/net/oauth-config.js: parse top-level `basic_login` (default on), tolerate 0 IdPs (credentials-only deployments). - src/main.js: bootstrap decision → app.isSignedIn() (covers both modes). - docs: config.json.example `basic_login`; README credentials section incl. the CSP connect-src + target CORS caveats for cross-origin hosts. Tests at 100% per-file gate (452 passing); build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WFt3fLEJW7XdBhoc8SgH9A
1 parent 6e5fd79 commit a2a3dbd

15 files changed

Lines changed: 788 additions & 138 deletions

‎README.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,38 @@ still accepted — it's treated as a one-IdP list. ClickHouse needs a matching
107107
`<token_processor>` per issuer; it validates each inbound JWT against whichever
108108
one matches the token's `iss`, so no extra CH wiring is required to offer several.
109109

110+
### Credentials login (username / password)
111+
112+
Alongside SSO, the sign-in screen offers a **ClickHouse username + password**
113+
path (HTTP Basic). It is shown by default; set top-level `"basic_login": false`
114+
in `config.json` to hide it and force SSO-only. A deployment with no OAuth at all
115+
can ship a credentials-only config (no `idps`):
116+
117+
```json
118+
{ "basic_login": true }
119+
```
120+
121+
Credentials authenticate against the **serving host** by default. The login
122+
screen's **Advanced → Server address** field can aim the credential path at a
123+
**different** `host:port` (a bare host defaults to `https://…:8443`); SSO always
124+
stays on the serving host. The same-origin path needs no extra setup, but a
125+
**cross-origin** target has two requirements:
126+
127+
- **The SPA's own CSP.** `deploy/http_handlers.xml` sets `connect-src 'self'`
128+
(+ the IdP origins). The browser will block a query POST to any other origin
129+
until you add that origin to `connect-src` — otherwise the request never
130+
leaves the page.
131+
- **The target ClickHouse must allow CORS** for this origin: answer the
132+
`Authorization`-header preflight (`OPTIONS`) and return
133+
`Access-Control-Allow-Origin`. ClickHouse's `add_http_cors_header` covers the
134+
actual request; the preflight may need handler/proxy configuration on that
135+
server.
136+
137+
The password is held in `sessionStorage` for the tab session (same lifetime as
138+
the OAuth token) and sent as `Authorization: Basic base64(user:password)`. A
139+
wrong password is surfaced on the login screen — the connect probe runs a
140+
`SELECT 1` before entering the workbench.
141+
110142
### Security headers
111143

112144
`deploy/http_handlers.xml` sends a strict **Content-Security-Policy** plus

‎deploy/config.json.example‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{
2+
"basic_login": true,
23
"idps": [
34
{
45
"id": "google",

‎src/core/target.js‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Pure helper for the credentials login path: turn a user-typed server address
2+
// into a clean origin to POST queries at. No DOM, no globals — unit-testable.
3+
4+
/**
5+
* Resolve a host:port (or full URL) the user typed into a ClickHouse origin.
6+
*
7+
* '' → currentOrigin (blank = use the serving host)
8+
* 'ch.example' → 'https://ch.example:8443' (default scheme+port)
9+
* 'ch.example:9000' → 'https://ch.example:9000' (explicit port kept)
10+
* 'http://ch.example:8123' → 'http://ch.example:8123' (explicit scheme kept)
11+
*
12+
* Defaults to HTTPS and ClickHouse's 8443 only for the bare-host case. Anything
13+
* unparseable falls back to currentOrigin so we never POST somewhere bogus.
14+
*/
15+
export function resolveTarget(hostInput, currentOrigin) {
16+
const raw = String(hostInput || '').trim();
17+
if (!raw) return currentOrigin;
18+
// With an explicit scheme, trust it as-is; otherwise default to https and
19+
// append :8443 when no port was given.
20+
const urlStr = /^https?:\/\//i.test(raw)
21+
? raw
22+
: 'https://' + (raw.includes(':') ? raw : raw + ':8443');
23+
try {
24+
return new URL(urlStr).origin;
25+
} catch {
26+
return currentOrigin;
27+
}
28+
}

‎src/main.js‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { createApp } from './ui/app.js';
77
import { handleKeydown } from './ui/shortcuts.js';
88
import { exchangeCodeForTokens, bearerFromTokens } from './net/oauth.js';
99
import { decodeSqlFromHash } from './core/share.js';
10-
import { isTokenExpired } from './core/jwt.js';
1110

1211
export async function bootstrap(app, env) {
1312
const loc = env.location;
@@ -61,10 +60,12 @@ export async function bootstrap(app, env) {
6160
hist.replaceState(null, '', loc.pathname + loc.search);
6261
}
6362

64-
if (app.token && !isTokenExpired(app.token, 0)) {
63+
if (app.isSignedIn()) {
64+
// Signed in either via a valid OAuth token or a restored basic session.
6565
ss.removeItem('oauth_shared_sql'); // consumed
6666
// Resolve config first so the header shows the real CH identity (the
6767
// ch_auth=basic username, not the raw email claim) on first paint.
68+
// (ensureConfig is a no-op in basic mode.)
6869
await app.ensureConfig();
6970
app.renderApp();
7071
} else {

‎src/net/oauth-config.js‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,12 @@ function normalizeEntry(e) {
4747
}
4848

4949
/**
50-
* Fetch config.json and normalize to `{ idps: [descriptor, ...] }`. Accepts a
51-
* list (`{ idps: [...] }`) or a single bare object (legacy) wrapped into one
52-
* entry. Throws if no usable IdP is present.
50+
* Fetch config.json and normalize to `{ idps: [descriptor, ...], basicLogin }`.
51+
* Accepts a list (`{ idps: [...] }`) or a single bare object (legacy) wrapped
52+
* into one entry. An IdP-less config (no `idps`, no `issuer`) is valid — it
53+
* describes a credentials-only deployment, so `idps` comes back empty rather
54+
* than throwing. `basicLogin` (top-level `basic_login`, default true) lets an
55+
* SSO-only deployment hide the username/password path.
5356
* @param {(url: string, init?: object) => Promise<Response>} fetchFn
5457
* @param {string} basePath e.g. location.pathname ('/sql')
5558
*/
@@ -58,9 +61,11 @@ export async function loadConfigDoc(fetchFn, basePath = '') {
5861
const cfgResp = await fetchFn(cfgUrl, { cache: 'no-store' });
5962
if (!cfgResp.ok) throw new Error('GET ' + cfgUrl + ': ' + cfgResp.status);
6063
const cfg = await cfgResp.json();
61-
const list = Array.isArray(cfg.idps) ? cfg.idps : [cfg];
62-
if (!list.length) throw new Error('config.json has no IdPs');
63-
return { idps: list.map(normalizeEntry) };
64+
// A list, a legacy bare IdP object, or neither (credentials-only → no IdPs).
65+
const list = Array.isArray(cfg.idps)
66+
? cfg.idps
67+
: (cfg.issuer || cfg.client_id) ? [cfg] : [];
68+
return { idps: list.map(normalizeEntry), basicLogin: cfg.basic_login !== false };
6469
}
6570

6671
/**

‎src/styles.css‎

Lines changed: 92 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -80,19 +80,22 @@ body {
8080
.login-screen {
8181
flex: 1;
8282
display: flex; align-items: center; justify-content: center;
83+
padding: 24px;
8384
background:
84-
radial-gradient(circle at 30% 10%, rgba(0, 121, 173, .08), transparent 50%),
85-
radial-gradient(circle at 80% 90%, rgba(146, 225, 216, .05), transparent 50%),
85+
radial-gradient(700px 380px at 50% -8%, color-mix(in oklab, var(--accent) 13%, transparent), transparent 70%),
8686
var(--bg);
8787
}
88+
.login-screen .mono { font-family: var(--mono); }
8889
.login-card {
8990
width: 380px; padding: 40px 36px 36px;
9091
background: var(--bg-header);
9192
border: 1px solid var(--border);
9293
border-radius: 12px;
93-
box-shadow: 0 20px 60px rgba(0, 0, 0, .25);
94+
box-shadow: 0 24px 70px rgba(0, 0, 0, .35);
9495
text-align: center;
9596
}
97+
/* Wider, left-aligned card for the SSO + credentials layout. */
98+
.login-card-wide { width: 400px; max-width: 100%; padding: 30px 30px 22px; border-radius: 16px; text-align: left; }
9699
.login-logo {
97100
width: 48px; height: 48px;
98101
margin: 0 auto 14px;
@@ -102,30 +105,91 @@ body {
102105
color: #fff; font-weight: 700; font-size: 22px;
103106
letter-spacing: -0.02em;
104107
}
105-
.login-title { font-size: 20px; font-weight: 600; color: var(--fg); margin-bottom: 6px; }
106-
.login-sub { font-size: 13px; color: var(--fg-mute); margin-bottom: 22px; }
107-
.login-env {
108-
display: inline-flex; align-items: center; gap: 6px;
109-
padding: 4px 10px; background: var(--bg-chip);
110-
border-radius: 12px;
111-
font-family: var(--mono); font-size: 11px; color: var(--fg-mute);
112-
margin-bottom: 24px;
113-
}
114-
.login-env::before {
115-
content: ''; width: 6px; height: 6px; border-radius: 4px;
116-
background: #22c55e; box-shadow: 0 0 6px #22c55e;
108+
.login-card-wide .login-logo { width: 30px; height: 30px; margin: 0; border-radius: 8px; font-size: 15px; }
109+
110+
.login-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 22px; }
111+
.login-brand-text { display: flex; flex-direction: column; line-height: 1.2; }
112+
.login-brand-name { font-size: 14px; font-weight: 600; color: var(--fg); }
113+
.login-brand-sub { font-size: 11px; color: var(--fg-faint); }
114+
.login-h1 { font-size: 19px; font-weight: 600; letter-spacing: -.3px; color: var(--fg); margin-bottom: 4px; }
115+
.login-sub { font-size: 12.5px; color: var(--fg-mute); margin-bottom: 20px; line-height: 1.5; }
116+
117+
/* SSO section */
118+
.login-sso { display: flex; flex-direction: column; gap: 8px; }
119+
.login-sso-note {
120+
display: flex; align-items: center; justify-content: center; gap: 5px;
121+
margin-top: 7px; font-size: 11px; color: var(--fg-faint);
117122
}
118-
.login-actions { display: flex; flex-direction: column; gap: 8px; }
123+
.login-sso-note .mono { color: var(--fg-mute); }
124+
125+
/* Buttons (primary / ghost) */
119126
.login-btn {
120-
width: 100%; height: 40px; padding: 0 16px;
121-
background: var(--accent); color: #fff;
122-
border: none; border-radius: 6px;
127+
width: 100%; height: 42px; padding: 0 16px;
128+
display: flex; align-items: center; justify-content: center; gap: 9px;
129+
border: 1px solid transparent; border-radius: 9px;
123130
font-family: inherit; font-size: 13.5px; font-weight: 600;
124-
cursor: pointer; transition: filter .12s, transform .04s;
131+
cursor: pointer; transition: background .14s, border-color .14s, filter .12s, opacity .14s, transform .04s;
125132
}
126-
.login-btn:hover { filter: brightness(1.06); }
133+
.login-btn.btn-primary { background: var(--accent); color: #fff; }
134+
.login-btn.btn-primary:hover { filter: brightness(1.06); }
135+
.login-btn.btn-ghost { background: transparent; color: var(--fg); border-color: var(--border); }
136+
.login-btn.btn-ghost:hover { background: var(--bg-hover); }
127137
.login-btn:active { transform: translateY(0.5px); }
128-
.login-btn:disabled { opacity: .5; cursor: wait; }
138+
.login-btn:disabled { opacity: .45; cursor: not-allowed; }
139+
.login-btn:disabled:active { transform: none; }
140+
141+
/* Divider */
142+
.login-divider { display: flex; align-items: center; gap: 12px; margin: 18px 0; }
143+
.login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: var(--border-faint); }
144+
.login-divider span { font-size: 10.5px; color: var(--fg-faint); text-transform: uppercase; letter-spacing: .07em; }
145+
146+
/* Credential fields */
147+
.login-creds { display: flex; flex-direction: column; }
148+
.login-field { display: flex; flex-direction: column; margin-bottom: 13px; }
149+
.login-lbl { font-size: 11.5px; font-weight: 500; color: var(--fg-mute); margin-bottom: 6px; }
150+
.login-input {
151+
width: 100%; height: 38px; padding: 0 11px;
152+
background: var(--bg-input); border: 1px solid var(--border);
153+
border-radius: 8px; color: var(--fg); font-size: 13px; outline: none;
154+
font-family: inherit; transition: border-color .12s, box-shadow .12s;
155+
}
156+
.login-input.mono { font-family: var(--mono); font-size: 12.5px; }
157+
.login-input::placeholder { color: var(--fg-faint); }
158+
.login-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 22%, transparent); }
159+
.login-input-wrap { position: relative; }
160+
.login-input-wrap .login-input { padding-right: 38px; }
161+
.login-eye {
162+
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
163+
width: 28px; height: 28px; border: none; background: transparent;
164+
color: var(--fg-faint); cursor: pointer; border-radius: 6px;
165+
display: flex; align-items: center; justify-content: center;
166+
}
167+
.login-eye:hover { background: var(--bg-hover); color: var(--fg-mute); }
168+
169+
/* Advanced disclosure */
170+
.login-advanced { margin-bottom: 16px; }
171+
.login-disc {
172+
appearance: none; background: transparent; border: none; cursor: pointer; padding: 4px 0;
173+
color: var(--fg-mute); font-family: inherit; font-size: 12px; font-weight: 500;
174+
display: flex; align-items: center; gap: 6px;
175+
}
176+
.login-disc:hover { color: var(--fg); }
177+
.login-disc-chev { display: flex; transition: transform .15s; }
178+
.login-adv-field { margin-top: 10px; display: flex; flex-direction: column; }
179+
.login-hint { font-size: 11px; color: var(--fg-faint); margin-top: 6px; line-height: 1.5; }
180+
181+
/* Live target summary */
182+
.login-target {
183+
margin-top: 14px; padding: 9px 11px;
184+
background: var(--bg-input); border: 1px solid var(--border-faint);
185+
border-radius: 8px; font-family: var(--mono); font-size: 11px; color: var(--fg-mute);
186+
display: flex; align-items: center; gap: 7px; line-height: 1.4;
187+
}
188+
.login-target .lt-dot { width: 6px; height: 6px; border-radius: 3px; background: #22c55e; box-shadow: 0 0 6px #22c55e; flex-shrink: 0; }
189+
.login-target .lt-key { color: var(--fg-faint); }
190+
.login-target .lt-host { color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
191+
.login-target .lt-as { color: var(--fg-faint); }
192+
129193
.login-error {
130194
margin-top: 14px; padding: 8px 12px;
131195
background: var(--error-bg);
@@ -134,7 +198,12 @@ body {
134198
font-size: 12px; text-align: left;
135199
white-space: pre-wrap; word-break: break-word;
136200
}
137-
.login-foot { margin-top: 18px; font-size: 11px; color: var(--fg-faint); font-family: var(--mono); }
201+
.login-foot {
202+
margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-faint);
203+
display: flex; align-items: center; font-size: 11px; color: var(--fg-faint);
204+
}
205+
.login-foot-link { display: flex; align-items: center; gap: 5px; color: var(--fg-mute); }
206+
.login-foot-link:hover { color: var(--fg); }
138207

139208
/* ------------ header ------------ */
140209
.app-header {

0 commit comments

Comments
 (0)