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
24 changes: 22 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
## [Unreleased]

### Added
- **OAuth reauthentication now preserves dirty document work across its page
redirect** (#512 phase 3). Before navigation, a versioned, expiring
`sessionStorage` checkpoint captures authored tab state only and binds it to
the OAuth attempt plus the current workspace. A successful callback loads
the committed workspace, reconciles linked tabs, restores and revalidates raw
Spec drafts before first render, reinstalls the dirty unload guard, and only
then consumes the checkpoint. Results, ClickHouse session ids, credentials,
and other execution state are never checkpointed. Failed callbacks retain
the drafts for a state-rebound retry; malformed, expired, or
workspace-mismatched payloads fail closed into the normal boot path. The
separate tab-scoped, 15-minute validated-callback marker contains only state
and validation time; a checkpoint alone is never retry authority, and expiry
is logical eligibility rather than a promise of eager deletion. Unavailable
workspaces and prepublication storage/validation failures retain unpublished
recovery. Valid pending recovery suppresses legacy shared content and retries
after authoritative workspace load without overwriting newer save-relevant
dirty RAM; its marker is retired before publication. A new OAuth attempt
invalidates older authority. The intentional redirect receives a one-shot
unload bypass only after storage succeeds, and explicit Log out clears the
checkpoint and marker.
- **Temporary ClickHouse authentication loss now suspends only authenticated
execution, not the in-memory document session** (#512 phase 2, absorbing
#502/#520/#522). A disposable, epoch-fenced execution scope coordinates
Expand All @@ -24,8 +44,8 @@ auto-generated per-PR notes; this file is the curated, human-readable history.
Successful Basic reauthentication installs a fresh scope and reloads
connection metadata in place; its reusable inline form clears the password,
resets submission state, and hides password visibility after success, ready
for a later recovery. Inline OAuth deliberately remains unavailable until the
Phase 3 checkpoint; explicit Log out remains destructive.
for a later recovery. Inline OAuth uses Phase 3's durable document checkpoint;
explicit Log out remains destructive.
Async error-body classification and config discovery are epoch-fenced; refresh
authority snapshots its epoch and rechecks it after config discovery before
token-endpoint I/O, so stale discovery cannot replace a newer auth-header
Expand Down
109 changes: 107 additions & 2 deletions build/e2e-serve.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,36 @@ const MIME = {
'.png': 'image/png',
'.map': 'application/json; charset=utf-8',
};
const oauthRecoveryRoot = '/tests/e2e/oauth-document-recovery';
const oauthRecoveryPage = `${oauthRecoveryRoot}/index.html`;
const oauthRecoveryConfig = `${oauthRecoveryRoot}/config.json`;
const oauthRecoveryOidc = `${oauthRecoveryRoot}/oidc`;
const oauthRecoveryCh = `${oauthRecoveryRoot}/ch`;
const oauthRecoveryClient = 'fixture-client';
const oauthRecoveryCode = 'fixture-code';
const maxFixtureBodyBytes = 64 * 1024;

function json(res, value, status = 200) {
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }).end(JSON.stringify(value));
}

function fixtureToken() {
const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
return `${encode({ alg: 'none' })}.${encode({
email: 'recovery@example.test', exp: Math.floor(Date.now() / 1000) + 3600,
})}.sig`;
}

async function requestBody(req, limit = maxFixtureBodyBytes) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > limit) return null;
chunks.push(chunk);
}
return Buffer.concat(chunks).toString('utf8');
}

