From 3726360baa5f8214a508de21640d56c9a1eea5d3 Mon Sep 17 00:00:00 2001 From: timmy Date: Sat, 8 Aug 2026 21:04:28 +0000 Subject: [PATCH] security: require fresh authorization for high-impact actions (#341) --- README.md | 8 ++ frontend/session.js | 38 +++++- src/dashboard_auth.py | 23 ++++ src/main.py | 146 ++++++++++++++++++++- src/session_store.py | 108 +++++++++++++++- tests/test_dashboard_auth.py | 153 ++++++++++++++++++++++- tests/test_dashboard_session_frontend.py | 41 +++++- tests/test_session_store.py | 89 +++++++++++++ 8 files changed, 597 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index c8424f9..c31a3fb 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,14 @@ exposes only independent management IDs and bounded labels—never cookie values session hashes, CSRF proofs, or source addresses. Existing two-column registries are migrated in place and their live sessions remain valid. +High-impact actions—merging a pull request, closing an assigned issue, revoking a +remote device, or signing out every device—require the operator access token again. +The server issues a random 90-second grant bound to the active session, exact action, +and exact target. Only its digest is stored, and the grant is consumed atomically on +first use. Expired, replayed, cross-session, and target-substituted grants fail before +Gitea or session state is changed. The browser preserves the pending request and +retries it once after the built-in mobile/keyboard-accessible authorization prompt. + If an active session expires, the first authenticated API rejection replaces the dashboard with sign-in and explains that private drafts remain on the device; signing in again resumes account-bound queued delivery. Expiry recovery does not clear offline diff --git a/frontend/session.js b/frontend/session.js index 40de582..25be1c3 100644 --- a/frontend/session.js +++ b/frontend/session.js @@ -16,6 +16,9 @@ MessageChannel: root.MessageChannel, location: root.location, confirmAction: message => root.confirm(message), + promptAuthorization: details => root.prompt( + `Confirm ${String(details.action || 'this action').replaceAll('_', ' ')} by entering your dashboard access token.`, + ), onExpired: () => root.dispatchEvent(new CustomEvent('stackchain:session-expired')), onClearError: error => { root.dispatchEvent(new CustomEvent('stackchain:device-clear-failed', { detail: error.message })); @@ -87,6 +90,7 @@ } })(typeof window !== 'undefined' ? window : this, function createSessionBoundary({ cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction, + promptAuthorization = () => null, onExpired = () => {}, onClearError = () => {}, }) { @@ -118,7 +122,7 @@ catch (_error) { return false; } } - async function sessionFetch(input, options = {}) { + async function sessionFetch(input, options = {}, allowStepUp = true) { const method = String(options.method || input?.method || 'GET').toUpperCase(); const requestOptions = { ...options }; if (!SAFE_METHODS.has(method) && isSameOrigin(input)) { @@ -128,6 +132,38 @@ requestOptions.headers = headers; } const response = await fetchImpl(input, requestOptions); + if (response.status === 428 && isSameOrigin(input) && allowStepUp) { + const payload = await response.clone().json().catch(() => ({})); + const detail = payload?.detail || {}; + if (detail.code === 'step_up_required' && detail.action && detail.target) { + const accessToken = await promptAuthorization({ + action: detail.action, + target: detail.target, + }); + if (!accessToken) return response; + const authorizationHeaders = new Headers({ + Accept: 'application/json', + 'Content-Type': 'application/json', + }); + const csrf = csrfToken(); + if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf); + const authorization = await fetchImpl(base + 'api/v1/fresh-authorization', { + method: 'POST', + headers: authorizationHeaders, + body: JSON.stringify({ + access_token: accessToken, + action: detail.action, + target: detail.target, + }), + }); + if (!authorization.ok) return authorization; + const grant = await authorization.json().catch(() => ({})); + if (!grant.grant) return response; + const retryHeaders = new Headers(requestOptions.headers || input?.headers || {}); + retryHeaders.set('X-Step-Up-Grant', grant.grant); + return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false); + } + } if (response.status === 401 && isSameOrigin(input) && !expirationStarted) { expirationStarted = true; const payload = await response.clone().json().catch(() => ({})); diff --git a/src/dashboard_auth.py b/src/dashboard_auth.py index e7ba0b0..834c2a7 100644 --- a/src/dashboard_auth.py +++ b/src/dashboard_auth.py @@ -18,6 +18,7 @@ from src.session_store import SessionStore, SessionStoreError SESSION_COOKIE = "stackchain_session" CSRF_COOKIE = "stackchain_csrf" DEFAULT_TTL_SECONDS = 8 * 60 * 60 +STEP_UP_TTL_SECONDS = 90 MIN_SECRET_LENGTH = 24 OPERATOR_MODE = "operator" INSECURE_LOCAL_MODE = "insecure-local" @@ -170,6 +171,28 @@ async def revoke_managed_session(management_id: str) -> bool: return await asyncio.to_thread(_session_store().revoke_managed, management_id) +async def issue_step_up(session: Session, *, action: str, target: str) -> str: + return await asyncio.to_thread( + _session_store().mint_step_up, + session.session_id, + action=action, + target=target, + ttl_seconds=STEP_UP_TTL_SECONDS, + ) + + +async def consume_step_up( + grant: str, session: Session, *, action: str, target: str +) -> bool: + return await asyncio.to_thread( + _session_store().consume_step_up, + grant, + session.session_id, + action=action, + target=target, + ) + + async def request_session(request: Request) -> Session | None: return await asyncio.to_thread(verify_session, request.cookies.get(SESSION_COOKIE)) diff --git a/src/main.py b/src/main.py index cfa86c6..8a462fa 100644 --- a/src/main.py +++ b/src/main.py @@ -146,6 +146,14 @@ class DashboardSignIn(BaseModel): return normalized +class FreshAuthorization(BaseModel): + access_token: str = Field(min_length=1, max_length=1_024) + action: Literal[ + "merge_pull", "close_issue", "revoke_device", "revoke_all_sessions" + ] + target: str = Field(min_length=1, max_length=255) + + def _login_attempt_store() -> LoginAttemptStore: state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state") return LoginAttemptStore( @@ -160,6 +168,33 @@ def _login_attempt_store() -> LoginAttemptStore: ) +async def _require_step_up( + request: Request, + grant: str | None, + *, + action: str, + target: str, +) -> None: + if dashboard_auth.mode() != dashboard_auth.OPERATOR_MODE: + return + try: + valid = bool(grant) and await dashboard_auth.consume_step_up( + grant, request.state.dashboard_session, action=action, target=target + ) + except dashboard_auth.SessionStoreError: + raise HTTPException( + status_code=503, detail="Session registry is temporarily unavailable" + ) + if not valid: + return_payload = { + "detail": "Fresh authorization required", + "code": "step_up_required", + "action": action, + "target": target, + } + raise HTTPException(status_code=428, detail=return_payload) + + class NotificationReadBatch(BaseModel): ids: list[PositiveInt] = Field(min_length=1, max_length=50) @@ -732,6 +767,68 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response return {"authenticated": True} +@app.post("/api/v1/fresh-authorization", status_code=201) +async def fresh_authorization(payload: FreshAuthorization, request: Request): + peer_host = request.client.host if request.client is not None else "unknown" + source = client_source( + peer_host, + request.headers.get("x-forwarded-for", ""), + os.getenv("STACKCHAIN_TRUSTED_PROXY_CIDRS", ""), + ) + attempts = _login_attempt_store() + try: + retry_after = await asyncio.to_thread(attempts.retry_after, source) + except LoginAttemptStoreError: + return JSONResponse( + {"detail": "Sign-in throttling is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) + if retry_after: + return JSONResponse( + {"detail": "Too many sign-in attempts"}, + status_code=429, + headers={"Cache-Control": "no-store", "Retry-After": str(retry_after)}, + ) + configured_token = dashboard_auth.access_token() + if not configured_token or not hmac.compare_digest( + payload.access_token, configured_token + ): + try: + await asyncio.to_thread(attempts.record_failure, source) + except LoginAttemptStoreError: + return JSONResponse( + {"detail": "Sign-in throttling is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) + raise HTTPException(status_code=401, detail="Invalid access token") + try: + await asyncio.to_thread(attempts.clear, source) + grant = await dashboard_auth.issue_step_up( + request.state.dashboard_session, + action=payload.action, + target=payload.target, + ) + except LoginAttemptStoreError: + return JSONResponse( + {"detail": "Sign-in throttling is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) + except dashboard_auth.SessionStoreError: + return JSONResponse( + {"detail": "Session registry is temporarily unavailable"}, + status_code=503, + headers={"Cache-Control": "no-store"}, + ) + return JSONResponse( + {"grant": grant, "expires_in": dashboard_auth.STEP_UP_TTL_SECONDS}, + status_code=201, + headers={"Cache-Control": "no-store"}, + ) + + @app.get("/api/v1/session") async def session_status(request: Request): session = request.state.dashboard_session @@ -802,7 +899,16 @@ async def revoke_active_device( management_id: str = PathParam( min_length=16, max_length=64, pattern=r"^[A-Za-z0-9_-]+$" ), + step_up_grant: str | None = Header( + default=None, alias="X-Step-Up-Grant", max_length=128 + ), ): + await _require_step_up( + request, + step_up_grant, + action="revoke_device", + target=management_id, + ) try: devices = await dashboard_auth.active_devices(request.state.dashboard_session) target = next( @@ -821,7 +927,19 @@ async def revoke_active_device( @app.delete("/api/v1/sessions") -async def sign_out_all_devices(request: Request, response: Response): +async def sign_out_all_devices( + request: Request, + response: Response, + step_up_grant: str | None = Header( + default=None, alias="X-Step-Up-Grant", max_length=128 + ), +): + await _require_step_up( + request, + step_up_grant, + action="revoke_all_sessions", + target="all", + ) try: await dashboard_auth.revoke_all_sessions() except dashboard_auth.SessionStoreError: @@ -2102,8 +2220,22 @@ async def comment_on_assigned_issue( @app.patch("/api/v1/repos/{owner}/{repo}/issues/{number}/close") -async def close_assigned_issue(owner: str, repo: str, number: int = PathParam(gt=0)): +async def close_assigned_issue( + request: Request, + owner: str, + repo: str, + number: int = PathParam(gt=0), + step_up_grant: str | None = Header( + default=None, alias="X-Step-Up-Grant", max_length=128 + ), +): repository = f"{owner}/{repo}" + await _require_step_up( + request, + step_up_grant, + action="close_issue", + target=f"{repository}#{number}", + ) async def close_issue(): if not await gitea_proxy.is_assigned_issue(repository, number): @@ -2336,11 +2468,21 @@ async def comment_on_assigned_pull( @app.post("/api/v1/repos/{owner}/{repo}/pulls/{number}/merge") async def merge_assigned_pull( submission: PullMergeSubmission, + request: Request, owner: str, repo: str, number: int = PathParam(gt=0), + step_up_grant: str | None = Header( + default=None, alias="X-Step-Up-Grant", max_length=128 + ), ): repository = f"{owner}/{repo}" + await _require_step_up( + request, + step_up_grant, + action="merge_pull", + target=f"{repository}#{number}", + ) async def merge_pull(): if not await gitea_proxy.is_assigned_pull(repository, number): diff --git a/src/session_store.py b/src/session_store.py index 6a501f3..3b11f62 100644 --- a/src/session_store.py +++ b/src/session_store.py @@ -62,6 +62,21 @@ class SessionStore: ) """ ) + connection.execute( + """ + CREATE TABLE IF NOT EXISTS step_up_grants ( + grant_hash TEXT PRIMARY KEY, + session_hash TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT NOT NULL, + expires_at INTEGER NOT NULL + ) + """ + ) + connection.execute( + "CREATE INDEX IF NOT EXISTS step_up_grants_session_hash " + "ON step_up_grants(session_hash)" + ) columns = { row[1] for row in connection.execute("PRAGMA table_info(active_sessions)") } @@ -134,7 +149,11 @@ class SessionStore: def revoke(self, session_id: str) -> None: try: - with self._connect() as connection: + with self._connect(initialize=True) as connection: + connection.execute( + "DELETE FROM step_up_grants WHERE session_hash = ?", + (self._digest(session_id),), + ) connection.execute( "DELETE FROM active_sessions WHERE session_hash = ?", (self._digest(session_id),), @@ -169,6 +188,15 @@ class SessionStore: def revoke_managed(self, management_id: str) -> bool: try: with self._connect() as connection: + row = connection.execute( + "SELECT session_hash FROM active_sessions WHERE management_id = ?", + (management_id,), + ).fetchone() + if row is None: + return False + connection.execute( + "DELETE FROM step_up_grants WHERE session_hash = ?", (row[0],) + ) cursor = connection.execute( "DELETE FROM active_sessions WHERE management_id = ?", (management_id,) ) @@ -178,7 +206,83 @@ class SessionStore: def revoke_all(self) -> None: try: - with self._connect() as connection: + with self._connect(initialize=True) as connection: + connection.execute("DELETE FROM step_up_grants") connection.execute("DELETE FROM active_sessions") except (OSError, sqlite3.Error) as exc: raise SessionStoreError("Session registry is temporarily unavailable") from exc + + def mint_step_up( + self, + session_id: str, + *, + action: str, + target: str, + ttl_seconds: int, + ) -> str: + now = int(self.clock()) + grant = secrets.token_urlsafe(32) + session_hash = self._digest(session_id) + try: + with self._connect(initialize=True) as connection: + active = connection.execute( + "SELECT 1 FROM active_sessions " + "WHERE session_hash = ? AND expires_at > ?", + (session_hash, now), + ).fetchone() + if active is None: + raise SessionStoreError("Session is no longer active") + connection.execute( + "DELETE FROM step_up_grants WHERE expires_at <= ?", (now,) + ) + connection.execute( + "INSERT INTO step_up_grants(" + "grant_hash, session_hash, action, target, expires_at" + ") VALUES (?, ?, ?, ?, ?)", + ( + self._digest(grant), + session_hash, + action, + target, + now + max(1, ttl_seconds), + ), + ) + except SessionStoreError: + raise + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError("Session registry is temporarily unavailable") from exc + return grant + + def consume_step_up( + self, + grant: str, + session_id: str, + *, + action: str, + target: str, + ) -> bool: + now = int(self.clock()) + try: + with self._connect() as connection: + connection.execute( + "DELETE FROM step_up_grants WHERE expires_at <= ?", (now,) + ) + cursor = connection.execute( + "DELETE FROM step_up_grants WHERE grant_hash = ? " + "AND session_hash = ? AND action = ? AND target = ? " + "AND expires_at > ? AND EXISTS (" + "SELECT 1 FROM active_sessions " + "WHERE active_sessions.session_hash = step_up_grants.session_hash " + "AND active_sessions.expires_at > ?)", + ( + self._digest(grant), + self._digest(session_id), + action, + target, + now, + now, + ), + ) + return cursor.rowcount == 1 + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError("Session registry is temporarily unavailable") from exc diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index 8b2f1b1..d59ee23 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -20,6 +20,23 @@ def access_control(monkeypatch, tmp_path): monkeypatch.setenv("STACKCHAIN_LOGIN_WINDOW_SECONDS", "60") +async def fresh_grant(client, action: str, target: str) -> str: + response = await client.post( + "/api/v1/fresh-authorization", + json={ + "access_token": "correct horse battery staple", + "action": action, + "target": target, + }, + headers={ + "Origin": "https://test", + "X-CSRF-Token": client.cookies["stackchain_csrf"], + }, + ) + assert response.status_code == 201 + return response.json()["grant"] + + @pytest.mark.anyio async def test_default_operator_mode_fails_closed_before_gitea_when_secrets_are_missing(monkeypatch): monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False) @@ -257,6 +274,126 @@ async def test_authenticated_session_status_exposes_only_csrf_proof(access_contr assert "correct horse battery staple" not in response.text +@pytest.mark.anyio +async def test_merge_requires_single_use_fresh_authorization_bound_to_exact_target( + access_control, monkeypatch +): + merge_calls = [] + + async def assigned(repository, number): + return True + + async def merge(repository, number, expected_head_sha): + merge_calls.append((repository, number, expected_head_sha)) + return {"number": number, "merged": True, "state": "closed"} + + monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", assigned) + monkeypatch.setattr(main.gitea_proxy, "merge_assigned_pull", merge) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as client: + await client.post( + "/api/v1/session", json={"access_token": "correct horse battery staple"} + ) + csrf_headers = { + "Origin": "https://test", + "X-CSRF-Token": client.cookies["stackchain_csrf"], + } + missing = await client.post( + "/api/v1/repos/stackchain/api/pulls/7/merge", + json={"expected_head_sha": "abc123"}, + headers=csrf_headers, + ) + authorized = await client.post( + "/api/v1/fresh-authorization", + json={ + "access_token": "correct horse battery staple", + "action": "merge_pull", + "target": "stackchain/api#7", + }, + headers=csrf_headers, + ) + grant_headers = {**csrf_headers, "X-Step-Up-Grant": authorized.json()["grant"]} + merged = await client.post( + "/api/v1/repos/stackchain/api/pulls/7/merge", + json={"expected_head_sha": "abc123"}, + headers=grant_headers, + ) + replayed = await client.post( + "/api/v1/repos/stackchain/api/pulls/7/merge", + json={"expected_head_sha": "abc123"}, + headers=grant_headers, + ) + + assert missing.status_code == 428 + assert missing.json() == { + "detail": { + "detail": "Fresh authorization required", + "code": "step_up_required", + "action": "merge_pull", + "target": "stackchain/api#7", + } + } + assert authorized.status_code == 201 + assert authorized.json()["expires_in"] == 90 + assert merged.status_code == 200 + assert replayed.status_code == 428 + assert merge_calls == [("stackchain/api", 7, "abc123")] + + +@pytest.mark.anyio +async def test_other_high_impact_routes_require_fresh_authorization_before_mutation( + access_control, monkeypatch +): + close_calls = [] + + async def assigned(repository, number): + return True + + async def close(repository, number): + close_calls.append((repository, number)) + return {"number": number, "state": "closed"} + + monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned) + monkeypatch.setattr(main.gitea_proxy, "close_issue", close) + transport = httpx.ASGITransport(app=main.app) + async with ( + httpx.AsyncClient(transport=transport, base_url="https://test") as phone, + httpx.AsyncClient(transport=transport, base_url="https://test") as laptop, + ): + await phone.post( + "/api/v1/session", + json={"access_token": "correct horse battery staple", "device_label": "Phone"}, + ) + await laptop.post( + "/api/v1/session", + json={"access_token": "correct horse battery staple", "device_label": "Laptop"}, + ) + remote = next( + item + for item in (await phone.get("/api/v1/sessions")).json()["devices"] + if not item["current"] + ) + headers = { + "Origin": "https://test", + "X-CSRF-Token": phone.cookies["stackchain_csrf"], + } + closed = await phone.patch( + "/api/v1/repos/stackchain/api/issues/7/close", headers=headers + ) + revoked = await phone.delete( + f"/api/v1/sessions/{remote['management_id']}", headers=headers + ) + revoked_all = await phone.delete("/api/v1/sessions", headers=headers) + laptop_still_active = await laptop.get("/api/v1/session") + + assert [closed.status_code, revoked.status_code, revoked_all.status_code] == [428, 428, 428] + assert [closed.json()["detail"]["action"], revoked.json()["detail"]["action"], revoked_all.json()["detail"]["action"]] == [ + "close_issue", "revoke_device", "revoke_all_sessions" + ] + assert close_calls == [] + assert laptop_still_active.status_code == 200 + + @pytest.mark.anyio async def test_operator_can_review_and_revoke_one_remote_device(access_control): transport = httpx.ASGITransport(app=main.app) @@ -287,11 +424,15 @@ async def test_operator_can_review_and_revoke_one_remote_device(access_control): missing_csrf = await laptop.delete( f"/api/v1/sessions/{phone_device['management_id']}" ) + grant = await fresh_grant( + laptop, "revoke_device", phone_device["management_id"] + ) revoked = await laptop.delete( f"/api/v1/sessions/{phone_device['management_id']}", headers={ "Origin": "https://test", "X-CSRF-Token": laptop.cookies["stackchain_csrf"], + "X-Step-Up-Grant": grant, }, ) phone_status = await phone.get("/api/v1/session") @@ -401,11 +542,13 @@ async def test_sign_out_all_devices_revokes_every_existing_session(access_contro "/api/v1/session", json={"access_token": "correct horse battery staple"} ) + grant = await fresh_grant(phone, "revoke_all_sessions", "all") response = await phone.delete( "/api/v1/sessions", headers={ "Origin": "https://test", "X-CSRF-Token": phone.cookies["stackchain_csrf"], + "X-Step-Up-Grant": grant, }, ) phone_private = await phone.get("/api/v1/background-identity") @@ -452,18 +595,26 @@ async def test_sign_out_all_devices_registry_failure_sets_no_cookies(access_cont "/api/v1/session", json={"access_token": "correct horse battery staple"} ) csrf = client.cookies["stackchain_csrf"] + grant = await fresh_grant(client, "revoke_all_sessions", "all") class BrokenStore: def is_active(self, session_id, expires_at): return True + def consume_step_up(self, grant, session_id, *, action, target): + return True + def revoke_all(self): raise SessionStoreError("database path and secret details") monkeypatch.setattr(main.dashboard_auth, "_session_store", lambda now=None: BrokenStore()) response = await client.delete( "/api/v1/sessions", - headers={"Origin": "https://test", "X-CSRF-Token": csrf}, + headers={ + "Origin": "https://test", + "X-CSRF-Token": csrf, + "X-Step-Up-Grant": grant, + }, ) assert response.status_code == 503 diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index 6d921eb..18b7973 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -14,7 +14,7 @@ SESSION_JS = ROOT / "frontend" / "session.js" def run_session_scenario(scenario: str) -> dict: harness = f""" const createSessionBoundary = require({json.dumps(str(SESSION_JS))}); -const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, workerMessages: [], confirmations: [], clearErrors: [], responseStatus: 200, responsePayload: {{}} }}; +const state = {{ requests: [], removed: [], deletedDatabases: [], deletionCompleted: false, deletedCaches: [], assigned: '', replaced: [], assignedAfterDeletion: false, workerMessages: [], confirmations: [], prompts: [], clearErrors: [], responseStatus: 200, responsePayload: {{}}, responses: [] }}; const storage = {{ values: new Map([['stackchain.private', 'secret'], ['gitea.preference', 'keep']]), get length() {{ return this.values.size; }}, @@ -26,8 +26,9 @@ const boundary = createSessionBoundary({{ origin: 'https://forge.example', base: '/dashboard/', fetchImpl: async (url, options = {{}}) => {{ - state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})) }}); - return new Response(JSON.stringify(state.responsePayload), {{ status: state.responseStatus, headers: {{ 'Content-Type': 'application/json' }} }}); + state.requests.push({{ url: String(url), method: options.method || 'GET', headers: Object.fromEntries(new Headers(options.headers || {{}})), body: options.body || null }}); + const configured = state.responses.length ? state.responses.shift() : {{ status: state.responseStatus, payload: state.responsePayload }}; + return new Response(JSON.stringify(configured.payload), {{ status: configured.status, headers: {{ 'Content-Type': 'application/json' }} }}); }}, localStorage: storage, sessionStorage: storage, @@ -53,6 +54,7 @@ const boundary = createSessionBoundary({{ }}, onClearError: error => state.clearErrors.push(error.message), confirmAction: message => {{ state.confirmations.push(message); return true; }}, + promptAuthorization: details => {{ state.prompts.push(details); return 'correct horse battery staple'; }}, }}); (async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }}); """ @@ -71,6 +73,39 @@ process.stdout.write(JSON.stringify(state)); assert result["requests"][0]["headers"]["x-csrf-token"] == "csrf-proof" +def test_high_impact_fetch_prompts_once_and_retries_original_request_with_grant(): + result = run_session_scenario( + """ +state.responses = [ + {status:428, payload:{detail:{code:'step_up_required', action:'merge_pull', target:'stackchain/api#7'}}}, + {status:201, payload:{grant:'one-time-grant', expires_in:90}}, + {status:200, payload:{merged:true}}, +]; +const original = { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({expected_head_sha:'abc123'}) }; +const response = await boundary.fetch('/dashboard/api/v1/repos/stackchain/api/pulls/7/merge', original); +state.finalStatus = response.status; +process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["finalStatus"] == 200 + assert result["prompts"] == [ + {"action": "merge_pull", "target": "stackchain/api#7"} + ] + assert [request["url"] for request in result["requests"]] == [ + "/dashboard/api/v1/repos/stackchain/api/pulls/7/merge", + "/dashboard/api/v1/fresh-authorization", + "/dashboard/api/v1/repos/stackchain/api/pulls/7/merge", + ] + assert json.loads(result["requests"][1]["body"]) == { + "access_token": "correct horse battery staple", + "action": "merge_pull", + "target": "stackchain/api#7", + } + assert result["requests"][2]["headers"]["x-step-up-grant"] == "one-time-grant" + assert result["requests"][2]["body"] == result["requests"][0]["body"] + + def test_same_origin_unauthorized_response_replaces_dashboard_once_without_clearing_private_work(): result = run_session_scenario( """ diff --git a/tests/test_session_store.py b/tests/test_session_store.py index b4240d3..9c7e603 100644 --- a/tests/test_session_store.py +++ b/tests/test_session_store.py @@ -119,3 +119,92 @@ def test_existing_session_registry_migrates_without_invalidating_sessions(tmp_pa devices = store.list_active("existing-session") assert len(devices) == 2 assert next(device for device in devices if device.current).device_label == "Existing device" + + +def test_existing_session_can_mint_first_step_up_grant_during_schema_upgrade(tmp_path): + database = tmp_path / "sessions.sqlite3" + digest = SessionStore._digest("existing-session") + with sqlite3.connect(database) as connection: + connection.execute( + "CREATE TABLE active_sessions (session_hash TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)" + ) + connection.execute("INSERT INTO active_sessions VALUES (?, ?)", (digest, 2_000)) + + store = SessionStore(database, clock=lambda: 1_000.0) + grant = store.mint_step_up( + "existing-session", action="revoke_all_sessions", target="all", ttl_seconds=90 + ) + + assert store.consume_step_up( + grant, + "existing-session", + action="revoke_all_sessions", + target="all", + ) is True + + +def test_step_up_grant_is_single_use_and_bound_to_session_action_and_target(tmp_path): + now = [1_000.0] + store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0]) + store.activate("phone-session", 2_000) + + grant = store.mint_step_up( + "phone-session", action="merge_pull", target="stackchain/api#7", ttl_seconds=90 + ) + + assert store.consume_step_up( + grant, "phone-session", action="merge_pull", target="stackchain/api#8" + ) is False + assert store.consume_step_up( + grant, "other-session", action="merge_pull", target="stackchain/api#7" + ) is False + assert store.consume_step_up( + grant, "phone-session", action="close_issue", target="stackchain/api#7" + ) is False + assert store.consume_step_up( + grant, "phone-session", action="merge_pull", target="stackchain/api#7" + ) is True + assert store.consume_step_up( + grant, "phone-session", action="merge_pull", target="stackchain/api#7" + ) is False + assert grant.encode() not in store.path.read_bytes() + + +def test_step_up_grants_expire_and_are_removed_with_parent_session(tmp_path): + now = [1_000.0] + store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: now[0]) + store.activate("phone-session", 2_000) + expired = store.mint_step_up( + "phone-session", action="close_issue", target="stackchain/api#7", ttl_seconds=90 + ) + revoked = store.mint_step_up( + "phone-session", action="revoke_all_sessions", target="all", ttl_seconds=90 + ) + + now[0] = 1_091.0 + assert store.consume_step_up( + expired, "phone-session", action="close_issue", target="stackchain/api#7" + ) is False + + now[0] = 1_010.0 + store.revoke("phone-session") + assert store.consume_step_up( + revoked, "phone-session", action="revoke_all_sessions", target="all" + ) is False + + +def test_managed_session_revocation_invalidates_its_outstanding_grants(tmp_path): + store = SessionStore(tmp_path / "sessions.sqlite3", clock=lambda: 1_000.0) + store.activate("phone-session", 2_000, device_label="Phone") + grant = store.mint_step_up( + "phone-session", action="merge_pull", target="stackchain/api#7", ttl_seconds=90 + ) + phone = store.list_active("phone-session")[0] + + assert store.revoke_managed(phone.management_id) is True + assert store.consume_step_up( + grant, + "phone-session", + action="merge_pull", + target="stackchain/api#7", + ) is False