1/** 2 * MIT License 3 * 4 * Copyright (c) Italo Israel Baeza Cabrera 5 * 6 * Permission is hereby granted, free of charge, to any person obtaining a copy 7 * of this software and associated documentation files (the "Software"), to deal 8 * in the Software without restriction, including without limitation the rights 9 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 * copies of the Software, and to permit persons to whom the Software is 11 * furnished to do so, subject to the following conditions: 12 * 13 * The above copyright notice and this permission notice shall be included in all 14 * copies or substantial portions of the Software. 15 * 16 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 * SOFTWARE. 23 */ 24 25class WebAuthn { 26 /** 27 * Routes for WebAuthn assertion (login) and attestation (register). 28 * 29 * @type {{registerOptions: string, register: string, loginOptions: string, login: string, }} 30 */ 31 #routes = { 32 registerOptions: "webauthn/register/options", 33 register: "webauthn/register", 34 loginOptions: "webauthn/login/options", 35 login: "webauthn/login", 36 } 37 38 /** 39 * Headers to use in ALL requests done. 40 * 41 * @type {{Accept: string, "Content-Type": string, "X-Requested-With": string}} 42 */ 43 #headers = { 44 "Accept": "application/json", 45 "Content-Type": "application/json", 46 "X-Requested-With": "XMLHttpRequest" 47 }; 48 49 /** 50 * If set to true, the credentials option will be set to 'include' on all fetch calls, 51 * or else it will use the default 'same-origin'. Use this if the backend is not the 52 * same origin as the client or the XSRF protection will break without the session. 53 * 54 * @type {boolean} 55 */ 56 #includeCredentials = false 57 58 /** 59 * Create a new WebAuthn instance. 60 * 61 * @param routes {{registerOptions: string, register: string, loginOptions: string, login: string}} 62 * @param headers {{string}} 63 * @param includeCredentials {boolean} 64 * @param xcsrfToken {string|null} Either a csrf token (40 chars) or xsrfToken (224 chars) 65 */ 66 constructor(routes = {}, headers = {}, includeCredentials = false, xcsrfToken = null) { 67 Object.assign(this.#routes, routes); 68 Object.assign(this.#headers, headers); 69 70 this.#includeCredentials = includeCredentials; 71 72 let xsrfToken; 73 let csrfToken; 74 75 if (xcsrfToken === null) { 76 // If the developer didn't issue an XSRF token, we will find it ourselves. 77 xsrfToken = WebAuthn.#XsrfToken; 78 csrfToken = WebAuthn.#firstInputWithCsrfToken; 79 } else{ 80 // Check if it is a CSRF or XSRF token 81 if (xcsrfToken.length === 40) { 82 csrfToken = xcsrfToken; 83 } else if (xcsrfToken.length === 224) { 84 xsrfToken = xcsrfToken; 85 } else { 86 throw new TypeError('CSRF token or XSRF token provided does not match requirements. Must be 40 or 224 characters.'); 87 } 88 } 89 90 if (xsrfToken !== null) { 91 this.#headers["X-XSRF-TOKEN"] ??= xsrfToken; 92 } else if (csrfToken !== null) { 93 this.#headers["X-CSRF-TOKEN"] ??= csrfToken; 94 } else { 95 // We didn't find it, and since is required, we will bail out. 96 throw new TypeError('Ensure a CSRF/XSRF token is manually set, or provided in a cookie "XSRF-TOKEN" or or there is meta tag named "csrf-token".'); 97 } 98 } 99 100 /** 101 * Returns the CSRF token if it exists as a form input tag. 102 * 103 * @returns string 104 * @throws TypeError 105 */ 106 static get #firstInputWithCsrfToken() { 107 // First, try finding an CSRF Token in the head. 108 let token = Array.from(document.head.getElementsByTagName("meta")) 109 .find(element => element.name === "csrf-token"); 110 111 if (token) { 112 return token.content; 113 } 114 115 // Then, try to find a hidden input containing the CSRF token. 116 token = Array.from(document.getElementsByTagName('input')) 117 .find(input => input.name === "_token" && input.type === "hidden") 118 119 if (token) { 120 return token.value; 121 } 122 123 return null; 124 } 125 126 /** 127 * Returns the value of the XSRF token if it exists in a cookie. 128 * 129 * Inspired by https://developer.mozilla.org/en-US/docs/Web/API/Document/cookie#example_2_get_a_sample_cookie_named_test2 130 *
131 * @returns {?string} 132 */ 133 static get #XsrfToken() { 134 const cookie = document.cookie.split(";").find((row) => /^\s*(X-)?[XC]SRF-TOKEN\s*=/.test(row)); 135 // We must remove all '%3D' from the end of the string. 136 // Background: 137 // The actual binary value of the CSFR value is encoded in Base64. 138 // If the length of original, binary value is not a multiple of 3 bytes, 139 // the encoding gets padded with `=` on the right; i.e. there might be 140 // zero, one or two `=` at the end of the encoded value. 141 // If the value is sent from the server to the client as part of a cookie, 142 // the `=` character is URL-encoded as `%3D`, because `=` is already used 143 // to separate a cookie key from its value. 144 // When we send back the value to the server as part of an AJAX request, 145 // Laravel expects an unpadded value. 146 // Hence, we must remove the `%3D`. 147 return cookie ? cookie.split("=")[1].trim().replaceAll("%3D", "") : null; 148 }; 149 150 /** 151 * Returns a fetch promise to resolve later. 152 * 153 * @param data {Object} 154 * @param route {string} 155 * @param headers {{string}} 156 * @returns {Promise<Response>} 157 */ 158 #fetch(data, route, headers = {}) { 159 const url = new URL(route, window.location.origin).href; 160 161 return fetch(url, { 162 method: "POST", 163 credentials: this.#includeCredentials ? "include" : "same-origin", 164 redirect: "error", 165 headers: {...this.#headers, ...headers}, 166 body: JSON.stringify(data) 167 }); 168 } 169 170 /** 171 * Decodes a BASE64 URL string into a normal string. 172 * 173 * @param input {string} 174 * @returns {string|Iterable} 175 */ 176 static #base64UrlDecode(input) { 177 input = input.replace(/-/g, "+").replace(/_/g, "/"); 178 179 const pad = input.length % 4; 180 181 if (pad) { 182 if (pad === 1) { 183 throw new Error("InvalidLengthError: Input base64url string is the wrong length to determine padding"); 184 } 185 186 input += new Array(5 - pad).join("="); 187 } 188 189 return atob(input); 190 } 191 192 /** 193 * Transform a string into Uint8Array instance. 194 * 195 * @param input {string} 196 * @param useAtob {boolean} 197 * @returns {Uint8Array} 198 */ 199 static #uint8Array(input, useAtob = false) { 200 return Uint8Array.from( 201 useAtob ? atob(input) : WebAuthn.#base64UrlDecode(input), c => c.charCodeAt(0) 202 ); 203 } 204 205 /** 206 * Encodes an array of bytes to a BASE64 URL string 207 * 208 * @param arrayBuffer {ArrayBuffer|Uint8Array} 209 * @returns {string} 210 */ 211 static #arrayToBase64String(arrayBuffer) { 212 return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); 213 } 214 215 /** 216 * Parses the Public Key Options received from the Server for the browser. 217 * 218 * @param publicKey {Object} 219 * @returns {Object} 220 */ 221 #parseIncomingServerOptions(publicKey) { 222 console.debug(publicKey); 223 224 publicKey.challenge = WebAuthn.#uint8Array(publicKey.challenge); 225 226 if ('user' in publicKey) { 227 publicKey.user = { 228 ...publicKey.user, 229 id: WebAuthn.#uint8Array(publicKey.user.id) 230 }; 231 } 232 233 [ 234 "excludeCredentials", 235 "allowCredentials" 236 ] 237 .filter(key => key in publicKey) 238 .forEach(key => { 239 publicKey[key] = publicKey[key].map(data => { 240 return {...data, id: WebAuthn.#uint8Array(data.id)}; 241 }); 242 }); 243 244 console.log(publicKey); 245 246 return publicKey; 247 } 248 249 /** 250 * Parses the outgoing credentials from the browser to the server. 251 * 252 * @param credentials {Credential|PublicKeyCredential} 253 * @return {{response: {string}, rawId: string, id: string, type: string}} 254 */ 255 #parseOutgoingCredentials(credentials) { 256 let parseCredentials = { 257 id: credentials.id, 258 type: credentials.type, 259 rawId: WebAuthn.#arrayToBase64String(credentials.rawId), 260 response: {} 261 }; 262 263 [ 264 "clientDataJSON", 265 "attestationObject", 266 "authenticatorData", 267 "signature", 268 "userHandle" 269 ] 270 .filter(key => key in credentials.response) 271 .forEach(key => parseCredentials.response[key] = WebAuthn.#arrayToBase64String(credentials.response[key])); 272 273 return parseCredentials; 274 } 275 276 /** 277 * Handles the response from the Server. 278 * 279 * Throws the entire response if is not OK (HTTP 2XX). 280 * 281 * @param response {Response} 282 * @returns Promise<JSON|ReadableStream> 283 * @throws Response 284 */ 285 static #handleResponse(response) { 286 if (!response.ok) { 287 throw response; 288 } 289 290 // Here we will do a small trick. Since most of the responses from the server 291 // are JSON, we will automatically parse the JSON body from the response. If 292 // it's not JSON, we will push the body verbatim and let the dev handle it. 293 return new Promise((resolve) => { 294 response 295 .json() 296 .then((json) => resolve(json)) 297 .catch(() => resolve(response.body)); 298 }); 299 } 300 301 /** 302 * Register the user credentials from the browser/device. 303 * 304 * You can add request input if you are planning to register a user with WebAuthn from scratch. 305 * 306 * @param request {{string}} 307 * @param response {{string}} 308 * @returns Promise<JSON|ReadableStream> 309 */ 310 async register(request = {}, response = {}) { 311 const optionsResponse = await this.#fetch(request, this.#routes.registerOptions); 312 const json = await optionsResponse.json(); 313 const publicKey = this.#parseIncomingServerOptions(json); 314 const credentials = await navigator.credentials.create({publicKey}); 315 const publicKeyCredential = this.#parseOutgoingCredentials(credentials); 316 317 Object.assign(publicKeyCredential, response); 318 Object.assign(publicKeyCredential, request); 319 320 return await this.#fetch(publicKeyCredential, this.#routes.register).then(WebAuthn.#handleResponse); 321 } 322 323 /** 324 * Log in a user with his credentials. 325 * 326 * If no credentials are given, the app may return a blank assertion for userless login. 327 * 328 * @param request {{string}} 329 * @param response {{string}} 330 * @returns Promise<JSON|ReadableStream> 331 */ 332 async login(request = {}, response = {}) { 333 const optionsResponse = await this.#fetch(request, this.#routes.loginOptions); 334 const json = await optionsResponse.json(); 335 const publicKey = this.#parseIncomingServerOptions(json); 336 const credentials = await navigator.credentials.get({publicKey}) 337 .catch((err) => { 338 if (err.name === 'NotAllowedError') { 339 document.getElementById("alert").innerHTML = 'This device has not a MeetYou registered account.'; 340 } else { 341 342 } 343 }); 344 const publicKeyCredential = this.#parseOutgoingCredentials(credentials); 345 346 Object.assign(publicKeyCredential, response); 347 348 console.log(publicKeyCredential); 349 350 return await this.#fetch(publicKeyCredential, this.#routes.login, response).then(WebAuthn.#handleResponse); 351 } 352 353 /** 354 * Checks if the browser supports WebAuthn. 355 * 356 * @returns {boolean} 357 */
358 static supportsWebAuthn() { 359 return typeof PublicKeyCredential != "undefined"; 360 } 361 362 /** 363 * Checks if the browser doesn't support WebAuthn. 364 * 365 * @returns {boolean} 366 */ 367 static doesntSupportWebAuthn() { 368 return !this.supportsWebAuthn(); 369 } 370}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.