Files
verdande/web/src/lib/passkey.js
T

116 lines
3.7 KiB
JavaScript
Raw Normal View History

/**
* The browser half of a passkey ceremony.
*
* WebAuthn passes binary as ArrayBuffers and JSON cannot carry those, so every
* boundary between this and the server is a base64url conversion. Getting one of
* them wrong produces a signature that will not verify, reported as "that key was
* not accepted" — which reads as a broken key rather than a broken encoder. That
* is the whole reason this is one file and not three call sites.
*/
import { api } from './api.js';
/** Whether this browser can do it at all. */
export function supported() {
return typeof window !== 'undefined' && Boolean(window.PublicKeyCredential);
}
/**
* Whether a key can be offered without the person typing anything first.
*
* Not the same question as `supported()`: a browser can do WebAuthn and still have
* nowhere to put a discoverable credential. Asking first is what stops the sign-in
* page offering a button that cannot work.
*/
export async function available() {
if (!supported()) return false;
try {
return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
} catch {
return false;
}
}
const toBuffer = (value) => {
const normalised = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalised.padEnd(normalised.length + ((4 - (normalised.length % 4)) % 4), '=');
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
};
const toBase64URL = (buffer) =>
btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
/** Registers a key on the signed-in account. Returns the row the server stored. */
export async function register(name) {
const { challenge_id, options } = await api.beginPasskeyRegistration();
const publicKey = {
...options.publicKey,
challenge: toBuffer(options.publicKey.challenge),
user: { ...options.publicKey.user, id: toBuffer(options.publicKey.user.id) },
excludeCredentials: (options.publicKey.excludeCredentials ?? []).map((c) => ({
...c,
id: toBuffer(c.id)
}))
};
const credential = await navigator.credentials.create({ publicKey });
if (!credential) throw new Error('cancelled');
return api.finishPasskeyRegistration({
challenge_id,
name,
credential: {
id: credential.id,
rawId: toBase64URL(credential.rawId),
type: credential.type,
response: {
clientDataJSON: toBase64URL(credential.response.clientDataJSON),
attestationObject: toBase64URL(credential.response.attestationObject)
},
clientExtensionResults: credential.getClientExtensionResults()
}
});
}
/**
* Signs in with a key.
*
* No email is asked for and none is sent: the device knows which account its key
* belongs to. That is not only convenience — it means the sign-in page cannot be
* used to find out who has an account here.
*/
export async function signIn() {
const { challenge_id, options } = await api.beginPasskeyLogin();
const publicKey = {
...options.publicKey,
challenge: toBuffer(options.publicKey.challenge),
allowCredentials: (options.publicKey.allowCredentials ?? []).map((c) => ({
...c,
id: toBuffer(c.id)
}))
};
const credential = await navigator.credentials.get({ publicKey });
if (!credential) throw new Error('cancelled');
return api.finishPasskeyLogin({
challenge_id,
credential: {
id: credential.id,
rawId: toBase64URL(credential.rawId),
type: credential.type,
response: {
clientDataJSON: toBase64URL(credential.response.clientDataJSON),
authenticatorData: toBase64URL(credential.response.authenticatorData),
signature: toBase64URL(credential.response.signature),
userHandle: credential.response.userHandle
? toBase64URL(credential.response.userHandle)
: null
},
clientExtensionResults: credential.getClientExtensionResults()
}
});
}