├── .firebaserc ├── .gcloudignore ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── app.yaml ├── dev.yaml ├── firebase.json ├── firestore.indexes.json ├── firestore.rules ├── package-lock.json ├── package.json ├── rollup.config.js └── src ├── .env.development ├── libs ├── auth.mts ├── config.mts ├── credential.mts ├── helper.mts └── webauthn.mts ├── public ├── favicon.svg ├── scripts │ ├── base64url.ts │ ├── common.ts │ ├── components.ts │ ├── main.ts │ └── util.ts ├── styles │ ├── style.js │ └── style.scss ├── tsconfig.json └── user.svg ├── server.mts ├── templates ├── index.html ├── layouts │ └── main.html └── partials │ ├── footer.html │ ├── head.html │ ├── sidebar.html │ └── top-app-bar.html ├── tsconfig.json └── types.d.ts /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "try-webauthn" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gcloudignore: -------------------------------------------------------------------------------- 1 | # This file specifies files that are *not* uploaded to Google Cloud Platform 2 | # using gcloud. It follows the same syntax as .gitignore, with the addition of 3 | # "#!include" directives (which insert the entries of the given .gitignore-style 4 | # file at that point). 5 | # 6 | # For more information, run: 7 | # $ gcloud topic gcloudignore 8 | # 9 | .gcloudignore 10 | # If you would like to upload your .git directory, .gitignore file or files 11 | # from your .gitignore file, remove the corresponding line 12 | # below: 13 | .git 14 | .gitignore 15 | 16 | # Node.js dependencies: 17 | node_modules/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules 4 | .env 5 | .data 6 | .log 7 | service-account.json 8 | .vscode 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution, 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult 22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 23 | information on using pull requests. 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebAuthnDemo 2 | 3 | An example TypeScript Relying Party implementation of the [WebAuthn 4 | specification](https://w3c.github.io/webauthn/). 5 | 6 | ## Install 7 | 8 | Checkout the repository, then install. 9 | 10 | ```sh 11 | $ npm install 12 | ``` 13 | 14 | ## Build 15 | 16 | Bulid the project. 17 | 18 | ```sh 19 | $ npm run build 20 | ``` 21 | 22 | ## Start a local server 23 | 24 | Run the Firestore emulator: 25 | 26 | ```sh 27 | $ npm run emulator 28 | ``` 29 | 30 | Run the server: 31 | 32 | ```sh 33 | $ npm run dev 34 | ``` 35 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | runtime: nodejs20 2 | env_variables: 3 | NODE_ENV: production 4 | PROJECT_NAME: "WebAuthn Demo" 5 | GOOGLE_CLOUD_PROJECT: try-webauthn 6 | handlers: 7 | - url: /.* 8 | secure: always 9 | redirect_http_response_code: 301 10 | script: auto 11 | -------------------------------------------------------------------------------- /dev.yaml: -------------------------------------------------------------------------------- 1 | runtime: nodejs20 2 | service: dev 3 | env_variables: 4 | NODE_ENV: development 5 | PROJECT_NAME: "WebAuthn Demo" 6 | GOOGLE_CLOUD_PROJECT: try-webauthn 7 | handlers: 8 | - url: /.* 9 | secure: always 10 | redirect_http_response_code: 301 11 | script: auto 12 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "firestore": { 3 | "rules": "firestore.rules", 4 | "indexes": "firestore.indexes.json" 5 | }, 6 | "emulators": { 7 | "firestore": { 8 | "host": "localhost", 9 | "port": 8081 10 | }, 11 | "ui": { 12 | "enabled": true, 13 | "port": 4000 14 | }, 15 | "auth": { 16 | "host": "127.0.0.1", 17 | "port": 9099 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | "indexes": [], 3 | "fieldOverrides": [] 4 | } 5 | -------------------------------------------------------------------------------- /firestore.rules: -------------------------------------------------------------------------------- 1 | service cloud.firestore { 2 | match /databases/{database}/documents { 3 | match /{document=**} { 4 | // This rule allows anyone with your database reference to view, edit, 5 | // and delete all data in your database. It is useful for getting 6 | // started, but it is configured to expire after 30 days because it 7 | // leaves your app open to attackers. At that time, all client 8 | // requests to your database will be denied. 9 | // 10 | // Make sure to write security rules for your app before that time, or 11 | // else all client requests to your database will be denied until you 12 | // update your rules. 13 | allow read, write: if request.time < timestamp.date(2022, 1, 12); 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webauthndemo", 3 | "version": "2.2.0", 4 | "description": "An example JavaScript Relying Party implementation of the WebAuthn specification.", 5 | "main": "dist/server.mjs", 6 | "engines": { 7 | "node": ">=20.x" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1", 11 | "clean": "rimraf ./dist", 12 | "build": "npm run clean && rollup -c && tsc --project ./src/tsconfig.json", 13 | "build:prod": "npm run clean && rollup -c --environment NODE_ENV:production && tsc --project ./src/tsconfig.json", 14 | "deploy": "gcloud app deploy dev.yaml --project=try-webauthn", 15 | "deploy:prod": "gcloud app deploy app.yaml --project=try-webauthn", 16 | "gcp-build": "npm run build:prod", 17 | "dev": "NODE_ENV=localhost node dist/server.mjs", 18 | "emulator": "firebase emulators:start --only firestore,auth --project try-webauthn --import=./.data --export-on-exit", 19 | "start": "node dist/server.mjs" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "git+https://github.com/google/webauthndemo.git" 24 | }, 25 | "keywords": [ 26 | "WebAuthn", 27 | "FIDO2" 28 | ], 29 | "author": "", 30 | "license": "Apache-2.0", 31 | "bugs": { 32 | "url": "https://github.com/google/webauthndemo/issues" 33 | }, 34 | "homepage": "https://github.com/google/webauthndemo#readme", 35 | "type": "module", 36 | "dependencies": { 37 | "@alenaksu/json-viewer": "github:alenaksu/json-viewer", 38 | "@google-cloud/connect-firestore": "^3.0.0", 39 | "@material/card": "^14.0.0", 40 | "@material/mwc-button": "^0.27.0", 41 | "@material/mwc-checkbox": "^0.27.0", 42 | "@material/mwc-dialog": "^0.27.0", 43 | "@material/mwc-drawer": "^0.27.0", 44 | "@material/mwc-formfield": "^0.27.0", 45 | "@material/mwc-icon-button": "^0.27.0", 46 | "@material/mwc-linear-progress": "^0.27.0", 47 | "@material/mwc-list": "^0.27.0", 48 | "@material/mwc-select": "^0.27.0", 49 | "@material/mwc-snackbar": "^0.27.0", 50 | "@material/mwc-switch": "^0.27.0", 51 | "@material/mwc-textfield": "^0.27.0", 52 | "@material/mwc-top-app-bar-fixed": "^0.27.0", 53 | "@simplewebauthn/server": "^10.0.1", 54 | "aaguid": "git+https://github.com/agektmr/passkey-authenticator-aaguids.git", 55 | "cbor": "^9.0.2", 56 | "dotenv": "^16.4.7", 57 | "express": "^4.21.2", 58 | "express-handlebars": "^8.0.1", 59 | "express-session": "^1.18.1", 60 | "express-useragent": "^1.0.15", 61 | "express-validator": "^7.2.1", 62 | "firebase": "^10.14.1", 63 | "firebase-admin": "^12.7.0", 64 | "firebaseui": "^6.1.0", 65 | "helmet": "^7.2.0", 66 | "lit": "^2.8.0", 67 | "uid-safe": "^2.1.5" 68 | }, 69 | "devDependencies": { 70 | "@rollup/plugin-commonjs": "^26.0.3", 71 | "@rollup/plugin-json": "^6.1.0", 72 | "@rollup/plugin-node-resolve": "^15.3.1", 73 | "@rollup/plugin-typescript": "^11.1.6", 74 | "@simplewebauthn/types": "^10.0.0", 75 | "@types/express": "^4.17.21", 76 | "@types/express-handlebars": "^5.3.1", 77 | "@types/express-session": "^1.18.1", 78 | "@types/express-useragent": "^1.0.5", 79 | "@types/node": "^22.10.5", 80 | "@types/rollup-plugin-node-builtins": "^2.1.5", 81 | "@types/rollup-plugin-node-globals": "^1.4.5", 82 | "@types/uid-safe": "^2.1.5", 83 | "firebase-tools": "^13.29.1", 84 | "rimraf": "^6.0.1", 85 | "rollup": "^4.29.1", 86 | "rollup-plugin-copy": "^3.5.0", 87 | "rollup-plugin-import-css": "^3.5.8", 88 | "rollup-plugin-node-builtins": "^2.1.2", 89 | "rollup-plugin-node-globals": "^1.4.0", 90 | "rollup-plugin-scss": "^4.0.1", 91 | "rollup-plugin-sourcemaps": "^0.6.3", 92 | "sass": "^1.83.0", 93 | "typescript": "^5.7.2" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import path from 'path'; 18 | import url from 'url'; 19 | import typescript from '@rollup/plugin-typescript'; 20 | import commonjs from '@rollup/plugin-commonjs'; 21 | import nodeResolve from '@rollup/plugin-node-resolve'; 22 | import json from '@rollup/plugin-json'; 23 | import builtins from 'rollup-plugin-node-builtins'; 24 | import globals from 'rollup-plugin-node-globals'; 25 | import copy from 'rollup-plugin-copy'; 26 | import scss from 'rollup-plugin-scss'; 27 | import css from 'rollup-plugin-import-css'; 28 | import sourcemaps from 'rollup-plugin-sourcemaps'; 29 | 30 | const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); 31 | 32 | const serverSrc = path.join(__dirname, 'src'); 33 | const clientSrc = path.join(__dirname, 'src', 'public'); 34 | const dstRoot = path.join(__dirname, 'dist'); 35 | const clientDst = path.join(dstRoot, 'public'); 36 | 37 | export default () => { 38 | const sourcemap = process.env.NODE_ENV != 'production' ? 'inline' : false; 39 | const env = process.env.NODE_ENV != 'production' ? '.env.development' : '.env'; 40 | 41 | const plugins = [ 42 | typescript({ 43 | sourceMap: true, 44 | inlineSources: true, 45 | tsconfig: path.join(clientSrc, 'tsconfig.json'), 46 | }), 47 | commonjs({ extensions: ['.js', '.ts', '.mts'] }), 48 | nodeResolve({ 49 | browser: true, 50 | preferBuiltins: false 51 | }), 52 | builtins(), 53 | globals(), 54 | json(), 55 | sourcemaps(), 56 | ]; 57 | 58 | const files = [ 'components' ]; 59 | const config = files.map(fileName => { 60 | return { 61 | input: path.join(clientSrc, 'scripts', `${fileName}.ts`), 62 | output: { 63 | file: path.join(clientDst, 'scripts', `${fileName}.js`), 64 | format: 'es', 65 | sourcemap, 66 | }, 67 | plugins 68 | }; 69 | }); 70 | return [ ...config, { 71 | input: path.join(clientSrc, 'scripts', 'main.ts'), 72 | output: { 73 | file: path.join(clientDst, 'scripts', 'main.js'), 74 | format: 'es', 75 | sourcemap, 76 | }, 77 | plugins: [ 78 | ...plugins, 79 | copy({ 80 | targets: [{ 81 | src: 'firebase.json', 82 | dest: dstRoot, 83 | }, { 84 | src: path.join(clientSrc, '*.svg'), 85 | dest: clientDst, 86 | }, { 87 | src: path.join(clientSrc, 'manifest.json'), 88 | dest: clientDst, 89 | }, { 90 | src: path.join(serverSrc, 'templates', '*'), 91 | dest: path.join(dstRoot, 'templates'), 92 | }, { 93 | src: path.join(serverSrc, env), 94 | dest: dstRoot, 95 | rename: '.env' 96 | }] 97 | }), 98 | ] 99 | }, { 100 | input: path.join(clientSrc, 'styles', 'style.js'), 101 | output: { 102 | file: path.join(clientDst, 'styles', 'style.js'), 103 | format: 'esm', 104 | assetFileNames: '[name][extname]', 105 | }, 106 | plugins: [ 107 | scss({ 108 | include: [ 109 | path.join(clientSrc, 'styles', '*.css'), 110 | path.join(clientSrc, 'styles', '*.scss'), 111 | './node_modules/**/*.*' 112 | ], 113 | name: 'style.css', 114 | outputStyle: 'compressed', 115 | }), 116 | nodeResolve({ 117 | browser: true, 118 | preferBuiltins: false 119 | }), 120 | css(), 121 | ] 122 | }]; 123 | }; 124 | 125 | -------------------------------------------------------------------------------- /src/.env.development: -------------------------------------------------------------------------------- 1 | SECRET='this is a secret' 2 | ORIGIN='https://dev-dot-try-webauthn.appspot.com' 3 | ANDROID_PACKAGENAME='com.fido.example.fido2apiexample,com.google.android.gms.auth.api.identity.onetaptestapp,com.google.android.gms.auth.api.identity.onetaptestapp.onetap_third_party_test_app,com.fido.testapp.fido3p' 4 | ANDROID_SHA256HASH='83:1E:EC:AB:FA:71:87:18:6B:21:07:4B:C9:F1:B4:A7:12:B0:88:9E:E1:3A:4D:83:25:0E:31:BC:A7:78:DF:C4,19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00,AF:95:EE:FC:36:FC:BD:11:4D:2B:82:7B:52:DA:48:73:5D:1F:0E:B2:F0:3E:2D:0E:2D:CF:74:EC:34:6B:61:0E,19:75:B2:F1:71:77:BC:89:A5:DF:F3:1F:9E:64:A6:CA:E2:81:A5:3D:C1:D1:D5:9B:1D:14:7F:E1:C8:2A:FA:00' -------------------------------------------------------------------------------- /src/libs/auth.mts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { config } from './config.mjs'; 18 | import express, { Request, Response } from 'express'; 19 | import { getAuth } from 'firebase-admin/auth'; 20 | import { UserInfo } from '../public/scripts/common'; 21 | 22 | const auth = getAuth(); 23 | const router = express.Router(); 24 | 25 | router.post('/userInfo', async (req: Request, res: Response) => { 26 | if (req.session.user_id) { 27 | const user = { 28 | user_id: req.session.user_id, 29 | name: req.session.name, 30 | displayName: req.session.displayName, 31 | picture: req.session.picture 32 | } as UserInfo; 33 | return res.json(user); 34 | } else { 35 | return res.status(401).send('Unauthorized'); 36 | } 37 | }); 38 | 39 | router.post('/verify', async (req: Request, res: Response) => { 40 | const { id_token } = req.body; 41 | 42 | try { 43 | const result = await auth.verifyIdToken(id_token as string, true); 44 | if (result) { 45 | console.log(result); 46 | req.session.user_id = result.user_id; 47 | req.session.name = result.email; 48 | req.session.displayName = result.name; 49 | req.session.picture = result.picture || `${config.origin}/user.svg`; 50 | return res.json({ 51 | user_id: req.session.user_id, 52 | name: req.session.name, 53 | displayName: req.session.displayName, 54 | picture: req.session.picture 55 | } as UserInfo); 56 | } else { 57 | throw new Error('Verification failed.'); 58 | } 59 | } catch (e) { 60 | console.error(e); 61 | return res.status(400).json({ 62 | status: false, 63 | message: 'Verification failed.' 64 | }); 65 | } 66 | }); 67 | 68 | router.post('/signout', (req: Request, res: Response) => { 69 | req.session.destroy(error => { 70 | if (error) { 71 | return res.status(500).send(error); 72 | } else { 73 | return res.json({ 74 | status: true, 75 | message: 'Successfully signed out.' 76 | }); 77 | } 78 | }); 79 | }); 80 | 81 | export { router as auth }; 82 | -------------------------------------------------------------------------------- /src/libs/config.mts: -------------------------------------------------------------------------------- 1 | /* 2 | * @license 3 | * Copyright 2024 Google Inc. All rights reserved. 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * https://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License 16 | */ 17 | 18 | import url from 'url'; 19 | import path from 'path'; 20 | import crypto from 'crypto'; 21 | 22 | import dotenv from 'dotenv'; 23 | 24 | import session from 'express-session'; 25 | import { initializeApp } from 'firebase-admin/app'; 26 | import { getFirestore } from 'firebase-admin/firestore'; 27 | import { FirestoreStore } from '@google-cloud/connect-firestore'; 28 | 29 | import packageConfig from '../../package.json' with { type: 'json' }; 30 | import firebaseConfig from '../../firebase.json' with { type: 'json' }; 31 | 32 | const is_localhost = 33 | process.env.NODE_ENV === 'localhost' || !process.env.NODE_ENV; 34 | 35 | /** 36 | * During development, the server application only receives requests proxied 37 | * from the frontend tooling (e.g. Vite). This is because the frontend tooling 38 | * is responsible for serving the frontend application during development, to 39 | * enable hot module reloading and other development features. 40 | */ 41 | const is_development_proxy = process.env.PROXY; 42 | 43 | const project_root_file_path = path.join( 44 | url.fileURLToPath(import.meta.url), 45 | '../../..' 46 | ); 47 | const dist_root_file_path = path.join(project_root_file_path, 'dist'); 48 | 49 | console.log('Reading config from', path.join(dist_root_file_path, '/.env')); 50 | dotenv.config({path: path.join(dist_root_file_path, '/.env')}); 51 | 52 | if (is_localhost) { 53 | process.env.FIRESTORE_EMULATOR_HOST = `${firebaseConfig.emulators.firestore.host}:${firebaseConfig.emulators.firestore.port}`; 54 | process.env.FIREBASE_AUTH_EMULATOR_HOST = `${firebaseConfig.emulators.auth.host}:${firebaseConfig.emulators.auth.port}`; 55 | } 56 | 57 | initializeApp({ 58 | projectId: process.env.GOOGLE_CLOUD_PROJECT || 'try-webauthn', 59 | }); 60 | 61 | export const store = getFirestore(process.env.FIRESTORE_DATABASENAME || ''); 62 | store.settings({ignoreUndefinedProperties: true}); 63 | 64 | export function initializeSession() { 65 | let session_name; 66 | if (is_localhost) { 67 | session_name = process.env.SESSION_STORE_NAME || 'session'; 68 | } else { 69 | session_name = `__Host-${process.env.SESSION_STORE_NAME || 'session'}`; 70 | } 71 | 72 | return session({ 73 | name: session_name, 74 | secret: process.env.SECRET || 'secret', 75 | resave: false, 76 | saveUninitialized: false, 77 | proxy: true, 78 | store: new FirestoreStore({ 79 | dataset: store, 80 | kind: 'express-sessions', 81 | }), 82 | cookie: { 83 | secure: !is_localhost, 84 | path: '/', 85 | sameSite: 'strict', 86 | httpOnly: true, 87 | maxAge: 1000 * 60 * 60 * 24 * 365, // 1 year 88 | } 89 | }); 90 | } 91 | 92 | function configureApp() { 93 | const localhost = `http://localhost:${process.env.PORT || 8080}`; 94 | const origin = is_localhost ? localhost : process.env.ORIGIN || localhost; 95 | const project_name = process.env.PROJECT_NAME || 'try-webauthn'; 96 | 97 | return { 98 | project_name, 99 | debug: is_localhost || process.env.NODE_ENV === 'development', 100 | project_root_file_path, 101 | dist_root_file_path, 102 | views_root_file_path: path.join(dist_root_file_path, 'templates'), 103 | is_localhost, 104 | port: is_development_proxy ? 8080 : process.env.PORT || 8080, 105 | origin, 106 | secret: process.env.SECRET || crypto.randomBytes(32).toString('hex'), 107 | hostname: new URL(origin).hostname, 108 | title: project_name, 109 | repository_url: packageConfig.repository?.url, 110 | id_token_lifetime: parseInt( 111 | process.env.ID_TOKEN_LIFETIME || `${1 * 24 * 60 * 60 * 1000}` 112 | ), 113 | forever_cookie_duration: 1000 * 60 * 60 * 24 * 365, 114 | short_session_duration: parseInt( 115 | process.env.SHORT_SESSION_DURATION || `${3 * 60 * 1000}` 116 | ), 117 | long_session_duration: parseInt( 118 | process.env.LONG_SESSION_DURATION || `${1000 * 60 * 60 * 24 * 365}` 119 | ), 120 | }; 121 | } 122 | 123 | export const config = configureApp(); 124 | -------------------------------------------------------------------------------- /src/libs/credential.mts: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { store } from './config.mjs'; 18 | import { 19 | user_id, 20 | credential_id, 21 | StoredCredential, 22 | } from '../public/scripts/common'; 23 | 24 | export async function getCredentials( 25 | user_id: user_id 26 | ): Promise { 27 | const results: StoredCredential[] = []; 28 | const refs = await store.collection('credentials') 29 | .where('user_id', '==', user_id) 30 | .orderBy('registered', 'desc').get(); 31 | refs.forEach(cred => results.push(cred.data() as StoredCredential)); 32 | return results; 33 | }; 34 | 35 | export async function getCredential( 36 | credential_id: credential_id 37 | ): Promise { 38 | const doc = await store.collection('credentials').doc(credential_id).get(); 39 | const credential = doc.data() as StoredCredential; 40 | return credential; 41 | } 42 | 43 | export function storeCredential( 44 | credential: StoredCredential 45 | ): Promise { 46 | const ref = store.collection('credentials').doc(credential.credentialID); 47 | return ref.set(credential); 48 | } 49 | 50 | export async function removeCredential( 51 | credential_id: credential_id 52 | ): Promise { 53 | const ref = store.collection('credentials').doc(credential_id); 54 | return ref.delete(); 55 | } 56 | -------------------------------------------------------------------------------- /src/libs/helper.mts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { config } from './config.mjs'; 18 | import { Request, Response, NextFunction } from 'express'; 19 | import { UserInfo } from '../public/scripts/common'; 20 | 21 | const getNow = () => new Date().getTime(); 22 | 23 | /** 24 | * Checks CSRF protection using custom header `X-Requested-With` 25 | * If cookie doesn't contain `username`, consider the user is not authenticated. 26 | **/ 27 | const csrfCheck = ( 28 | req: Request, 29 | res: Response, 30 | next: NextFunction 31 | ) => { 32 | if (req.header("X-Requested-With") != "XMLHttpRequest") { 33 | res.status(400).json({ error: "invalid access." }); 34 | return; 35 | } 36 | next(); 37 | }; 38 | 39 | /** 40 | * Middleware that checks authorization status required to access this API. 41 | * Rejects if insufficient. 42 | * @param {number} requirement The authorization requirement to access this API. 43 | * @returns 44 | */ 45 | const authzAPI = async ( 46 | req: Request, 47 | res: Response, 48 | next: NextFunction 49 | ): Promise => { 50 | const { user_id, name, displayName, picture } = req.session; 51 | 52 | if (config.debug) { 53 | console.log('Session:', req.session); 54 | } 55 | if (!user_id) { 56 | // When a non-signed-in user is trying to access. 57 | return res.status(401).json({ error: 'User not signed in.' }); 58 | } 59 | 60 | res.locals.user = {user_id, name, displayName, picture } as UserInfo; 61 | if (config.debug) { 62 | console.log('User:', res.locals.user); 63 | } 64 | return next(); 65 | }; 66 | 67 | export { csrfCheck, authzAPI, getNow }; 68 | -------------------------------------------------------------------------------- /src/libs/webauthn.mts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { config } from './config.mjs'; 18 | import express, { Request, Response } from 'express'; 19 | import { 20 | WebAuthnRegistrationObject, 21 | WebAuthnAuthenticationObject, 22 | AAGUIDs, 23 | AAGUID, 24 | } from '../public/scripts/common'; 25 | import { createHash } from 'crypto'; 26 | import { getNow, csrfCheck, authzAPI } from './helper.mjs'; 27 | import { 28 | getCredentials, 29 | removeCredential, 30 | storeCredential, 31 | } from './credential.mjs'; 32 | import { 33 | generateAuthenticationOptions, 34 | generateRegistrationOptions, 35 | verifyAuthenticationResponse, 36 | verifyRegistrationResponse, 37 | } from '@simplewebauthn/server'; 38 | import {isoBase64URL} from '@simplewebauthn/server/helpers'; 39 | import { 40 | AuthenticationResponseJSON, 41 | RegistrationResponseJSON, 42 | AuthenticatorSelectionCriteria, 43 | AuthenticatorDevice, 44 | AttestationConveyancePreference, 45 | PublicKeyCredentialParameters, 46 | PublicKeyCredentialUserEntityJSON, 47 | } from '@simplewebauthn/types'; 48 | 49 | import aaguids from 'aaguid' with { type: 'json' }; 50 | 51 | const router = express.Router(); 52 | 53 | const RP_NAME = process.env.PROJECT_NAME || 'WebAuthn Demo'; 54 | const WEBAUTHN_TIMEOUT = 1000 * 60 * 5; // 5 minutes 55 | 56 | export const getOrigin = ( 57 | _origin: string, 58 | userAgent?: string 59 | ): string => { 60 | let origin = _origin; 61 | if (!userAgent) return origin; 62 | 63 | const appRe = /^[a-zA-z0-9_.]+/; 64 | const match = userAgent.match(appRe); 65 | if (match) { 66 | // Check if UserAgent comes from a supported Android app. 67 | if (process.env.ANDROID_PACKAGENAME && process.env.ANDROID_SHA256HASH) { 68 | const package_names = process.env.ANDROID_PACKAGENAME.split(",").map(name => name.trim()); 69 | const hashes = process.env.ANDROID_SHA256HASH.split(",").map(hash => hash.trim()); 70 | const appName = match[0]; 71 | for (let i = 0; i < package_names.length; i++) { 72 | if (appName === package_names[i]) { 73 | // We recognize this app, so use the corresponding hash. 74 | const octArray = hashes[i].split(':').map((h) => 75 | parseInt(h, 16), 76 | ); 77 | // @ts-ignore 78 | const androidHash = isoBase64URL.fromBuffer(octArray); 79 | origin = `android:apk-key-hash:${androidHash}`; 80 | break; 81 | } 82 | } 83 | } 84 | } 85 | 86 | return origin; 87 | } 88 | 89 | router.get('/aaguids', (req: Request, res: Response): Response => { 90 | if (Object.keys(aaguids).length === 0) { 91 | return res.json(); 92 | } 93 | if (req.query.id) { 94 | const id = req.query.id as string; 95 | if (Object.keys(aaguids).indexOf(id) > -1) { 96 | return res.json((aaguids as AAGUIDs)[id] as AAGUID); 97 | } 98 | return res.json({ 99 | name: 'Unknown', 100 | } as AAGUID); 101 | } 102 | return res.json(aaguids); 103 | }); 104 | 105 | /** 106 | * Returns a list of credentials 107 | **/ 108 | router.post('/getCredentials', csrfCheck, authzAPI, async ( 109 | req: Request, 110 | res: Response 111 | ): Promise => { 112 | if (!res.locals.user) throw 'Unauthorized.'; 113 | 114 | const user = res.locals.user; 115 | 116 | try { 117 | const credentials = await getCredentials(user.user_id); 118 | return res.json(credentials); 119 | } catch (error) { 120 | console.error(error); 121 | return res.status(401).json({ 122 | status: false, 123 | error: 'Unauthorized' 124 | }); 125 | } 126 | }); 127 | 128 | /** 129 | * Removes a credential id attached to the user 130 | * Responds with empty JSON `{}` 131 | **/ 132 | router.post('/removeCredential', csrfCheck, authzAPI, async ( 133 | req: Request, 134 | res: Response 135 | ): Promise => { 136 | if (!res.locals.user) throw 'Unauthorized.'; 137 | 138 | const { credId } = req.body; 139 | 140 | try { 141 | await removeCredential(credId); 142 | return res.json({ 143 | status: true 144 | }); 145 | } catch (error) { 146 | console.error(error); 147 | return res.status(400).json({ 148 | status: false 149 | }); 150 | } 151 | }); 152 | 153 | router.post('/registerRequest', csrfCheck, authzAPI, async ( 154 | req: Request, 155 | res: Response 156 | ): Promise => { 157 | try { 158 | if (!res.locals.user) throw new Error('Unauthorized.'); 159 | 160 | const googleUser = res.locals.user; 161 | const creationOptions = req.body as WebAuthnRegistrationObject || {}; 162 | 163 | // const excludeCredentials: PublicKeyCredentialDescriptor[] = []; 164 | // if (creationOptions.credentialsToExclude) { 165 | // const credentials = await getCredentials(googleUser.user_id); 166 | // if (credentials.length > 0) { 167 | // for (let cred of credentials) { 168 | // if (creationOptions.credentialsToExclude.includes(`ID-${cred.credentialID}`)) { 169 | // excludeCredentials.push({ 170 | // id: base64url.toBuffer(cred.credentialID), 171 | // type: 'public-key', 172 | // transports: cred.transports, 173 | // }); 174 | // } 175 | // } 176 | // } 177 | // } 178 | const pubKeyCredParams: PublicKeyCredentialParameters[] = []; 179 | // const params = [-7, -35, -36, -257, -258, -259, -37, -38, -39, -8]; 180 | const params = [-7, -257]; 181 | for (let param of params) { 182 | pubKeyCredParams.push({ type: 'public-key', alg: param }); 183 | } 184 | const authenticatorSelection: AuthenticatorSelectionCriteria = {}; 185 | const aa = creationOptions.authenticatorSelection?.authenticatorAttachment; 186 | const rk = creationOptions.authenticatorSelection?.residentKey; 187 | const uv = creationOptions.authenticatorSelection?.userVerification; 188 | const cp = creationOptions.attestation; // attestationConveyancePreference 189 | let attestation: AttestationConveyancePreference = 'none'; 190 | 191 | if (aa === 'platform' || aa === 'cross-platform') { 192 | authenticatorSelection.authenticatorAttachment = aa; 193 | } 194 | const enrollmentType = aa || 'undefined'; 195 | if (rk === 'required' || rk === 'preferred' || rk === 'discouraged') { 196 | authenticatorSelection.residentKey = rk; 197 | } 198 | if (uv === 'required' || uv === 'preferred' || uv === 'discouraged') { 199 | authenticatorSelection.userVerification = uv; 200 | } 201 | if (cp === 'none' || cp === 'indirect' || cp === 'direct' || cp === 'enterprise') { 202 | attestation = cp; 203 | } 204 | 205 | const encoder = new TextEncoder(); 206 | const name = creationOptions.user?.name || googleUser.name || 'Unnamed User'; 207 | const displayName = creationOptions.user?.displayName || googleUser.displayName || 'Unnamed User'; 208 | const data = encoder.encode(`${name}${displayName}`) 209 | const userId = createHash('sha256').update(data).digest(); 210 | 211 | const user = { 212 | id: isoBase64URL.fromBuffer(Buffer.from(userId)), 213 | name, 214 | displayName 215 | } as PublicKeyCredentialUserEntityJSON 216 | 217 | // TODO: Validate 218 | const extensions = creationOptions.extensions; 219 | const timeout = creationOptions.customTimeout || WEBAUTHN_TIMEOUT; 220 | 221 | const options = await generateRegistrationOptions({ 222 | rpName: RP_NAME, 223 | rpID: config.hostname, 224 | userID: userId, 225 | userName: user.name, 226 | userDisplayName: user.displayName, 227 | timeout, 228 | // Prompt users for additional information about the authenticator. 229 | attestationType: attestation, 230 | // Prevent users from re-registering existing authenticators 231 | // excludeCredentials, 232 | authenticatorSelection, 233 | extensions, 234 | }); 235 | 236 | req.session.challenge = options.challenge; 237 | req.session.timeout = getNow() + WEBAUTHN_TIMEOUT; 238 | req.session.type = enrollmentType; 239 | 240 | return res.json(options); 241 | } catch (error: any) { 242 | console.error(error); 243 | return res.status(400).send({ status: false, error: error.message }); 244 | } 245 | }); 246 | 247 | router.post('/registerResponse', csrfCheck, authzAPI, async ( 248 | req: Request, 249 | res: Response 250 | ): Promise => { 251 | try { 252 | if (!res.locals.user) throw new Error('Unauthorized.'); 253 | if (!req.session.challenge) throw new Error('No challenge found.'); 254 | 255 | const user = res.locals.user; 256 | const credential = req.body as RegistrationResponseJSON; 257 | 258 | const expectedChallenge = req.session.challenge; 259 | const expectedRPID = config.hostname; 260 | 261 | let expectedOrigin = getOrigin(config.origin, req.get('User-Agent')); 262 | 263 | const verification = await verifyRegistrationResponse({ 264 | response: credential, 265 | expectedChallenge, 266 | expectedOrigin, 267 | expectedRPID, 268 | // Since this is testing the client, verifying the UV flag here doesn't matter. 269 | requireUserVerification: false, 270 | }); 271 | 272 | const { verified, registrationInfo } = verification; 273 | 274 | if (!verified || !registrationInfo) { 275 | throw new Error('User verification failed.'); 276 | } 277 | 278 | const { 279 | aaguid, 280 | credentialPublicKey, 281 | credentialID, 282 | counter, 283 | credentialDeviceType, 284 | credentialBackedUp, 285 | } = registrationInfo; 286 | const base64PublicKey = isoBase64URL.fromBuffer(credentialPublicKey); 287 | const { response, clientExtensionResults } = credential; 288 | const transports = response.transports || []; 289 | 290 | await storeCredential({ 291 | user_id: user.user_id, 292 | credentialID, 293 | credentialPublicKey: base64PublicKey, 294 | aaguid, 295 | counter, 296 | registered: getNow(), 297 | user_verifying: registrationInfo.userVerified, 298 | authenticatorAttachment: req.session.type || "undefined", 299 | credentialDeviceType, 300 | credentialBackedUp, 301 | browser: req.useragent?.browser, 302 | os: req.useragent?.os, 303 | platform: req.useragent?.platform, 304 | transports, 305 | clientExtensionResults, 306 | }); 307 | 308 | delete req.session.challenge; 309 | delete req.session.timeout; 310 | delete req.session.type; 311 | 312 | // Respond with user info 313 | return res.json(credential); 314 | } catch (error: any) { 315 | console.error(error); 316 | 317 | delete req.session.challenge; 318 | delete req.session.timeout; 319 | delete req.session.type; 320 | 321 | return res.status(400).send({ status: false, error: error.message }); 322 | } 323 | }); 324 | 325 | router.post('/authRequest', csrfCheck, authzAPI, async ( 326 | req: Request, 327 | res: Response 328 | ): Promise => { 329 | if (!res.locals.user) throw new Error('Unauthorized.'); 330 | 331 | try { 332 | // const user = res.locals.user; 333 | 334 | const requestOptions = req.body as WebAuthnAuthenticationObject; 335 | 336 | const userVerification = requestOptions.userVerification || 'preferred'; 337 | const timeout = requestOptions.customTimeout || WEBAUTHN_TIMEOUT; 338 | // const allowCredentials: PublicKeyCredentialDescriptor[] = []; 339 | const extensions = requestOptions.extensions || {}; 340 | const rpID = config.hostname; 341 | 342 | // // If `.allowCredentials` is not defined, leave `allowCredentials` an empty array. 343 | // if (requestOptions.allowCredentials) { 344 | // const credentials = await getCredentials(user.user_id); 345 | // for (let cred of credentials) { 346 | // // Find the credential in the list of allowed credentials. 347 | // const _cred = requestOptions.allowCredentials.find(_cred => { 348 | // return _cred.id == cred.credentialID; 349 | // }); 350 | // // If the credential is found, add it to the list of allowed credentials. 351 | // if (_cred) { 352 | // allowCredentials.push({ 353 | // id: base64url.toBuffer(_cred.id), 354 | // type: 'public-key', 355 | // transports: _cred.transports 356 | // }); 357 | // } 358 | // } 359 | // } 360 | 361 | const options = await generateAuthenticationOptions({ 362 | timeout, 363 | // allowCredentials, 364 | userVerification, 365 | rpID, 366 | extensions, 367 | }); 368 | 369 | req.session.challenge = options.challenge; 370 | req.session.timeout = getNow() + WEBAUTHN_TIMEOUT; 371 | 372 | return res.json(options); 373 | } catch (error: any) { 374 | console.error(error); 375 | 376 | return res.status(400).json({ status: false, error: error.message }); 377 | } 378 | }); 379 | 380 | router.post('/authResponse', csrfCheck, authzAPI, async ( 381 | req: Request, 382 | res: Response 383 | ): Promise => { 384 | if (!res.locals.user) throw new Error('Unauthorized.'); 385 | 386 | const user = res.locals.user; 387 | const expectedChallenge = req.session.challenge || ''; 388 | const expectedRPID = config.hostname; 389 | const expectedOrigin = getOrigin(config.origin, req.get('User-Agent')); 390 | 391 | try { 392 | const claimedCred = req.body as AuthenticationResponseJSON; 393 | 394 | const credentials = await getCredentials(user.user_id); 395 | let storedCred = credentials.find((cred) => cred.credentialID === claimedCred.id); 396 | 397 | if (!storedCred) { 398 | throw new Error('Authenticating credential not found.'); 399 | } 400 | 401 | const credentialPublicKey = isoBase64URL.toBuffer(storedCred.credentialPublicKey); 402 | const { counter, transports } = storedCred; 403 | 404 | const authenticator: AuthenticatorDevice = { 405 | credentialPublicKey, 406 | credentialID: storedCred.credentialID, 407 | counter, 408 | transports 409 | } 410 | 411 | console.log('Claimed credential', claimedCred); 412 | console.log('Stored credential', storedCred); 413 | 414 | const verification = await verifyAuthenticationResponse({ 415 | response: claimedCred, 416 | expectedChallenge, 417 | expectedOrigin, 418 | expectedRPID, 419 | authenticator, 420 | // Since this is testing the client, verifying the UV flag here doesn't matter. 421 | requireUserVerification: false, 422 | }); 423 | 424 | const { verified, authenticationInfo } = verification; 425 | 426 | if (!verified) { 427 | throw new Error('User verification failed.'); 428 | } 429 | 430 | storedCred.counter = authenticationInfo.newCounter; 431 | storedCred.last_used = getNow(); 432 | 433 | delete req.session.challenge; 434 | delete req.session.timeout; 435 | return res.json(storedCred); 436 | } catch (error: any) { 437 | console.error(error); 438 | 439 | delete req.session.challenge; 440 | delete req.session.timeout; 441 | return res.status(400).json({ status: false, error: error.message }); 442 | } 443 | }); 444 | 445 | export { router as webauthn }; 446 | -------------------------------------------------------------------------------- /src/public/favicon.svg: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /src/public/scripts/base64url.ts: -------------------------------------------------------------------------------- 1 | const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; 2 | 3 | // Use a lookup table to find the index. 4 | const lookup = new Uint8Array(256); 5 | for (let i = 0; i < chars.length; i++) { 6 | lookup[chars.charCodeAt(i)] = i; 7 | } 8 | 9 | const encode = function ( 10 | arraybuffer: ArrayBuffer 11 | ): string { 12 | const bytes = new Uint8Array(arraybuffer); 13 | const len = bytes.length; 14 | let base64 = ''; 15 | 16 | for (let i = 0; i < len; i += 3) { 17 | base64 += chars[bytes[i] >> 2]; 18 | base64 += chars[((bytes[i] & 3) << 4) | (bytes[i + 1] >> 4)]; 19 | base64 += chars[((bytes[i + 1] & 15) << 2) | (bytes[i + 2] >> 6)]; 20 | base64 += chars[bytes[i + 2] & 63]; 21 | } 22 | 23 | if (len % 3 === 2) { 24 | base64 = base64.substring(0, base64.length - 1); 25 | } else if (len % 3 === 1) { 26 | base64 = base64.substring(0, base64.length - 2); 27 | } 28 | 29 | return base64; 30 | }; 31 | 32 | const decode = function ( 33 | base64: string 34 | ): ArrayBuffer { 35 | const len = base64.length; 36 | const bufferLength = base64.length * 0.75; 37 | const arraybuffer = new ArrayBuffer(bufferLength); 38 | const bytes = new Uint8Array(arraybuffer); 39 | 40 | let p = 0; 41 | for (let i = 0; i < len; i += 4) { 42 | const encoded1 = lookup[base64.charCodeAt(i)]; 43 | const encoded2 = lookup[base64.charCodeAt(i + 1)]; 44 | const encoded3 = lookup[base64.charCodeAt(i + 2)]; 45 | const encoded4 = lookup[base64.charCodeAt(i + 3)]; 46 | 47 | bytes[p++] = (encoded1 << 2) | (encoded2 >> 4); 48 | bytes[p++] = ((encoded2 & 15) << 4) | (encoded3 >> 2); 49 | bytes[p++] = ((encoded3 & 3) << 6) | (encoded4 & 63); 50 | } 51 | 52 | return arraybuffer; 53 | }; 54 | 55 | const base64url = { encode, decode }; 56 | export { base64url }; 57 | -------------------------------------------------------------------------------- /src/public/scripts/common.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { 18 | CredentialDeviceType, 19 | PublicKeyCredentialCreationOptionsJSON, 20 | PublicKeyCredentialRequestOptionsJSON, 21 | AuthenticatorTransportFuture 22 | } from '@simplewebauthn/types'; 23 | 24 | export interface UserInfo { 25 | user_id: string 26 | name: string 27 | displayName: string 28 | picture: string 29 | } 30 | 31 | export interface WebAuthnRegistrationObject extends 32 | Omit { 33 | hints?: string[] 34 | credentialsToExclude?: string[] 35 | customTimeout?: number 36 | abortTimeout?: number 37 | } 38 | 39 | export interface WebAuthnAuthenticationObject extends Omit { 40 | hints?: string[] 41 | customTimeout?: number 42 | abortTimeout?: number 43 | } 44 | 45 | export type user_id = string; 46 | export type credential_id = string; 47 | 48 | export interface StoredCredential { 49 | user_id: user_id 50 | // User visible identifier. 51 | credentialID: credential_id // roaming authenticator's credential id, 52 | credentialPublicKey: string // public key, 53 | counter: number // previous counter, 54 | aaguid?: string // AAGUID, 55 | registered?: number // registered epoc time, 56 | user_verifying: boolean // user verifying authenticator, 57 | authenticatorAttachment: "platform" | "cross-platform" | "undefined" // authenticator attachment, 58 | transports?: AuthenticatorTransportFuture[] // list of transports, 59 | browser?: string 60 | os?: string 61 | platform?: string 62 | last_used?: number // last used epoc time, 63 | credentialDeviceType?: CredentialDeviceType, 64 | credentialBackedUp?: boolean, 65 | clientExtensionResults?: any 66 | } 67 | 68 | export interface AAGUID { 69 | name: string; 70 | icon_light?: string; 71 | icon_dark?: string; 72 | } 73 | 74 | export interface AAGUIDs { 75 | [key: string]: AAGUID 76 | } 77 | -------------------------------------------------------------------------------- /src/public/scripts/components.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import '@material/mwc-top-app-bar-fixed'; 18 | import '@material/mwc-button'; 19 | import '@material/mwc-checkbox'; 20 | import '@material/mwc-dialog'; 21 | import '@material/mwc-drawer'; 22 | import '@material/mwc-linear-progress'; 23 | import '@material/mwc-list'; 24 | import '@material/mwc-list/mwc-check-list-item'; 25 | import '@material/mwc-radio'; 26 | import '@material/mwc-snackbar'; 27 | import '@material/mwc-select'; 28 | import '@material/mwc-switch'; 29 | import '@material/mwc-formfield'; 30 | import '@material/mwc-icon-button'; 31 | import '@material/mwc-textfield'; 32 | import { TopAppBarFixed } from '@material/mwc-top-app-bar-fixed'; 33 | import { Drawer } from '@material/mwc-drawer'; 34 | 35 | const topAppBar = document.querySelector('#top-app-bar') as TopAppBarFixed; 36 | const drawer = document.querySelector('#drawer') as Drawer; 37 | 38 | if (topAppBar && drawer) { 39 | topAppBar.addEventListener('MDCTopAppBar:nav', () => { 40 | drawer.open = !drawer.open; 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /src/public/scripts/main.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { html, render, $, showSnackbar, loading, _fetch } from './util'; 18 | import { WebAuthnRegistrationObject, WebAuthnAuthenticationObject, UserInfo, AAGUIDs } from './common'; 19 | import { base64url } from './base64url'; 20 | import { MDCRipple } from '@material/ripple'; 21 | import { initializeApp } from 'firebase/app'; 22 | import { Checkbox } from '@material/mwc-checkbox'; 23 | import cbor from 'cbor'; 24 | import * as firebaseui from 'firebaseui'; 25 | import { 26 | getAuth, 27 | connectAuthEmulator, 28 | GoogleAuthProvider, 29 | onAuthStateChanged, 30 | User 31 | } from 'firebase/auth'; 32 | import { getAnalytics } from 'firebase/analytics'; 33 | import { 34 | RegistrationCredential, 35 | RegistrationResponseJSON, 36 | AuthenticationCredential, 37 | AuthenticationResponseJSON, 38 | PublicKeyCredentialCreationOptions, 39 | PublicKeyCredentialCreationOptionsJSON, 40 | PublicKeyCredentialRequestOptions, 41 | PublicKeyCredentialRequestOptionsJSON, 42 | PublicKeyCredentialDescriptorJSON, 43 | } from '@simplewebauthn/types'; 44 | import { IconButton } from '@material/mwc-icon-button'; 45 | import { StoredCredential } from './common'; 46 | 47 | const aaguids = await fetch('/webauthn/aaguids').then(res => res.json()); 48 | 49 | const app = initializeApp({ 50 | apiKey: "AIzaSyBC_U6UbKJE0evrgaITJSk6T_sZmMaZO-4", 51 | authDomain: "try-webauthn.firebaseapp.com", 52 | projectId: "try-webauthn", 53 | storageBucket: "try-webauthn.appspot.com", 54 | messagingSenderId: "557912693280", 55 | appId: "1:557912693280:web:c47da88d666eaf0f40fa45", 56 | measurementId: "G-NWVKPRNL5Q" 57 | }); 58 | 59 | getAnalytics(app); 60 | 61 | const auth = getAuth(); 62 | if (location.hostname === 'localhost') { 63 | connectAuthEmulator(auth, 'http://localhost:9099'); 64 | } 65 | const ui = new firebaseui.auth.AuthUI(auth); 66 | const icon = $('#user-icon'); 67 | const transportIconMap = { 68 | internal: "devices", 69 | usb: "usb", 70 | nfc: "nfc", 71 | ble: "bluetooth", 72 | cable: "cable", 73 | hybrid: "cable", 74 | } as { [key: string]: string }; 75 | 76 | /** 77 | * Verify ID Token received via Firebase Auth 78 | * @param authResult 79 | * @returns always return `false` 80 | */ 81 | const verifyIdToken = async (user: User): Promise => { 82 | const id_token = await user.getIdToken(); 83 | return await _fetch('/auth/verify', { id_token }); 84 | } 85 | 86 | /** 87 | * Display Firebase Auth UI 88 | */ 89 | const displaySignin = () => { 90 | loading.start(); 91 | ui.start('#firebaseui-auth-container', { 92 | signInOptions: [ GoogleAuthProvider.PROVIDER_ID ], 93 | signInFlow: 'popup', 94 | callbacks: { signInSuccessWithAuthResult: () => false, } 95 | }); 96 | $('#dialog').show(); 97 | }; 98 | 99 | /** 100 | * Sign out from Firebase Auth 101 | */ 102 | const onSignout = async (e: any) => { 103 | if (!confirm('Do you want to sign out?')) { 104 | e.stopPropagation(); 105 | return; 106 | } 107 | $('#user-info').close(); 108 | await auth.signOut(); 109 | await _fetch('/auth/signout'); 110 | icon.innerText = ''; 111 | icon.setAttribute('icon', 'account_circle'); 112 | $('#drawer').open = false; 113 | $('#credentials').innerText = ''; 114 | showSnackbar('You are signed out.'); 115 | displaySignin(); 116 | }; 117 | 118 | /** 119 | * Invoked when Firebase Auth status is changed. 120 | */ 121 | onAuthStateChanged(auth, async token => { 122 | if (!window.PublicKeyCredential) { 123 | render(html` 124 |

