feat: add device passkeys for operator access (Closes #485)
This commit is contained in:
1 parent
131848cc43
commit
ced644b275
15 files changed
+1228
-33
No files matched your search
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user