Passkeys: one-time invite codes to enrol another device
build-container / build (push) Successful in 2m14s
build-container / build (push) Successful in 2m14s
A signed-in device creates a short-lived, single-use code in Settings
("Invite a device"); the new device enters it on the sign-in page to create
its own passkey. Codes are throttled like other secrets and cannot be used
by signed-out clients to create more codes.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e8b46bb3aa
commit
6ec98d2527
4 files changed
+58
-12
No files matched your search
@@ -46,16 +46,31 @@ function startSession(req, res) {
|
||||
}
|
||||
|
||||
const challenges = new Map(); // cid -> { challenge, kind, exp }
|
||||
const newChallenge = (challenge, kind) => {
|
||||
const newChallenge = (challenge, kind, extra) => {
|
||||
for (const [k, v] of challenges) if (v.exp < Date.now()) challenges.delete(k);
|
||||
const cid = crypto.randomBytes(16).toString('base64url');
|
||||
challenges.set(cid, { challenge, kind, exp: Date.now() + 5 * 60e3 });
|
||||
challenges.set(cid, { challenge, kind, extra, exp: Date.now() + 5 * 60e3 });
|
||||
return cid;
|
||||
};
|
||||
const takeChallenge = (cid, kind) => {
|
||||
const c = challenges.get(cid); challenges.delete(cid);
|
||||
return c && c.kind === kind && c.exp > Date.now() ? c.challenge : null;
|
||||
};
|
||||
const takeRecord = cid => { const c = challenges.get(cid); challenges.delete(cid); return c && c.exp > Date.now() ? c : null; };
|
||||
|
||||
// One-time invite codes: a signed-in device creates one, a new device uses it to enrol its own passkey.
|
||||
const invites = new Map(); // code -> expiry
|
||||
const INVITE_MINUTES = 10;
|
||||
const newInvite = () => {
|
||||
for (const [k, v] of invites) if (v < Date.now()) invites.delete(k);
|
||||
const a = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // no look-alike characters
|
||||
let code = ''; for (const b of crypto.randomBytes(8)) code += a[b % a.length];
|
||||
code = code.slice(0, 4) + '-' + code.slice(4);
|
||||
invites.set(code, Date.now() + INVITE_MINUTES * 60e3);
|
||||
return code;
|
||||
};
|
||||
const normInvite = c => { const t = String(c || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); return t.length === 8 ? t.slice(0, 4) + '-' + t.slice(4) : ''; };
|
||||
const inviteValid = c => { const k = normInvite(c); return !!k && (invites.get(k) || 0) > Date.now() ? k : null; };
|
||||
|
||||
const hits = new Map(); // crude per-IP throttle on anything that checks a secret
|
||||
const throttled = req => {
|
||||
@@ -93,9 +108,12 @@ async function handle(req, res) {
|
||||
|
||||
if (p === '/api/auth/register/options' && req.method === 'POST') {
|
||||
const authed = isAuthed(req);
|
||||
let kind = 'reg-authed', extra;
|
||||
if (!authed) {
|
||||
if (throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
|
||||
if (store.creds.length || !equalStr(body.setupCode || '', SETUP_CODE)) return send(res, 403, { error: 'Wrong setup code' });
|
||||
if (!store.creds.length && equalStr(body.setupCode || '', SETUP_CODE)) kind = 'reg-setup';
|
||||
else if (body.inviteCode && inviteValid(body.inviteCode)) { kind = 'reg-invite'; extra = inviteValid(body.inviteCode); }
|
||||
else return send(res, 403, { error: body.inviteCode ? 'That invite code is wrong or has expired' : 'Wrong setup code' });
|
||||
}
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: 'NE Hub', rpID, userName: 'NE Hub owner', userID: Buffer.from(store.userId, 'base64url'),
|
||||
@@ -103,20 +121,24 @@ async function handle(req, res) {
|
||||
excludeCredentials: store.creds.map(c => ({ id: c.id, transports: c.transports })),
|
||||
authenticatorSelection: { residentKey: 'required', userVerification: 'preferred' },
|
||||
});
|
||||
return send(res, 200, { cid: newChallenge(options.challenge, authed ? 'reg-authed' : 'reg-setup'), options });
|
||||
return send(res, 200, { cid: newChallenge(options.challenge, kind, extra), options });
|
||||
}
|
||||
if (p === '/api/auth/register/verify' && req.method === 'POST') {
|
||||
const authed = isAuthed(req);
|
||||
if (!authed && throttled(req)) return send(res, 429, { error: 'Too many attempts. Wait a minute.' });
|
||||
const challenge = takeChallenge(body.cid, authed ? 'reg-authed' : 'reg-setup');
|
||||
if (!challenge) return send(res, 400, { error: 'Request expired, try again' });
|
||||
if (!authed && store.creds.length) return send(res, 403, { error: 'Not allowed' });
|
||||
const v = await verifyRegistrationResponse({ response: body.response, expectedChallenge: challenge, expectedOrigin: origin, expectedRPID: rpID });
|
||||
const rec = takeRecord(body.cid);
|
||||
if (!rec) return send(res, 400, { error: 'Request expired, try again' });
|
||||
const okKind = rec.kind === 'reg-authed' ? authed
|
||||
: rec.kind === 'reg-setup' ? !store.creds.length
|
||||
: rec.kind === 'reg-invite' ? !!inviteValid(rec.extra) : false;
|
||||
if (!okKind) return send(res, 403, { error: 'Not allowed' });
|
||||
const v = await verifyRegistrationResponse({ response: body.response, expectedChallenge: rec.challenge, expectedOrigin: origin, expectedRPID: rpID });
|
||||
if (!v.verified) return send(res, 400, { error: 'Could not verify passkey' });
|
||||
const c = v.registrationInfo.credential;
|
||||
store.creds.push({ id: c.id, publicKey: Buffer.from(c.publicKey).toString('base64url'), counter: c.counter, transports: c.transports || [],
|
||||
name: String(body.name || 'Passkey').slice(0, 40), created: Date.now() });
|
||||
save();
|
||||
if (rec.kind === 'reg-invite') invites.delete(rec.extra); // single use
|
||||
if (!authed) startSession(req, res);
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
@@ -141,6 +163,9 @@ async function handle(req, res) {
|
||||
}
|
||||
// Everything below needs a signed-in session.
|
||||
if (!isAuthed(req)) return send(res, 401, { error: 'Not signed in' });
|
||||
if (p === '/api/auth/invite' && req.method === 'POST') {
|
||||
return send(res, 200, { code: newInvite(), minutes: INVITE_MINUTES });
|
||||
}
|
||||
if (p === '/api/auth/passkeys' && req.method === 'GET') {
|
||||
return send(res, 200, store.creds.map(c => ({ id: c.id, name: c.name, created: c.created, lastUsed: c.lastUsed || null })));
|
||||
}
|
||||
|
||||
+9
-1
@@ -779,7 +779,7 @@ function viewSettings() {
|
||||
const signed = !!sf.token();
|
||||
$('#view').innerHTML = `<div class="wrap"><h1>Settings</h1>
|
||||
<h2>Passkeys</h2><div class="list" id="pks"><div class="row"><span class="grow"><small>Loading…</small></span></div></div>
|
||||
<div class="bar"><button class="btn ghost" id="addpk">Add a passkey</button><button class="btn danger" id="hubout">Sign out of NE Hub</button></div>
|
||||
<div class="bar"><button class="btn ghost" id="addpk">Add a passkey</button><button class="btn ghost" id="invpk">Invite a device</button><button class="btn danger" id="hubout">Sign out of NE Hub</button></div>
|
||||
<h2>Connections</h2><div class="list">
|
||||
<div class="row"><span class="grow"><b>Seafile</b><small>${esc(cfg.seafile)} · ${signed ? 'signed in' : 'signed out'}</small></span></div>
|
||||
<div class="row"><span class="grow"><b>Plex</b><small id="plexst">Checking…</small></span><button class="btn danger" id="plexout" hidden>Sign out</button></div>
|
||||
@@ -820,6 +820,14 @@ function viewSettings() {
|
||||
$('#addpk').onclick = () => textSheet('Add a passkey', 'Name this device', 'Create', async name => {
|
||||
try { await passkeyRegister('', name); toast('Passkey added'); loadPks(); } catch (e) { toast(e.name === 'NotAllowedError' ? 'Cancelled' : e.message); }
|
||||
});
|
||||
$('#invpk').onclick = async () => {
|
||||
try {
|
||||
const { code, minutes } = await authPost('/api/auth/invite');
|
||||
const el = openSheet('Invite a device', 'On the new device, open this app, tap "New device? Use an invite code", and enter:', `<p style="font:700 34px ui-monospace,monospace;letter-spacing:.08em;text-align:center;margin:16px 0">${esc(code)}</p>
|
||||
<p class="sub" style="text-align:center">Works once, and expires in ${minutes} minutes.</p><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Done</button></div>`);
|
||||
el.querySelector('#sx').onclick = closeSheet;
|
||||
} catch (e) { toast(e.message); }
|
||||
};
|
||||
$('#hubout').onclick = async () => { await authPost('/api/auth/logout'); location.replace('/login'); };
|
||||
const out = $('#out'); if (out) out.onclick = () => { store.del('sf_token'); toast('Signed out'); viewSettings(); };
|
||||
}
|
||||
|
||||
+14
-1
@@ -14,7 +14,20 @@ async function init() {
|
||||
$('#sub').textContent = 'Sign in with your passkey';
|
||||
$('#ui').innerHTML = '<button class="btn" id="go">Sign in with passkey</button>';
|
||||
$('#go').onclick = signIn;
|
||||
signIn(); // prompt straight away; the button is there if it's dismissed
|
||||
$('#ui').insertAdjacentHTML('beforeend', '<p style="margin-top:18px"><a href="#" id="inv" style="color:var(--accent)">New device? Use an invite code</a></p>');
|
||||
$('#inv').onclick = e => {
|
||||
e.preventDefault(); $('#err').textContent = ''; $('#sub').textContent = 'Add this device';
|
||||
$('#ui').innerHTML = `<form id="f"><p class="sub">On a device that is already signed in, open Settings and tap <b>Invite a device</b>.</p>
|
||||
<label for="ic">Invite code</label><input type="text" id="ic" placeholder="ABCD-2345" autocomplete="off" autocapitalize="characters" required>
|
||||
<label for="nm">Name this passkey</label><input type="text" id="nm" value="${/iPad|iPhone/.test(navigator.userAgent) ? 'iPhone/iPad' : 'This device'}" autocomplete="off">
|
||||
<div class="bar"><button class="btn">Create passkey</button></div><p style="text-align:center"><a href="/login" style="color:var(--accent)">Back to sign in</a></p></form>`;
|
||||
$('#f').onsubmit = async ev => {
|
||||
ev.preventDefault(); $('#err').textContent = '';
|
||||
try { await passkeyRegister('', $('#nm').value.trim() || 'Passkey', $('#ic').value.trim()); location.replace('/'); } catch (err) { fail(err); }
|
||||
};
|
||||
};
|
||||
// Offer the passkey straight away unless the user came here to use an invite code.
|
||||
signIn();
|
||||
} else {
|
||||
$('#sub').textContent = 'First-time setup: create a passkey for this device';
|
||||
$('#ui').innerHTML = `<form id="f"><label for="sc">Setup code (from the server log or HUB_SETUP_CODE)</label>
|
||||
|
||||
+2
-2
@@ -9,8 +9,8 @@ async function authPost(path, body, method = 'POST') {
|
||||
if (!r.ok) throw new Error(j.error || 'Request failed (' + r.status + ')');
|
||||
return j;
|
||||
}
|
||||
async function passkeyRegister(setupCode, name) {
|
||||
const { cid, options } = await authPost('/api/auth/register/options', { setupCode });
|
||||
async function passkeyRegister(setupCode, name, inviteCode) {
|
||||
const { cid, options } = await authPost('/api/auth/register/options', { setupCode, inviteCode });
|
||||
options.challenge = b64u.dec(options.challenge);
|
||||
options.user.id = b64u.dec(options.user.id);
|
||||
(options.excludeCredentials || []).forEach(c => { c.id = b64u.dec(c.id); });
|
||||
|
||||
Reference in new issue
Block a user