const msg = document.getElementById('msg');
function show(text, cls) { msg.textContent = text; msg.className = 'msg ' + (cls || ''); }
const INVITE = (() => {
try {
const params = new URLSearchParams(location.search);
const secret = params.get('invite') || '';
if (secret) {
params.delete('invite');
const rest = params.toString();
history.replaceState(null, '', location.pathname + (rest ? '?' + rest : ''));
}
return secret;
} catch (e) { return ''; }
})();
function inviteSecret() { return INVITE; }
const SIGNUP = (() => {
try {
const params = new URLSearchParams(location.search);
const s = params.get('signup') || params.get('session') || '';
if (s) {
params.delete('signup');
params.delete('session');
const rest = params.toString();
history.replaceState(null, '', location.pathname + (rest ? '?' + rest : ''));
}
return s;
} catch (e) { return ''; }
})();
const LINK = (() => {
try {
const params = new URLSearchParams(location.search);
const found = { who: (params.get('user') || '').trim(), kind: params.get('kind') || '' };
if (INVITE || SIGNUP) {
params.delete('user');
params.delete('kind');
const rest = params.toString();
history.replaceState(null, '', location.pathname + (rest ? '?' + rest : ''));
}
return found;
} catch (e) { return { who: '', kind: '' }; }
})();
(function bootstrapDoor() {
if (inviteSecret() || SIGNUP) return;
fetch('/-/auth/passkey/config', { credentials: 'omit' })
.then(r => (r.ok ? r.json() : null))
.then(s => {
if (!s) return;
if (s.bootstrap_spent === true) {
const reg = document.getElementById('reg');
if (reg) reg.hidden = true;
}
})
.catch(() => {});
})();
const NEWCOMER_FRONT = 'New here? Make an account from the ';
const NEWCOMER_INVITE = 'New here? Ask whoever runs this Holger for an invite link.';
let ACCOUNTS_HERE = false;
(function newcomerLine() {
const line = document.getElementById('invite-offer');
const draw = accounts => {
ACCOUNTS_HERE = accounts;
line.textContent = '';
if (accounts) {
line.append(NEWCOMER_FRONT);
const front = document.createElement('a');
front.setAttribute('href', '/');
front.textContent = 'front page';
line.append(front, '.');
} else line.textContent = NEWCOMER_INVITE;
line.hidden = false;
};
fetch('/-/signup', { credentials: 'omit' })
.then(r => (r.ok ? r.json() : null))
.then(links => draw(!!(links && links.account_url)))
.catch(() => draw(false));
})();
function noPasskeyHere() {
return 'No passkey for this site on this device. Use the phone, computer or password manager where you made your passkey. '
+ (ACCOUNTS_HERE ? 'New here? Make an account from the front page.' : NEWCOMER_INVITE);
}
async function forgetPasskey(rpId, credentialId) {
try {
if (window.PublicKeyCredential && typeof PublicKeyCredential.signalUnknownCredential === 'function') {
await PublicKeyCredential.signalUnknownCredential({ rpId: rpId || location.hostname, credentialId: credentialId });
}
} catch (e) { }
}
const REGISTERED_SIGN_IN = 'Your security key is registered. Sign in with it.';
let LINK_DONE = false;
function onInviteDone() { LINK_DONE = true; }
async function refusal(res) {
const text = await res.text();
try { const v = JSON.parse(text); if (v && typeof v.error === 'string') return v.error; } catch (e) {}
return text;
}
function b64urlToBuf(s) {
s = s.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4) s += '=';
const bin = atob(s), bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes.buffer;
}
function bufToB64url(buf) {
const bytes = new Uint8Array(buf);
let s = '';
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
const CONSOLE_HOME = '/overview';
let loginReady = null;
function loginAsk() {
const uv = new URLSearchParams(location.search).get('uv') === 'required';
return JSON.stringify(uv ? { uv: 'required' } : {});
}
function prepareLogin() {
const body = loginAsk();
if (loginReady && loginReady.body === body && Date.now() - loginReady.at < PREPARE_MS) return loginReady;
const p = { body, at: Date.now(), answer: null };
p.done = fetch('/-/auth/passkey/login/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body })
.then(async res => (res.ok ? { ok: true, start: await res.json() } : { ok: false, status: res.status, words: await refusal(res) }))
.catch(e => ({ ok: false, error: e }))
.then(answer => { p.answer = answer; return answer; });
loginReady = p;
return p;
}
async function login() {
const btn = document.getElementById('login');
const label = btn.textContent;
btn.disabled = true;
btn.classList.add('working');
btn.textContent = 'Processing …';
try {
show('waiting for your key …');
const p = prepareLogin();
let answer = p.answer;
if (!answer) {
answer = await p.done;
if (answer.ok && !gestureLive()) { show(PRESS_AGAIN, 'ok'); return; }
}
loginReady = null;
if (answer.error) throw answer.error;
if (!answer.ok) throw new Error(answer.words || 'could not start');
const start = answer.start;
const pk = start.publicKey;
pk.challenge = b64urlToBuf(pk.challenge);
pk.allowCredentials = (pk.allowCredentials || []).map(c => ({ ...c, id: b64urlToBuf(c.id) }));
const cred = await navigator.credentials.get({ publicKey: pk });
const body = { ceremony_id: start.ceremony_id, id: cred.id, response: {
clientDataJSON: bufToB64url(cred.response.clientDataJSON),
authenticatorData: bufToB64url(cred.response.authenticatorData),
signature: bufToB64url(cred.response.signature),
}};
const fin = await fetch('/-/auth/passkey/login/finish', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (fin.ok) {
show('logged in — redirecting …', 'ok');
location.href = nextDestination(CONSOLE_HOME);
}
else show(await refusal(fin) || 'login was denied', 'err');
} catch (e) { show(e && e.name === 'NotAllowedError' ? noPasskeyHere() : (e.message || String(e)), 'err'); }
finally {
btn.disabled = false;
btn.classList.remove('working');
btn.textContent = label;
}
}
const PASSKEY_WORDS = {
NotAllowedError: 'The passkey request was cancelled or timed out. Try again.',
AbortError: 'The passkey request was cancelled. Try again.',
InvalidStateError: 'This key already holds a passkey for this site.',
SecurityError: 'Passkeys do not work at this address. Open the page at its own https address.',
NotSupportedError: 'This browser or key cannot make a passkey here. Try another browser or key.',
ConstraintError: 'This browser or key cannot make a passkey here. Try another browser or key.',
};
function said(e) {
if (e instanceof TypeError) return 'Holger did not answer. Check your connection and try again.';
if (typeof DOMException !== 'undefined' && e instanceof DOMException) {
return Object.hasOwn(PASSKEY_WORDS, e.name) ? PASSKEY_WORDS[e.name] : 'Something went wrong with the passkey. Try again.';
}
return (e && e.message) || String(e);
}
const PREPARE_MS = 4 * 60 * 1000;
const PRESS_AGAIN = 'Ready. Touch your security key again.';
let prepared = null;
let registering = false;
function gestureLive() { return !navigator.userActivation || navigator.userActivation.isActive; }
function registerAsk() {
const invite = LINK_DONE ? '' : inviteSecret();
const token = document.getElementById('token').value.trim();
const label = document.getElementById('label').value.trim() || 'passkey';
const user = document.getElementById('user').value.trim();
const role = document.getElementById('role').value;
const hdr = (!invite && !SIGNUP && token) ? { 'Authorization': 'Bearer ' + token } : {};
const ask = {};
if (user) ask.display = user;
if (invite) ask.invite = invite;
else if (SIGNUP) ask.signup = SIGNUP;
if (!invite && role) ask.role = role;
if (label) ask.label = label;
return { invite, token, label, user, role, hdr, ask };
}
function prepare() {
const a = registerAsk();
const key = JSON.stringify([a.ask, a.hdr]);
if (prepared && prepared.key === key && Date.now() - prepared.at < PREPARE_MS) return prepared;
const p = { key, at: Date.now(), a, answer: null };
p.done = fetch('/-/auth/passkey/register/start', {
method: 'POST', headers: { ...a.hdr, 'Content-Type': 'application/json' }, body: JSON.stringify(a.ask) })
.then(async res => (res.ok ? { ok: true, start: await res.json() } : { ok: false, status: res.status, words: await refusal(res) }))
.catch(e => ({ ok: false, error: e }))
.then(answer => { p.answer = answer; return answer; });
prepared = p;
return p;
}
function prepareAhead() {
if (registering) return;
const btn = document.getElementById('register');
const p = prepare();
if (p.answer) return;
btn.disabled = true;
p.done.then(answer => {
if (prepared === p) btn.disabled = registering;
if (answer.ok || prepared !== p) return;
prepared = null;
if (p.a.invite && (answer.status === 401 || answer.status === 410)) inviteDone(answer.words || INVITE_SPENT, 'err');
else if (answer.error) show(said(answer.error), 'err');
else if (![400, 401, 403].includes(answer.status)) show(answer.words || 'requires a valid invite or admin token', 'err');
});
}
async function register() {
if (registering) return;
registering = true;
const btn = document.getElementById('register');
btn.disabled = true;
try {
const p = prepare();
let answer = p.answer;
if (!answer) {
show('starting registration …');
answer = await p.done;
if (answer.ok && !gestureLive()) { show(PRESS_AGAIN, 'ok'); return; }
}
prepared = null;
const { invite, label, user, role, hdr } = p.a;
if (answer.error) throw answer.error;
if (!answer.ok && invite && (answer.status === 401 || answer.status === 410)) { inviteDone(answer.words || INVITE_SPENT, 'err'); return; }
if (!answer.ok) throw new Error(answer.words || 'requires a valid invite or admin token');
const start = answer.start;
const pk = start.publicKey;
pk.challenge = b64urlToBuf(pk.challenge);
pk.user.id = b64urlToBuf(pk.user.id);
pk.excludeCredentials = (pk.excludeCredentials || []).map(c => ({ ...c, id: b64urlToBuf(c.id) }));
let cred;
try { cred = await navigator.credentials.create({ publicKey: pk }); }
catch (e) {
if (e && e.name === 'InvalidStateError' && (invite || SIGNUP)) {
inviteDone('This device already holds your passkey for this site. Sign in with it.', 'ok');
return;
}
throw e;
}
const body = { ceremony_id: start.ceremony_id, label, user, role, response: {
clientDataJSON: bufToB64url(cred.response.clientDataJSON),
attestationObject: bufToB64url(cred.response.attestationObject),
}};
if (invite) body.invite = invite;
const fin = await fetch('/-/auth/passkey/register/finish', {
method: 'POST', headers: { ...hdr, 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (fin.ok) {
const done = await fin.json().catch(() => ({}));
if (done && done.signed_in) {
show('Your security key is registered. Signing you in …', 'ok');
location.href = nextDestination(CONSOLE_HOME);
} else show(REGISTERED_SIGN_IN, 'ok');
} else {
const text = await refusal(fin);
await forgetPasskey(pk.rp && pk.rp.id, cred.id);
if (invite && (fin.status === 401 || fin.status === 410)) inviteDone((text || INVITE_SPENT) + ' ' + ORPHAN_HINT, 'err');
else show((text || 'registration failed') + ' ' + ORPHAN_HINT, 'err');
}
} catch (e) { show(said(e), 'err'); }
finally {
registering = false;
btn.disabled = false;
if (!document.getElementById('reg').hidden && (inviteSecret() || SIGNUP) && !LINK_DONE) prepareAhead();
}
}
let SIGNED_IN_HERE = false;
(function signedInAlready() {
if (inviteSecret() || SIGNUP || new URLSearchParams(location.search).get('uv') === 'required') return;
fetch('/-/auth/passkey/session', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: '{}' })
.then(r => (r.ok ? r.json() : null))
.then(me => {
if (!me || typeof me !== 'object') return;
const who = String(me.display || me.subject || '').trim();
SIGNED_IN_HERE = true;
document.getElementById('login').textContent = 'Open the console';
show(who ? 'Signed in as ' + who + '.' : 'Signed in.', 'ok');
})
.catch(() => {});
})();
document.getElementById('login').onclick = () => (SIGNED_IN_HERE ? (location.href = nextDestination(CONSOLE_HOME)) : login());
for (const ev of ['pointerenter', 'focus', 'touchstart']) {
document.getElementById('login').addEventListener(ev, () => { if (!SIGNED_IN_HERE) prepareLogin(); }, { passive: true });
}
document.getElementById('register').onclick = register;
if (!window.PublicKeyCredential) show('this browser does not support passkeys', 'err');
function registerPanel(line) {
document.getElementById('reg').open = true;
document.getElementById('token-row').hidden = true;
document.getElementById('role-row').hidden = true;
document.getElementById('login').hidden = true;
document.getElementById('reg-summary').textContent = 'Register your security key';
document.getElementById('register').textContent = 'Touch your security key';
document.getElementById('register').classList.add('primary');
show(line, 'ok');
}
const named = LINK.who;
if (named) {
document.getElementById('user').value = named;
if (inviteSecret() || SIGNUP) document.getElementById('user-row').hidden = true;
}
if (SIGNUP && !inviteSecret()) {
registerPanel(named
? 'Your account ' + named + ' is ready. Touch your security key to finish.'
: 'Your account is ready. Choose your user name and touch your security key to finish.');
} else if (inviteSecret()) {
const who = named || 'your account';
registerPanel(LINK.kind === 'site'
? 'Welcome — register your security key for ' + who + ' on ' + location.hostname + '.'
: named
? 'Your account ' + named + ' is ready. Touch your security key to finish.'
: 'You were invited to Holger. Choose your user name and touch your security key to finish.');
}
if ((inviteSecret() || SIGNUP) && !LINK_DONE && (document.getElementById('user').value.trim() || inviteSecret())) prepareAhead();
for (const id of ['user', 'label', 'token', 'role']) {
document.getElementById(id).addEventListener('change', () => { if (document.getElementById('user').value.trim() || ((inviteSecret() || SIGNUP) && !LINK_DONE)) prepareAhead(); });
}
setInterval(() => { if (!registering && prepared && Date.now() - prepared.at >= PREPARE_MS) prepareAhead(); }, 30 * 1000);