async function read(path) {
try { return await readFile(path); } catch { return null; }
Expand All @@ -36,7 +66,82 @@ async function stripTypes(source) {
}

createServer(async (req, res) => {
const pathname = decodeURIComponent(new URL(req.url, `http://127.0.0.1:${port}`).pathname);
const origin = `http://127.0.0.1:${port}`;
const url = new URL(req.url, origin);
const pathname = decodeURIComponent(url.pathname);
// Isolated real-browser OAuth recovery fixture (#512 Phase 3). These are
// deliberately server routes rather than Playwright interception so the app
// follows its normal config discovery, authorization redirect, token exchange
// and ClickHouse 401 paths across real documents.
if (pathname === oauthRecoveryConfig && req.method === 'GET') {
json(res, {
idps: [{ id: 'fixture', label: 'Fixture SSO', issuer: `${origin}${oauthRecoveryOidc}`, client_id: oauthRecoveryClient }],
basic_login: false,
});
return;
}
if (pathname === `${oauthRecoveryOidc}/.well-known/openid-configuration` && req.method === 'GET') {
json(res, {
authorization_endpoint: `${origin}${oauthRecoveryOidc}/authorize`,
token_endpoint: `${origin}${oauthRecoveryOidc}/token`,
});
return;
}
if (pathname === `${oauthRecoveryOidc}/authorize` && req.method === 'GET') {
const redirectUri = url.searchParams.get('redirect_uri');
const state = url.searchParams.get('state');
if (
redirectUri !== origin + oauthRecoveryPage
|| url.searchParams.get('client_id') !== oauthRecoveryClient
|| url.searchParams.get('response_type') !== 'code'
|| url.searchParams.get('code_challenge_method') !== 'S256'
|| !url.searchParams.get('code_challenge')
|| !state
) {
res.writeHead(400).end('invalid authorize request');
return;
}
const callback = new URL(origin + oauthRecoveryPage);
callback.searchParams.set('code', oauthRecoveryCode);
callback.searchParams.set('state', state);
res.writeHead(302, { location: callback.href }).end();
return;
}
if (pathname === `${oauthRecoveryOidc}/token` && req.method === 'POST') {
const body = await requestBody(req);
if (body === null) {
res.writeHead(413).end('request body too large');
return;
}
const form = new URLSearchParams(body);
if (
form.get('grant_type') !== 'authorization_code'
|| form.get('code') !== oauthRecoveryCode
|| form.get('redirect_uri') !== origin + oauthRecoveryPage
|| form.get('client_id') !== oauthRecoveryClient
|| !form.get('code_verifier')
) {
res.writeHead(400).end('invalid token request');
return;
}
json(res, { id_token: fixtureToken() });
return;
}
if (req.method === 'POST' && pathname === oauthRecoveryCh) {
const body = await requestBody(req);
if (body === null) {
res.writeHead(413).end('request body too large');
return;
}
if (body.includes('E2E_FORCE_AUTH_LOSS')) {
res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' }).end('Code: 516. Authentication failed');
return;
}
// Catalog/version reads are real application traffic but must not consume
// the fixture's deliberate first-contact 401.
json(res, { data: [{ v: '25.1.0', u: 1 }] });
return;
}
const path = resolve(join(root, pathname));
if (path !== root && !path.startsWith(root + sep)) {
res.writeHead(403).end();
Expand All @@ -58,6 +163,6 @@ createServer(async (req, res) => {
return;
}
res.writeHead(200, { 'content-type': type }).end(body);
}).listen(port, () => {
}).listen(port, '127.0.0.1', () => {
console.log(`e2e harness serving ${root} on http://127.0.0.1:${port}`);
});
41 changes: 37 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,43 @@ controls replace authenticated actions. In-place Basic login creates a fresh
scope for the new credential epoch and reloads connection-scoped metadata
without rebuilding the shell. Its inline form is reusable: a successful
recovery resets submission state, clears the password, and hides password
visibility, so a later loss can be recovered the same way. Inline OAuth is
deliberately unavailable until the Phase 3 checkpoint. Explicit Log out is the
separate destructive policy: it closes the scope, tears down the workbench and
Dashboard, clears credentials, and renders the signed-out login surface.
visibility, so a later loss can be recovered the same way.

OAuth reauthentication must reload the page, so it crosses that boundary with
a separate, versioned `sessionStorage` checkpoint. The checkpoint is written
only for save-relevant dirty work and is bound to both the generated OAuth
state and the current workspace id/key. It contains authored document state
only: tab order and identity, query or Dashboard-variable bindings, SQL and raw
Spec drafts, editor mode, dirty flags, and saved-query reconciliation metadata.
It never contains credentials, results, ClickHouse session ids, result-column
metadata, or running/export state. A retained checkpoint is rebound to a new
OAuth state on retry without replacing its drafts. Retry authority is kept
separately in a tab-scoped, TTL-bound validated-callback marker containing only
the callback state and validation time; a checkpoint alone never authorizes an
automatic restore. Starting a new OAuth attempt invalidates an older marker.

After a successful callback, bootstrap restores the return route and loads the
committed workspace normally. It then applies a matching checkpoint, reconciles
linked tabs, and revalidates every raw Spec draft before the first signed-in
render; invalid in-progress Spec text remains byte-for-byte authored while its
diagnostics are rebuilt. Workspace-unavailable and prepublication storage or
validator failures retain the recovery unpublished. A valid pending recovery
suppresses legacy shared content and retries only after an authoritative
workspace load; it never replaces newer save-relevant dirty work in RAM. Its
validated-callback marker is retired before publication. The ordinary dirty
unload guard is restored before the checkpoint is consumed. Malformed,
unsupported, logically expired after 15 minutes, or workspace-mismatched
checkpoints are ineligible and cleared best-effort while normal bootstrap
continues; an OAuth-state mismatch is not consumed, so an older callback cannot
apply or destroy a newer retry. TTL expiry controls restore eligibility and
does not promise eager physical deletion from browser storage. The one-shot
unload bypass is armed only after a durable checkpoint write; a write failure
leaves navigation and the normal dirty guard untouched.

Explicit Log out remains the separate destructive policy: it closes the scope,
tears down the workbench and Dashboard, clears credentials and any pending
OAuth document checkpoint and validated-callback marker, and renders the
signed-out login surface.

Connection lifecycle ownership follows the same rule. `ConnectionSession`
publishes one read-only signal and assigns a monotonically increasing
Expand Down
44 changes: 44 additions & 0 deletions docs/LOGIN-SCREEN.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,50 @@ on your IdP and threat model. Common, all valid, variants:
The code treats `client_secret` as optional, so any of these is a config-only
choice.

### Reauthentication and unsaved work

Temporary ClickHouse authentication loss does not end the document session.
The editor, tabs, drafts, navigation, dirty state, and completed results remain
mounted while the same authentication controls appear in the application
shell. A successful Basic sign-in resumes that exact in-memory session without
a page reload; the inline form clears its password and resets password
visibility after success so it is safe to reuse after another loss.

OAuth requires a page redirect. Before navigating, the browser writes a
versioned recovery checkpoint to this tab's `sessionStorage`, but only when
there is save-relevant dirty work. The checkpoint is bound to the OAuth
attempt's random state and the current workspace id/key. It contains authored
tab state (including SQL and raw Spec drafts and Dashboard-variable bindings),
not OAuth tokens, passwords, query results, ClickHouse session ids, result
columns, or running/export state. Like the OAuth token itself,
`sessionStorage` is tab-scoped browser storage, not encryption: script running
in the same origin can read it.

The intentional redirect bypasses the dirty-page warning exactly once, and
only after that checkpoint write succeeds. If serialization or storage fails,
the redirect does not start, the warning remains armed, and the inline controls
show the failure so sign-in can be retried. A failed IdP callback keeps the
draft checkpoint; a retry binds the same authored payload to its new OAuth
state. A separate tab-scoped, TTL-bound validated-callback marker contains only
that state and its validation time; the checkpoint alone never authorizes an
automatic restore. Starting another OAuth attempt invalidates the older marker.

After a successful callback, the app restores the route, loads the committed
workspace, validates the checkpoint against that workspace and callback state,
reconciles linked saved-query tabs, and reparses every raw Spec before the first
signed-in render. Invalid JSON drafts survive as authored and regain normal
diagnostics. If the workspace is unavailable, or storage/validation fails
before publication, recovery remains unpublished and retryable. A valid pending
recovery suppresses legacy shared content and retries automatically only after
an authoritative workspace load; it never overwrites newer save-relevant dirty
work in memory, and its marker is retired before publication. Results and other
execution state do not survive an OAuth reload. Malformed, unsupported,
logically expired (after 15 minutes), or workspace-mismatched checkpoints are
ineligible and the normal signed-in flow continues; expiry does not promise
eager physical removal from browser storage. A stale callback cannot consume a
newer retry's checkpoint. Explicit **Log out** clears any pending checkpoint
and validated-callback marker.

### Multiple IdPs

`config.json` may instead list several providers, and the login screen shows
Expand Down
Loading