diff --git a/client/Client.js b/client/Client.js index f40305a..b6c59ee 100644 --- a/client/Client.js +++ b/client/Client.js @@ -68,6 +68,9 @@ class Client { static preformatMakeCredReq (makeCredReq) { makeCredReq.challenge = base64url.decode(makeCredReq.challenge) makeCredReq.user.id = base64url.decode(makeCredReq.user.id) + for (let excludeCred of makeCredReq.excludeCredentials) { + excludeCred.id = base64url.decode(excludeCred.id) + } return makeCredReq } diff --git a/example/server.js b/example/server.js index d7e0e3d..4c501e7 100644 --- a/example/server.js +++ b/example/server.js @@ -62,10 +62,8 @@ const webauthn = new Webauthn({ app.use('/webauthn', webauthn.initialize()) // Endpoint without passport -app.get('/authenticators', webauthn.authenticate(), async (req, res) => { - res.status(200).json([ - await webauthn.store.get(req.session.username) - ].map(user => user.authenticator)) +app.get('/credentials', webauthn.authenticate(), async (req, res) => { + res.status(200).json((await webauthn.store.get(req.session.username)).credentials) }) // Debug diff --git a/example/src/AuthenticatorCard.js b/example/src/AuthenticatorCard.js deleted file mode 100644 index cf298be..0000000 --- a/example/src/AuthenticatorCard.js +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react' -import { Card } from 'react-bootstrap' -import 'bootstrap/dist/css/bootstrap.min.css' - -class AuthenticatorCard extends React.Component { - render () { - return ( - - - {this.props.authenticator.credID} - -

Format: {this.props.authenticator.fmt}

-

Counter: {this.props.authenticator.counter}

-

Public key: {this.props.authenticator.publicKey}

-
-
-
- ) - } -} - -export default AuthenticatorCard; diff --git a/example/src/CredentialCard.js b/example/src/CredentialCard.js new file mode 100644 index 0000000..1cab762 --- /dev/null +++ b/example/src/CredentialCard.js @@ -0,0 +1,22 @@ +import React from 'react' +import { Card } from 'react-bootstrap' +import 'bootstrap/dist/css/bootstrap.min.css' + +class CredentialCard extends React.Component { + render () { + return ( + + + {this.props.credential.credID} + +

Format: {this.props.credential.fmt}

+

Counter: {this.props.credential.counter}

+

Public key: {this.props.credential.publicKey}

+
+
+
+ ) + } +} + +export default CredentialCard; diff --git a/example/src/User.js b/example/src/User.js index 477299d..50842f4 100644 --- a/example/src/User.js +++ b/example/src/User.js @@ -1,6 +1,6 @@ import React from 'react' -import { Container, Row, Col, Button } from 'react-bootstrap' -import AuthenticatorCard from './AuthenticatorCard' +import { Container, Row, Col, Button, CardColumns } from 'react-bootstrap' +import CredentialCard from './CredentialCard' import Client from 'webauthn/client' import 'bootstrap/dist/css/bootstrap.min.css' @@ -9,10 +9,10 @@ class User extends React.Component { super(props) this.state = { - authenticators: [] + credentials: [] } - fetch('authenticators', { + fetch('credentials', { method: 'GET', credentials: 'include', }).then(response => { @@ -21,8 +21,8 @@ class User extends React.Component { return } return response.json() - }).then(authenticators => { - this.setState({ authenticators }) + }).then(credentials => { + this.setState({ credentials }) }) } @@ -36,15 +36,17 @@ class User extends React.Component {

Welcome {this.props.user.username}

-

Your authenticators:

+

Your credentials:

- {this.state.authenticators.map(authenticator => - - )} + + {this.state.credentials.map(credential => + + )} + ) } diff --git a/src/AttestationChallengeBuilder.js b/src/AttestationChallengeBuilder.js index 169620d..bd1bcf2 100644 --- a/src/AttestationChallengeBuilder.js +++ b/src/AttestationChallengeBuilder.js @@ -87,39 +87,31 @@ class AttestationChallengeBuilder { return this } - addCredentialExclusion (options = {}) { - const { AuthenticatorTransport, PublicKeyCredentialType } = Dictionaries - let { excludeCredentials = [] } = this.result - - if (!Array.isArray(excludeCredentials)) { - excludeCredentials = [excludeCredentials] - } - - if (Array.isArray(options)) { - options.forEach(option => this.addCredentialRequest(option)) - return this - } + addCredentialExclusion (credentials) { + if (typeof credentials === 'undefined') + credentials = [] - const { type, id, transports = [] } = options + if (!Array.isArray(credentials)) + credentials = [credentials] - if ( - !type - || !id - || !Object.values(PublicKeyCredentialType).includes(type) - || !Array.isArray(transports) - ) { - throw new Error('Invalid PublicKeyCredentialDescriptor. See https://www.w3.org/TR/webauthn/#dictdef-publickeycredentialdescriptor') - } + const { AuthenticatorTransport, PublicKeyCredentialType } = Dictionaries + let { excludeCredentials = [] } = this.result - const transportValues = Object.values(AuthenticatorTransport) - transports.forEach(transport => { - if (!transportValues.includes(transport)) { - throw new Error('Invalid AuthenticatorTransport. See https://www.w3.org/TR/webauthn/#enumdef-authenticatortransport') + credentials.map(credential => ({ + type: credential.type || 'public-key', + id: credential.id, + transports: credential.transports || [], + })).forEach(excluded => { + if ( + !excluded.type + || !excluded.id + || !Object.values(PublicKeyCredentialType).includes(excluded.type) + || !Array.isArray(excluded.transports) + ) { + throw new Error('Invalid PublicKeyCredentialDescriptor. See https://www.w3.org/TR/webauthn/#dictdef-publickeycredentialdescriptor') } - }) - - // Add credential request - excludeCredentials.push({ type, id, transports }) + excludeCredentials.push(excluded) + }); this.result.excludeCredentials = excludeCredentials return this @@ -180,7 +172,7 @@ class AttestationChallengeBuilder { build (override = {}) { const challenge = base64url(crypto.randomBytes(32)) - const { rp, user, attestation, pubKeyCredParams } = this.result + const { rp, user, attestation, pubKeyCredParams, excludeCredentials } = this.result if (!rp) { throw new Error('Requires RP information') @@ -199,6 +191,10 @@ class AttestationChallengeBuilder { this.addCredentialRequest({ type: 'public-key', alg: -7 }) } + if (!excludeCredentials) { + this.addCredentialExclusion() + } + return { ...this.result, ...override, challenge } } } diff --git a/src/Webauthn.js b/src/Webauthn.js index 942ef94..640e071 100644 --- a/src/Webauthn.js +++ b/src/Webauthn.js @@ -95,32 +95,27 @@ class Webauthn { return res.status(400).json({ message: 'bad request' }) } - const user = { - id: base64url(crypto.randomBytes(32)), - [usernameField]: username, - } - - Object.entries(this.config.userFields).forEach(([bodyKey, dbKey]) => { - user[dbKey] = req.body[bodyKey] - }) - - const existing = await this.store.get(username) - if (existing && existing.authenticator) { - return res.status(403).json({ - 'status': 'failed', - 'message': `${usernameField} ${username} already exists`, + let user = await this.store.get(username) + if (!user) { + user = { + id: base64url(crypto.randomBytes(32)), + [usernameField]: username, + credentials: [], + } + Object.entries(this.config.userFields).forEach(([bodyKey, dbKey]) => { + user[dbKey] = req.body[bodyKey] }) + if (this.config.enableLogging) console.log('PUT', user) + await this.store.put(username, user) + if (this.config.enableLogging) console.log('STORED') } - if (this.config.enableLogging) console.log('PUT', user) - await this.store.put(username, user) - - if (this.config.enableLogging) console.log('STORED') - const attestation = new AttestationChallengeBuilder(this) .setUserInfo(user) .setAttestationType(this.config.attestation) .setAuthenticator(this.config.authenticator) + .addCredentialExclusion( + user.credentials.map(credential => ({ id: credential.credID }))) .setRelyingPartyInfo({ name: this.config.rpName || options.rpName }) .build({ status: 'ok' }) @@ -160,15 +155,16 @@ class Webauthn { }) } - if (!user.authenticator) { + if (user.credentials.length === 0) { return res.status(401).json({ message: 'user has not registered an authenticator', }) } const assertion = new AssertionChallengeBuilder(this) - .addAllowedCredential({ id: user.authenticator.credID }) - .build({ status: 'ok' }) + .addAllowedCredential( + user.credentials.map(credential => ({ id: credential.credID }))) + .build({ status: 'ok' }) req.session.challenge = assertion.challenge req.session[usernameField] = username @@ -256,18 +252,22 @@ class Webauthn { result = this.verifyAuthenticatorAttestationResponse(response); if (result.verified) { - user.authenticator = result.authrInfo + user.credentials.push(result.authrInfo) await this.store.put(username, user) } } else if (response.authenticatorData !== undefined) { - result = Webauthn.verifyAuthenticatorAssertionResponse(response, user.authenticator, this.config.enableLogging) + let authenticator = + user.credentials.find(credential => credential.credID === id) + + result = Webauthn.verifyAuthenticatorAssertionResponse( + response, authenticator, this.config.enableLogging) if (result.verified) { - if (result.counter <= user.authenticator.counter) + if (result.counter <= authenticator.counter) throw new Error('Authr counter did not increase!') - user.authenticator.counter = result.counter + authenticator.counter = result.counter await this.store.put(username, user) } diff --git a/test/AttestationChallengeBuilder_test.js b/test/AttestationChallengeBuilder_test.js new file mode 100644 index 0000000..e4de7b8 --- /dev/null +++ b/test/AttestationChallengeBuilder_test.js @@ -0,0 +1,64 @@ +let assert = require('assert') +let sinon = require('sinon') + +let AttestationChallengeBuilder = require('../src/AttestationChallengeBuilder') + +describe('AttestationChallengeBuilder', () => { + describe('addCredentialExclusion', () => { + let service = { + config: { + usernameField: 'id', + }, + } + + it('adds a single credential to the exclusion list', () => { + let credential = { id: 'id', type: 'public-key', transports: ['usb'] } + let attestation = new AttestationChallengeBuilder(service) + .setUserInfo({id: 'nina'}) + .setRelyingPartyInfo({id: 'rp.com'}) + .addCredentialExclusion(credential) + .build() + assert.deepEqual([credential], attestation.excludeCredentials) + }) + + it('defaults to public keys with no transports', () => { + let credential = { id: 'id' } + let attestation = new AttestationChallengeBuilder(service) + .setUserInfo({id: 'nina'}) + .setRelyingPartyInfo({id: 'rp.com'}) + .addCredentialExclusion(credential) + .build() + assert.deepEqual([{ id: 'id', type: 'public-key', transports: [] }], + attestation.excludeCredentials) + }) + + it('adds an array of credentials to the exclusion list', () => { + let credential1 = { id: 'id1', type: 'public-key', transports: ['usb'] } + let credential2 = { id: 'id2', type: 'public-key', transports: ['nfc'] } + let attestation = new AttestationChallengeBuilder(service) + .setUserInfo({id: 'nina'}) + .setRelyingPartyInfo({id: 'rp.com'}) + .addCredentialExclusion([credential1, credential2]) + .build() + assert.deepEqual([credential1, credential2], attestation.excludeCredentials) + }) + + it('includes an empty excludeCredentials if not called', () => { + let attestation = new AttestationChallengeBuilder(service) + .setUserInfo({id: 'nina'}) + .setRelyingPartyInfo({id: 'rp.com'}) + .build() + assert.deepEqual([], attestation.excludeCredentials) + }) + + it('throws if the credential is not valid', () => { + let credential = { id: null, type: 'public-key', transports: ['usb'] } + assert.throws(() => { + new AttestationChallengeBuilder(service) + .setUserInfo({id: 'nina'}) + .setRelyingPartyInfo({id: 'rp.com'}) + .addCredentialExclusion(credential) + }, new Error('Invalid PublicKeyCredentialDescriptor. See https://www.w3.org/TR/webauthn/#dictdef-publickeycredentialdescriptor')) + }) + }) +}) diff --git a/test/webauthn_test.js b/test/Webauthn_test.js similarity index 100% rename from test/webauthn_test.js rename to test/Webauthn_test.js