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>'
|
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
|
||||||
# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3.
|
# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3.
|
||||||
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/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.
|
# Optional; defaults to eight hours.
|
||||||
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
|
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
|
||||||
# Optional; explicit pointer, keyboard, or touch activity renews this idle window.
|
# 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
|
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.
|
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
|
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,
|
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
|
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
|
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>
|
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="active-devices-status" class="small" role="status" aria-live="polite"></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>
|
<div id="active-devices-list" class="active-devices-list"></div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,8 @@
|
||||||
const form = options.form;
|
const form = options.form;
|
||||||
const status = options.status;
|
const status = options.status;
|
||||||
const button = options.button;
|
const button = options.button;
|
||||||
|
const passkeyButton = options.passkeyButton;
|
||||||
|
const credentials = options.credentials;
|
||||||
const fetchImpl = options.fetchImpl;
|
const fetchImpl = options.fetchImpl;
|
||||||
const location = options.location;
|
const location = options.location;
|
||||||
const clearPrivateDeviceData = options.clearPrivateDeviceData;
|
const clearPrivateDeviceData = options.clearPrivateDeviceData;
|
||||||
|
|
@ -28,6 +30,34 @@
|
||||||
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
|
const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
|
||||||
let timer = null;
|
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.';
|
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
|
||||||
|
|
||||||
async function showReason(reason) {
|
async function showReason(reason) {
|
||||||
|
|
@ -95,18 +125,60 @@
|
||||||
status.textContent = 'Sign-in failed. Check the token and try again.';
|
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') {
|
if (typeof document !== 'undefined') {
|
||||||
const form = document.getElementById('sign-in');
|
const form = document.getElementById('sign-in');
|
||||||
const status = document.getElementById('status');
|
const status = document.getElementById('status');
|
||||||
const button = document.getElementById('submit-sign-in');
|
const button = document.getElementById('submit-sign-in');
|
||||||
|
const passkeyButton = document.getElementById('passkey-sign-in');
|
||||||
const loginParams = new URLSearchParams(window.location.search);
|
const loginParams = new URLSearchParams(window.location.search);
|
||||||
const controller = createLoginController({
|
const controller = createLoginController({
|
||||||
form,
|
form,
|
||||||
status,
|
status,
|
||||||
button,
|
button,
|
||||||
|
passkeyButton,
|
||||||
|
credentials: window.navigator?.credentials,
|
||||||
fetchImpl: fetch.bind(window),
|
fetchImpl: fetch.bind(window),
|
||||||
location: window.location,
|
location: window.location,
|
||||||
continuation: loginParams.get('continue'),
|
continuation: loginParams.get('continue'),
|
||||||
|
|
@ -120,4 +192,8 @@ if (typeof document !== 'undefined') {
|
||||||
const deviceLabel = data.get('device_label');
|
const deviceLabel = data.get('device_label');
|
||||||
controller.submit(accessToken, deviceLabel);
|
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,
|
indexedDB: root.indexedDB,
|
||||||
caches: root.caches,
|
caches: root.caches,
|
||||||
serviceWorker: root.navigator?.serviceWorker,
|
serviceWorker: root.navigator?.serviceWorker,
|
||||||
|
credentials: root.navigator?.credentials,
|
||||||
MessageChannel: root.MessageChannel,
|
MessageChannel: root.MessageChannel,
|
||||||
location: root.location,
|
location: root.location,
|
||||||
addActivityListener: (type, listener, options) => root.addEventListener(type, listener, options),
|
addActivityListener: (type, listener, options) => root.addEventListener(type, listener, options),
|
||||||
|
|
@ -38,6 +39,7 @@
|
||||||
const devicesList = root.document.getElementById('active-devices-list');
|
const devicesList = root.document.getElementById('active-devices-list');
|
||||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||||
const closeDevices = root.document.getElementById('close-active-devices');
|
const closeDevices = root.document.getElementById('close-active-devices');
|
||||||
|
const enrollPasskey = root.document.getElementById('enroll-passkey');
|
||||||
const renderDevices = async () => {
|
const renderDevices = async () => {
|
||||||
devicesStatus.textContent = 'Loading active devices…';
|
devicesStatus.textContent = 'Loading active devices…';
|
||||||
devicesList.replaceChildren();
|
devicesList.replaceChildren();
|
||||||
|
|
@ -83,6 +85,18 @@
|
||||||
devicesSheet.hidden = true;
|
devicesSheet.hidden = true;
|
||||||
devicesButton?.focus();
|
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 => {
|
boundary.refreshOfflineLease().then(valid => {
|
||||||
if (valid) boundary.resumeQueuedWork();
|
if (valid) boundary.resumeQueuedWork();
|
||||||
});
|
});
|
||||||
|
|
@ -93,7 +107,7 @@
|
||||||
root.stackchainSession = boundary;
|
root.stackchainSession = boundary;
|
||||||
}
|
}
|
||||||
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
|
})(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,
|
promptAuthorization = () => null,
|
||||||
onExpired = () => {},
|
onExpired = () => {},
|
||||||
onClearError = () => {},
|
onClearError = () => {},
|
||||||
|
|
@ -164,6 +178,33 @@
|
||||||
return entry ? decodeURIComponent(entry.slice('stackchain_csrf='.length)) : '';
|
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) {
|
function isSameOrigin(input) {
|
||||||
try { return new URL(String(input?.url || input), origin).origin === origin; }
|
try { return new URL(String(input?.url || input), origin).origin === origin; }
|
||||||
catch (_error) { return false; }
|
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) {
|
async function sessionFetch(input, options = {}, allowStepUp = true) {
|
||||||
const method = String(options.method || input?.method || 'GET').toUpperCase();
|
const method = String(options.method || input?.method || 'GET').toUpperCase();
|
||||||
const requestOptions = { ...options };
|
const requestOptions = { ...options };
|
||||||
|
|
@ -233,6 +305,10 @@
|
||||||
const payload = await response.clone().json().catch(() => ({}));
|
const payload = await response.clone().json().catch(() => ({}));
|
||||||
const detail = payload?.detail || {};
|
const detail = payload?.detail || {};
|
||||||
if (detail.code === 'step_up_required' && detail.action && detail.target) {
|
if (detail.code === 'step_up_required' && detail.action && detail.target) {
|
||||||
|
let grant = null;
|
||||||
|
try { grant = await authorizeWithPasskey(detail); }
|
||||||
|
catch (_error) { /* Cancellation and unavailable passkeys fall back to recovery. */ }
|
||||||
|
if (!grant) {
|
||||||
const accessToken = await promptAuthorization({
|
const accessToken = await promptAuthorization({
|
||||||
action: detail.action,
|
action: detail.action,
|
||||||
target: detail.target,
|
target: detail.target,
|
||||||
|
|
@ -254,10 +330,12 @@
|
||||||
}),
|
}),
|
||||||
}, 'POST', 'fresh-authorization');
|
}, 'POST', 'fresh-authorization');
|
||||||
if (!authorization.ok) return authorization;
|
if (!authorization.ok) return authorization;
|
||||||
const grant = await authorization.json().catch(() => ({}));
|
const authorizationPayload = await authorization.json().catch(() => ({}));
|
||||||
if (!grant.grant) return response;
|
grant = authorizationPayload.grant;
|
||||||
|
}
|
||||||
|
if (!grant) return response;
|
||||||
const retryHeaders = new Headers(requestOptions.headers || input?.headers || {});
|
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);
|
return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -374,6 +452,41 @@
|
||||||
} catch (_error) { /* A later service-worker activation can clear stale caches. */ }
|
} 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() {
|
async function listActiveDevices() {
|
||||||
const response = await sessionFetch(base + 'api/v1/sessions');
|
const response = await sessionFetch(base + 'api/v1/sessions');
|
||||||
if (!response.ok) throw new Error('Could not load active devices');
|
if (!response.ok) throw new Error('Could not load active devices');
|
||||||
|
|
@ -424,6 +537,7 @@
|
||||||
signOut,
|
signOut,
|
||||||
signOutAllDevices,
|
signOutAllDevices,
|
||||||
listActiveDevices,
|
listActiveDevices,
|
||||||
|
enrollPasskey,
|
||||||
revokeActiveDevice,
|
revokeActiveDevice,
|
||||||
clearPrivateDeviceData,
|
clearPrivateDeviceData,
|
||||||
handleServiceWorkerMessage,
|
handleServiceWorkerMessage,
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,6 @@ fastapi==0.133.1
|
||||||
httpx==0.28.1
|
httpx==0.28.1
|
||||||
pydantic==2.13.4
|
pydantic==2.13.4
|
||||||
pytest==9.1.1
|
pytest==9.1.1
|
||||||
|
rjsmin==1.2.5
|
||||||
uvicorn==0.41.0
|
uvicorn==0.41.0
|
||||||
|
webauthn==3.0.0
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,10 @@ def idle_timeout_seconds() -> int:
|
||||||
|
|
||||||
|
|
||||||
def issue_session(
|
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]:
|
) -> tuple[str, Session]:
|
||||||
issued_at = int(time.time() if now is None else now)
|
issued_at = int(time.time() if now is None else now)
|
||||||
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
|
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
|
||||||
|
|
@ -127,7 +130,10 @@ def issue_session(
|
||||||
encoded = _encode(payload)
|
encoded = _encode(payload)
|
||||||
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
|
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
|
||||||
_session_store(now).activate(
|
_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
|
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)
|
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:
|
async def touch_session(session: Session) -> bool:
|
||||||
return await asyncio.to_thread(
|
return await asyncio.to_thread(
|
||||||
_session_store().touch,
|
_session_store().touch,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import rjsmin
|
||||||
|
|
||||||
|
|
||||||
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
|
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
|
||||||
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
|
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:
|
for source in sources:
|
||||||
path = frontend_dir / source.removeprefix("static/")
|
path = frontend_dir / source.removeprefix("static/")
|
||||||
chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n")
|
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:
|
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 fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator
|
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.available_issue_snapshot_store import AvailableIssueSnapshotStore
|
||||||
from src.compression import NegotiatedGZipMiddleware
|
from src.compression import NegotiatedGZipMiddleware
|
||||||
from src.gitea_proxy import (
|
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.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source
|
||||||
from src.live_snapshot_store import LiveSnapshotState, LiveSnapshotStore, RefreshLeaseLost
|
from src.live_snapshot_store import LiveSnapshotState, LiveSnapshotStore, RefreshLeaseLost
|
||||||
from src.models import Issue, Milestone, PullRequest, Repo, User
|
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.request_boundary import RequestBodyLimitMiddleware, request_body_limit
|
||||||
from src.suggestion_engine import compute
|
from src.suggestion_engine import compute
|
||||||
from src.later_store import LaterStore
|
from src.later_store import LaterStore
|
||||||
|
|
@ -199,12 +200,57 @@ class DashboardSignIn(BaseModel):
|
||||||
|
|
||||||
class FreshAuthorization(BaseModel):
|
class FreshAuthorization(BaseModel):
|
||||||
access_token: str = Field(min_length=1, max_length=1_024)
|
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[
|
action: Literal[
|
||||||
"merge_pull", "close_issue", "revoke_device", "revoke_all_sessions"
|
"merge_pull", "close_issue", "revoke_device", "revoke_all_sessions"
|
||||||
]
|
]
|
||||||
target: str = Field(min_length=1, max_length=255)
|
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:
|
def _login_attempt_store() -> LoginAttemptStore:
|
||||||
state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state")
|
state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state")
|
||||||
return LoginAttemptStore(
|
return LoginAttemptStore(
|
||||||
|
|
@ -696,6 +742,13 @@ async def require_operator_session(request: Request, call_next):
|
||||||
}
|
}
|
||||||
or path.startswith("/static/")
|
or path.startswith("/static/")
|
||||||
or (path == "/api/v1/session" and request.method == "POST")
|
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 = None
|
||||||
session_reason = 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")
|
@app.get("/api/v1/session")
|
||||||
async def session_status(request: Request):
|
async def session_status(request: Request):
|
||||||
session = request.state.dashboard_session
|
session = request.state.dashboard_session
|
||||||
|
|
@ -1196,6 +1499,7 @@ async def revoke_active_device(
|
||||||
target = next(
|
target = next(
|
||||||
(device for device in devices if device.management_id == management_id), None
|
(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)
|
revoked = await dashboard_auth.revoke_managed_session(management_id)
|
||||||
except dashboard_auth.SessionStoreError:
|
except dashboard_auth.SessionStoreError:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|
@ -1223,6 +1527,7 @@ async def sign_out_all_devices(
|
||||||
target="all",
|
target="all",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
await asyncio.to_thread(_passkey_store().revoke_all)
|
||||||
await dashboard_auth.revoke_all_sessions()
|
await dashboard_auth.revoke_all_sessions()
|
||||||
except dashboard_auth.SessionStoreError:
|
except dashboard_auth.SessionStoreError:
|
||||||
return JSONResponse(
|
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
|
raise SessionStoreError("Session registry is temporarily unavailable") from exc
|
||||||
|
|
||||||
def activate(
|
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:
|
) -> None:
|
||||||
label = " ".join(str(device_label).split())[:64] or "This device"
|
label = " ".join(str(device_label).split())[:64] or "This device"
|
||||||
now = int(self.clock())
|
now = int(self.clock())
|
||||||
|
|
@ -133,7 +138,7 @@ class SessionStore:
|
||||||
(
|
(
|
||||||
self._digest(session_id),
|
self._digest(session_id),
|
||||||
expires_at,
|
expires_at,
|
||||||
secrets.token_urlsafe(18),
|
management_id or secrets.token_urlsafe(18),
|
||||||
label,
|
label,
|
||||||
now,
|
now,
|
||||||
now,
|
now,
|
||||||
|
|
@ -241,6 +246,19 @@ class SessionStore:
|
||||||
for row in rows
|
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:
|
def revoke_managed(self, management_id: str) -> bool:
|
||||||
try:
|
try:
|
||||||
with self._connect() as connection:
|
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">
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>Sign in · Stackchain Dashboard</title>
|
<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>
|
<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>
|
<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><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>
|
<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>"""
|
<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"]
|
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
|
@pytest.mark.anyio
|
||||||
async def test_default_operator_mode_fails_closed_before_gitea_when_secrets_are_missing(monkeypatch):
|
async def test_default_operator_mode_fails_closed_before_gitea_when_secrets_are_missing(monkeypatch):
|
||||||
monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False)
|
monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False)
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,77 @@ process.stdout.write(JSON.stringify(state));
|
||||||
assert result["replacedAfterDeletion"] is True
|
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():
|
def test_high_impact_fetch_prompts_once_and_retries_original_request_with_grant():
|
||||||
result = run_session_scenario(
|
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():
|
def test_login_sends_a_bounded_device_label_with_the_access_token():
|
||||||
harness = f"""
|
harness = f"""
|
||||||
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
const createLoginController = require({json.dumps(str(LOGIN_JS))});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user