diff --git a/CHANGELOG.md b/CHANGELOG.md index 18b56a8..d69bfcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] +### Fixed +- Remove obsolete `iss` and `hd` login-link hints from SQL Browser URLs. IdP + selection remains owned by the deployment configuration and session state. + ## [0.6.3] - 2026-07-24 ### Fixed diff --git a/src/core/sql-route.ts b/src/core/sql-route.ts index 041bf8f..fad9655 100644 --- a/src/core/sql-route.ts +++ b/src/core/sql-route.ts @@ -1,6 +1,8 @@ // Pure parser/builder for the single `/sql` application route (#407). // The caller supplies `location.search`; this module owns only `ws`, `surface`, // and `mode`, leaving OAuth callback and other application parameters intact. +// `iss`/`hd` are retired legacy login-link hints: IdP selection comes from +// config.json/session state, never from URL-supplied issuer/domain values. export type SqlRoute = | { surface: 'workspace'; workspaceKey: string | null } @@ -32,6 +34,8 @@ export function buildSqlRouteSearch(route: SqlRoute, currentSearch = ''): string // Retired Dashboard snapshot route state (#407); never carry it forward. params.delete('st'); params.delete('dash'); + params.delete('iss'); + params.delete('hd'); if (route.workspaceKey !== null) params.set('ws', route.workspaceKey); if (route.surface === 'dashboard') { params.set('surface', 'dashboard'); diff --git a/src/main.ts b/src/main.ts index f50570b..7e62e9b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,7 +18,7 @@ import { handleKeydown } from './ui/shortcuts.js'; import { exchangeCodeForTokens, bearerFromTokens } from './net/oauth.js'; import { decodeShare } from './core/share.js'; import { cloneJson, queryName, queryPanel, queryView, upgradeSavedQuery } from './core/saved-query.js'; -import { parseSqlRoute } from './core/sql-route.js'; +import { normalizeSqlRouteSearch, parseSqlRoute } from './core/sql-route.js'; import { rolePreviewView } from './core/result-choice.js'; import { isQuerylessPanel } from './core/panel-cfg.js'; import { setTabSpecDraft, SAVED_VIEWS } from './state.js'; @@ -63,8 +63,21 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ const loc = env.location; const ss = env.sessionStorage; const hist = env.history; - let dash = parseSqlRoute(loc.search).surface === 'dashboard'; + // Canonicalize retired route parameters before showing either the login or + // workbench surface. `iss`/`hd` must not look like active IdP selectors: + // actual selection is config/session-owned. + const initialParams = new URLSearchParams(loc.search); + const hasRetiredLoginHint = initialParams.has('iss') || initialParams.has('hd'); + const normalizedRoute = hasRetiredLoginHint + ? normalizeSqlRouteSearch(loc.search) + : { route: parseSqlRoute(loc.search), search: loc.search }; + if (normalizedRoute.search !== loc.search) { + hist.replaceState(null, '', loc.origin + loc.pathname + normalizedRoute.search + loc.hash); + app.syncSqlRoute(normalizedRoute.search); + } + let dash = normalizedRoute.route.surface === 'dashboard'; const u = new URL(loc.href); + u.search = normalizedRoute.search; const code = u.searchParams.get('code'); const stateParam = u.searchParams.get('state'); const expectedState = ss.getItem('oauth_state'); @@ -118,7 +131,14 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ } } const qs = u.searchParams.toString(); - const cleanedSearch = qs ? '?' + qs : ''; + const callbackSearch = qs ? '?' + qs : ''; + // A callback can restore return-route state saved by an older version. + // Canonicalize retired hints again so they cannot reappear on a failed + // sign-in, which renders the login screen without a workspace rewrite. + const callbackParams = new URLSearchParams(callbackSearch); + const cleanedSearch = callbackParams.has('iss') || callbackParams.has('hd') + ? normalizeSqlRouteSearch(callbackSearch).search + : callbackSearch; hist.replaceState(null, '', loc.origin + loc.pathname + cleanedSearch + loc.hash); app.syncSqlRoute(cleanedSearch); dash = parseSqlRoute(cleanedSearch).surface === 'dashboard'; diff --git a/tests/unit/main.test.ts b/tests/unit/main.test.ts index a274c2b..27f52c2 100644 --- a/tests/unit/main.test.ts +++ b/tests/unit/main.test.ts @@ -478,4 +478,38 @@ describe('bootstrap', () => { expect(url).toContain('keep=1'); expect(url).not.toContain('code='); }); + + it('removes retired issuer and hosted-domain hints before rendering the login screen', async () => { + const app = fakeApp(); + const env = fakeEnv({ + location: asLocation({ + href: 'https://ch/sql?iss=https%3A%2F%2Faccounts.google.com&hd=altinity.com&ws=ops', + origin: 'https://ch', pathname: '/sql', + search: '?iss=https%3A%2F%2Faccounts.google.com&hd=altinity.com&ws=ops', hash: '', + }), + }); + await bootstrap(app, env); + expect(env.history.replaceState).toHaveBeenCalledWith(null, '', 'https://ch/sql?ws=ops'); + expect(app.syncSqlRoute).toHaveBeenCalledWith('?ws=ops'); + expect(app.showLogin).toHaveBeenCalled(); + }); + + it('removes retired login hints restored by an OAuth error callback', async () => { + const app = fakeApp(); + const env = fakeEnv({ + location: asLocation({ + href: 'https://ch/sql?error=access_denied&state=st', origin: 'https://ch', + pathname: '/sql', search: '?error=access_denied&state=st', hash: '', + }), + }); + env.sessionStorage.setItem('oauth_state', 'st'); + env.sessionStorage.setItem('oauth_return_route', JSON.stringify({ + state: 'st', search: '?iss=https%3A%2F%2Faccounts.google.com&hd=altinity.com&ws=ops', + })); + await bootstrap(app, env); + expect(env.history.replaceState).toHaveBeenCalledWith(null, '', 'https://ch/sql?ws=ops'); + expect(app.syncSqlRoute).toHaveBeenCalledWith('?ws=ops'); + expect(env.sessionStorage.getItem('oauth_return_route')).toBeNull(); + expect(app.showLogin).toHaveBeenCalledWith('Sign-in failed: access_denied'); + }); }); diff --git a/tests/unit/sql-route.test.ts b/tests/unit/sql-route.test.ts index 55c47fd..f9bcf4d 100644 --- a/tests/unit/sql-route.test.ts +++ b/tests/unit/sql-route.test.ts @@ -54,6 +54,11 @@ describe('buildSqlRouteSearch', () => { expect(normalizeSqlRouteSearch('?ws=x&st=token&dash=old&keep=yes').search) .toBe('?keep=yes&ws=x'); }); + + it('drops the retired issuer and hosted-domain login-link hints', () => { + expect(normalizeSqlRouteSearch('?iss=https%3A%2F%2Faccounts.google.com&hd=altinity.com&ws=x').search) + .toBe('?ws=x'); + }); }); describe('routeForWorkspace', () => {