/*
* Author: Vlad Seryakov vseryakov@gmail.com
* backendjs 2024
*/
const api = require(__dirname + '/../api');
const lib = require(__dirname + '/../lib');
const cache = require(__dirname + '/../cache');
const logger = require(__dirname + '/../logger');
/**
* @module api/passkey
*/
const mod = {
name: "api.passkey",
args: [
{ name: "err-(.+)", descr: "Error messages for various cases" },
{ name: "cap-(.+)", type: "int", strip: "cap-", descr: "Capability parameters" },
{ name: "secret", descr: "Cookies secret" },
{ name: "cache", descr: "Cache for challenges" },
{ name: "cookie", descr: "Cookie name" },
{ name: "domain", descr: "Explicit domain to use instead of host" },
{ name: "endpoint", descr: "Root endpoint for the api routes to remount under differnet top path" },
],
ttl: 30000,
max: 5,
/**
* Router base endpoint
* @var {string}
* @default
*/
endpoint: "/passkey",
/**
* Cookie name
* @var {string}
* @default
*/
cookie: "bk_passkey",
errPasskeyMax: "No more passkeys can be added to your profile",
errPasskeyChallenge: "Your passkey request has expired, please try again",
errPasskeyRegistration: "Passkey provided cannot be registered, please try again",
errPasskeyVerification: "Passkey provided cannot be verified, please try again",
};
/**
* Passkey management
*
* To allow login via passkey enable
*
* -api-passkey-cap-enabled 1
*/
module.exports = mod;
mod.configureWeb = function(options, callback)
{
if (!this.enabled) return callback();
api.registerAccessCheck('', /^\/passkey\/login$/, (req, cb) => {
cb({ status: 200 });
});
this.init();
api.app.use(mod.endpoint,
api.express.Router().
get(/^\/(login|register)$/, (req, res) => {
if (!this.enabled) return api.sendReply(res, 400, "disabled");
api.sendJSON(req, null, mod.createChallenge(req));
}).
post("/login", mod.login).
post("/register", mod.register));
callback();
}
mod.register = function(req, res)
{
var query = api.getQuery(req, {
username: { required: 1 },
credential: { type: "object", required: 1, max: 1024 },
authenticatorData: { required: 1, max: 1024 },
clientData: { required: 1, max: 1024 },
});
if (typeof query == "string") return api.sendReply(res, 400, query);
lib.series([
function(next) {
mod.verifyChallenge(req, next);
},
function(next, challenge) {
mod.verifyRegistration(req, { query, challenge }, next);
},
function(next, passkey) {
mod.update({ user: req.user, passkey: passkey.credential }, next);
}
], (err) => {
api.sendJSON(req, err);
}, true);
}
mod.login = function(req, res)
{
var query = api.getQuery(req, {
credentialId: { required: 1 },
authenticatorData: { required: 1, max: 1024 },
clientData: { required: 1, max: 1024 },
signature: { required: 1, max: 1024 },
userHandle: { required: 1, base64: 1 },
});
if (typeof query == "string") return api.sendReply(res, 400, query);
lib.series([
function(next) {
mod.verifyChallenge(req, next);
},
function(next, challenge) {
mod.read(query.userHandle, query.credentialId, (err, user, passkey) => {
if (err || !passkey) return next(err || { status: 401, message: mod.errInvalidPasskey, code: "NOLOGIN" });
mod.verifyAuthentication(req, { query, challenge, passkey }, (err) => {
if (!err) {
api.access.setUser(req, user);
api.session.setup(req, next);
}
next(err, user);
});
});
},
], (err) => {
req.options.cleanup = api.users.table;
req.options.cleanup_strict = 1;
api.sendJSON(req, err, req.user);
}, true);
}
mod.init = async function()
{
if (mod.server) return;
try {
var w = await import(__dirname + "/../../web/js/webauthn.min.mjs");
mod.server = w.server;
} catch (e) {
logger.error("init:", "passkey", e);
}
}
mod.createChallenge = function(req)
{
var uuid = lib.uuid();
var ttl = Date.now() + mod.ttl;
if (req.res) {
var cookie = `${ttl},${uuid},${req.user?.id?1:0}`;
req.res.cookie(mod.cookie,
lib.encrypt(mod.secret || api.accessTokenSecret, cookie), {
path: mod.endpoint,
httpOnly: true,
sameSite: "strict",
maxAge: mod.ttl,
});
}
logger.debug("createChallenge:", "passkey", req.user?.id, cookie);
return {
challenge: uuid,
domain: mod.domain && lib.domainName(req.options?.host) || undefined,
id: req.user?.id,
ttl: ttl,
}
}
mod.getChallenge = function(req, callback)
{
var cookie = req.cookies?.[mod.cookie];
var rc = lib.decrypt(mod.secret || api.accessTokenSecret, cookie).split(",");
logger.debug("getChallenge:", "passkey", req.user?.id, "H:", rc);
return lib.toNumber(rc[0]) > Date.now() ? rc[1] : "";
}
mod.verifyChallenge = function(req, callback)
{
var challenge = mod.getChallenge(req);
if (!challenge) return callback({ status: 429, message: mod.errPasskeyChallenge, code: "PSKC" });
cache.incr("PSK:" + challenge, 1, { ttl: mod.ttl, cacheName: mod.cache || api.limiterCache }, (err, rc) => {
logger.debug("verifyChallenge:", "passkey", req.user?.id, challenge, rc);
callback(rc !== 1 ? { status: 429, message: mod.errPasskeyChallenge, code: "PSKC" + rc } : null, challenge);
});
}
mod.verifyRegistration = async function(req, options, callback)
{
const expected = {
challenge: options.challenge,
origin: `http${req.options?.secure ? "s" : ""}://${req.headers?.host}`,
}
try {
var passkey = await mod.server.verifyRegistration(options.query, expected);
} catch (e) {
logger.info("verifyRegistration:", "passkey", req.user?.id, options, "ERR:", e.stack);
return callback({ status: 403, message: mod.errPasskeyRegistration, code: "PSKR" })
}
// Keep device and date for each passkey
passkey.credential.mtime = Date.now();
passkey.credential.aname = passkey.authenticator.name;
logger.debug("verifyRegistration:", "passkey", req.user?.id, passkey);
callback(null, passkey);
}
mod.verifyAuthentication = async function(req, options, callback)
{
const expected = {
challenge: options.challenge,
origin: `http${req.options?.secure ? "s" : ""}://${req.headers?.host}`,
domain: mod.domain && lib.domainName(req.options?.host) || undefined,
userVerified: true,
}
try {
await mod.server.verifyAuthentication(options.query, options.passkey, expected);
} catch (e) {
logger.info("verifyAuthentication:", "passkey", req.user?.id, options, "ERR:", e.stack);
return callback({ status: 403, message: mod.errPasskeyVerification, code: "PSKA" })
}
logger.debug("verifyAuthentication:", "passkey", options.passkey);
callback();
}
// Return a list of all passkyes for the user
mod.get = function(options)
{
return lib.jsonParse(options?.passkey, { datatype: "list" });
}
// Read a user and passkey, user can be id/login or an user object
mod.read = function(options, passkeyId, callback)
{
api.users.get(options, (err, row) => {
callback(err, row, mod.get(row).filter((x) => (x.id == passkeyId)).pop());
});
}
/**
* Add/delete a passkey,
* - user - full user record
* - passkey - a registration credential object to add or just { id: id } to remove
* - query - optional additional fields to update
*/
mod.update = function(options, callback)
{
var allkeys = mod.get(options.user);
var passkeys = allkeys.filter((x) => (x.id != options.passkey?.id));
if (options.passkey?.id && options.passkey?.publicKey && options.passkey?.algorithm) {
passkeys.push(options.passkey);
}
if (passkeys.length > mod.passkey.max) {
return lib.tryCall(callback, { status: 400, message: mod.errPasskeyMax });
}
if (allkeys.length == passkeys.length) {
return lib.tryCall(callback);
}
var query = Object.assign({ login: options.user?.login, passkey: lib.stringify(passkeys) }, options.query);
logger.info("update:", "passkey", query);
api.users.update(query, { isInternal: 1 }, callback);
}