feat: add device passkeys for operator access (Closes #485)
CI / lint (pull_request) Successful in 1m3s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-10 13:14:18 +00:00
1 parent 131848cc43
commit ced644b275
15 files changed
+1228 -33

No files matched your search

+1
View File
@@ -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>
+77 -1
View File
@@ -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'));
});
}
+137 -23
View File
@@ -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,