├── .gitignore ├── LICENSE.md ├── README.md ├── app.js ├── fido.js ├── package-lock.json ├── package.json ├── public ├── index.css ├── index.html └── index.js └── storage.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) Microsoft Corporation 2 | All rights reserved. 3 | 4 | #### MIT License 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED \*AS IS\*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # About this sample app 2 | 3 | This is a simple NodeJS app that demonstrates the Web Authentication APIs. 4 | 5 | You can see a live version at https://webauthnsample.azurewebsites.net 6 | 7 | ## Deploying a local instance 8 | 9 | 1. Download and install [NodeJS 8.9 or newer](https://nodejs.org/en/) 10 | 2. Download and install [VS Code](https://code.visualstudio.com/) 11 | 3. Download and install [MongoDB Community](https://www.mongodb.com/download-center#community) 12 | 4. Clone this repository 13 | 5. Open this repository in VS Code 14 | 6. Run npm install in the root directory 15 | 7. Launch program - configurations should already be set 16 | 8. In Edge, navigate to localhost:3000 17 | 18 | ## Deploying to Azure 19 | 20 | First, in Azure Portal: 21 | 22 | - Create an app services web instance 23 | - Create a Cosmos DB instance with API set to mongodb 24 | 25 | Before deploying, you'll need to define the following environment variables inside app services application settings so they can be accessed by this NodeJS app at runtime: 26 | 27 | - MONGODB_URL - connection URL to your mongodb. Get it from Cosmos DB settings. Pick the latest Node.js 3.0 connection string under Quick Start. 28 | - JWT_SECRET - some long random string 29 | - HOSTNAME - hostname of your deployed service (e.g. "webauthnsample.azurewebsites.net") 30 | - ENFORCE_SSL_AZURE - set to "true" 31 | - WEBSITE_NODE_DEFAULT_VERSION - set to "8.9.4" or newer 32 | 33 | ## Code of Conduct 34 | 35 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 36 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const express = require("express"); 2 | const app = express(); 3 | const fido = require('./fido.js'); 4 | const bodyParser = require('body-parser'); 5 | const enforce = require('express-sslify'); 6 | 7 | if (process.env.ENFORCE_SSL_HEROKU === "true") { 8 | app.use(enforce.HTTPS({ trustProtoHeader: true })); 9 | } else if (process.env.ENFORCE_SSL_AZURE === "true") { 10 | app.use(enforce.HTTPS({ trustAzureHeader: true })); 11 | } 12 | app.use(express.static('public')); 13 | app.use(bodyParser.json()); 14 | app.use(bodyParser.urlencoded({ extended: true })); 15 | 16 | 17 | app.get('/challenge', async (req, res) => { 18 | try { 19 | const challenge = await fido.getChallenge(); 20 | res.json({ 21 | result: challenge 22 | }); 23 | } catch (e) { 24 | res.json({ 25 | error: e.message 26 | }); 27 | }; 28 | }); 29 | 30 | app.put('/credentials', async (req, res) => { 31 | try { 32 | const credential = await fido.makeCredential(req.body); 33 | res.json({ 34 | result: credential 35 | }); 36 | } catch (e) { 37 | res.json({ 38 | error: e.message 39 | }); 40 | } 41 | }); 42 | 43 | app.put('/assertion', async (req, res) => { 44 | try { 45 | const credential = await fido.verifyAssertion(req.body); 46 | res.json({ 47 | result: credential 48 | }); 49 | } catch (e) { 50 | res.json({ 51 | error: e.message 52 | }); 53 | } 54 | }); 55 | 56 | app.listen(process.env.PORT || 3000, () => console.log('App launched.')); 57 | -------------------------------------------------------------------------------- /fido.js: -------------------------------------------------------------------------------- 1 | const base64url = require('base64url'); 2 | const cbor = require('cbor'); 3 | const uuid = require('uuid-parse'); 4 | const jwkToPem = require('jwk-to-pem'); 5 | const jwt = require('jsonwebtoken'); 6 | const crypto = require('crypto'); 7 | const url = require('url'); 8 | 9 | const storage = require('./storage.js'); 10 | 11 | const hostname = process.env.HOSTNAME || "localhost"; 12 | const jwt_secret = process.env.JWT_SECRET || "defaultsecret"; 13 | 14 | 15 | const fido = {}; 16 | 17 | /** 18 | * Gets an opaque challenge for the client. 19 | * Internally, this challenge is a JWT with a timeout. 20 | * @returns {string} challenge 21 | */ 22 | fido.getChallenge = () => { 23 | return jwt.sign({}, jwt_secret, { 24 | expiresIn: 120 * 1000 25 | }); 26 | }; 27 | 28 | /** 29 | * Creates a FIDO credential and stores it 30 | * @param {any} attestation AuthenticatorAttestationResponse received from client 31 | */ 32 | fido.makeCredential = async (attestation) => { 33 | //https://w3c.github.io/webauthn/#registering-a-new-credential 34 | 35 | if (!attestation.id) 36 | throw new Error("id is missing"); 37 | 38 | if (!attestation.attestationObject) 39 | throw new Error("attestationObject is missing") 40 | 41 | if (!attestation.clientDataJSON) 42 | throw new Error("clientDataJSON is missing"); 43 | 44 | //Step 1-2: Let C be the parsed the client data claimed as collected during 45 | //the credential creation 46 | let C; 47 | try { 48 | C = JSON.parse(attestation.clientDataJSON); 49 | } catch (e) { 50 | throw new Error("clientDataJSON could not be parsed"); 51 | } 52 | 53 | //Step 3-6: Verify client data 54 | validateClientData(C, "webauthn.create"); 55 | //Step 7: Compute the hash of response.clientDataJSON using SHA-256. 56 | const clientDataHash = sha256(attestation.clientDataJSON); 57 | 58 | //Step 8: Perform CBOR decoding on the attestationObject 59 | let attestationObject; 60 | try { 61 | attestationObject = cbor.decodeFirstSync(Buffer.from(attestation.attestationObject, 'base64')); 62 | } catch (e) { 63 | throw new Error("attestationObject could not be decoded"); 64 | } 65 | //Step 8.1: Parse authData data inside the attestationObject 66 | const authenticatorData = parseAuthenticatorData(attestationObject.authData); 67 | //Step 8.2: authenticatorData should contain attestedCredentialData 68 | if (!authenticatorData.attestedCredentialData) 69 | throw new Exception("Did not see AD flag in authenticatorData"); 70 | 71 | //Step 9: Verify that the RP ID hash in authData is indeed the SHA-256 hash 72 | //of the RP ID expected by the RP. 73 | if (!authenticatorData.rpIdHash.equals(sha256(hostname))) { 74 | throw new Error("RPID hash does not match expected value: sha256(" + rpId + ")"); 75 | } 76 | 77 | //Step 10: Verify that the User Present bit of the flags in authData is set 78 | if ((authenticatorData.flags & 0b00000001) == 0) { 79 | throw new Error("User Present bit was not set."); 80 | } 81 | 82 | //Step 11: Verify that the User Verified bit of the flags in authData is set 83 | if ((authenticatorData.flags & 0b00000100) == 0) { 84 | throw new Error("User Verified bit was not set."); 85 | } 86 | 87 | //Steps 12-19 are skipped because this is a sample app. 88 | 89 | //Store the credential 90 | const credential = await storage.Credentials.create({ 91 | id: authenticatorData.attestedCredentialData.credentialId.toString('base64'), 92 | publicKeyJwk: authenticatorData.attestedCredentialData.publicKeyJwk, 93 | signCount: authenticatorData.signCount 94 | }); 95 | 96 | return credential; 97 | }; 98 | 99 | /** 100 | * Verifies a FIDO assertion 101 | * @param {any} assertion AuthenticatorAssertionResponse received from client 102 | * @return {any} credential object 103 | */ 104 | fido.verifyAssertion = async (assertion) => { 105 | 106 | // https://w3c.github.io/webauthn/#verifying-assertion 107 | 108 | // Step 1 and 2 are skipped because this is a sample app 109 | 110 | // Step 3: Using credential’s id attribute look up the corresponding 111 | // credential public key. 112 | let credential = await storage.Credentials.findOne({ 113 | id: assertion.id 114 | }); 115 | if (!credential) { 116 | throw new Error("Could not find credential with that ID"); 117 | } 118 | const publicKey = credential.publicKeyJwk; 119 | if (!publicKey) 120 | throw new Error("Could not read stored credential public key"); 121 | 122 | // Step 4: Let cData, authData and sig denote the value of credential’s 123 | // response's clientDataJSON, authenticatorData, and signature respectively 124 | const cData = assertion.clientDataJSON; 125 | const authData = Buffer.from(assertion.authenticatorData, 'base64'); 126 | const sig = Buffer.from(assertion.signature, 'base64'); 127 | 128 | // Step 5 and 6: Let C be the decoded client data claimed by the signature. 129 | let C; 130 | try { 131 | C = JSON.parse(cData); 132 | } catch (e) { 133 | throw new Error("clientDataJSON could not be parsed"); 134 | } 135 | //Step 7-10: Verify client data 136 | validateClientData(C, "webauthn.get"); 137 | 138 | //Parse authenticator data used for the next few steps 139 | const authenticatorData = parseAuthenticatorData(authData); 140 | 141 | //Step 11: Verify that the rpIdHash in authData is the SHA-256 hash of the 142 | //RP ID expected by the Relying Party. 143 | if (!authenticatorData.rpIdHash.equals(sha256(hostname))) { 144 | throw new Error("RPID hash does not match expected value: sha256(" + rpId + ")"); 145 | } 146 | 147 | //Step 12: Verify that the User Present bit of the flags in authData is set 148 | if ((authenticatorData.flags & 0b00000001) == 0) { 149 | throw new Error("User Present bit was not set."); 150 | } 151 | 152 | //Step 13: Verify that the User Verified bit of the flags in authData is set 153 | if ((authenticatorData.flags & 0b00000100) == 0) { 154 | throw new Error("User Verified bit was not set."); 155 | } 156 | 157 | //Step 14: Verify that the values of the client extension outputs in 158 | //clientExtensionResults and the authenticator extension outputs in the 159 | //extensions in authData are as expected 160 | if (authenticatorData.extensionData) { 161 | //We didn't request any extensions. If extensionData is defined, fail. 162 | throw new Error("Received unexpected extension data"); 163 | } 164 | 165 | //Step 15: Let hash be the result of computing a hash over the cData using 166 | //SHA-256. 167 | const hash = sha256(cData); 168 | 169 | //Step 16: Using the credential public key looked up in step 3, verify 170 | //that sig is a valid signature over the binary concatenation of authData 171 | //and hash. 172 | const verify = (publicKey.kty === "RSA") ? crypto.createVerify('RSA-SHA256') : crypto.createVerify('sha256'); 173 | verify.update(authData); 174 | verify.update(hash); 175 | if (!verify.verify(jwkToPem(publicKey), sig)) 176 | throw new Error("Could not verify signature"); 177 | 178 | //Step 17: verify signCount 179 | if (authenticatorData.signCount != 0 && 180 | authenticatorData.signCount < credential.signCount) { 181 | throw new Error("Received signCount of " + authenticatorData.signCount + 182 | " expected signCount > " + credential.signCount); 183 | } 184 | 185 | //Update signCount 186 | credential = await storage.Credentials.findOneAndUpdate({ 187 | id: credential.id 188 | }, { 189 | signCount: authenticatorData.signCount 190 | }, { new: true }); 191 | 192 | //Return credential object that was verified 193 | return credential; 194 | }; 195 | 196 | /** 197 | * Parses authData buffer and returns an authenticator data object 198 | * @param {Buffer} authData 199 | * @returns {AuthenticatorData} Parsed AuthenticatorData object 200 | * @typedef {Object} AuthenticatorData 201 | * @property {Buffer} rpIdHash 202 | * @property {number} flags 203 | * @property {number} signCount 204 | * @property {AttestedCredentialData} attestedCredentialData 205 | * @property {string} extensionData 206 | * @typedef {Object} AttestedCredentialData 207 | * @property {string} aaguid 208 | * @property {any} publicKeyJwk 209 | * @property {string} credentialId 210 | * @property {number} credentialIdLength 211 | */ 212 | const parseAuthenticatorData = authData => { 213 | try { 214 | const authenticatorData = {}; 215 | 216 | authenticatorData.rpIdHash = authData.slice(0, 32); 217 | authenticatorData.flags = authData[32]; 218 | authenticatorData.signCount = (authData[33] << 24) | (authData[34] << 16) | (authData[35] << 8) | (authData[36]); 219 | 220 | if (authenticatorData.flags & 64) { 221 | const attestedCredentialData = {}; 222 | attestedCredentialData.aaguid = uuid.unparse(authData.slice(37, 53)).toUpperCase(); 223 | attestedCredentialData.credentialIdLength = (authData[53] << 8) | authData[54]; 224 | attestedCredentialData.credentialId = authData.slice(55, 55 + attestedCredentialData.credentialIdLength); 225 | //Public key is the first CBOR element of the remaining buffer 226 | const publicKeyCoseBuffer = authData.slice(55 + attestedCredentialData.credentialIdLength, authData.length); 227 | 228 | //convert public key to JWK for storage 229 | attestedCredentialData.publicKeyJwk = coseToJwk(publicKeyCoseBuffer); 230 | 231 | authenticatorData.attestedCredentialData = attestedCredentialData; 232 | } 233 | 234 | if (authenticatorData.flags & 128) { 235 | //has extension data 236 | 237 | let extensionDataCbor; 238 | 239 | if (authenticatorData.attestedCredentialData) { 240 | //if we have attesttestedCredentialData, then extension data is 241 | //the second element 242 | extensionDataCbor = cbor.decodeAllSync(authData.slice(55 + authenticatorData.attestedCredentialData.credentialIdLength, authData.length)); 243 | extensionDataCbor = extensionDataCbor[1]; 244 | } else { 245 | //Else it's the first element 246 | extensionDataCbor = cbor.decodeFirstSync(authData.slice(37, authData.length)); 247 | } 248 | 249 | authenticatorData.extensionData = cbor.encode(extensionDataCbor).toString('base64'); 250 | } 251 | 252 | return authenticatorData; 253 | } catch (e) { 254 | throw new Error("Authenticator Data could not be parsed") 255 | } 256 | } 257 | 258 | /** 259 | * Validates CollectedClientData 260 | * @param {any} clientData JSON parsed client data object received from client 261 | * @param {string} type Operation type: webauthn.create or webauthn.get 262 | */ 263 | const validateClientData = (clientData, type) => { 264 | if (clientData.type !== type) 265 | throw new Error("collectedClientData type was expected to be " + type); 266 | 267 | let origin; 268 | try { 269 | origin = url.parse(clientData.origin); 270 | } catch (e) { 271 | throw new Error("Invalid origin in collectedClientData"); 272 | } 273 | 274 | if (origin.hostname !== hostname) 275 | throw new Error("Invalid origin in collectedClientData. Expected hostname " + hostname); 276 | 277 | if (hostname !== "localhost" && origin.protocol !== "https:") 278 | throw new Error("Invalid origin in collectedClientData. Expected HTTPS protocol."); 279 | 280 | let decodedChallenge; 281 | try { 282 | decodedChallenge = jwt.verify(base64url.decode(clientData.challenge), jwt_secret); 283 | } catch (err) { 284 | throw new Error("Invalid challenge in collectedClientData"); 285 | } 286 | }; 287 | 288 | /** 289 | * Converts a COSE key to a JWK 290 | * @param {Buffer} cose Buffer containing COSE key data 291 | * @returns {any} JWK object 292 | */ 293 | const coseToJwk = cose => { 294 | try { 295 | let publicKeyJwk = {}; 296 | const publicKeyCbor = cbor.decodeFirstSync(cose); 297 | 298 | if (publicKeyCbor.get(3) == -7) { 299 | publicKeyJwk = { 300 | kty: "EC", 301 | crv: "P-256", 302 | x: publicKeyCbor.get(-2).toString('base64'), 303 | y: publicKeyCbor.get(-3).toString('base64') 304 | } 305 | } else if (publicKeyCbor.get(3) == -257) { 306 | publicKeyJwk = { 307 | kty: "RSA", 308 | n: publicKeyCbor.get(-1).toString('base64'), 309 | e: publicKeyCbor.get(-2).toString('base64') 310 | } 311 | } else { 312 | throw new Error("Unknown public key algorithm"); 313 | } 314 | 315 | return publicKeyJwk; 316 | } catch (e) { 317 | throw new Error("Could not decode COSE Key"); 318 | } 319 | } 320 | 321 | /** 322 | * Evaluates the sha256 hash of a buffer 323 | * @param {Buffer} data 324 | * @returns sha256 of the input data 325 | */ 326 | const sha256 = data => { 327 | const hash = crypto.createHash('sha256'); 328 | hash.update(data); 329 | return hash.digest(); 330 | } 331 | 332 | module.exports = fido; 333 | 334 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webauthnsample", 3 | "version": "1.0.0", 4 | "description": "WebAuthn Sample App", 5 | "main": "app.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node app.js" 9 | }, 10 | "author": "Ibrahim Damlaj (https://github.com/MicrosoftEdge/webauthnsample)", 11 | "license": "MIT", 12 | "dependencies": { 13 | "base64url": "^3.0.0", 14 | "body-parser": "^1.18.3", 15 | "cbor": "^4.1.0", 16 | "express": "^4.16.3", 17 | "express-sslify": "^1.2.0", 18 | "jsonwebtoken": "^8.3.0", 19 | "jwk-to-pem": "^2.0.0", 20 | "mongoose": "^5.7.5", 21 | "npm": "^6.1.0", 22 | "uuid-parse": "^1.0.0" 23 | }, 24 | "engines": { 25 | "node": ">=8.9.4" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /public/index.css: -------------------------------------------------------------------------------- 1 | .btn { 2 | width: 240px; 3 | margin-right: 10px; 4 | margin-bottom: 10px; 5 | } 6 | 7 | .spinner { 8 | margin-right: 10px; 9 | } 10 | 11 | .hidden { 12 | display: none; 13 | } 14 | 15 | .errorText { 16 | color: red; 17 | } 18 | 19 | .wordWrap { 20 | word-break: break-all; 21 | word-wrap: break-word; 22 | } -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 12 | 14 | 15 | 16 | 17 | WebAuthn Sample App 18 | 19 | 20 | 21 |
22 |
23 |

