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
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,38 @@ still accepted — it's treated as a one-IdP list. ClickHouse needs a matching
`<token_processor>` per issuer; it validates each inbound JWT against whichever
one matches the token's `iss`, so no extra CH wiring is required to offer several.

### Credentials login (username / password)

Alongside SSO, the sign-in screen offers a **ClickHouse username + password**
path (HTTP Basic). It is shown by default; set top-level `"basic_login": false`
in `config.json` to hide it and force SSO-only. A deployment with no OAuth at all
can ship a credentials-only config (no `idps`):

```json
{ "basic_login": true }
```

Credentials authenticate against the **serving host** by default. The login
screen's **Advanced → Server address** field can aim the credential path at a
**different** `host:port` (a bare host defaults to `https://…:8443`); SSO always
stays on the serving host. The same-origin path needs no extra setup, but a
**cross-origin** target has two requirements:

- **The SPA's own CSP.** `deploy/http_handlers.xml` sets `connect-src 'self'`
(+ the IdP origins). The browser will block a query POST to any other origin
until you add that origin to `connect-src` — otherwise the request never
leaves the page.
- **The target ClickHouse must allow CORS** for this origin: answer the
`Authorization`-header preflight (`OPTIONS`) and return
`Access-Control-Allow-Origin`. ClickHouse's `add_http_cors_header` covers the
actual request; the preflight may need handler/proxy configuration on that
server.

The password is held in `sessionStorage` for the tab session (same lifetime as
the OAuth token) and sent as `Authorization: Basic base64(user:password)`. A
wrong password is surfaced on the login screen — the connect probe runs a
`SELECT 1` before entering the workbench.

### Security headers

