Merge pull request 'Require fresh authorization for high-impact actions' (#342) from timmy/341-fresh-authorization into main
All checks were successful
CI / lint (push) Successful in 34s
CI / build-release (push) Successful in 4s
CI / release-candidate (push) Successful in 5s

This commit is contained in:
timmy 2026-08-08 21:06:04 +00:00
commit fa4ad28961
8 changed files with 597 additions and 9 deletions

View File

@ -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

View File

@ -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(() => ({}));

View File

@ -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))

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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(
"""

View File

@ -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