Merge pull request 'Add device passkeys for operator access' (#486)
This commit is contained in:
commit
b1b3f758e4
15
README.md
15
README.md
|
|
@ -154,6 +154,9 @@ export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
|
|||
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-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
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
|
||||
</div>
|
||||
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
|
||||
<button id="enroll-passkey" type="button">Add a passkey for this device</button>
|
||||
<div id="active-devices-list" class="active-devices-list"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import re
|
|||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import rjsmin
|
||||
|
||||
|
||||
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', 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:
|
||||
|
|
|
|||
307
src/main.py
307
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(
|
||||
|
|
|
|||
199
src/passkey_store.py
Normal file
199
src/passkey_store.py
Normal file
|
|
@ -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
|
||||
87
src/passkeys.py
Normal file
87
src/passkeys.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ LOGIN_HTML = """<!doctype html>
|
|||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Sign in · Stackchain Dashboard</title>
|
||||
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
|
||||
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p>
|
||||
<form id="sign-in"><label>Device name<input name="device_label" type="text" autocomplete="name" maxlength="64" value="This device" required></label><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status" aria-live="polite"></p></form></main>
|
||||
<body><main><h1>Operator sign in</h1><p>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.</p>
|
||||
<form id="sign-in"><label>Device name<input name="device_label" type="text" autocomplete="name" maxlength="64" value="This device" required></label><button id="passkey-sign-in" type="button">Sign in with a passkey</button><p>Recovery</p><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in with access token</button><p id="status" role="status" aria-live="polite"></p></form></main>
|
||||
<script src="static/private-device-data.js"></script><script src="static/login.js"></script></body></html>"""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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))});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user