From ced644b27504c46234433c53ca352c5c8275ef3e Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 10 Aug 2026 13:14:18 +0000 Subject: [PATCH] feat: add device passkeys for operator access (Closes #485) --- README.md | 15 +- frontend/index.html | 1 + frontend/login.js | 78 +++++- frontend/session.js | 160 ++++++++++-- requirements.txt | 2 + src/dashboard_auth.py | 14 +- src/frontend_bundle.py | 7 +- src/main.py | 307 ++++++++++++++++++++++- src/passkey_store.py | 199 +++++++++++++++ src/passkeys.py | 87 +++++++ src/session_store.py | 22 +- src/views.py | 4 +- tests/test_dashboard_auth.py | 233 +++++++++++++++++ tests/test_dashboard_session_frontend.py | 71 ++++++ tests/test_login_frontend.py | 61 +++++ 15 files changed, 1228 insertions(+), 33 deletions(-) create mode 100644 src/passkey_store.py create mode 100644 src/passkeys.py diff --git a/README.md b/README.md index 18f682d..e344185 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,9 @@ export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='' export STACKCHAIN_DASHBOARD_SESSION_SECRET='' # Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3. export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3' +# Recommended behind a proxy; WebAuthn assertions must match these public values. +export STACKCHAIN_PASSKEY_RP_ID='forge.example.com' +export STACKCHAIN_PASSKEY_ORIGIN='https://forge.example.com' # Optional; defaults to eight hours. export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800 # Optional; explicit pointer, keyboard, or touch activity renews this idle window. @@ -196,8 +199,18 @@ session hashes, CSRF proofs, or source addresses. The registry also stores each session's last explicit activity. Existing two-column registries are migrated in place, their live sessions remain valid, and their idle clock starts at migration. +After token bootstrap, **Active devices → Add a passkey for this device** enrolls a +WebAuthn credential with required user verification. That device can then sign in +and authorize high-impact actions with its biometric/PIN gesture. The access token +remains the recovery fallback for browsers without WebAuthn or devices without an +enrolled credential. Registration and authentication challenges are exact-purpose, +single-use, and short-lived. Remotely revoking an enrolled device deletes its passkey +as well as its active session; signing out normally keeps the passkey available for +the next sign-in. + 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. +remote device, or signing out every device—require a passkey assertion or 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 diff --git a/frontend/index.html b/frontend/index.html index ab44127..84a0164 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -36,6 +36,7 @@
+
diff --git a/frontend/login.js b/frontend/login.js index 1aa61ef..3b77af4 100644 --- a/frontend/login.js +++ b/frontend/login.js @@ -20,6 +20,8 @@ const form = options.form; const status = options.status; const button = options.button; + const passkeyButton = options.passkeyButton; + const credentials = options.credentials; const fetchImpl = options.fetchImpl; const location = options.location; const clearPrivateDeviceData = options.clearPrivateDeviceData; @@ -28,6 +30,34 @@ const clearIntervalImpl = options.clearIntervalImpl || clearInterval; let timer = null; + function decodeBase64Url(value) { + const padded = String(value).replaceAll('-', '+').replaceAll('_', '/') + + '='.repeat((4 - String(value).length % 4) % 4); + return Uint8Array.from(atob(padded), character => character.charCodeAt(0)); + } + + function encodeBase64Url(value) { + if (value === null || value === undefined) return null; + const bytes = new Uint8Array(value); + let binary = ''; + bytes.forEach(byte => { binary += String.fromCharCode(byte); }); + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); + } + + function credentialJSON(credential) { + return { + id: credential.id, + type: credential.type, + rawId: encodeBase64Url(credential.rawId), + response: { + authenticatorData: encodeBase64Url(credential.response.authenticatorData), + clientDataJSON: encodeBase64Url(credential.response.clientDataJSON), + signature: encodeBase64Url(credential.response.signature), + userHandle: encodeBase64Url(credential.response.userHandle), + }, + }; + } + if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.'; async function showReason(reason) { @@ -95,18 +125,60 @@ status.textContent = 'Sign-in failed. Check the token and try again.'; } - return { submit, showReason }; + async function signInWithPasskey(deviceLabel = 'This device') { + if (!credentials?.get) { + status.textContent = 'Passkeys are not supported in this browser. Use the access token.'; + return false; + } + status.textContent = 'Waiting for your passkey…'; + try { + const optionsResponse = await fetchImpl('api/v1/passkeys/authentication/options', { + method: 'POST', headers: { Accept: 'application/json' }, + }); + if (!optionsResponse.ok) throw new Error('No enrolled passkey'); + const publicKey = await optionsResponse.json(); + const challenge = publicKey.challenge; + publicKey.challenge = decodeBase64Url(publicKey.challenge); + publicKey.allowCredentials = (publicKey.allowCredentials || []).map(item => ({ + ...item, id: decodeBase64Url(item.id), + })); + const credential = await credentials.get({ publicKey }); + const response = await fetchImpl('api/v1/passkeys/authentication/verify', { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge, + credential: credentialJSON(credential), + device_label: deviceLabel, + action: 'sign_in', + target: 'dashboard', + }), + }); + if (!response.ok) throw new Error('Passkey verification failed'); + location.replace(continuation); + return true; + } catch (_error) { + status.textContent = 'Passkey sign-in was not completed. Try again or use the access token.'; + return false; + } + } + + if (passkeyButton) passkeyButton.disabled = !credentials?.get; + return { submit, signInWithPasskey, showReason }; })); if (typeof document !== 'undefined') { const form = document.getElementById('sign-in'); const status = document.getElementById('status'); const button = document.getElementById('submit-sign-in'); + const passkeyButton = document.getElementById('passkey-sign-in'); const loginParams = new URLSearchParams(window.location.search); const controller = createLoginController({ form, status, button, + passkeyButton, + credentials: window.navigator?.credentials, fetchImpl: fetch.bind(window), location: window.location, continuation: loginParams.get('continue'), @@ -120,4 +192,8 @@ if (typeof document !== 'undefined') { const deviceLabel = data.get('device_label'); controller.submit(accessToken, deviceLabel); }); + passkeyButton?.addEventListener('click', () => { + const data = new FormData(form); + controller.signInWithPasskey(data.get('device_label')); + }); } diff --git a/frontend/session.js b/frontend/session.js index a75ef09..d1005f8 100644 --- a/frontend/session.js +++ b/frontend/session.js @@ -13,6 +13,7 @@ indexedDB: root.indexedDB, caches: root.caches, serviceWorker: root.navigator?.serviceWorker, + credentials: root.navigator?.credentials, MessageChannel: root.MessageChannel, location: root.location, addActivityListener: (type, listener, options) => root.addEventListener(type, listener, options), @@ -38,6 +39,7 @@ const devicesList = root.document.getElementById('active-devices-list'); const devicesStatus = root.document.getElementById('active-devices-status'); const closeDevices = root.document.getElementById('close-active-devices'); + const enrollPasskey = root.document.getElementById('enroll-passkey'); const renderDevices = async () => { devicesStatus.textContent = 'Loading active devices…'; devicesList.replaceChildren(); @@ -83,6 +85,18 @@ devicesSheet.hidden = true; devicesButton?.focus(); }); + enrollPasskey?.addEventListener('click', async () => { + enrollPasskey.disabled = true; + devicesStatus.textContent = 'Waiting for your device passkey…'; + try { + await boundary.enrollPasskey(); + devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.'; + } catch (_error) { + devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.'; + } finally { + enrollPasskey.disabled = false; + } + }); boundary.refreshOfflineLease().then(valid => { if (valid) boundary.resumeQueuedWork(); }); @@ -93,7 +107,7 @@ root.stackchainSession = boundary; } })(typeof window !== 'undefined' ? window : this, function createSessionBoundary({ - cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction, addActivityListener, + cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, credentials, MessageChannel, location, confirmAction, addActivityListener, promptAuthorization = () => null, onExpired = () => {}, onClearError = () => {}, @@ -164,6 +178,33 @@ return entry ? decodeURIComponent(entry.slice('stackchain_csrf='.length)) : ''; } + function decodeBase64Url(value) { + const padded = String(value).replaceAll('-', '+').replaceAll('_', '/') + + '='.repeat((4 - String(value).length % 4) % 4); + return Uint8Array.from(atob(padded), character => character.charCodeAt(0)); + } + + function encodeBase64Url(value) { + if (value === null || value === undefined) return null; + let binary = ''; + new Uint8Array(value).forEach(byte => { binary += String.fromCharCode(byte); }); + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); + } + + function authenticationCredentialJSON(credential) { + return { + id: credential.id, + type: credential.type, + rawId: encodeBase64Url(credential.rawId), + response: { + authenticatorData: encodeBase64Url(credential.response.authenticatorData), + clientDataJSON: encodeBase64Url(credential.response.clientDataJSON), + signature: encodeBase64Url(credential.response.signature), + userHandle: encodeBase64Url(credential.response.userHandle), + }, + }; + } + function isSameOrigin(input) { try { return new URL(String(input?.url || input), origin).origin === origin; } catch (_error) { return false; } @@ -219,6 +260,37 @@ } } + async function authorizeWithPasskey(details) { + if (!credentials?.get) return null; + const headers = new Headers({ Accept: 'application/json', 'Content-Type': 'application/json' }); + const csrf = csrfToken(); + if (csrf) headers.set('X-CSRF-Token', csrf); + const optionsResponse = await fetchWithDeadline(base + 'api/v1/passkeys/authorization/options', { + method: 'POST', headers, + body: JSON.stringify({ action: details.action, target: details.target }), + }, 'POST', 'passkey-authorization'); + if (!optionsResponse.ok) return null; + const publicKey = await optionsResponse.json(); + const challenge = publicKey.challenge; + publicKey.challenge = decodeBase64Url(publicKey.challenge); + publicKey.allowCredentials = (publicKey.allowCredentials || []).map(item => ({ + ...item, id: decodeBase64Url(item.id), + })); + const credential = await credentials.get({ publicKey }); + const verified = await fetchWithDeadline(base + 'api/v1/passkeys/authorization/verify', { + method: 'POST', headers, + body: JSON.stringify({ + challenge, + credential: authenticationCredentialJSON(credential), + action: details.action, + target: details.target, + }), + }, 'POST', 'passkey-authorization'); + if (!verified.ok) return null; + const payload = await verified.json().catch(() => ({})); + return payload.grant || null; + } + async function sessionFetch(input, options = {}, allowStepUp = true) { const method = String(options.method || input?.method || 'GET').toUpperCase(); const requestOptions = { ...options }; @@ -233,31 +305,37 @@ 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 fetchWithDeadline(base + 'api/v1/fresh-authorization', { - method: 'POST', - headers: authorizationHeaders, - body: JSON.stringify({ - access_token: accessToken, + let grant = null; + try { grant = await authorizeWithPasskey(detail); } + catch (_error) { /* Cancellation and unavailable passkeys fall back to recovery. */ } + if (!grant) { + const accessToken = await promptAuthorization({ action: detail.action, target: detail.target, - }), - }, 'POST', 'fresh-authorization'); - if (!authorization.ok) return authorization; - const grant = await authorization.json().catch(() => ({})); - if (!grant.grant) return response; + }); + 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 fetchWithDeadline(base + 'api/v1/fresh-authorization', { + method: 'POST', + headers: authorizationHeaders, + body: JSON.stringify({ + access_token: accessToken, + action: detail.action, + target: detail.target, + }), + }, 'POST', 'fresh-authorization'); + if (!authorization.ok) return authorization; + const authorizationPayload = await authorization.json().catch(() => ({})); + grant = authorizationPayload.grant; + } + if (!grant) return response; const retryHeaders = new Headers(requestOptions.headers || input?.headers || {}); - retryHeaders.set('X-Step-Up-Grant', grant.grant); + retryHeaders.set('X-Step-Up-Grant', grant); return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false); } } @@ -374,6 +452,41 @@ } catch (_error) { /* A later service-worker activation can clear stale caches. */ } } + async function enrollPasskey() { + if (!credentials?.create) throw new Error('Passkeys are not supported'); + const optionsResponse = await sessionFetch( + base + 'api/v1/passkeys/registration/options', { method: 'POST' } + ); + if (!optionsResponse.ok) throw new Error('Could not start passkey enrollment'); + const publicKey = await optionsResponse.json(); + const challenge = publicKey.challenge; + publicKey.challenge = decodeBase64Url(publicKey.challenge); + publicKey.user.id = decodeBase64Url(publicKey.user.id); + publicKey.excludeCredentials = (publicKey.excludeCredentials || []).map(item => ({ + ...item, id: decodeBase64Url(item.id), + })); + const credential = await credentials.create({ publicKey }); + const response = await sessionFetch(base + 'api/v1/passkeys/registration/verify', { + method: 'POST', + headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ + challenge, + credential: { + id: credential.id, + type: credential.type, + rawId: encodeBase64Url(credential.rawId), + response: { + attestationObject: encodeBase64Url(credential.response.attestationObject), + clientDataJSON: encodeBase64Url(credential.response.clientDataJSON), + transports: credential.response.getTransports?.() || [], + }, + }, + }), + }); + if (!response.ok) throw new Error('Could not verify passkey enrollment'); + return true; + } + async function listActiveDevices() { const response = await sessionFetch(base + 'api/v1/sessions'); if (!response.ok) throw new Error('Could not load active devices'); @@ -424,6 +537,7 @@ signOut, signOutAllDevices, listActiveDevices, + enrollPasskey, revokeActiveDevice, clearPrivateDeviceData, handleServiceWorkerMessage, diff --git a/requirements.txt b/requirements.txt index 35a6f1f..0446007 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,6 @@ fastapi==0.133.1 httpx==0.28.1 pydantic==2.13.4 pytest==9.1.1 +rjsmin==1.2.5 uvicorn==0.41.0 +webauthn==3.0.0 diff --git a/src/dashboard_auth.py b/src/dashboard_auth.py index acb7a98..17c336d 100644 --- a/src/dashboard_auth.py +++ b/src/dashboard_auth.py @@ -110,7 +110,10 @@ def idle_timeout_seconds() -> int: def issue_session( - now: int | None = None, *, device_label: str = "This device" + now: int | None = None, + *, + device_label: str = "This device", + management_id: str | None = None, ) -> tuple[str, Session]: issued_at = int(time.time() if now is None else now) ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS))) @@ -127,7 +130,10 @@ def issue_session( encoded = _encode(payload) signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest()) _session_store(now).activate( - session.session_id, session.expires_at, device_label=device_label + session.session_id, + session.expires_at, + device_label=device_label, + management_id=management_id, ) return f"{encoded}.{signature}", session @@ -187,6 +193,10 @@ async def active_devices(session: Session): return await asyncio.to_thread(_session_store().list_active, session.session_id) +async def session_management_id(session: Session) -> str: + return await asyncio.to_thread(_session_store().management_id, session.session_id) + + async def touch_session(session: Session) -> bool: return await asyncio.to_thread( _session_store().touch, diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index d834685..5e3bd29 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -8,6 +8,8 @@ import re from dataclasses import dataclass from pathlib import Path +import rjsmin + SCRIPT_TAG = re.compile(r'^$', re.MULTILINE) WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js" @@ -29,7 +31,10 @@ def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes: for source in sources: path = frontend_dir / source.removeprefix("static/") chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n") - return b"".join(chunks) + source = b"".join(chunks) + revision = hashlib.sha256(source).hexdigest() + minified = rjsmin.jsmin(source.decode()).encode() + return minified + f';"source-sha256:{revision}";'.encode() def build_frontend(frontend_dir: Path) -> FrontendBuild: diff --git a/src/main.py b/src/main.py index d86bccd..33bb2ab 100644 --- a/src/main.py +++ b/src/main.py @@ -21,7 +21,7 @@ from fastapi.responses import JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator -from src import dashboard_auth, gitea_proxy +from src import dashboard_auth, gitea_proxy, passkeys from src.available_issue_snapshot_store import AvailableIssueSnapshotStore from src.compression import NegotiatedGZipMiddleware from src.gitea_proxy import ( @@ -40,6 +40,7 @@ from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source from src.live_snapshot_store import LiveSnapshotState, LiveSnapshotStore, RefreshLeaseLost from src.models import Issue, Milestone, PullRequest, Repo, User +from src.passkey_store import PasskeyStore from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit from src.suggestion_engine import compute from src.later_store import LaterStore @@ -199,12 +200,57 @@ class DashboardSignIn(BaseModel): 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", + "enroll_passkey", + ] + target: str = Field(min_length=1, max_length=255) + + +class PasskeyCeremony(BaseModel): + challenge: str = Field(min_length=20, max_length=200) + credential: dict = Field() + + +class PasskeyAuthentication(PasskeyCeremony): + device_label: str = Field(default="This device", min_length=1, max_length=64) + action: Literal[ + "sign_in", "merge_pull", "close_issue", "revoke_device", "revoke_all_sessions" + ] = "sign_in" + target: str = Field(default="dashboard", min_length=1, max_length=255) + + +class PasskeyAuthorizationTarget(BaseModel): action: Literal[ "merge_pull", "close_issue", "revoke_device", "revoke_all_sessions" ] target: str = Field(min_length=1, max_length=255) +class PasskeyAuthorization(PasskeyCeremony, PasskeyAuthorizationTarget): + pass + + +def _passkey_store() -> PasskeyStore: + state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state") + database = os.getenv( + "STACKCHAIN_SESSION_DB", os.path.join(state_dir, "sessions.sqlite3") + ) + return PasskeyStore(database, clock=time.time) + + +def _passkey_relying_party(request: Request) -> tuple[str, str]: + rp_id = os.getenv("STACKCHAIN_PASSKEY_RP_ID", request.url.hostname or "") + origin = os.getenv( + "STACKCHAIN_PASSKEY_ORIGIN", + f"{request.url.scheme}://{request.url.netloc}", + ) + return rp_id, origin + + def _login_attempt_store() -> LoginAttemptStore: state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state") return LoginAttemptStore( @@ -696,6 +742,13 @@ async def require_operator_session(request: Request, call_next): } or path.startswith("/static/") or (path == "/api/v1/session" and request.method == "POST") + or ( + path in { + "/api/v1/passkeys/authentication/options", + "/api/v1/passkeys/authentication/verify", + } + and request.method == "POST" + ) ) session = None session_reason = None @@ -969,6 +1022,256 @@ async def fresh_authorization(payload: FreshAuthorization, request: Request): ) +@app.post("/api/v1/passkeys/registration/options", status_code=201) +async def create_passkey_registration_options( + request: Request, + 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="enroll_passkey", + target="current_device", + ) + rp_id, _origin = _passkey_relying_party(request) + store = _passkey_store() + existing = await asyncio.to_thread(store.all) + options, challenge = passkeys.registration_options( + rp_id=rp_id, + excluded=[item.credential_id for item in existing], + ) + await asyncio.to_thread( + store.issue_challenge, + challenge, + session_id=request.state.dashboard_session.session_id, + purpose="registration", + action="enroll_passkey", + target="current_device", + ) + return JSONResponse( + options, + status_code=201, + headers={"Cache-Control": "no-store"}, + ) + + +@app.post("/api/v1/passkeys/registration/verify", status_code=201) +async def verify_passkey_registration(payload: PasskeyCeremony, request: Request): + try: + challenge = passkeys.decode(payload.challenge) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid passkey ceremony") + store = _passkey_store() + valid = await asyncio.to_thread( + store.consume_challenge, + challenge, + session_id=request.state.dashboard_session.session_id, + purpose="registration", + action="enroll_passkey", + target="current_device", + ) + if not valid: + raise HTTPException(status_code=409, detail="Passkey challenge expired or already used") + rp_id, origin = _passkey_relying_party(request) + try: + verified = await asyncio.to_thread( + passkeys.verify_registration, + credential=payload.credential, + challenge=challenge, + rp_id=rp_id, + origin=origin, + ) + devices = await dashboard_auth.active_devices(request.state.dashboard_session) + current = next(device for device in devices if device.current) + await asyncio.to_thread( + store.register, + credential_id=verified.credential_id, + public_key=verified.credential_public_key, + sign_count=verified.sign_count, + device_label=current.device_label, + management_id=current.management_id, + ) + except dashboard_auth.SessionStoreError: + raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable") + except Exception as exc: + raise HTTPException(status_code=400, detail="Passkey verification failed") from exc + return JSONResponse( + {"enrolled": True}, status_code=201, headers={"Cache-Control": "no-store"} + ) + + +@app.post("/api/v1/passkeys/authentication/options") +async def create_passkey_authentication_options(request: Request): + store = _passkey_store() + credentials = await asyncio.to_thread(store.all) + if not credentials: + raise HTTPException(status_code=404, detail="No passkeys enrolled") + rp_id, _origin = _passkey_relying_party(request) + options, challenge = passkeys.authentication_options( + rp_id=rp_id, + credentials=[item.credential_id for item in credentials], + ) + await asyncio.to_thread( + store.issue_challenge, + challenge, + session_id=None, + purpose="authentication", + action="sign_in", + target="dashboard", + ) + return JSONResponse(options, headers={"Cache-Control": "no-store"}) + + +@app.post("/api/v1/passkeys/authorization/options") +async def create_passkey_authorization_options( + payload: PasskeyAuthorizationTarget, request: Request +): + store = _passkey_store() + credentials = await asyncio.to_thread(store.all) + if not credentials: + raise HTTPException(status_code=404, detail="No passkeys enrolled") + rp_id, _origin = _passkey_relying_party(request) + options, challenge = passkeys.authentication_options( + rp_id=rp_id, + credentials=[item.credential_id for item in credentials], + ) + await asyncio.to_thread( + store.issue_challenge, + challenge, + session_id=request.state.dashboard_session.session_id, + purpose="authorization", + action=payload.action, + target=payload.target, + ) + return JSONResponse(options, headers={"Cache-Control": "no-store"}) + + +@app.post("/api/v1/passkeys/authorization/verify", status_code=201) +async def verify_passkey_authorization( + payload: PasskeyAuthorization, request: Request +): + try: + challenge = passkeys.decode(payload.challenge) + credential_id = passkeys.decode(str(payload.credential.get("id", ""))) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid passkey ceremony") + store = _passkey_store() + valid = await asyncio.to_thread( + store.consume_challenge, + challenge, + session_id=request.state.dashboard_session.session_id, + purpose="authorization", + action=payload.action, + target=payload.target, + ) + stored = await asyncio.to_thread(store.get, credential_id) + if not valid or stored is None: + raise HTTPException(status_code=409, detail="Passkey challenge expired or already used") + rp_id, origin = _passkey_relying_party(request) + try: + verified = await asyncio.to_thread( + passkeys.verify_authentication, + credential=payload.credential, + challenge=challenge, + rp_id=rp_id, + origin=origin, + stored=stored, + ) + updated = await asyncio.to_thread( + store.update_counter, stored.credential_id, verified.new_sign_count + ) + if not updated: + raise ValueError("stale passkey counter") + grant = await dashboard_auth.issue_step_up( + request.state.dashboard_session, + action=payload.action, + target=payload.target, + ) + except dashboard_auth.SessionStoreError: + raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable") + except Exception as exc: + raise HTTPException(status_code=401, detail="Passkey authorization failed") from exc + return JSONResponse( + {"grant": grant, "expires_in": dashboard_auth.STEP_UP_TTL_SECONDS}, + status_code=201, + headers={"Cache-Control": "no-store"}, + ) + + +@app.post("/api/v1/passkeys/authentication/verify") +async def verify_passkey_authentication( + payload: PasskeyAuthentication, request: Request, response: Response +): + if payload.action != "sign_in" or payload.target != "dashboard": + raise HTTPException(status_code=400, detail="Invalid passkey sign-in target") + try: + challenge = passkeys.decode(payload.challenge) + credential_id = passkeys.decode(str(payload.credential.get("id", ""))) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid passkey ceremony") + store = _passkey_store() + valid = await asyncio.to_thread( + store.consume_challenge, + challenge, + session_id=None, + purpose="authentication", + action="sign_in", + target="dashboard", + ) + stored = await asyncio.to_thread(store.get, credential_id) + if not valid or stored is None: + raise HTTPException(status_code=401, detail="Passkey sign-in failed") + rp_id, origin = _passkey_relying_party(request) + try: + verified = await asyncio.to_thread( + passkeys.verify_authentication, + credential=payload.credential, + challenge=challenge, + rp_id=rp_id, + origin=origin, + stored=stored, + ) + updated = await asyncio.to_thread( + store.update_counter, stored.credential_id, verified.new_sign_count + ) + if not updated: + raise ValueError("stale passkey counter") + await dashboard_auth.revoke_managed_session(stored.management_id) + signed, session = await asyncio.to_thread( + dashboard_auth.issue_session, + device_label=stored.device_label, + management_id=stored.management_id, + ) + except dashboard_auth.SessionStoreError: + raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable") + except Exception as exc: + raise HTTPException(status_code=401, detail="Passkey sign-in failed") from exc + path = dashboard_auth.cookie_path(request) + max_age = max(1, session.expires_at - int(time.time())) + response.set_cookie( + dashboard_auth.SESSION_COOKIE, + signed, + max_age=max_age, + path=path, + secure=True, + httponly=True, + samesite="strict", + ) + response.set_cookie( + dashboard_auth.CSRF_COOKIE, + session.csrf, + max_age=max_age, + path=path, + secure=True, + httponly=False, + samesite="strict", + ) + response.headers["Cache-Control"] = "no-store" + return {"authenticated": True, "method": "passkey"} + + @app.get("/api/v1/session") async def session_status(request: Request): session = request.state.dashboard_session @@ -1196,6 +1499,7 @@ async def revoke_active_device( target = next( (device for device in devices if device.management_id == management_id), None ) + await asyncio.to_thread(_passkey_store().revoke_management_id, management_id) revoked = await dashboard_auth.revoke_managed_session(management_id) except dashboard_auth.SessionStoreError: return JSONResponse( @@ -1223,6 +1527,7 @@ async def sign_out_all_devices( target="all", ) try: + await asyncio.to_thread(_passkey_store().revoke_all) await dashboard_auth.revoke_all_sessions() except dashboard_auth.SessionStoreError: return JSONResponse( diff --git a/src/passkey_store.py b/src/passkey_store.py new file mode 100644 index 0000000..0c3de2b --- /dev/null +++ b/src/passkey_store.py @@ -0,0 +1,199 @@ +"""Durable, one-time WebAuthn challenges and device-bound passkey credentials.""" + +import hashlib +import sqlite3 +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +from src.session_store import SessionStoreError + + +@dataclass(frozen=True) +class StoredPasskey: + credential_id: bytes + public_key: bytes + sign_count: int + device_label: str + management_id: str + + +class PasskeyStore: + def __init__(self, path: str | Path, *, clock: Callable[[], float]) -> None: + self.path = Path(path) + self.clock = clock + + @staticmethod + def _digest(value: bytes | str) -> str: + raw = value if isinstance(value, bytes) else value.encode() + return hashlib.sha256(raw).hexdigest() + + def _connect(self) -> sqlite3.Connection: + try: + self.path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(self.path, timeout=0.1) + connection.execute( + """ + CREATE TABLE IF NOT EXISTS passkey_credentials ( + credential_id BLOB PRIMARY KEY, + public_key BLOB NOT NULL, + sign_count INTEGER NOT NULL, + device_label TEXT NOT NULL, + management_id TEXT NOT NULL UNIQUE, + created_at INTEGER NOT NULL + ) + """ + ) + connection.execute( + """ + CREATE TABLE IF NOT EXISTS passkey_challenges ( + challenge_hash TEXT PRIMARY KEY, + session_hash TEXT, + purpose TEXT NOT NULL, + action TEXT NOT NULL, + target TEXT NOT NULL, + expires_at INTEGER NOT NULL + ) + """ + ) + return connection + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError("Passkey registry is temporarily unavailable") from exc + + def issue_challenge( + self, + challenge: bytes, + *, + session_id: str | None, + purpose: str, + action: str, + target: str, + ttl_seconds: int = 120, + ) -> None: + now = int(self.clock()) + try: + with self._connect() as connection: + connection.execute("DELETE FROM passkey_challenges WHERE expires_at <= ?", (now,)) + connection.execute( + "INSERT INTO passkey_challenges(" + "challenge_hash, session_hash, purpose, action, target, expires_at" + ") VALUES (?, ?, ?, ?, ?, ?)", + ( + self._digest(challenge), + self._digest(session_id) if session_id else None, + purpose, + action, + target, + now + max(1, ttl_seconds), + ), + ) + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError("Passkey registry is temporarily unavailable") from exc + + def consume_challenge( + self, + challenge: bytes, + *, + session_id: str | None, + purpose: str, + action: str, + target: str, + ) -> bool: + now = int(self.clock()) + try: + with self._connect() as connection: + connection.execute("DELETE FROM passkey_challenges WHERE expires_at <= ?", (now,)) + cursor = connection.execute( + "DELETE FROM passkey_challenges WHERE challenge_hash = ? " + "AND session_hash IS ? AND purpose = ? AND action = ? AND target = ? " + "AND expires_at > ?", + ( + self._digest(challenge), + self._digest(session_id) if session_id else None, + purpose, + action, + target, + now, + ), + ) + return cursor.rowcount == 1 + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError("Passkey registry is temporarily unavailable") from exc + + def register( + self, + *, + credential_id: bytes, + public_key: bytes, + sign_count: int, + device_label: str, + management_id: str, + ) -> None: + try: + with self._connect() as connection: + connection.execute( + "INSERT INTO passkey_credentials(credential_id, public_key, sign_count, " + "device_label, management_id, created_at) VALUES (?, ?, ?, ?, ?, ?)", + ( + credential_id, + public_key, + sign_count, + device_label, + management_id, + int(self.clock()), + ), + ) + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError("Passkey registry is temporarily unavailable") from exc + + def all(self) -> list[StoredPasskey]: + try: + with self._connect() as connection: + rows = connection.execute( + "SELECT credential_id, public_key, sign_count, device_label, management_id " + "FROM passkey_credentials ORDER BY created_at DESC" + ).fetchall() + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError("Passkey registry is temporarily unavailable") from exc + return [StoredPasskey(*row) for row in rows] + + def get(self, credential_id: bytes) -> StoredPasskey | None: + try: + with self._connect() as connection: + row = connection.execute( + "SELECT credential_id, public_key, sign_count, device_label, management_id " + "FROM passkey_credentials WHERE credential_id = ?", + (credential_id,), + ).fetchone() + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError("Passkey registry is temporarily unavailable") from exc + return StoredPasskey(*row) if row else None + + def update_counter(self, credential_id: bytes, new_sign_count: int) -> bool: + try: + with self._connect() as connection: + cursor = connection.execute( + "UPDATE passkey_credentials SET sign_count = ? " + "WHERE credential_id = ? AND sign_count <= ?", + (new_sign_count, credential_id, new_sign_count), + ) + return cursor.rowcount == 1 + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError("Passkey registry is temporarily unavailable") from exc + + def revoke_management_id(self, management_id: str) -> None: + try: + with self._connect() as connection: + connection.execute( + "DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,) + ) + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError("Passkey registry is temporarily unavailable") from exc + + def revoke_all(self) -> None: + try: + with self._connect() as connection: + connection.execute("DELETE FROM passkey_credentials") + connection.execute("DELETE FROM passkey_challenges") + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError("Passkey registry is temporarily unavailable") from exc diff --git a/src/passkeys.py b/src/passkeys.py new file mode 100644 index 0000000..5af9c94 --- /dev/null +++ b/src/passkeys.py @@ -0,0 +1,87 @@ +"""WebAuthn passkey ceremony helpers for the single dashboard operator.""" + +import base64 +import json +import secrets + +from webauthn import ( + generate_authentication_options, + generate_registration_options, + options_to_json, + verify_authentication_response, + verify_registration_response, +) +from webauthn.helpers.structs import ( + AuthenticatorSelectionCriteria, + PublicKeyCredentialDescriptor, + ResidentKeyRequirement, + UserVerificationRequirement, +) + + +def encode(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode() + + +def decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def registration_options(*, rp_id: str, excluded: list[bytes] | None = None) -> tuple[dict, bytes]: + challenge = secrets.token_bytes(32) + options = generate_registration_options( + rp_id=rp_id, + rp_name="Stackchain Dashboard", + user_name="stackchain-operator", + user_display_name="Stackchain operator", + challenge=challenge, + exclude_credentials=[ + PublicKeyCredentialDescriptor(id=credential_id) + for credential_id in (excluded or []) + ], + authenticator_selection=AuthenticatorSelectionCriteria( + resident_key=ResidentKeyRequirement.PREFERRED, + user_verification=UserVerificationRequirement.REQUIRED, + ), + ) + return json.loads(options_to_json(options)), challenge + + +def authentication_options(*, rp_id: str, credentials: list[bytes]) -> tuple[dict, bytes]: + challenge = secrets.token_bytes(32) + options = generate_authentication_options( + rp_id=rp_id, + challenge=challenge, + allow_credentials=[ + PublicKeyCredentialDescriptor(id=credential_id) + for credential_id in credentials + ], + user_verification=UserVerificationRequirement.REQUIRED, + ) + return json.loads(options_to_json(options)), challenge + + +def verify_registration( + *, credential: dict, challenge: bytes, rp_id: str, origin: str +): + return verify_registration_response( + credential=credential, + expected_challenge=challenge, + expected_rp_id=rp_id, + expected_origin=origin, + require_user_verification=True, + ) + + +def verify_authentication( + *, credential: dict, challenge: bytes, rp_id: str, origin: str, stored +): + return verify_authentication_response( + credential=credential, + expected_challenge=challenge, + expected_rp_id=rp_id, + expected_origin=origin, + credential_public_key=stored.public_key, + credential_current_sign_count=stored.sign_count, + require_user_verification=True, + ) diff --git a/src/session_store.py b/src/session_store.py index 200d77e..65c451d 100644 --- a/src/session_store.py +++ b/src/session_store.py @@ -117,7 +117,12 @@ class SessionStore: raise SessionStoreError("Session registry is temporarily unavailable") from exc def activate( - self, session_id: str, expires_at: int, *, device_label: str = "This device" + self, + session_id: str, + expires_at: int, + *, + device_label: str = "This device", + management_id: str | None = None, ) -> None: label = " ".join(str(device_label).split())[:64] or "This device" now = int(self.clock()) @@ -133,7 +138,7 @@ class SessionStore: ( self._digest(session_id), expires_at, - secrets.token_urlsafe(18), + management_id or secrets.token_urlsafe(18), label, now, now, @@ -241,6 +246,19 @@ class SessionStore: for row in rows ] + def management_id(self, session_id: str) -> str: + try: + with self._connect() as connection: + row = connection.execute( + "SELECT management_id FROM active_sessions WHERE session_hash = ?", + (self._digest(session_id),), + ).fetchone() + except (OSError, sqlite3.Error) as exc: + raise SessionStoreError("Session registry is temporarily unavailable") from exc + if row is None: + raise SessionStoreError("Session is no longer active") + return row[0] + def revoke_managed(self, management_id: str) -> bool: try: with self._connect() as connection: diff --git a/src/views.py b/src/views.py index 367b047..34dab13 100644 --- a/src/views.py +++ b/src/views.py @@ -15,8 +15,8 @@ LOGIN_HTML = """ Sign in · Stackchain Dashboard -

Operator sign in

Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.

-

+

Operator sign in

Use an enrolled passkey, or enter the dashboard access token for bootstrap and recovery. The token is exchanged for a private, short-lived session and is never stored on this device.

+

Recovery

""" diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index c2bed7e..c3fc802 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -39,6 +39,239 @@ async def fresh_grant(client, action: str, target: str) -> str: return response.json()["grant"] +@pytest.mark.anyio +async def test_passkey_enrollment_options_require_fresh_authorization_and_are_one_time( + access_control, +): + 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", + "device_label": "Timmy's phone", + }, + ) + headers = { + "Origin": "https://test", + "X-CSRF-Token": client.cookies["stackchain_csrf"], + } + missing = await client.post("/api/v1/passkeys/registration/options", headers=headers) + grant = await fresh_grant(client, "enroll_passkey", "current_device") + created = await client.post( + "/api/v1/passkeys/registration/options", + headers={**headers, "X-Step-Up-Grant": grant}, + ) + replayed = await client.post( + "/api/v1/passkeys/registration/options", + headers={**headers, "X-Step-Up-Grant": grant}, + ) + + assert missing.status_code == 428 + assert missing.json()["detail"]["action"] == "enroll_passkey" + assert created.status_code == 201 + assert created.headers["cache-control"] == "no-store" + options = created.json() + assert options["rp"] == {"id": "test", "name": "Stackchain Dashboard"} + assert options["user"]["name"] == "stackchain-operator" + assert options["authenticatorSelection"]["userVerification"] == "required" + assert options["challenge"] + assert replayed.status_code == 428 + + +@pytest.mark.anyio +async def test_enrolled_passkey_can_sign_in_without_the_operator_token( + access_control, monkeypatch +): + class VerifiedRegistration: + credential_id = b"phone-credential" + credential_public_key = b"credential-public-key" + sign_count = 0 + + class VerifiedAuthentication: + new_sign_count = 1 + + monkeypatch.setattr( + main.passkeys, + "verify_registration", + lambda **_kwargs: VerifiedRegistration(), + raising=False, + ) + monkeypatch.setattr( + main.passkeys, + "verify_authentication", + lambda **_kwargs: VerifiedAuthentication(), + raising=False, + ) + transport = httpx.ASGITransport(app=main.app) + async with httpx.AsyncClient(transport=transport, base_url="https://test") as bootstrap: + await bootstrap.post( + "/api/v1/session", + json={"access_token": "correct horse battery staple", "device_label": "Phone"}, + ) + csrf_headers = { + "Origin": "https://test", + "X-CSRF-Token": bootstrap.cookies["stackchain_csrf"], + } + grant = await fresh_grant(bootstrap, "enroll_passkey", "current_device") + options = await bootstrap.post( + "/api/v1/passkeys/registration/options", + headers={**csrf_headers, "X-Step-Up-Grant": grant}, + ) + enrolled = await bootstrap.post( + "/api/v1/passkeys/registration/verify", + json={"challenge": options.json()["challenge"], "credential": {"id": "fake"}}, + headers=csrf_headers, + ) + + async with httpx.AsyncClient(transport=transport, base_url="https://test") as returning: + sign_in_options = await returning.post("/api/v1/passkeys/authentication/options") + signed_in = await returning.post( + "/api/v1/passkeys/authentication/verify", + json={ + "challenge": sign_in_options.json()["challenge"], + "credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"}, + "device_label": "Phone", + "action": "sign_in", + "target": "dashboard", + }, + ) + + assert enrolled.status_code == 201 + assert enrolled.json() == {"enrolled": True} + assert sign_in_options.status_code == 200 + assert sign_in_options.json()["allowCredentials"][0]["id"] == "cGhvbmUtY3JlZGVudGlhbA" + assert signed_in.status_code == 200 + assert signed_in.json() == {"authenticated": True, "method": "passkey"} + assert "stackchain_session=" in signed_in.headers["set-cookie"] + assert "correct horse battery staple" not in signed_in.text + + +@pytest.mark.anyio +async def test_passkey_fresh_authorization_is_exact_target_bound_and_single_use( + access_control, monkeypatch +): + class VerifiedRegistration: + credential_id = b"phone-credential" + credential_public_key = b"credential-public-key" + sign_count = 0 + + class VerifiedAuthentication: + new_sign_count = 1 + + monkeypatch.setattr( + main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration() + ) + monkeypatch.setattr( + main.passkeys, "verify_authentication", lambda **_kwargs: VerifiedAuthentication() + ) + 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", "device_label": "Phone"}, + ) + headers = { + "Origin": "https://test", + "X-CSRF-Token": client.cookies["stackchain_csrf"], + } + enrollment_grant = await fresh_grant(client, "enroll_passkey", "current_device") + registration = await client.post( + "/api/v1/passkeys/registration/options", + headers={**headers, "X-Step-Up-Grant": enrollment_grant}, + ) + await client.post( + "/api/v1/passkeys/registration/verify", + json={"challenge": registration.json()["challenge"], "credential": {"id": "fake"}}, + headers=headers, + ) + options = await client.post( + "/api/v1/passkeys/authorization/options", + json={"action": "close_issue", "target": "stackchain/api#7"}, + headers=headers, + ) + ceremony = { + "challenge": options.json()["challenge"], + "credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"}, + "action": "close_issue", + "target": "stackchain/api#7", + } + authorized = await client.post( + "/api/v1/passkeys/authorization/verify", json=ceremony, headers=headers + ) + replayed = await client.post( + "/api/v1/passkeys/authorization/verify", json=ceremony, headers=headers + ) + wrong_target = await client.post( + "/api/v1/passkeys/authorization/verify", + json={**ceremony, "target": "stackchain/api#8"}, + headers=headers, + ) + + assert options.status_code == 200 + assert authorized.status_code == 201 + assert authorized.json()["grant"] + assert authorized.json()["expires_in"] == 90 + assert replayed.status_code == 409 + assert wrong_target.status_code == 409 + + +@pytest.mark.anyio +async def test_revoking_an_enrolled_device_also_revokes_its_passkey( + access_control, monkeypatch +): + class VerifiedRegistration: + credential_id = b"phone-credential" + credential_public_key = b"credential-public-key" + sign_count = 0 + + monkeypatch.setattr( + main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration() + ) + 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"}, + ) + phone_headers = { + "Origin": "https://test", + "X-CSRF-Token": phone.cookies["stackchain_csrf"], + } + enrollment_grant = await fresh_grant(phone, "enroll_passkey", "current_device") + registration = await phone.post( + "/api/v1/passkeys/registration/options", + headers={**phone_headers, "X-Step-Up-Grant": enrollment_grant}, + ) + await phone.post( + "/api/v1/passkeys/registration/verify", + json={"challenge": registration.json()["challenge"], "credential": {"id": "fake"}}, + headers=phone_headers, + ) + await laptop.post( + "/api/v1/session", + json={"access_token": "correct horse battery staple", "device_label": "Laptop"}, + ) + devices = (await laptop.get("/api/v1/sessions")).json()["devices"] + phone_device = next(item for item in devices if item["device_label"] == "Phone") + 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, + }, + ) + passkey_options = await laptop.post("/api/v1/passkeys/authentication/options") + + assert revoked.status_code == 200 + assert passkey_options.status_code == 404 + + @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) diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index 5d319d0..153ecb2 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -297,6 +297,77 @@ process.stdout.write(JSON.stringify(state)); assert result["replacedAfterDeletion"] is True +def test_enroll_passkey_bootstraps_with_fresh_authorization_and_web_authentication(): + script = f""" +const createSessionBoundary=require({json.dumps(str(SESSION_JS))}); +const requests=[]; +const responses=[ + {{status:428,payload:{{detail:{{code:'step_up_required',action:'enroll_passkey',target:'current_device'}}}}}}, + {{status:201,payload:{{grant:'bootstrap-grant',expires_in:90}}}}, + {{status:201,payload:{{challenge:'AQID',rp:{{id:'forge.example',name:'Stackchain Dashboard'}},user:{{id:'BAUG',name:'stackchain-operator',displayName:'Stackchain operator'}},pubKeyCredParams:[],excludeCredentials:[],authenticatorSelection:{{userVerification:'required'}}}}}}, + {{status:201,payload:{{enrolled:true}}}}, +]; +const credential={{id:'new-passkey',type:'public-key',rawId:Uint8Array.from([1]).buffer,response:{{attestationObject:Uint8Array.from([2]).buffer,clientDataJSON:Uint8Array.from([3]).buffer,getTransports:()=>['internal']}}}}; +const boundary=createSessionBoundary({{ + cookie:()=> 'stackchain_csrf=proof',origin:'https://forge.example',base:'/dashboard/', + credentials:{{create:async options=>{{globalThis.creation=options.publicKey;return credential;}}}}, + promptAuthorization:async()=> 'recovery-token', + fetchImpl:async(url,options={{}})=>{{requests.push({{url:String(url),body:options.body||null,grant:new Headers(options.headers||{{}}).get('X-Step-Up-Grant')}});const item=responses.shift();return new Response(JSON.stringify(item.payload),{{status:item.status,headers:{{'Content-Type':'application/json'}}}});}}, + location:{{replace:()=>{{}}}}, +}}); +(async()=>{{const enrolled=await boundary.enrollPasskey();process.stdout.write(JSON.stringify({{enrolled,requests,creation:globalThis.creation}}));}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + result = subprocess.run( + ["node", "-e", script], text=True, capture_output=True, check=True + ) + output = json.loads(result.stdout) + + assert output["enrolled"] is True + assert output["creation"]["challenge"] == {"0": 1, "1": 2, "2": 3} + assert output["creation"]["user"]["id"] == {"0": 4, "1": 5, "2": 6} + assert output["requests"][2]["grant"] == "bootstrap-grant" + verification = json.loads(output["requests"][3]["body"]) + assert verification["challenge"] == "AQID" + assert verification["credential"]["response"]["transports"] == ["internal"] + + +def test_high_impact_fetch_uses_passkey_before_access_token_fallback(): + script = f""" +const createSessionBoundary = require({json.dumps(str(SESSION_JS))}); +const requests=[]; +const responses=[ + {{status:428,payload:{{detail:{{code:'step_up_required',action:'close_issue',target:'stackchain/api#7'}}}}}}, + {{status:200,payload:{{challenge:'AQID',rpId:'forge.example',userVerification:'required',allowCredentials:[{{type:'public-key',id:'BAUG'}}]}}}}, + {{status:201,payload:{{grant:'passkey-grant',expires_in:90}}}}, + {{status:200,payload:{{closed:true}}}}, +]; +let prompted=false; +const credential={{id:'credential',type:'public-key',rawId:Uint8Array.from([4,5,6]).buffer,response:{{authenticatorData:Uint8Array.from([7]).buffer,clientDataJSON:Uint8Array.from([8]).buffer,signature:Uint8Array.from([9]).buffer,userHandle:null}}}}; +const boundary=createSessionBoundary({{ + cookie:()=> 'stackchain_csrf=proof',origin:'https://forge.example',base:'/dashboard/', + credentials:{{get:async()=>credential}}, + promptAuthorization:async()=>{{prompted=true;return 'must-not-be-requested';}}, + fetchImpl:async(url,options={{}})=>{{requests.push({{url:String(url),body:options.body||null,grant:new Headers(options.headers||{{}}).get('X-Step-Up-Grant')}});const item=responses.shift();return new Response(JSON.stringify(item.payload),{{status:item.status,headers:{{'Content-Type':'application/json'}}}});}}, + location:{{replace:()=>{{}}}}, +}}); +(async()=>{{const response=await boundary.fetch('/dashboard/api/v1/repos/stackchain/api/issues/7/close',{{method:'PATCH'}});process.stdout.write(JSON.stringify({{status:response.status,requests,prompted}}));}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + result = subprocess.run( + ["node", "-e", script], text=True, capture_output=True, check=True + ) + output = json.loads(result.stdout) + + assert output["status"] == 200 + assert output["prompted"] is False + assert output["requests"][1]["url"].endswith("/passkeys/authorization/options") + assert json.loads(output["requests"][1]["body"]) == { + "action": "close_issue", + "target": "stackchain/api#7", + } + assert output["requests"][2]["url"].endswith("/passkeys/authorization/verify") + assert output["requests"][3]["grant"] == "passkey-grant" + + def test_high_impact_fetch_prompts_once_and_retries_original_request_with_grant(): result = run_session_scenario( """ diff --git a/tests/test_login_frontend.py b/tests/test_login_frontend.py index 89a2f91..3a38cb1 100644 --- a/tests/test_login_frontend.py +++ b/tests/test_login_frontend.py @@ -193,6 +193,67 @@ const controller = createLoginController({{ ) +def test_passkey_login_uses_web_authentication_without_sending_the_operator_token(): + harness = f""" +const createLoginController = require({json.dumps(str(LOGIN_JS))}); +const requests = []; +const state = {{ replaced: null }}; +const credential = {{ + id:'credential-id', type:'public-key', rawId:Uint8Array.from([1,2,3]).buffer, + response:{{ + authenticatorData:Uint8Array.from([4]).buffer, + clientDataJSON:Uint8Array.from([5]).buffer, + signature:Uint8Array.from([6]).buffer, + userHandle:null, + }}, +}}; +const controller = createLoginController({{ + form:{{reset:()=>{{}}}}, status:{{textContent:''}}, button:{{disabled:false}}, + passkeyButton:{{disabled:false}}, + credentials:{{get:async options=>{{ state.publicKey=options.publicKey; return credential; }}}}, + fetchImpl:async (url, options={{}})=>{{ + requests.push({{url, body:options.body ? JSON.parse(options.body) : null}}); + if (url.endsWith('/options')) return new Response(JSON.stringify({{ + challenge:'AQID', rpId:'forge.example', userVerification:'required', + allowCredentials:[{{type:'public-key',id:'BAUG'}}], + }}),{{status:200,headers:{{'Content-Type':'application/json'}}}}); + return new Response('{{}}',{{status:200}}); + }}, + location:{{replace:value=>state.replaced=value}}, +}}); +(async()=>{{ + await controller.signInWithPasskey('Timmy’s Pixel'); + process.stdout.write(JSON.stringify({{requests,state}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + result = subprocess.run( + ["node", "-e", harness], text=True, capture_output=True, check=True + ) + output = json.loads(result.stdout) + + assert output["state"]["publicKey"]["challenge"] == {"0": 1, "1": 2, "2": 3} + assert output["state"]["replaced"] == "./" + assert output["requests"][1]["url"] == "api/v1/passkeys/authentication/verify" + assert output["requests"][1]["body"] == { + "challenge": "AQID", + "credential": { + "id": "credential-id", + "type": "public-key", + "rawId": "AQID", + "response": { + "authenticatorData": "BA", + "clientDataJSON": "BQ", + "signature": "Bg", + "userHandle": None, + }, + }, + "device_label": "Timmy’s Pixel", + "action": "sign_in", + "target": "dashboard", + } + assert "access_token" not in json.dumps(output) + + def test_login_sends_a_bounded_device_label_with_the_access_token(): harness = f""" const createLoginController = require({json.dumps(str(LOGIN_JS))}); -- 2.43.0