Your browser does not support WebAuthn.

125 | `, $('#firebaseui-auth-container')); 126 | $('#dialog').show(); 127 | return false; 128 | } 129 | 130 | let user: UserInfo; 131 | 132 | if (token) { 133 | // When signed in. 134 | try { 135 | user = await verifyIdToken(token); 136 | 137 | // User Info is stored in the local storage. 138 | // This will be deleted when signing out. 139 | const _userInfo = localStorage.getItem('userInfo'); 140 | // If there's already stored user info, fill the User Info dialog with them. 141 | if (!_userInfo) { 142 | // If there's no previous user info, store the current user info. 143 | localStorage.setItem('userInfo', JSON.stringify(user)); 144 | $('#username').value = user.name; 145 | $('#display-name').value = user.displayName; 146 | $('#picture-url').value = user.picture; 147 | } else { 148 | // If there's user info in the local storage, use it. 149 | const userInfo = JSON.parse(_userInfo); 150 | $('#username').value = userInfo.name; 151 | $('#display-name').value = userInfo.displayName; 152 | $('#picture-url').value = userInfo.picture; 153 | } 154 | } catch (error) { 155 | console.error(error); 156 | showSnackbar('Sign-in failed.'); 157 | return false; 158 | }; 159 | 160 | } else { 161 | // When signed out. 162 | try { 163 | user = await _fetch('/auth/userInfo'); 164 | } catch { 165 | // Signed out 166 | displaySignin(); 167 | return false; 168 | } 169 | } 170 | $('#dialog').close(); 171 | icon.removeAttribute('icon'); 172 | render(html``, icon); 173 | showSnackbar('You are signed in!'); 174 | loading.stop(); 175 | listCredentials(); 176 | return true; 177 | }); 178 | 179 | /** 180 | * Collect advanced options and return a JSON object. 181 | * @returns WebAuthnRegistrationObject 182 | */ 183 | const collectOptions = ( 184 | mode: 'registration' | 'authentication' = 'registration' 185 | ): WebAuthnRegistrationObject|WebAuthnAuthenticationObject => { 186 | // const specifyCredentials = $('#switch-rr').checked; 187 | const authenticatorAttachment = $('#attachment').value; 188 | const attestation = $('#conveyance').value; 189 | const residentKey = $('#resident-key').value; 190 | const userVerification = $('#user-verification').value; 191 | const hints = [ 192 | ...$('#hints1').value?[$('#hints1').value]:[], 193 | ...$('#hints2').value?[$('#hints2').value]:[], 194 | ...$('#hints3').value?[$('#hints3').value]:[], 195 | ]; 196 | const credProps = $('#switch-cred-props').checked || false; 197 | const tasSwitch = $('#switch-tx-auth-simple').checked || undefined; 198 | const tas = $('#tx-auth-simple').value.trim() || undefined; 199 | const customTimeout = parseInt($('#custom-timeout').value); 200 | // const abortTimeout = parseInt($('#abort-timeout').value); 201 | 202 | let txAuthSimple; 203 | // Simple Transaction Authorization extension 204 | if (tasSwitch) { 205 | txAuthSimple = tas ?? undefined; 206 | } 207 | 208 | // This is registration 209 | if (mode === 'registration') { 210 | const userInfo = localStorage.getItem('userInfo'); 211 | const user = userInfo ? JSON.parse(userInfo) : undefined; 212 | 213 | return { 214 | attestation, 215 | authenticatorSelection: { 216 | authenticatorAttachment, 217 | userVerification, 218 | residentKey 219 | }, 220 | extensions: { credProps, }, 221 | customTimeout, 222 | hints, 223 | user, 224 | // abortTimeout, 225 | } as WebAuthnRegistrationObject; 226 | 227 | // This is authentication 228 | } else { 229 | return { 230 | userVerification, 231 | hints, 232 | extensions: { txAuthSimple }, 233 | customTimeout, 234 | // abortTimeout, 235 | } as WebAuthnAuthenticationObject 236 | } 237 | } 238 | 239 | const collectCredentials = () => { 240 | const cards = document.querySelectorAll('#credentials .mdc-card__primary-action'); 241 | 242 | const credentials: PublicKeyCredentialDescriptorJSON[] = []; 243 | 244 | // Traverse all checked credentials 245 | cards.forEach(card => { 246 | const checkbox = card.querySelector('mwc-checkbox.credential-checkbox'); 247 | if (checkbox?.checked) { 248 | // Look for all checked transport checkboxes 249 | const _transports = card.querySelectorAll('mwc-checkbox.transport-checkbox[checked]'); 250 | // Convert checkboxes into a list of transports 251 | const transports = Array.from(_transports).map(_transport => { 252 | const iconNode = _transport.previousElementSibling; 253 | const index = Object.values(transportIconMap).findIndex(_transport => _transport == iconNode.icon); 254 | return Object.keys(transportIconMap)[index]; 255 | }); 256 | credentials.push({ 257 | id: card.id.substring(3), // Remove first `ID-` 258 | type: 'public-key', 259 | transports 260 | }); 261 | } 262 | }); 263 | 264 | return credentials; 265 | }; 266 | 267 | /** 268 | * Ripple on the specified credential card to indicate it's found. 269 | * @param credID 270 | */ 271 | const rippleCard = (credID: string) => { 272 | const ripple = new MDCRipple($(`#${credID}`)); 273 | ripple.activate(); 274 | ripple.deactivate(); 275 | } 276 | 277 | async function parseRegistrationCredential( 278 | cred: RegistrationCredential 279 | ): Promise { 280 | const credJSON = { 281 | id: cred.id, 282 | rawId: cred.id, 283 | type: cred.type, 284 | response: { 285 | clientDataJSON: {}, 286 | attestationObject: { 287 | fmt: 'none', 288 | attStmt: {}, 289 | authData: {}, 290 | }, 291 | transports: [], 292 | }, 293 | clientExtensionResults: {}, 294 | }; 295 | 296 | const decoder = new TextDecoder('utf-8'); 297 | credJSON.response.clientDataJSON = JSON.parse(decoder.decode(cred.response.clientDataJSON)); 298 | const attestationObject = cbor.decodeAllSync(cred.response.attestationObject)[0] 299 | 300 | attestationObject.authData = await parseAuthData(attestationObject.authData); 301 | credJSON.response.attestationObject = attestationObject; 302 | 303 | if (cred.response.getTransports) { 304 | credJSON.response.transports = cred.response.getTransports(); 305 | } 306 | 307 | credJSON.clientExtensionResults = parseClientExtensionResults(cred); 308 | 309 | return credJSON; 310 | }; 311 | 312 | async function parseAuthenticationCredential( 313 | cred: AuthenticationCredential 314 | ): Promise { 315 | const userHandle = cred.response.userHandle ? base64url.encode(cred.response.userHandle) : undefined; 316 | 317 | const credJSON = { 318 | id: cred.id, 319 | rawId: cred.id, 320 | type: cred.type, 321 | response: { 322 | clientDataJSON: {}, 323 | authenticatorData: {}, 324 | signature: base64url.encode(cred.response.signature), 325 | userHandle, 326 | }, 327 | clientExtensionResults: {}, 328 | }; 329 | 330 | const decoder = new TextDecoder('utf-8'); 331 | credJSON.response.clientDataJSON = JSON.parse(decoder.decode(cred.response.clientDataJSON)); 332 | credJSON.response.authenticatorData = await parseAuthenticatorData(new Uint8Array(cred.response.authenticatorData)); 333 | 334 | credJSON.clientExtensionResults = parseClientExtensionResults(cred); 335 | 336 | return credJSON; 337 | } 338 | 339 | async function parseAuthData( 340 | buffer: any 341 | ): Promise { 342 | const authData = { 343 | rpIdHash: '', 344 | flags: { 345 | up: false, 346 | uv: false, 347 | be: false, 348 | bs: false, 349 | at: false, 350 | ed: false, 351 | }, 352 | counter: 0, 353 | aaguid: '', 354 | credentialID: '', 355 | credentialPublicKey: '', 356 | extensions: {} 357 | }; 358 | 359 | const rpIdHash = buffer.slice(0, 32); 360 | buffer = buffer.slice(32); 361 | authData.rpIdHash = [...rpIdHash].map(x => x.toString(16).padStart(2, '0')).join(''); 362 | 363 | const flags = (buffer.slice(0, 1))[0]; 364 | buffer = buffer.slice(1); 365 | authData.flags = { 366 | up: !!(flags & (1 << 0)), 367 | uv: !!(flags & (1 << 2)), 368 | be: !!(flags & (1 << 3)), 369 | bs: !!(flags & (1 << 4)), 370 | at: !!(flags & (1 << 6)), 371 | ed: !!(flags & (1 << 7)), 372 | }; 373 | 374 | const counter = buffer.slice(0, 4); 375 | buffer = buffer.slice(4); 376 | authData.counter = counter.readUInt32BE(0); 377 | 378 | if (authData.flags.at) { 379 | // Decode AAGUID 380 | let AAGUID = buffer.slice(0, 16); 381 | AAGUID = Array.from(AAGUID).map(a => (a).toString(16).padStart(2, '0')); 382 | authData.aaguid = `${AAGUID.splice(0,4).join('')}-${AAGUID.splice(0,2).join('')}-${AAGUID.splice(0,2).join('')}-${AAGUID.splice(0).join('')}`; 383 | buffer = buffer.slice(16); 384 | 385 | const credIDLenBuf = buffer.slice(0, 2); 386 | buffer = buffer.slice(2); 387 | const credIDLen = credIDLenBuf.readUInt16BE(0) 388 | // Decode Credential ID 389 | authData.credentialID = base64url.encode(buffer.slice(0, credIDLen)); 390 | buffer = buffer.slice(credIDLen); 391 | 392 | const decodedResults = cbor.decodeAllSync(buffer.slice(0)); 393 | // Decode the public key 394 | if (decodedResults[0]) { 395 | authData.credentialPublicKey = base64url.encode(Uint8Array.from(cbor.encode(decodedResults[0])).buffer); 396 | } 397 | // Decode extensions 398 | if (decodedResults[1]) { 399 | authData.extensions = decodedResults[1]; 400 | } 401 | } 402 | 403 | return authData; 404 | } 405 | 406 | async function parseAuthenticatorData( 407 | buffer: any 408 | ): Promise { 409 | const authData = { 410 | rpIdHash: '', 411 | flags: { 412 | up: false, 413 | uv: false, 414 | be: false, 415 | bs: false, 416 | at: false, 417 | ed: false, 418 | }, 419 | }; 420 | 421 | const rpIdHash = buffer.slice(0, 32); 422 | buffer = buffer.slice(32); 423 | authData.rpIdHash = [...rpIdHash].map(x => x.toString(16).padStart(2, '0')).join(''); 424 | 425 | const flags = (buffer.slice(0, 1))[0]; 426 | buffer = buffer.slice(1); 427 | authData.flags = { 428 | up: !!(flags & (1 << 0)), 429 | uv: !!(flags & (1 << 2)), 430 | be: !!(flags & (1 << 3)), 431 | bs: !!(flags & (1 << 4)), 432 | at: !!(flags & (1 << 6)), 433 | ed: !!(flags & (1 << 7)), 434 | }; 435 | 436 | return authData; 437 | } 438 | 439 | function parseClientExtensionResults( 440 | credential: RegistrationCredential | AuthenticationCredential 441 | ): AuthenticationExtensionsClientOutputs { 442 | const clientExtensionResults: AuthenticationExtensionsClientOutputs = {}; 443 | if (credential.getClientExtensionResults) { 444 | const extensions: AuthenticationExtensionsClientOutputs = credential.getClientExtensionResults(); 445 | if (extensions.credProps) { 446 | clientExtensionResults.credProps = extensions.credProps; 447 | } 448 | } 449 | return clientExtensionResults; 450 | } 451 | 452 | /** 453 | * Fetch and render the list of credentials. 454 | */ 455 | const listCredentials = async (): Promise => { 456 | loading.start(); 457 | try { 458 | const credentials = await _fetch('/webauthn/getCredentials'); 459 | loading.stop(); 460 | render(credentials.map(cred => { 461 | const extensions = cred.clientExtensionResults; 462 | const transports = cred.transports as string[]; 463 | const aaguid = cred.aaguid || '00000000-0000-0000-0000-000000000000'; 464 | const authenticatorType = `${cred.user_verifying?'User Verifying ':''}`+ 465 | `${cred.authenticatorAttachment==='platform'?'Platform ': 466 | cred.authenticatorAttachment==='cross-platform'?'Roaming ':''}Authenticator`; 467 | return html` 468 |
469 |
470 |
471 |
472 | 473 | 474 | ${(aaguids as AAGUIDs)[aaguid] ? html` 475 | 476 | 477 | `:''} 478 | 479 |
480 |
481 | 482 |
483 |
484 |
485 |
Authenticator Type
486 |
${authenticatorType}
487 |
Credential Type
488 |
${cred.credentialBackedUp ? 'Multi device' : 'Single device'}
489 |
AAGUID
490 |
${aaguid}
491 |
Transports
492 |
493 | ${!transports.length ? html` 494 | N/A 495 | ` : transports.map(transport => html` 496 | 497 | 498 | 499 | 500 | `)} 501 |
502 | ${cred.registered ? html` 503 |
Enrolled at
504 |
${(new Date(cred.registered)).toLocaleString()}
`:''} 505 | ${extensions?.credProps ? html` 506 |
Credential Properties Extension
`:''} 507 | ${extensions.credProps?.rk ? html` 508 |
Discoverable Credentials: ${extensions.credProps.rk?'true':'false'}
`:''} 509 | ${extensions.credProps?.authenticatorDisplayName ? html` 510 |
Authenticator display name: ${extensions.credProps.authenticatorDisplayName}
`:''} 511 |
Public Key
512 |
${cred.credentialPublicKey}
513 |
Credential ID
514 |
${cred.credentialID}
515 |
516 |
517 |
518 |
519 | `}), $('#credentials')); 520 | loading.stop(); 521 | if (!$('#exclude-all-credentials').checked) { 522 | const cards = document.querySelectorAll('#credentials .mdc-card__primary-action'); 523 | cards.forEach(card => { 524 | const checkbox = card.querySelector('mwc-checkbox'); 525 | if (checkbox) checkbox.checked = false; 526 | }); 527 | } 528 | } catch (e) { 529 | console.error(e); 530 | showSnackbar('Loading credentials failed.'); 531 | loading.stop(); 532 | } 533 | }; 534 | 535 | /** 536 | * Register a new credential. 537 | * @param opts 538 | */ 539 | const registerCredential = async (opts: WebAuthnRegistrationObject): Promise => { 540 | // Fetch credential creation options from the server. 541 | const options: PublicKeyCredentialCreationOptionsJSON = 542 | await _fetch('/webauthn/registerRequest', opts); 543 | 544 | // Decode encoded parameters. 545 | const user = { 546 | ...options.user, 547 | id: base64url.decode(options.user.id) 548 | } as PublicKeyCredentialUserEntity; 549 | const challenge = base64url.decode(options.challenge); 550 | const _excludeCredentials: PublicKeyCredentialDescriptorJSON[] = collectCredentials(); 551 | const excludeCredentials = _excludeCredentials.map(cred => { 552 | return { 553 | ...cred, 554 | id: base64url.decode(cred.id), 555 | } as PublicKeyCredentialDescriptor; 556 | }); 557 | const decodedOptions = { 558 | ...options, 559 | user, 560 | challenge, 561 | hints: opts.hints, 562 | excludeCredentials, 563 | } as PublicKeyCredentialCreationOptions; 564 | 565 | console.log('[CredentialCreationOptions]', decodedOptions); 566 | 567 | // Create a new attestation. 568 | const credential = await navigator.credentials.create({ 569 | publicKey: decodedOptions 570 | }) as RegistrationCredential; 571 | 572 | // Encode the attestation. 573 | const rawId = base64url.encode(credential.rawId); 574 | const clientDataJSON = base64url.encode(credential.response.clientDataJSON); 575 | const attestationObject = base64url.encode(credential.response.attestationObject); 576 | const clientExtensionResults: AuthenticationExtensionsClientOutputs = {}; 577 | 578 | // if `getClientExtensionResults()` is supported, serialize the result. 579 | if (credential.getClientExtensionResults) { 580 | const extensions: AuthenticationExtensionsClientOutputs = credential.getClientExtensionResults(); 581 | if (extensions.credProps) { 582 | clientExtensionResults.credProps = extensions.credProps; 583 | } 584 | } 585 | let transports: any[] = []; 586 | 587 | // if `getTransports()` is supported, serialize the result. 588 | if (credential.response.getTransports) { 589 | transports = credential.response.getTransports(); 590 | } 591 | 592 | const encodedCredential = { 593 | id: credential.id, 594 | rawId, 595 | response: { 596 | clientDataJSON, 597 | attestationObject, 598 | transports, 599 | }, 600 | type: credential.type, 601 | clientExtensionResults, 602 | } as RegistrationResponseJSON; 603 | 604 | const parsedCredential = await parseRegistrationCredential(credential); 605 | 606 | console.log('[RegistrationResponseJSON]', parsedCredential); 607 | 608 | // Verify and store the attestation. 609 | await _fetch('/webauthn/registerResponse', encodedCredential); 610 | 611 | return parsedCredential; 612 | }; 613 | 614 | /** 615 | * Authenticate the user with a credential. 616 | * @param opts 617 | * @returns 618 | */ 619 | const authenticate = async (opts: WebAuthnAuthenticationObject): Promise => { 620 | // Fetch the credential request options. 621 | const options: PublicKeyCredentialRequestOptionsJSON = 622 | await _fetch('/webauthn/authRequest', opts); 623 | 624 | // Decode encoded parameters. 625 | const challenge = base64url.decode(options.challenge); 626 | 627 | 628 | const _allowCredentials: PublicKeyCredentialDescriptorJSON[] = 629 | $('#empty-allow-credentials').checked ? [] : collectCredentials(); 630 | const allowCredentials = _allowCredentials.map(cred => { 631 | return { 632 | ...cred, 633 | id: base64url.decode(cred.id), 634 | } as PublicKeyCredentialDescriptor; 635 | }); 636 | const decodedOptions = { 637 | ...options, 638 | allowCredentials, 639 | hints: opts.hints, 640 | challenge, 641 | } as PublicKeyCredentialRequestOptions; 642 | 643 | console.log('[CredentialRequestOptions]', decodedOptions); 644 | 645 | // Authenticate the user. 646 | const credential = await navigator.credentials.get({ 647 | publicKey: decodedOptions 648 | }) as AuthenticationCredential; 649 | 650 | // Encode the credential. 651 | const rawId = base64url.encode(credential.rawId); 652 | const authenticatorData = base64url.encode(credential.response.authenticatorData); 653 | const clientDataJSON = base64url.encode(credential.response.clientDataJSON); 654 | const signature = base64url.encode(credential.response.signature); 655 | const userHandle = credential.response.userHandle ? 656 | base64url.encode(credential.response.userHandle) : undefined; 657 | const clientExtensionResults: AuthenticationExtensionsClientOutputs = {}; 658 | 659 | // if `getClientExtensionResults()` is supported, serialize the result. 660 | if (credential.getClientExtensionResults) { 661 | const extensions: AuthenticationExtensionsClientOutputs = credential.getClientExtensionResults(); 662 | if (extensions.credProps) { 663 | clientExtensionResults.credProps = extensions.credProps; 664 | } 665 | } 666 | 667 | const encodedCredential = { 668 | id: credential.id, 669 | rawId, 670 | response: { 671 | authenticatorData, 672 | clientDataJSON, 673 | signature, 674 | userHandle, 675 | }, 676 | type: credential.type, 677 | clientExtensionResults, 678 | } as AuthenticationResponseJSON; 679 | 680 | const parsedCredential = await parseAuthenticationCredential(credential); 681 | 682 | console.log('[AuthenticationResponseJSON]', parsedCredential); 683 | 684 | // Verify and store the credential. 685 | await _fetch('/webauthn/authResponse', encodedCredential); 686 | 687 | return parsedCredential; 688 | }; 689 | 690 | /** 691 | * Remove a credential. 692 | * @param credId 693 | * @returns 694 | */ 695 | const removeCredential = (credId: string) => async () => { 696 | if (!confirm('Are you sure you want to remove this credential?')) { 697 | return; 698 | } 699 | try { 700 | loading.start(); 701 | await _fetch('/webauthn/removeCredential', { credId }); 702 | showSnackbar('The credential has been removed.'); 703 | listCredentials(); 704 | } catch (e) { 705 | console.error(e); 706 | showSnackbar('Removing the credential failed.'); 707 | } 708 | }; 709 | 710 | const onExcludeAllCredentials = (e: any): void => { 711 | const checked = !e.target.checked; 712 | const cards = document.querySelectorAll('#credentials .mdc-card__primary-action'); 713 | cards.forEach(card => { 714 | const checkbox = card.querySelector('mwc-checkbox'); 715 | if (checkbox) checkbox.checked = checked; 716 | }); 717 | e.target.checked = checked; 718 | } 719 | 720 | /** 721 | * When the user icon is clicked, show the User Info dialog. 722 | */ 723 | const onUserIconClicked = () => { 724 | const _userInfo = localStorage.getItem('userInfo'); 725 | if (_userInfo) { 726 | const userInfo = JSON.parse(_userInfo); 727 | $('#username').value = userInfo.name; 728 | $('#display-name').value = userInfo.displayName; 729 | $('#picture-url').value = userInfo.picture; 730 | } 731 | $('#user-info').show(); 732 | } 733 | 734 | /** 735 | * When "Save" button in the User Info dialog is clicked, update the user info. 736 | * @param e 737 | */ 738 | const onUserInfoUpdate = (e: any): void => { 739 | const username = $('#username'); 740 | const displayName = $('#display-name'); 741 | const pictureUrl = $('#picture-url'); 742 | 743 | let success = true; 744 | if (!username.checkValidity()) { 745 | username.reportValidity(); 746 | success = false; 747 | } 748 | if (!displayName.checkValidity()) { 749 | displayName.reportValidity(); 750 | success = false; 751 | } 752 | if(!pictureUrl.checkValidity()) { 753 | pictureUrl.reportValidity(); 754 | success = false; 755 | } 756 | 757 | if (!success) { 758 | e.stopPropagation(); 759 | } else { 760 | localStorage.setItem('userInfo', JSON.stringify({ 761 | name: username.value, 762 | displayName: displayName.value, 763 | picture: pictureUrl.value, 764 | })); 765 | } 766 | }; 767 | 768 | /** 769 | * Determine whether 770 | * `PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()` 771 | * function is available. 772 | */ 773 | const onISUVPAA = async (): Promise => { 774 | if (window.PublicKeyCredential) { 775 | if (PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable) { 776 | const result = await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); 777 | if (result) { 778 | showSnackbar('User Verifying Platform Authenticator is *available*.'); 779 | } else { 780 | showSnackbar('User Verifying Platform Authenticator is not available.'); 781 | } 782 | } else { 783 | showSnackbar('IUVPAA function is not available.'); 784 | } 785 | } else { 786 | showSnackbar('PublicKeyCredential is not availlable.'); 787 | } 788 | } 789 | 790 | /** 791 | * On "Register New Credential" button click, invoke `registerCredential()` 792 | * function to register a new credential with advanced options. 793 | */ 794 | const onRegisterNewCredential = async (): Promise => { 795 | loading.start(); 796 | const opts = collectOptions('registration'); 797 | try { 798 | const parsedCredential = await registerCredential(opts); 799 | showSnackbar('A credential successfully registered!', parsedCredential); 800 | listCredentials(); 801 | } catch (e: any) { 802 | console.error(e); 803 | showSnackbar(e.message); 804 | } finally { 805 | loading.stop(); 806 | } 807 | }; 808 | 809 | /** 810 | * On "Register Platform Authenticator" button click, invoke 811 | * `registerCredential()` function to register a new credential with advanced 812 | * options overridden by `authenticatorAttachment == 'platform'` and 813 | * `userVerification = 'required'`. 814 | */ 815 | const onRegisterPlatformAuthenticator = async (): Promise => { 816 | loading.start(); 817 | const opts = collectOptions('registration'); 818 | opts.authenticatorSelection = opts.authenticatorSelection || {}; 819 | opts.authenticatorSelection.authenticatorAttachment = 'platform'; 820 | try { 821 | const parsedCredential = await registerCredential(opts); 822 | showSnackbar('A credential successfully registered!', parsedCredential); 823 | listCredentials(); 824 | } catch (e: any) { 825 | console.error(e); 826 | showSnackbar(e.message); 827 | } finally { 828 | loading.stop(); 829 | } 830 | }; 831 | 832 | /** 833 | * On "Authenticate" button click, invoke `authenticate()` function to 834 | * authenticate the user. 835 | */ 836 | const onAuthenticate = async (): Promise => { 837 | loading.start(); 838 | const opts = collectOptions('authentication'); 839 | try { 840 | const parsedCredential = await authenticate(opts); 841 | // Prepended `ID-` is necessary to avoid IDs start with a number. 842 | rippleCard(`ID-${parsedCredential.id}`); 843 | showSnackbar('Authentication succeeded!', parsedCredential); 844 | listCredentials(); 845 | } catch (e: any) { 846 | console.error(e); 847 | showSnackbar(e.message); 848 | } finally { 849 | loading.stop(); 850 | } 851 | }; 852 | 853 | const onTxAuthSimpleSiwtch = async (): Promise => { 854 | $('#tx-auth-simple').disabled = $('#switch-tx-auth-simple').checked; 855 | } 856 | 857 | loading.start(); 858 | 859 | $('#isuvpaa-button').addEventListener('click', onISUVPAA); 860 | $('#credential-button').addEventListener('click', onRegisterNewCredential); 861 | $('#platform-button').addEventListener('click', onRegisterPlatformAuthenticator); 862 | $('#authenticate-button').addEventListener('click', onAuthenticate); 863 | $('#exclude-all-credentials').addEventListener('click', onExcludeAllCredentials); 864 | $('#user-icon').addEventListener('click', onUserIconClicked); 865 | $('#signout').addEventListener('click', onSignout); 866 | $('#save-user-info').addEventListener('click', onUserInfoUpdate); 867 | $('#switch-tx-auth-simple').addEventListener('click', onTxAuthSimpleSiwtch); 868 | -------------------------------------------------------------------------------- /src/public/scripts/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { Snackbar } from '@material/mwc-snackbar'; 18 | import { LinearProgress } from '@material/mwc-linear-progress'; 19 | import { html, render } from 'lit'; 20 | 21 | const $: any = document.querySelector.bind(document); 22 | const BASE64_SLICE_LENGTH = 40; 23 | 24 | const snackbar = $('#snackbar') as Snackbar; 25 | 26 | function showPayload( 27 | payload: any 28 | ): void { 29 | payload.id = payload.id.slice(0, BASE64_SLICE_LENGTH)+'...'; 30 | payload.rawId = payload.rawId.slice(0, BASE64_SLICE_LENGTH)+'...'; 31 | if (payload.response?.authData) { 32 | payload.response.authData = payload.response.authData.slice(0, BASE64_SLICE_LENGTH)+'...'; 33 | } 34 | $('#json-viewer').data = { payload }; 35 | $('#json-viewer').expandAll(); 36 | $('#payload-viewer').show(); 37 | }; 38 | 39 | function showSnackbar(message: string, payload?: any): void { 40 | $('#snack-button')?.remove(); 41 | snackbar.labelText = message; 42 | if (payload) { 43 | const button = document.createElement('mwc-button'); 44 | button.id = 'snack-button'; 45 | button.slot = 'action'; 46 | button.innerText = 'Show payload'; 47 | button.addEventListener('click', e => { 48 | showPayload(payload); 49 | }); 50 | snackbar.appendChild(button); 51 | } 52 | snackbar.show(); 53 | }; 54 | 55 | class Loading { 56 | private progress: LinearProgress 57 | 58 | constructor() { 59 | this.progress = $('#progress') as LinearProgress; 60 | } 61 | start() { 62 | this.progress.indeterminate = true; 63 | } 64 | stop() { 65 | this.progress.indeterminate = false; 66 | } 67 | } 68 | 69 | const loading = new Loading(); 70 | 71 | const _fetch = async ( 72 | path: string, 73 | payload: any = '' 74 | ): Promise => { 75 | const headers: any = { 76 | 'X-Requested-With': 'XMLHttpRequest', 77 | }; 78 | if (payload && !(payload instanceof FormData)) { 79 | headers['Content-Type'] = 'application/json'; 80 | payload = JSON.stringify(payload); 81 | } 82 | const res = await fetch(path, { 83 | method: 'POST', 84 | credentials: 'same-origin', 85 | headers: headers, 86 | body: payload, 87 | }); 88 | if (res.status === 200) { 89 | // Server authentication succeeded 90 | return res.json(); 91 | } else { 92 | // Server authentication failed 93 | const result = await res.json(); 94 | throw new Error(result.error); 95 | } 96 | }; 97 | 98 | export { html, render, $, showSnackbar, loading, _fetch }; 99 | -------------------------------------------------------------------------------- /src/public/styles/style.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import 'firebaseui/dist/firebaseui.css'; 18 | import './style.scss'; 19 | -------------------------------------------------------------------------------- /src/public/styles/style.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | @use '@material/card'; 18 | @include card.core-styles; 19 | 20 | * { 21 | box-sizing: border-box; 22 | font-family: var(--mdc-typography-body2-font-family, var(--mdc-typography-font-family, Roboto, sans-serif)) 23 | } 24 | 25 | * { 26 | --mdc-theme-primary: rgb(255,64,129); 27 | --mdc-dialog-max-width: 1000px; 28 | } 29 | 30 | html, body, main { 31 | height: 100%; 32 | } 33 | 34 | body { 35 | margin: 0; 36 | padding: 0; 37 | } 38 | 39 | .app-content { 40 | height: 100%; 41 | } 42 | 43 | .center { 44 | display: flex; 45 | flex-direction: column; 46 | align-items: center; 47 | } 48 | 49 | .flex-layout { 50 | display: flex; 51 | height: calc(100% - 138px); 52 | flex-direction: column; 53 | } 54 | 55 | .flex-content { 56 | flex: 1 1 auto; 57 | } 58 | 59 | .advanced-option { 60 | margin: 10px; 61 | } 62 | 63 | .flex-horizontal { 64 | width: 100%; 65 | padding: 3px 16px; 66 | display: flex; 67 | font-size: 14px; 68 | font-weight: 400; 69 | line-height: 20px; 70 | a { 71 | color: inherit; 72 | text-decoration: none; 73 | } 74 | ul { 75 | flex-grow: 1; 76 | list-style: none; 77 | padding: 0; 78 | li { 79 | display: inline-block; 80 | margin-right: 10px; 81 | } 82 | } 83 | .list-left { 84 | text-align: left; 85 | } 86 | .list-right { 87 | text-align: right; 88 | } 89 | } 90 | 91 | #top-app-bar { 92 | --mdc-theme-primary: rgb(0,150,136); 93 | background-color: rgb(0,150,136); 94 | #user-icon img { 95 | clip-path: circle(50%); 96 | } 97 | } 98 | 99 | #header-buttons ul { 100 | margin-top: 1em; 101 | margin-bottom: 0; 102 | li { 103 | margin-bottom: 1em; 104 | } 105 | } 106 | 107 | footer { 108 | color: #9e9e9e; 109 | background-color: #444444; 110 | position: fixed; 111 | bottom: 0; 112 | } 113 | 114 | #credentials { 115 | display: flex; 116 | flex-wrap: wrap; 117 | margin-bottom: 54px; 118 | } 119 | 120 | .mdc-card { 121 | width: 400px; 122 | height: 400px; 123 | margin: 20px; 124 | .card-title { 125 | padding: 0 20px; 126 | border-bottom: 1px solid #e0e0e0; 127 | } 128 | .card-body { 129 | padding: 20px; 130 | overflow-y: auto; 131 | dd { 132 | font-size: 90%; 133 | color: #666666; 134 | margin-inline-start: 10px; 135 | word-break: break-all; 136 | mwc-icon-button { 137 | --mdc-icon-button-size: 24px; 138 | --mdc-icon-size: 16px; 139 | } 140 | } 141 | } 142 | } 143 | 144 | mwc-dialog { 145 | mwc-textfield { 146 | margin: 8px 0; 147 | } 148 | } 149 | 150 | json-viewer { 151 | font-family: monospace; 152 | font-size: 12px; 153 | overflow: scroll; 154 | } 155 | -------------------------------------------------------------------------------- /src/public/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "lib": [ "ESNext", "DOM" ], 6 | "outDir": "./dist", 7 | "sourceMap": true, 8 | "strict": true, 9 | "allowJs": true, 10 | "checkJs": true, 11 | "noUnusedLocals": true, 12 | "noImplicitReturns": true, 13 | "noImplicitThis": false, 14 | "moduleResolution": "node", 15 | "allowSyntheticDefaultImports": true, 16 | "esModuleInterop": true 17 | }, 18 | "typeRoots": [ 19 | "./node_modules/@types" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/public/user.svg: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/server.mts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2022 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import path from 'path'; 18 | import express, { Request, Response, RequestHandler } from 'express'; 19 | import useragent from 'express-useragent'; 20 | import { engine } from 'express-handlebars'; 21 | import { config, initializeSession } from './libs/config.mjs'; 22 | import helmet from 'helmet'; 23 | 24 | import { auth } from './libs/auth.mjs'; 25 | import { webauthn } from './libs/webauthn.mjs'; 26 | 27 | const views = config.views_root_file_path; 28 | const app = express(); 29 | app.set('view engine', 'html'); 30 | app.engine('html', engine({ 31 | extname: 'html', 32 | })); 33 | app.set('views', views); 34 | app.use(express.static(path.join(config.dist_root_file_path, 'public'))); 35 | app.use(express.json() as RequestHandler); 36 | app.use(useragent.express()); 37 | app.use(initializeSession()); 38 | 39 | // Run helmet only when it's running on a remote server. 40 | if (!config.is_localhost) { 41 | app.use(helmet.hsts()); 42 | } 43 | 44 | app.get('/.well-known/assetlinks.json', (req, res) => { 45 | const assetlinks = []; 46 | const relation = [ 47 | 'delegate_permission/common.handle_all_urls', 48 | 'delegate_permission/common.get_login_creds', 49 | ]; 50 | assetlinks.push({ 51 | relation: relation, 52 | target: { 53 | namespace: 'web', 54 | site: config.origin, 55 | }, 56 | }); 57 | if (process.env.ANDROID_PACKAGENAME && process.env.ANDROID_SHA256HASH) { 58 | const package_names = process.env.ANDROID_PACKAGENAME.split(",").map(name => name.trim()); 59 | const hashes = process.env.ANDROID_SHA256HASH.split(",").map(hash => hash.trim()); 60 | for (let i = 0; i < package_names.length; i++) { 61 | assetlinks.push({ 62 | relation: relation, 63 | target: { 64 | namespace: 'android_app', 65 | package_name: package_names[i], 66 | sha256_cert_fingerprints: [hashes[i]], 67 | }, 68 | }); 69 | } 70 | } 71 | return res.json(assetlinks); 72 | }); 73 | 74 | app.get('/.well-known/passkey-endpoints', (req, res) => { 75 | // Temporarily hardcoded. 76 | const web_endpoint = config.origin; 77 | const enroll = { 78 | 'web': web_endpoint 79 | }; 80 | const manage = { 81 | 'web': web_endpoint 82 | } 83 | return res.json({ enroll, manage }); 84 | }); 85 | 86 | app.get('/', (req: Request, res: Response) => { 87 | return res.render('index.html'); 88 | }); 89 | 90 | // listen for requests :) 91 | app.listen(config.port || 8080, () => { 92 | console.log(`Your app is listening on port ${config.port || 8080}`); 93 | }); 94 | 95 | app.use('/auth', auth); 96 | app.use('/webauthn', webauthn); 97 | -------------------------------------------------------------------------------- /src/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | {{> sidebar this}} 3 |
4 | {{> top-app-bar this}} 5 | 6 |
7 |
8 |
9 |
10 | {{> footer this}} 11 |
12 |
13 |
14 | 20 |
21 |
22 | 25 |
26 | 31 | 36 | 41 |
42 | Cancel 45 | Save 50 | Sign-out 55 |
56 | 59 | 60 | Close 63 | 64 | -------------------------------------------------------------------------------- /src/templates/layouts/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{> head this}} 5 | 6 | 7 | {{{body}}} 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/templates/partials/footer.html: -------------------------------------------------------------------------------- 1 |
2 | 7 |
8 | 9 | -------------------------------------------------------------------------------- /src/templates/partials/head.html: -------------------------------------------------------------------------------- 1 | 2 | WebAuthn Demo 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/templates/partials/sidebar.html: -------------------------------------------------------------------------------- 1 | WebAuthn Demo 2 | Advanced Options 3 |
4 | 7 | 8 | 9 | 12 | 13 | 14 |
15 |
16 | 20 | 21 | Platform 22 | Cross-Platform 23 | 24 |
25 |
26 | 30 | 31 | Security Key 32 | Client Device 33 | Hybrid 34 | 35 |
36 |
37 | 41 | 42 | Security Key 43 | Client Device 44 | Hybrid 45 | 46 |
47 |
48 | 52 | 53 | Security Key 54 | Client Device 55 | Hybrid 56 | 57 |
58 |
59 | 63 | 64 | None 65 | Indirect 66 | Direct 67 | Enterprise 68 | 69 |
70 |
71 | 75 | 76 | Required 77 | Preferred 78 | Discouraged 79 | 80 |
81 |
82 | 86 | 87 | Required 88 | Preferred 89 | Discouraged 90 | 91 |
92 |
93 | 99 |
100 |
101 | 102 | 103 | 104 |
105 |
106 | 107 | 108 | 109 |
110 |
111 | 117 |
118 | 125 | 126 | -------------------------------------------------------------------------------- /src/templates/partials/top-app-bar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | WebAuthn Demo 4 | 8 |
9 |
    10 |
  • 11 | 15 | isUVPAA 16 |
  • 17 |
  • 18 | 23 | Register platform authenticator 24 |
  • 25 |
  • 26 | 31 | Register new credential 32 |
  • 33 |
  • 34 | 39 | Authenticate 40 |
  • 41 |
42 |
43 |
-------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "lib": [ "ESNext", "DOM" ], 6 | "rootDir": ".", 7 | "outDir": "../dist", 8 | "sourceMap": true, 9 | "strict": true, 10 | "allowJs": true, 11 | "checkJs": true, 12 | "noUnusedLocals": true, 13 | "noImplicitReturns": true, 14 | "noImplicitThis": false, 15 | "moduleResolution": "node", 16 | "resolveJsonModule": true, 17 | "allowSyntheticDefaultImports": true, 18 | "esModuleInterop": true 19 | }, 20 | "typeRoots": [ 21 | "../node_modules/@types" 22 | ], 23 | "exclude": [ 24 | "./public" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import './libs/helper.mjs'; 2 | 3 | declare module 'express-session' { 4 | interface Session { 5 | // User ID and the indicator that the user is signed in. 6 | user_id?: string, 7 | name?: string, 8 | displayName?: string, 9 | picture?: string, 10 | // Timestamp of the recent successful sign-in time. 11 | timeout?: number, 12 | // Enrollment session for the second step. 13 | challenge?: string 14 | // Enrollment type 15 | type?: 'platform' | 'cross-platform' | 'undefined' 16 | } 17 | } 18 | --------------------------------------------------------------------------------