webauthn4js
Version:
Node.js bindings to WebAuthn Go/Wasm library
773 lines (690 loc) • 27.7 kB
JavaScript
/* eslint-env node, mocha, browser */
/* global browser,
bufferEncode,
bufferDecode,
last_post_request,
last_put_request */
const { join } = require('path');
const { readFile } = require('fs').promises;
const mod_fastify = require('fastify');
const fastify_static = require('@fastify/static');
const makeWebAuthn = require('..');
let expect;
const crypto = require('crypto');
const { promisify } = require('util');
const port = 3000;
const origin = `https://localhost:${port}`;
const username = 'foo@bar.com';
const username2 = 'foo2@bar.com';
const username3 = 'foo3@bar.com';
const config = {
RPDisplayName: 'WebAuthnJS',
RPID: 'localhost',
RPOrigins: [origin],
AuthenticatorSelection: {
userVerification: 'preferred'
}
};
class ErrorWithStatus extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
}
}
function b64url(b64) {
return b64
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
const users = new Map();
before(async function () {
({ expect } = await import('chai'));
const keys_dir = join(__dirname, 'keys');
const fastify = mod_fastify({
logger: true,
https: {
key: await readFile(join(keys_dir, 'server.key')),
cert: await readFile(join(keys_dir, 'server.crt'))
},
forceCloseConnections: true
});
fastify.register(fastify_static, {
root: join(__dirname, 'fixtures'),
prefix: '/test'
});
const webAuthn = await makeWebAuthn(config);
const {
make_secret_session_data,
verify_secret_session_data
} = await import('./example/session.mjs');
const schemas = await import('./example/schemas.mjs');
async function register(fastify) {
fastify.get('/:username', {
schema: schemas.register.get
}, async request => {
let user = users.get(request.params.username);
if (!user) {
user = {
id: `user${users.size}`,
name: request.params.username,
displayName: request.params.username.split('@')[0],
iconURL: '',
credentials: []
};
if (request.params.username === username2) {
user.id = Buffer.from(user.id);
}
users.set(request.params.username, user);
}
const excludeCredentials = user.credentials.map(c => ({
type: 'public-key',
id: c.id
}));
const { options, sessionData } = await webAuthn.beginRegistration(
user,
cco => {
cco.excludeCredentials = excludeCredentials;
return cco;
});
return {
options,
session_data: await make_secret_session_data(
request.params.username, 'registration', sessionData)
};
});
fastify.put('/:username', {
schema: schemas.register.put
}, async (request, reply) => {
const user = users.get(request.params.username);
if (!user) {
throw new ErrorWithStatus('no user', 404);
}
const session_data = await verify_secret_session_data(
request.params.username, 'registration', request.body.session_data);
let credential;
try {
credential = await webAuthn.finishRegistration(
user, session_data, request.body.ccr);
} catch (ex) {
ex.statusCode = 400;
throw ex;
}
for (const u of users.values()) {
if (u.credentials.find(c => c.id === credential.id)) {
throw new ErrorWithStatus('credential in use', 409);
}
}
user.credentials.push(credential);
reply.code(204);
});
}
async function login(fastify) {
fastify.get('/:username', {
schema: schemas.login.get
}, async request => {
const user = users.get(request.params.username);
if (!user) {
throw new ErrorWithStatus('no user', 404);
}
const { options, sessionData } = await webAuthn.beginLogin(user);
return {
options,
session_data: await make_secret_session_data(
request.params.username, 'login', sessionData)
};
});
fastify.post('/:username', {
schema: schemas.login.post
}, async (request, reply) => {
const user = users.get(request.params.username);
if (!user) {
throw new ErrorWithStatus('no user', 404);
}
const session_data = await verify_secret_session_data(
request.params.username, 'login', request.body.session_data);
let credential;
try {
credential = await webAuthn.finishLogin(
user, session_data, request.body.car);
} catch (ex) {
ex.statusCode = 400;
throw ex;
}
if (credential.authenticator.cloneWarning) {
throw new ErrorWithStatus('credential appears to be cloned', 403);
}
const user_cred = user.credentials.find(c => c.id === credential.id);
/* istanbul ignore if */
if (!user_cred) {
// Should have been checked already in Go by webAuthn.finishLogin
throw new ErrorWithStatus('no credential', 500);
}
user_cred.authenticator.signCount = credential.authenticator.signCount;
reply.code(204);
});
}
fastify.register(register, {
prefix: '/register/'
});
fastify.register(login, {
prefix: '/login/'
});
await fastify.listen({ port });
browser.options.after.push(async function () {
await fastify.close();
await promisify(cb => {
webAuthn.on('exit', n => {
expect(n).to.equal(0);
cb();
});
webAuthn.exit();
})();
});
await browser.addVirtualAuthenticator('ctap2_1', 'usb');
await browser.url(`${origin}/test/test.html`);
});
/* istanbul ignore next */
async function executeAsync(f, ...args) {
const r = await browser.executeAsync(function (f, ...args) {
(async function () {
let done = args[args.length - 1];
window.bufferDecode = function (value) {
return Uint8Array.from(atob(value
.replace(/-/g, "+")
.replace(/_/g, "/")), c => c.charCodeAt(0));
};
window.bufferEncode = function (value) {
return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
};
try {
done(await eval(f)(...args.slice(0, -1)));
} catch (ex) {
done({ err: ex.message });
}
})();
}, f.toString(), ...args);
if (r && r.err) {
throw new Error(r.err);
}
return r;
}
/* istanbul ignore next */
async function register(username, opts) {
return await executeAsync(async (username, opts) => {
opts = Object.assign({
alter_challenge: false
}, opts);
const get_response = await fetch(`/register/${username}`);
if (!get_response.ok) {
throw new Error(`Registration GET failed with ${get_response.status} ${await get_response.text()}`);
}
const { options, session_data } = await get_response.json();
const { publicKey } = options;
publicKey.challenge = bufferDecode(publicKey.challenge);
publicKey.user.id = bufferDecode(publicKey.user.id);
if (publicKey.excludeCredentials) {
for (const c of publicKey.excludeCredentials) {
c.id = bufferDecode(c.id);
}
}
if (opts.alter_challenge) {
publicKey.challenge[0] ^= 0xff;
}
const credential = await navigator.credentials.create(options);
const { id, rawId, type, response: cred_response } = credential;
const { attestationObject, clientDataJSON } = cred_response;
const put_request = {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
ccr: {
id,
rawId: bufferEncode(rawId),
type,
response: {
attestationObject: bufferEncode(attestationObject),
clientDataJSON: bufferEncode(clientDataJSON)
}
},
session_data
})
};
const put_response = await fetch(`/register/${username}`, put_request);
if (!put_response.ok) {
throw new Error(`Registration PUT failed with ${put_response.status} ${await put_response.text()}`);
}
window.last_put_request = put_request;
return { id, type };
}, username, opts);
}
/* istanbul ignore next */
async function login(username, opts) {
return await executeAsync(async (username, opts) => {
opts = Object.assign({
post_username: username,
session_username: username
}, opts);
const get_response = await fetch(`/login/${username}`);
if (!get_response.ok) {
throw new Error(`Login GET failed with ${get_response.status} ${await get_response.text()}`);
}
let { options, session_data } = await get_response.json();
const { publicKey } = options;
publicKey.challenge = bufferDecode(publicKey.challenge);
for (const c of publicKey.allowCredentials) {
c.id = bufferDecode(c.id);
}
const assertion = await navigator.credentials.get(options);
const { id, rawId, type, response: assertion_response } = assertion;
const { authenticatorData, clientDataJSON, signature, userHandle } = assertion_response;
if (opts.session_username !== username) {
const get_response = await fetch(`/login/${opts.session_username}`);
if (!get_response.ok) {
throw new Error(`Login GET failed with ${get_response.status} ${await get_response.text()}`);
}
({ session_data } = await get_response.json());
}
if (opts.modify_sig) {
const sigview = new Uint8Array(signature);
for (let i = 0; i < sigview.length; ++i) {
sigview[i] ^= 1;
}
}
const post_request = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
car: {
id,
rawId: bufferEncode(rawId),
type,
response: {
authenticatorData: bufferEncode(authenticatorData),
clientDataJSON: bufferEncode(clientDataJSON),
signature: bufferEncode(signature),
userHandle: bufferEncode(userHandle)
}
},
session_data
})
};
const post_response = await fetch(`/login/${opts.post_username}`, post_request);
if (!post_response.ok) {
throw new Error(`Login POST failed with ${post_response.status} ${await post_response.text()}`);
}
window.last_post_request = post_request;
return { id, type };
}, username, opts);
}
let cred_id;
let cred_pubkey;
describe('register', function () {
it('should register credential', async function () {
const { id, type } = await register(username);
expect(users.size).to.equal(1);
expect(type).to.equal('public-key');
const user = users.get(username);
expect(user.id).to.equal('user0');
expect(user.name).to.equal(username);
expect(user.displayName).to.equal('foo');
expect(user.iconURL).to.equal('');
expect(user.credentials.length).to.equal(1);
const cred = user.credentials[0];
// id returned from credentials.create is b64url encoded
// ID from Go is b64 encoded
expect(b64url(cred.id)).to.equal(id);
expect(cred.attestationType).to.equal('none');
expect(cred.authenticator.signCount).to.equal(1);
expect(cred.authenticator.cloneWarning).to.be.false;
cred_id = id;
cred_pubkey = cred.publicKey;
});
it('should fail to use same credential', async function () {
let ex;
try {
await register(username);
} catch (e) {
ex = e;
}
expect(ex.message).to.equal('The user attempted to register an authenticator that contains one of the credentials already registered with the relying party.');
});
it('should register second user', async function () {
const { id, type } = await register(username2);
expect(users.size).to.equal(2);
expect(type).to.equal('public-key');
const user = users.get(username);
expect(user.id).to.equal('user0');
expect(user.name).to.equal(username);
expect(user.displayName).to.equal('foo');
expect(user.iconURL).to.equal('');
expect(user.credentials.length).to.equal(1);
const cred = user.credentials[0];
expect(b64url(cred.id)).to.equal(cred_id);
expect(cred.attestationType).to.equal('none');
expect(cred.authenticator.signCount).to.equal(1);
expect(cred.authenticator.cloneWarning).to.be.false;
expect(cred.publicKey).to.equal(cred_pubkey);
const user2 = users.get(username2);
expect(user2.id.toString()).to.equal('user1');
expect(user2.name).to.equal(username2);
expect(user2.displayName).to.equal('foo2');
expect(user2.iconURL).to.equal('');
expect(user2.credentials.length).to.equal(1);
const cred2 = user2.credentials[0];
expect(b64url(cred2.id)).to.equal(id);
expect(cred2.attestationType).to.equal('none');
expect(cred2.authenticator.signCount).to.equal(1);
expect(cred2.authenticator.cloneWarning).to.be.false;
expect(b64url(cred2.id)).not.to.equal(cred_id);
expect(cred2.publicKey).not.to.equal(cred_pubkey);
});
it('should fail to register with duplicate attestation', async function () {
let ex;
try {
/* istanbul ignore next */
await executeAsync(async username => {
const put_response = await fetch(`/register/${username}`, last_put_request);
if (!put_response.ok) {
throw new Error(`Registration PUT failed with ${put_response.status} ${await put_response.text()}`);
}
}, username2);
} catch (e) {
ex = e;
}
expect(ex.message).to.equal('Registration PUT failed with 409 {"statusCode":409,"error":"Conflict","message":"credential in use"}');
});
it('should fail to register with wrong challenge', async function () {
let ex;
try {
await register(username3, { alter_challenge: true });
} catch (e) {
ex = e;
}
expect(ex.message).to.equal('Registration PUT failed with 400 {"statusCode":400,"error":"Bad Request","message":"Error validating challenge"}');
});
});
describe('login', function () {
it('should login', async function () {
const { id, type } = await login(username);
// username3 although not registered is still in the DB
expect(users.size).to.equal(3);
expect(type).to.equal('public-key');
const user = users.get(username);
expect(user.id).to.equal('user0');
expect(user.name).to.equal(username);
expect(user.displayName).to.equal('foo');
expect(user.iconURL).to.equal('');
expect(user.credentials.length).to.equal(1);
const cred = user.credentials[0];
expect(b64url(cred.id)).to.equal(id);
expect(cred.attestationType).to.equal('none');
expect(cred.authenticator.signCount).to.equal(2);
expect(cred.authenticator.cloneWarning).to.be.false;
});
it('should fail to login with duplicate assertion', async function () {
let ex;
try {
/* istanbul ignore next */
await executeAsync(async username => {
const post_response = await fetch(`/login/${username}`, last_post_request);
if (!post_response.ok) {
throw new Error(`Login POST failed with ${post_response.status} ${await post_response.text()}`);
}
}, username);
} catch (e) {
ex = e;
}
expect(ex.message).to.equal('Login POST failed with 403 {"statusCode":403,"error":"Forbidden","message":"credential appears to be cloned"}');
});
it('should fail to login as different user', async function () {
let ex;
try {
await login(username, {
post_username: username2,
session_username: username2
});
} catch (e) {
ex = e;
}
expect(ex.message).to.equal('Login POST failed with 400 {"statusCode":400,"error":"Bad Request","message":"User does not own the credential returned"}');
});
it('should check username in session', async function () {
let ex;
try {
await login(username, { post_username: username2 });
} catch (e) {
ex = e;
}
expect(ex.message).to.equal('Login POST failed with 400 {"statusCode":400,"error":"Bad Request","message":"wrong username"}');
});
it('should check session type', async function () {
let ex;
try {
/* istanbul ignore next */
await executeAsync(async username => {
const get_response = await fetch(`/register/${username}`);
if (!get_response.ok) {
throw new Error(`Registration GET failed with ${get_response.status} ${await get_response.text()}`);
}
const { session_data } = await get_response.json();
const request = Object.assign({}, last_post_request);
const body = JSON.parse(request.body);
body.session_data = session_data;
request.body = JSON.stringify(body);
const post_response = await fetch(`/login/${username}`, request);
if (!post_response.ok) {
throw new Error(`Login POST failed with ${post_response.status} ${await post_response.text()}`);
}
}, username);
} catch (e) {
ex = e;
}
expect(ex.message).to.equal('Login POST failed with 400 {"statusCode":400,"error":"Bad Request","message":"wrong type"}');
});
it('should check user exists', async function () {
let ex;
try {
/* istanbul ignore next */
await executeAsync(async username => {
const put_response = await fetch(`/register/${username}`, last_put_request);
if (!put_response.ok) {
throw new Error(`Registration PUT failed with ${put_response.status} ${await put_response.text()}`);
}
}, 'bar@foo.com');
} catch (e) {
ex = e;
}
expect(ex.message).to.equal('Registration PUT failed with 404 {"statusCode":404,"error":"Not Found","message":"no user"}');
try {
/* istanbul ignore next */
await executeAsync(async username => {
const get_response = await fetch(`/login/${username}`);
if (!get_response.ok) {
throw new Error(`Login GET failed with ${get_response.status} ${await get_response.text()}`);
}
}, 'bar@foo.com');
} catch (e) {
ex = e;
}
expect(ex.message).to.equal('Login GET failed with 404 {"statusCode":404,"error":"Not Found","message":"no user"}');
try {
/* istanbul ignore next */
await executeAsync(async username => {
const post_response = await fetch(`/login/${username}`, last_post_request);
if (!post_response.ok) {
throw new Error(`Login POST failed with ${post_response.status} ${await post_response.text()}`);
}
}, 'bar@foo.com');
} catch (e) {
ex = e;
}
expect(ex.message).to.equal('Login POST failed with 404 {"statusCode":404,"error":"Not Found","message":"no user"}');
});
it('should check timestamp', async function () {
let ex;
const orig_now = Date.now;
Date.now = function (dummy) {
let now = orig_now.call(this);
if (dummy === 'dummy') {
now += 60000;
}
return now;
};
try {
/* istanbul ignore next */
await executeAsync(async username => {
const get_response = await fetch(`/login/${username}`);
if (!get_response.ok) {
throw new Error(`Login GET failed with ${get_response.status} ${await get_response.text()}`);
}
const { session_data } = await get_response.json();
const request = Object.assign({}, last_post_request);
const body = JSON.parse(request.body);
body.session_data = session_data;
request.body = JSON.stringify(body);
const post_response = await fetch(`/login/${username}`, request);
if (!post_response.ok) {
throw new Error(`Login POST failed with ${post_response.status} ${await post_response.text()}`);
}
}, username);
} catch (e) {
ex = e;
} finally {
Date.now = orig_now;
}
expect(ex.message).to.equal('Login POST failed with 400 {"statusCode":400,"error":"Bad Request","message":"session timed out"}');
});
it('should login second user', async function () {
const { id, type } = await login(username2);
// username3 although not registered is still in the DB
expect(users.size).to.equal(3);
expect(type).to.equal('public-key');
const user = users.get(username2);
expect(user.id.toString()).to.equal('user1');
expect(user.name).to.equal(username2);
expect(user.displayName).to.equal('foo2');
expect(user.iconURL).to.equal('');
expect(user.credentials.length).to.equal(1);
const cred = user.credentials[0];
expect(b64url(cred.id)).to.equal(id);
expect(cred.attestationType).to.equal('none');
expect(cred.authenticator.signCount).to.equal(2);
expect(cred.authenticator.cloneWarning).to.be.false;
});
it('should fail to login third user', async function () {
let ex;
try {
await login(username3);
} catch (e) {
ex = e;
}
expect(ex.message).to.equal('Login GET failed with 500 {"statusCode":500,"error":"Internal Server Error","message":"Found no credentials for user"}');
});
it('should fail to verify bad signature', async function () {
let ex;
try {
await login(username, { modify_sig: true });
} catch (e) {
ex = e;
}
expect(ex.message).to.equal('Login POST failed with 400 {"statusCode":400,"error":"Bad Request","message":"Error validating the assertion signature: Signature invalid or not provided"}');
await login(username);
});
});
describe('init', function () {
it('should error with invalid config', async function () {
let ex;
try {
await makeWebAuthn({});
} catch (e) {
ex = e;
}
expect(ex.message).to.equal("error occurred validating the configuration: the field 'RPDisplayName' must be configured but it is empty");
// Note: also tests exit without webauthn being made
});
it('should error if it fails to generate random bytes', async function () {
let ex;
const orig_randomBytes = crypto.randomBytes;
crypto.randomBytes = (n, cb) => cb(new Error('dummy'));
try {
await makeWebAuthn({});
} catch (e) {
ex = e;
} finally {
crypto.randomBytes = orig_randomBytes;
}
expect(ex.message).to.equal('dummy');
});
it('should emit exit event', async function () {
const webAuthn = await makeWebAuthn(config);
await promisify(cb => {
webAuthn.on('exit', n => {
expect(n).to.equal(0);
cb();
});
webAuthn.exit();
})();
});
it('should pass through exit code', async function () {
const webAuthn = await makeWebAuthn(config);
await promisify(cb => {
webAuthn.on('error', err => {
expect(err.message).to.equal('wasm_exec exit with code 123');
});
webAuthn.on('exit', n => {
expect(n).to.equal(123);
cb();
});
webAuthn.exit(123);
})();
});
it('should catch errors instantiating wasm', async function () {
const Mod = require('module');
const req = Mod.prototype.require;
Mod.prototype.require = function (f) {
const r = req.call(this, f);
if (f === './wasm_exec.js') {
return (...args) => {
r(...args);
throw new Error('dummy');
};
}
return r;
};
let ex;
try {
await makeWebAuthn(config);
} catch (e) {
ex = e;
} finally {
Mod.prototype.require = req;
}
expect(ex.message).to.equal('dummy');
});
it('should exit on beforeExit', async function () {
// should be two handlers:
// - async-exit-hook
// - one for webAuthn made in before()
expect(process.listeners('beforeExit').length).to.equal(2);
const webAuthn = await makeWebAuthn(config);
expect(process.listeners('beforeExit').length).to.equal(3);
await promisify(cb => {
webAuthn.on('exit', n => {
expect(n).to.equal(0);
expect(process.listeners('beforeExit').length).to.equal(2);
cb();
});
const h = process.listeners('beforeExit')[2];
h();
h(); // check Go exit isn't called twice
})();
});
});