WebAuthn Sample App

24 |

25 | Source code on 26 | GitHub 27 | 28 |

29 | 30 | 31 | 32 |
33 |
34 | 35 | Register credential 36 | 37 |
38 |
39 | 40 | 43 |
44 |
45 | 46 | 49 |
50 |
51 |
52 |
53 |
54 |
55 | 56 | Authenticate credential 57 | 58 |
59 |
60 | 61 | 64 |
65 |
66 | 67 | 70 |
71 |
72 |
73 |
74 |
75 | 76 | 78 | 80 |
81 |
82 |
83 | 86 |
87 | 88 | 90 | 92 | 94 | 95 | 96 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /public/index.js: -------------------------------------------------------------------------------- 1 | 2 | $(window).on('load', function () { 3 | $("#register").on('click', () => registerButtonClicked()); 4 | $("#authenticate").on('click', () => authenticateButtonClicked()); 5 | 6 | //Update UI to reflect availability of platform authenticator 7 | if (PublicKeyCredential && typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable !== "function") { 8 | markPlatformAuthenticatorUnavailable(); 9 | } else if (PublicKeyCredential && typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable === "function") { 10 | PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable().then(available => { 11 | if (!available) { 12 | markPlatformAuthenticatorUnavailable(); 13 | } 14 | }).catch(e=>{ 15 | markPlatformAuthenticatorUnavailable(); 16 | }); 17 | } 18 | }); 19 | 20 | /** 21 | * Marks platform authenticator as unavailable in UI 22 | */ 23 | function markPlatformAuthenticatorUnavailable() { 24 | $('label[for="attachmentPlatform"]').html('On bound (platform) authenticator - Reported as not available'); 25 | } 26 | 27 | /** 28 | * Disables all input controls and buttons on the page 29 | */ 30 | function disableControls() { 31 | $('#register').attr('disabled',''); 32 | $('#authenticate').attr('disabled',''); 33 | $("#status").addClass('hidden'); 34 | } 35 | 36 | /** 37 | * Enables all input controls and buttons on the page 38 | */ 39 | function enableControls() { 40 | $('#register').removeAttr('disabled'); 41 | $('#authenticate').removeAttr('disabled'); 42 | $("#status").removeClass('hidden'); 43 | } 44 | 45 | /** 46 | * Handler for create button being pressed 47 | */ 48 | function registerButtonClicked() { 49 | disableControls(); 50 | $("#registerSpinner").removeClass("hidden"); 51 | 52 | getChallenge().then(challenge => { 53 | return createCredential(challenge); 54 | }).then(credential => { 55 | localStorage.setItem("credentialId", credential.id); 56 | $("#status").text("Successfully created credential with ID: " + credential.id); 57 | $("#registerSpinner").addClass("hidden"); 58 | enableControls(); 59 | }).catch(e => { 60 | $("#status").text("Error: " + e); 61 | $("#registerSpinner").addClass("hidden"); 62 | enableControls(); 63 | }); 64 | } 65 | 66 | /** 67 | * Handler for get button being pressed 68 | */ 69 | function authenticateButtonClicked() { 70 | disableControls(); 71 | $("#authenticateSpinner").removeClass("hidden"); 72 | 73 | getChallenge().then(challenge => { 74 | return getAssertion(challenge); 75 | }).then(credential => { 76 | $("#status").text("Successfully verified credential with ID: " + credential.id); 77 | $("#authenticateSpinner").addClass("hidden"); 78 | enableControls(); 79 | }).catch(e => { 80 | $("#status").text("Error: " + e); 81 | $("#authenticateSpinner").addClass("hidden"); 82 | enableControls(); 83 | }); 84 | } 85 | 86 | /** 87 | * Retrieves a challenge from the server 88 | * @returns {Promise} Promise resolving to a ArrayBuffer challenge 89 | */ 90 | function getChallenge() { 91 | return rest_get( 92 | "/challenge" 93 | ).then(response => { 94 | if (response.error) { 95 | return Promise.reject(error); 96 | } 97 | else { 98 | var challenge = stringToArrayBuffer(response.result); 99 | return Promise.resolve(challenge); 100 | } 101 | }); 102 | } 103 | 104 | /** 105 | * Calls the .create() webauthn APIs and sends returns to server 106 | * @param {ArrayBuffer} challenge challenge to use 107 | * @return {any} server response object 108 | */ 109 | function createCredential(challenge) { 110 | if (!PublicKeyCredential || typeof PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable !== "function") 111 | return Promise.reject("WebAuthn APIs are not available on this user agent."); 112 | 113 | var attachment = $("input[name='attachment']:checked").val(); 114 | 115 | var createCredentialOptions = { 116 | rp: { 117 | name: "WebAuthn Sample App", 118 | icon: "https://example.com/rpIcon.png" 119 | }, 120 | user: { 121 | id: stringToArrayBuffer("some.user.id"), 122 | name: "bob.smith@contoso.com", 123 | displayName: "Bob Smith", 124 | icon: "https://example.com/userIcon.png" 125 | }, 126 | pubKeyCredParams: [ 127 | { 128 | //External authenticators support the ES256 algorithm 129 | type: "public-key", 130 | alg: -7 131 | }, 132 | { 133 | //Windows Hello supports the RS256 algorithm 134 | type: "public-key", 135 | alg: -257 136 | } 137 | ], 138 | authenticatorSelection: { 139 | //Select authenticators that support username-less flows 140 | requireResidentKey: true, 141 | //Select authenticators that have a second factor (e.g. PIN, Bio) 142 | userVerification: "required", 143 | //Selects between bound or detachable authenticators 144 | authenticatorAttachment: attachment 145 | }, 146 | //Since Edge shows UI, it is better to select larger timeout values 147 | timeout: 50000, 148 | //an opaque challenge that the authenticator signs over 149 | challenge: challenge, 150 | //prevent re-registration by specifying existing credentials here 151 | excludeCredentials: [], 152 | //specifies whether you need an attestation statement 153 | attestation: "none" 154 | }; 155 | 156 | return navigator.credentials.create({ 157 | publicKey: createCredentialOptions 158 | }).then(rawAttestation => { 159 | var attestation = { 160 | id: base64encode(rawAttestation.rawId), 161 | clientDataJSON: arrayBufferToString(rawAttestation.response.clientDataJSON), 162 | attestationObject: base64encode(rawAttestation.response.attestationObject) 163 | }; 164 | 165 | console.log("=== Attestation response ==="); 166 | logVariable("id (base64)", attestation.id); 167 | logVariable("clientDataJSON", attestation.clientDataJSON); 168 | logVariable("attestationObject (base64)", attestation.attestationObject); 169 | 170 | return rest_put("/credentials", attestation); 171 | }).then(response => { 172 | if (response.error) { 173 | return Promise.reject(response.error); 174 | } else { 175 | return Promise.resolve(response.result); 176 | } 177 | }); 178 | } 179 | 180 | /** 181 | * Calls the .get() API and sends result to server to verify 182 | * @param {ArrayBuffer} challenge 183 | * @return {any} server response object 184 | */ 185 | function getAssertion(challenge) { 186 | if (!PublicKeyCredential) 187 | return Promise.reject("WebAuthn APIs are not available on this user agent."); 188 | 189 | var allowCredentials = []; 190 | var allowCredentialsSelection = $("input[name='allowCredentials']:checked").val(); 191 | 192 | if (allowCredentialsSelection === "filled") { 193 | var credentialId = localStorage.getItem("credentialId"); 194 | 195 | if (!credentialId) 196 | return Promise.reject("Please create a credential first"); 197 | 198 | allowCredentials = [{ 199 | type: "public-key", 200 | id: Uint8Array.from(atob(credentialId), c=>c.charCodeAt(0)).buffer 201 | }]; 202 | } 203 | 204 | var getAssertionOptions = { 205 | //specifies which credential IDs are allowed to authenticate the user 206 | //if empty, any credential can authenticate the users 207 | allowCredentials: allowCredentials, 208 | //an opaque challenge that the authenticator signs over 209 | challenge: challenge, 210 | //Since Edge shows UI, it is better to select larger timeout values 211 | timeout: 50000 212 | }; 213 | 214 | return navigator.credentials.get({ 215 | publicKey: getAssertionOptions 216 | }).then(rawAssertion => { 217 | var assertion = { 218 | id: base64encode(rawAssertion.rawId), 219 | clientDataJSON: arrayBufferToString(rawAssertion.response.clientDataJSON), 220 | userHandle: base64encode(rawAssertion.response.userHandle), 221 | signature: base64encode(rawAssertion.response.signature), 222 | authenticatorData: base64encode(rawAssertion.response.authenticatorData) 223 | }; 224 | 225 | console.log("=== Assertion response ==="); 226 | logVariable("id (base64)", assertion.id); 227 | logVariable("userHandle (base64)", assertion.userHandle); 228 | logVariable("authenticatorData (base64)", assertion.authenticatorData); 229 | logVariable("clientDataJSON", assertion.clientDataJSON); 230 | logVariable("signature (base64)", assertion.signature); 231 | 232 | return rest_put("/assertion", assertion); 233 | }).then(response => { 234 | if (response.error) { 235 | return Promise.reject(response.error); 236 | } else { 237 | return Promise.resolve(response.result); 238 | } 239 | }); 240 | } 241 | 242 | /** 243 | * Base64 encodes an array buffer 244 | * @param {ArrayBuffer} arrayBuffer 245 | */ 246 | function base64encode(arrayBuffer) { 247 | if (!arrayBuffer || arrayBuffer.length == 0) 248 | return undefined; 249 | 250 | return btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer))); 251 | } 252 | 253 | /** 254 | * Converts an array buffer to a UTF-8 string 255 | * @param {ArrayBuffer} arrayBuffer 256 | * @returns {string} 257 | */ 258 | function arrayBufferToString(arrayBuffer) { 259 | return String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)); 260 | } 261 | 262 | /** 263 | * Converts a string to an ArrayBuffer 264 | * @param {string} string string to convert 265 | * @returns {ArrayBuffer} 266 | */ 267 | function stringToArrayBuffer(str){ 268 | return Uint8Array.from(str, c => c.charCodeAt(0)).buffer; 269 | } 270 | /** 271 | * Logs a variable to console 272 | * @param {string} name variable name 273 | * @param {string} text variable content 274 | */ 275 | function logVariable(name, text) { 276 | console.log(name + ": " + text); 277 | } 278 | 279 | /** 280 | * Performs an HTTP get operation 281 | * @param {string} endpoint endpoint URL 282 | * @returns {Promise} Promise resolving to javascript object received back 283 | */ 284 | function rest_get(endpoint) { 285 | return fetch(endpoint, { 286 | method: "GET", 287 | credentials: "same-origin" 288 | }).then(response => { 289 | return response.json(); 290 | }); 291 | } 292 | 293 | /** 294 | * Performs an HTTP put operation 295 | * @param {string} endpoint endpoint URL 296 | * @param {any} object 297 | * @returns {Promise} Promise resolving to javascript object received back 298 | */ 299 | function rest_put(endpoint, object) { 300 | return fetch(endpoint, { 301 | method: "PUT", 302 | credentials: "same-origin", 303 | body: JSON.stringify(object), 304 | headers: { 305 | "content-type": "application/json" 306 | } 307 | }).then(response => { 308 | return response.json(); 309 | }); 310 | } 311 | -------------------------------------------------------------------------------- /storage.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const mongodb_url = process.env.MONGODB_URL || 'mongodb://localhost/fido'; 4 | mongoose.connect(mongodb_url); 5 | 6 | const storage = {}; 7 | 8 | storage.Credentials = mongoose.model('Credential', new mongoose.Schema({ 9 | id: {type: String, index: true}, 10 | publicKeyJwk: Object, 11 | signCount: Number 12 | })); 13 | 14 | module.exports = storage; 15 | --------------------------------------------------------------------------------