Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(WebAuthn): formalize storage adapter #31

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 52 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,6 @@ const webauthn = new WebAuthn({
// store: {
// put: async (id, value) => {/* return <void> */},
// get: async (id) => {/* return User */},
// search: async (search) => {/* return { [username]: User } */},
// delete: async (id) => {/* return boolean */},
// },
rpName: 'Stranger Labs, Inc.',
Expand Down Expand Up @@ -157,28 +156,65 @@ redirected to the supplied URL.
### Storage Adapater

Storage adapters provide an interface to the WebAuthn RP to store and retrieve
data necessary for authentication, such as authenticator public keys. Storage
adapters must implement the following interface:

**`async get (id)`**
data necessary for authentication, such as authenticator public keys. You can
use the provided LevelDB adapter or write your own to interface with your
database.

The WebAuthnUser object interface is as follows:

```ts
interface Credential {
// The credential format (for now this is the string "fido-u2f").
fmt: string,

// base64url encoded credential ID returned by the authenticator on
// registration.
credID: string,

// PKCS#8-encoded encoded public key as a base64url string.
publicKey: string,

// Signature counter (https://w3c.github.io/webauthn/#signature-counter) for
// the credential.
counter: number,

// Credential transport
// (https://w3c.github.io/webauthn/#dom-publickeycredentialdescriptor-transports).
transports: string[],
};

interface WebAuthnUser {
// base64url-encoded sequence of 32 random bytes that identifies the user
// uniquely, generated by WebAuthn. Avoid replacing this ID with something
// else, user IDs in WebAuthn must be random.
id: string,

// List of credentials associated to the user.
credentials: Credential[],
};
```

Retrieves and returns the previously stored object with the provided `id`.
Additionally, `WebAuthnUser` will have any attributes declared on
`options.userFields` set.

**`async put (id, value)`**
Storage adapters must implement the following interface:

Stores an object so that it may be retrieved with the provided `id`. Returns
nothing.
```ts
async function get (id: string): WebAuthnUser
```

**`async search (startsWith, [options])`**
Retrieves and returns the previously stored WebAuthnUser with the provided `id`.

Returns a mapping of objects where the `id` of the objects return starts with
the provided query value. Available options include:
```ts
async function put (id: string, webAuthnUser: WebAuthnUser): void
```

- `limit`: Return the first N results.
- `reverse`: Return results in reverse lexicographical order. If used in
conjunction with limit then the _last_ N results are returned.
Stores a WebAuthnUser object so that it may be retrieved with the provided `id`.
Returns nothing.

**`async delete (id)`**
```ts
async function delete (id: string): boolean
```

Delete a previously stored object. Returns a boolean indicating success.

Expand Down
7 changes: 7 additions & 0 deletions client/Client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -149,6 +152,10 @@ class Client {
console.log('REGISTER CREDENTIAL', credential)

const credentialResponse = Client.publicKeyCredentialToJSON(credential)
if (credential.response.getTransports)
credentialResponse.response.transports = credential.response.getTransports()
else
credentialResponse.response.transports = []
console.log('REGISTER RESPONSE', credentialResponse)

return await this.sendWebAuthnResponse(credentialResponse)
Expand Down
12 changes: 2 additions & 10 deletions example/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,6 @@ const webauthn = new Webauthn({
// store: {
// put: async (id, value) => {/* return <void> */},
// get: async (id) => {/* return User */},
// search: async (search) => {/* return { [username]: User } */},
// delete: async (id) => {/* return boolean */},
// },
rpName: 'Stranger Labs, Inc.',
Expand All @@ -62,15 +61,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))
})

// Debug
app.get('/db', async (req, res) => {
res.status(200).json(await webauthn.store.search())
app.get('/credentials', webauthn.authenticate(), async (req, res) => {
res.status(200).json((await webauthn.store.get(req.session.username)).credentials)
})

// Debug
Expand Down
22 changes: 0 additions & 22 deletions example/src/AuthenticatorCard.js

This file was deleted.

22 changes: 22 additions & 0 deletions example/src/CredentialCard.js
Original file line number Diff line number Diff line change
@@ -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 (
<Card>
<Card.Body>
<Card.Title>{this.props.credential.credID}</Card.Title>
<Card.Text>
<p><strong>Format: </strong> {this.props.credential.fmt}</p>
<p><strong>Counter: </strong> {this.props.credential.counter}</p>
<p><strong>Public key: </strong> {this.props.credential.publicKey}</p>
</Card.Text>
</Card.Body>
</Card>
)
}
}

export default CredentialCard;
6 changes: 2 additions & 4 deletions example/src/Login.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,7 @@ function Login (props) {
webauthn.register({ name, username }).then(response => {
console.log('Register response: ', response)
setSuccess('Registration successful. Try logging in.')
}).catch(error => {
setError(error.message)
})
});
}

function onLogin () {
Expand All @@ -47,7 +45,7 @@ function Login (props) {
props.onLogin({
username,
});
}).catch(error => setError(error.message))
});
}

return (
Expand Down
22 changes: 12 additions & 10 deletions example/src/User.js
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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 => {
Expand All @@ -21,8 +21,8 @@ class User extends React.Component {
return
}
return response.json()
}).then(authenticators => {
this.setState({ authenticators })
}).then(credentials => {
this.setState({ credentials })
})
}

Expand All @@ -36,15 +36,17 @@ class User extends React.Component {
<Row style={{ paddingTop: 80}}>
<Col>
<h2>Welcome {this.props.user.username}</h2>
<h3>Your authenticators:</h3>
<h3>Your credentials:</h3>
</Col>
<Col className="text-right">
<Button variant="primary" onClick={this.logout}>Log Out</Button>
</Col>
</Row>
{this.state.authenticators.map(authenticator => <Row key={authenticator.credID}>
<Col><AuthenticatorCard authenticator={authenticator} /></Col>
</Row>)}
<CardColumns>
{this.state.credentials.map(credential =>
<Col key={credential.credID}><CredentialCard credential={credential} /></Col>
)}
</CardColumns>
</Container>
)
}
Expand Down
56 changes: 26 additions & 30 deletions src/AttestationChallengeBuilder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:', excluded, '. 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
Expand Down Expand Up @@ -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')
Expand All @@ -199,6 +191,10 @@ class AttestationChallengeBuilder {
this.addCredentialRequest({ type: 'public-key', alg: -7 })
}

if (!excludeCredentials) {
this.addCredentialExclusion()
}

return { ...this.result, ...override, challenge }
}
}
Expand Down
24 changes: 0 additions & 24 deletions src/LevelAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,30 +60,6 @@ class LevelAdapter {
throw err
}
}

async search (startsWith = '', options = {}) {
if (typeof startsWith === 'object') {
options = startsWith
startsWith = options.startsWith || options.gte || options.search
}

const { limit = -1, reverse = false, lte } = options

return await new Promise((resolve, reject) => {
const data = {}

// Get all values that start with `startsWith`
this.db.createReadStream({
gte: startsWith,
lte: lte ? lte : `${startsWith}\uffff`,
limit,
reverse,
})
.on('data', item => data[item.key] = item.value)
.on('end', () => resolve(data))
.on('error', err => reject(err))
})
}
}

/**
Expand Down
9 changes: 0 additions & 9 deletions src/MemoryAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,6 @@ class MemoryAdapter {

return false
}

async search (id = '') {
return Object.entries(this.db)
.filter(([key]) => key.startsWith(id))
.reduce((state, [key, value]) => {
state[key] = value
return state
}, {})
}
}

/**
Expand Down
Loading