`deploy/http_handlers.xml` sends a strict **Content-Security-Policy** plus
Expand Down
1 change: 1 addition & 0 deletions deploy/config.json.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"basic_login": true,
"idps": [
{
"id": "google",
Expand Down
28 changes: 28 additions & 0 deletions src/core/target.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Pure helper for the credentials login path: turn a user-typed server address
// into a clean origin to POST queries at. No DOM, no globals — unit-testable.

/**
* Resolve a host:port (or full URL) the user typed into a ClickHouse origin.
*
* '' → currentOrigin (blank = use the serving host)
* 'ch.example' → 'https://ch.example:8443' (default scheme+port)
* 'ch.example:9000' → 'https://ch.example:9000' (explicit port kept)
* 'http://ch.example:8123' → 'http://ch.example:8123' (explicit scheme kept)
*
* Defaults to HTTPS and ClickHouse's 8443 only for the bare-host case. Anything
* unparseable falls back to currentOrigin so we never POST somewhere bogus.
*/
export function resolveTarget(hostInput, currentOrigin) {
const raw = String(hostInput || '').trim();
if (!raw) return currentOrigin;
// With an explicit scheme, trust it as-is; otherwise default to https and
// append :8443 when no port was given.
const urlStr = /^https?:\/\//i.test(raw)
? raw
: 'https://' + (raw.includes(':') ? raw : raw + ':8443');
try {
return new URL(urlStr).origin;
} catch {
return currentOrigin;
}
}
5 changes: 3 additions & 2 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { createApp } from './ui/app.js';
import { handleKeydown } from './ui/shortcuts.js';
import { exchangeCodeForTokens, bearerFromTokens } from './net/oauth.js';
import { decodeSqlFromHash } from './core/share.js';
import { isTokenExpired } from './core/jwt.js';

export async function bootstrap(app, env) {
const loc = env.location;
Expand Down Expand Up @@ -61,10 +60,12 @@ export async function bootstrap(app, env) {
hist.replaceState(null, '', loc.pathname + loc.search);
}

if (app.token && !isTokenExpired(app.token, 0)) {
if (app.isSignedIn()) {
// Signed in either via a valid OAuth token or a restored basic session.
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.
// (ensureConfig is a no-op in basic mode.)
await app.ensureConfig();
app.renderApp();
} else {
Expand Down
17 changes: 11 additions & 6 deletions src/net/oauth-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,12 @@ function normalizeEntry(e) {
}

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

/**
Expand Down
115 changes: 92 additions & 23 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -80,19 +80,22 @@ body {
.login-screen {
flex: 1;
display: flex; align-items: center; justify-content: center;
padding: 24px;
background:
radial-gradient(circle at 30% 10%, rgba(0, 121, 173, .08), transparent 50%),
radial-gradient(circle at 80% 90%, rgba(146, 225, 216, .05), transparent 50%),
radial-gradient(700px 380px at 50% -8%, color-mix(in oklab, var(--accent) 13%, transparent), transparent 70%),
var(--bg);
}
.login-screen .mono { font-family: var(--mono); }
.login-card {
width: 380px; padding: 40px 36px 36px;
background: var(--bg-header);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, .25);
box-shadow: 0 24px 70px rgba(0, 0, 0, .35);
text-align: center;
}
/* Wider, left-aligned card for the SSO + credentials layout. */
.login-card-wide { width: 400px; max-width: 100%; padding: 30px 30px 22px; border-radius: 16px; text-align: left; }
.login-logo {
width: 48px; height: 48px;
margin: 0 auto 14px;
Expand All @@ -102,30 +105,91 @@ body {
color: #fff; font-weight: 700; font-size: 22px;
letter-spacing: -0.02em;
}
.login-title { font-size: 20px; font-weight: 600; color: var(--fg); margin-bottom: 6px; }
.login-sub { font-size: 13px; color: var(--fg-mute); margin-bottom: 22px; }
.login-env {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; background: var(--bg-chip);
border-radius: 12px;
font-family: var(--mono); font-size: 11px; color: var(--fg-mute);
margin-bottom: 24px;
}
.login-env::before {
content: ''; width: 6px; height: 6px; border-radius: 4px;
background: #22c55e; box-shadow: 0 0 6px #22c55e;
.login-card-wide .login-logo { width: 30px; height: 30px; margin: 0; border-radius: 8px; font-size: 15px; }

.login-brand { display: flex; align-items: center; gap: 10px; margin-bottom: 22px; }
.login-brand-text { display: flex; flex-direction: column; line-height: 1.2; }
.login-brand-name { font-size: 14px; font-weight: 600; color: var(--fg); }
.login-brand-sub { font-size: 11px; color: var(--fg-faint); }
.login-h1 { font-size: 19px; font-weight: 600; letter-spacing: -.3px; color: var(--fg); margin-bottom: 4px; }
.login-sub { font-size: 12.5px; color: var(--fg-mute); margin-bottom: 20px; line-height: 1.5; }

/* SSO section */
.login-sso { display: flex; flex-direction: column; gap: 8px; }
.login-sso-note {
display: flex; align-items: center; justify-content: center; gap: 5px;
margin-top: 7px; font-size: 11px; color: var(--fg-faint);
}
.login-actions { display: flex; flex-direction: column; gap: 8px; }
.login-sso-note .mono { color: var(--fg-mute); }

/* Buttons (primary / ghost) */
.login-btn {
width: 100%; height: 40px; padding: 0 16px;
background: var(--accent); color: #fff;
border: none; border-radius: 6px;
width: 100%; height: 42px; padding: 0 16px;
display: flex; align-items: center; justify-content: center; gap: 9px;
border: 1px solid transparent; border-radius: 9px;
font-family: inherit; font-size: 13.5px; font-weight: 600;
cursor: pointer; transition: filter .12s, transform .04s;
cursor: pointer; transition: background .14s, border-color .14s, filter .12s, opacity .14s, transform .04s;
}
.login-btn:hover { filter: brightness(1.06); }
.login-btn.btn-primary { background: var(--accent); color: #fff; }
.login-btn.btn-primary:hover { filter: brightness(1.06); }
.login-btn.btn-ghost { background: transparent; color: var(--fg); border-color: var(--border); }
.login-btn.btn-ghost:hover { background: var(--bg-hover); }
.login-btn:active { transform: translateY(0.5px); }
.login-btn:disabled { opacity: .5; cursor: wait; }
.login-btn:disabled { opacity: .45; cursor: not-allowed; }
.login-btn:disabled:active { transform: none; }

/* Divider */
.login-divider { display: flex; align-items: center; gap: 12px; margin: 18px 0; }
.login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: var(--border-faint); }
.login-divider span { font-size: 10.5px; color: var(--fg-faint); text-transform: uppercase; letter-spacing: .07em; }

/* Credential fields */
.login-creds { display: flex; flex-direction: column; }
.login-field { display: flex; flex-direction: column; margin-bottom: 13px; }
.login-lbl { font-size: 11.5px; font-weight: 500; color: var(--fg-mute); margin-bottom: 6px; }
.login-input {
width: 100%; height: 38px; padding: 0 11px;
background: var(--bg-input); border: 1px solid var(--border);
border-radius: 8px; color: var(--fg); font-size: 13px; outline: none;
font-family: inherit; transition: border-color .12s, box-shadow .12s;
}
.login-input.mono { font-family: var(--mono); font-size: 12.5px; }
.login-input::placeholder { color: var(--fg-faint); }
.login-input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklab, var(--accent) 22%, transparent); }
.login-input-wrap { position: relative; }
.login-input-wrap .login-input { padding-right: 38px; }
.login-eye {
position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
width: 28px; height: 28px; border: none; background: transparent;
color: var(--fg-faint); cursor: pointer; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
}
.login-eye:hover { background: var(--bg-hover); color: var(--fg-mute); }

/* Advanced disclosure */
.login-advanced { margin-bottom: 16px; }
.login-disc {
appearance: none; background: transparent; border: none; cursor: pointer; padding: 4px 0;
color: var(--fg-mute); font-family: inherit; font-size: 12px; font-weight: 500;
display: flex; align-items: center; gap: 6px;
}
.login-disc:hover { color: var(--fg); }
.login-disc-chev { display: flex; transition: transform .15s; }
.login-adv-field { margin-top: 10px; display: flex; flex-direction: column; }
.login-hint { font-size: 11px; color: var(--fg-faint); margin-top: 6px; line-height: 1.5; }

/* Live target summary */
.login-target {
margin-top: 14px; padding: 9px 11px;
background: var(--bg-input); border: 1px solid var(--border-faint);
border-radius: 8px; font-family: var(--mono); font-size: 11px; color: var(--fg-mute);
display: flex; align-items: center; gap: 7px; line-height: 1.4;
}
.login-target .lt-dot { width: 6px; height: 6px; border-radius: 3px; background: #22c55e; box-shadow: 0 0 6px #22c55e; flex-shrink: 0; }
.login-target .lt-key { color: var(--fg-faint); }
.login-target .lt-host { color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.login-target .lt-as { color: var(--fg-faint); }

.login-error {
margin-top: 14px; padding: 8px 12px;
background: var(--error-bg);
Expand All @@ -134,7 +198,12 @@ body {
font-size: 12px; text-align: left;
white-space: pre-wrap; word-break: break-word;
}
.login-foot { margin-top: 18px; font-size: 11px; color: var(--fg-faint); font-family: var(--mono); }
.login-foot {
margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border-faint);
display: flex; align-items: center; font-size: 11px; color: var(--fg-faint);
}
.login-foot-link { display: flex; align-items: center; gap: 5px; color: var(--fg-mute); }
.login-foot-link:hover { color: var(--fg); }

/* ------------ header ------------ */
.app-header {
Expand Down
Loading
Loading