From 453c3ef995d2b6b72e013b350a76801aab612976 Mon Sep 17 00:00:00 2001 From: Akhil Meka Date: Mon, 17 Feb 2025 14:48:55 +0530 Subject: [PATCH] add webauthn routes --- package.json | 2 + pnpm-lock.yaml | 287 +++++++++++++++++++++++++++++++++ src/server.ts | 4 +- src/user/user.schema.ts | 15 ++ src/utils/id.ts | 10 ++ src/utils/mail.ts | 59 +++++++ src/webauthn/webauthn.route.ts | 213 ++++++++++++++++++++++++ 7 files changed, 589 insertions(+), 1 deletion(-) create mode 100644 src/utils/mail.ts create mode 100644 src/webauthn/webauthn.route.ts diff --git a/package.json b/package.json index a225591..f6307a3 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "@fastify/multipart": "^9.0.1", "@fastify/oauth2": "^8.1.0", "@paralleldrive/cuid2": "^2.2.2", + "@simplewebauthn/server": "^13.1.1", + "axios": "^1.7.9", "bcryptjs": "^3.0.0", "fastify": "^5.2.0", "fastify-type-provider-zod": "^4.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30f07e0..9360dcb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,12 @@ importers: '@paralleldrive/cuid2': specifier: ^2.2.2 version: 2.2.2 + '@simplewebauthn/server': + specifier: ^13.1.1 + version: 13.1.1 + axios: + specifier: ^1.7.9 + version: 1.7.9 bcryptjs: specifier: ^3.0.0 version: 3.0.0 @@ -301,6 +307,12 @@ packages: '@hapi/wreck@18.1.0': resolution: {integrity: sha512-0z6ZRCmFEfV/MQqkQomJ7sl/hyxvcZM7LtuVqN3vdAO4vM9eBbowl0kaqQj9EJJQab+3Uuh1GxbGIBFy4NfJ4w==} + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@lukeed/ms@2.0.2': resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} engines: {node: '>=8'} @@ -315,6 +327,21 @@ packages: '@paralleldrive/cuid2@2.2.2': resolution: {integrity: sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==} + '@peculiar/asn1-android@2.3.15': + resolution: {integrity: sha512-8U2TIj59cRlSXTX2d0mzUKP7whfWGFMzTeC3qPgAbccXFrPNZLaDhpNEdG5U2QZ/tBv/IHlCJ8s+KYXpJeop6w==} + + '@peculiar/asn1-ecc@2.3.15': + resolution: {integrity: sha512-/HtR91dvgog7z/WhCVdxZJ/jitJuIu8iTqiyWVgRE9Ac5imt2sT/E4obqIVGKQw7PIy+X6i8lVBoT6wC73XUgA==} + + '@peculiar/asn1-rsa@2.3.15': + resolution: {integrity: sha512-p6hsanvPhexRtYSOHihLvUUgrJ8y0FtOM97N5UEpC+VifFYyZa0iZ5cXjTkZoDwxJ/TTJ1IJo3HVTB2JJTpXvg==} + + '@peculiar/asn1-schema@2.3.15': + resolution: {integrity: sha512-QPeD8UA8axQREpgR5UTAfu2mqQmm97oUqahDtNdBcfj3qAnoXzFdQW+aNf/tD2WVXF8Fhmftxoj0eMIT++gX2w==} + + '@peculiar/asn1-x509@2.3.15': + resolution: {integrity: sha512-0dK5xqTqSLaxv1FHXIcd4Q/BZNuopg+u1l23hT9rOmQ1g4dNtw0g/RnEi+TboB0gOwGtrWn269v27cMgchFIIg==} + '@sideway/address@4.1.5': resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} @@ -324,6 +351,10 @@ packages: '@sideway/pinpoint@2.0.0': resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} + '@simplewebauthn/server@13.1.1': + resolution: {integrity: sha512-1hsLpRHfSuMB9ee2aAdh0Htza/X3f4djhYISrggqGe3xopNjOcePiSDkDDoPzDYaaMCrbqGP1H2TYU7bgL9PmA==} + engines: {node: '>=20.0.0'} + '@smithy/abort-controller@3.1.9': resolution: {integrity: sha512-yiW0WI30zj8ZKoSYNx90no7ugVn3khlyH/z5W8qtKBtVE6awRALbhSG+2SAHA1r6bO/6M9utxYKVZ3PCJ1rWxw==} engines: {node: '>=16.0.0'} @@ -559,6 +590,13 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + asn1js@3.0.5: + resolution: {integrity: sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==} + engines: {node: '>=12.0.0'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + atomic-sleep@1.0.0: resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} engines: {node: '>=8.0.0'} @@ -566,6 +604,9 @@ packages: avvio@9.1.0: resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==} + axios@1.7.9: + resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -583,6 +624,10 @@ packages: resolution: {integrity: sha512-P92xmHDQjSKPLHqFxefqMxASNq/aWJMEZugpCjf+AF/pgcUpMMQCg7t7+ewko0/u8AapvF3luf/FoehddEK+sA==} engines: {node: '>=16.20.1'} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + camel-case@4.1.2: resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} @@ -592,6 +637,10 @@ packages: change-case@4.1.2: resolution: {integrity: sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + constant-case@3.0.4: resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==} @@ -612,6 +661,10 @@ packages: supports-color: optional: true + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -619,6 +672,26 @@ packages: dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -675,6 +748,19 @@ packages: resolution: {integrity: sha512-Y5jIsuYR4BwWDYYQ2A/RWWE6gD8a0FMgtU+HOq1WKku+Cwdz8M1v8wcAmRXXM1/iqtoqg06v+LjAxMYbCjViMw==} engines: {node: '>=14'} + follow-redirects@1.15.9: + resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.2: + resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==} + engines: {node: '>= 6'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -682,11 +768,38 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.2.7: + resolution: {integrity: sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} deprecated: Glob versions prior to v9 are no longer supported + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + header-case@2.0.4: resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} @@ -739,9 +852,21 @@ packages: resolution: {integrity: sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==} engines: {node: 20 || >=22} + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + memory-pager@1.5.0: resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime@3.0.0: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} @@ -845,10 +970,20 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.3: + resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==} + engines: {node: '>=6.0.0'} + quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} @@ -1621,6 +1756,10 @@ snapshots: '@hapi/bourne': 3.0.0 '@hapi/hoek': 11.0.7 + '@hexagon/base64@1.1.28': {} + + '@levischuck/tiny-cbor@0.2.11': {} + '@lukeed/ms@2.0.2': {} '@mongodb-js/saslprep@1.1.9': @@ -1633,6 +1772,39 @@ snapshots: dependencies: '@noble/hashes': 1.6.1 + '@peculiar/asn1-android@2.3.15': + dependencies: + '@peculiar/asn1-schema': 2.3.15 + asn1js: 3.0.5 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.3.15': + dependencies: + '@peculiar/asn1-schema': 2.3.15 + '@peculiar/asn1-x509': 2.3.15 + asn1js: 3.0.5 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.3.15': + dependencies: + '@peculiar/asn1-schema': 2.3.15 + '@peculiar/asn1-x509': 2.3.15 + asn1js: 3.0.5 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.3.15': + dependencies: + asn1js: 3.0.5 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.3.15': + dependencies: + '@peculiar/asn1-schema': 2.3.15 + asn1js: 3.0.5 + pvtsutils: 1.3.6 + tslib: 2.8.1 + '@sideway/address@4.1.5': dependencies: '@hapi/hoek': 9.3.0 @@ -1641,6 +1813,16 @@ snapshots: '@sideway/pinpoint@2.0.0': {} + '@simplewebauthn/server@13.1.1': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.3.15 + '@peculiar/asn1-ecc': 2.3.15 + '@peculiar/asn1-rsa': 2.3.15 + '@peculiar/asn1-schema': 2.3.15 + '@peculiar/asn1-x509': 2.3.15 + '@smithy/abort-controller@3.1.9': dependencies: '@smithy/types': 3.7.2 @@ -2003,6 +2185,14 @@ snapshots: argparse@2.0.1: {} + asn1js@3.0.5: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.3 + tslib: 2.8.1 + + asynckit@0.4.0: {} + atomic-sleep@1.0.0: {} avvio@9.1.0: @@ -2010,6 +2200,14 @@ snapshots: '@fastify/error': 4.0.0 fastq: 1.17.1 + axios@1.7.9: + dependencies: + follow-redirects: 1.15.9 + form-data: 4.0.2 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + balanced-match@1.0.2: {} bcryptjs@3.0.0: {} @@ -2022,6 +2220,11 @@ snapshots: bson@6.10.1: {} + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + camel-case@4.1.2: dependencies: pascal-case: 3.1.2 @@ -2048,6 +2251,10 @@ snapshots: snake-case: 3.0.4 tslib: 2.8.1 + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + constant-case@3.0.4: dependencies: no-case: 3.0.4 @@ -2064,6 +2271,8 @@ snapshots: dependencies: ms: 2.1.3 + delayed-stream@1.0.0: {} + depd@2.0.0: {} dot-case@3.0.4: @@ -2071,6 +2280,27 @@ snapshots: no-case: 3.0.4 tslib: 2.8.1 + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.2.7 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + escape-html@1.0.3: {} fast-decode-uri-component@1.0.1: {} @@ -2155,10 +2385,39 @@ snapshots: fast-querystring: 1.1.2 safe-regex2: 4.0.0 + follow-redirects@1.15.9: {} + + form-data@4.0.2: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + mime-types: 2.1.35 + forwarded@0.2.0: {} fs.realpath@1.0.0: {} + function-bind@1.1.2: {} + + get-intrinsic@1.2.7: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + glob@8.1.0: dependencies: fs.realpath: 1.0.0 @@ -2167,6 +2426,18 @@ snapshots: minimatch: 5.1.6 once: 1.4.0 + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + header-case@2.0.4: dependencies: capital-case: 1.0.4 @@ -2231,8 +2502,16 @@ snapshots: lru-cache@11.0.2: {} + math-intrinsics@1.1.0: {} + memory-pager@1.5.0: {} + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime@3.0.0: {} minimatch@5.1.6: @@ -2344,8 +2623,16 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.3: {} + quick-format-unescaped@4.0.4: {} real-require@0.2.0: {} diff --git a/src/server.ts b/src/server.ts index 39c2b90..de7cff1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -16,8 +16,9 @@ import { rtsSchemas } from "./rts/rts.schema"; import { taskSchemas } from "./task/task.schema"; import { notificationSchemas } from "./notification/notification.schema"; import { noteSchemas } from "./note/note.schema"; +import { webAuthnRoutes } from "./webauthn/webauthn.route"; -const app = fastify({ logger: true }); +const app = fastify({ logger: true, trustProxy: true }); app.get("/health", (req, res) => { return { status: "OK" }; @@ -33,6 +34,7 @@ app.register(cors, { }); app.register(multipart, { limits: { fileSize: 50000000 } }); app.register(authRoutes, { prefix: "/auth" }); +app.register(webAuthnRoutes, { prefix: "/webauthn" }); app.register(routes, { prefix: "/api/v1" }); for (const schema of [ diff --git a/src/user/user.schema.ts b/src/user/user.schema.ts index 9ec2ed5..a6aaec8 100644 --- a/src/user/user.schema.ts +++ b/src/user/user.schema.ts @@ -28,6 +28,21 @@ const userSchema = new mongoose.Schema({ type: String, required: true, }, + passKeys: [], + challenge: new mongoose.Schema( + { + value: String, + expiry: Date, + }, + { _id: false } + ), + otp: new mongoose.Schema( + { + value: String, + expiry: Date, + }, + { _id: false } + ), createdAt: Date, createdBy: mongoose.Types.ObjectId, lastLogin: Date, diff --git a/src/utils/id.ts b/src/utils/id.ts index d68f997..c81f4dd 100644 --- a/src/utils/id.ts +++ b/src/utils/id.ts @@ -17,3 +17,13 @@ export async function generateToken(): Promise { }); }); } + +export function generateOTP() { + let otp = ""; + + for (let i = 0; i < 6; i++) { + otp += crypto.randomInt(10); + } + + return parseInt(otp); +} diff --git a/src/utils/mail.ts b/src/utils/mail.ts new file mode 100644 index 0000000..3bef401 --- /dev/null +++ b/src/utils/mail.ts @@ -0,0 +1,59 @@ +import axios from "axios"; + +let token = { + value: "", + expiry: new Date(), +}; + +export async function getToken() { + if (token.value != "" && new Date() < token.expiry) { + return token.value; + } + + const res = await axios({ + url: `https://accounts.zoho.com/oauth/v2/token?refresh_token=${process.env.ZOHO_REFRESH_TOKEN}&grant_type=refresh_token&client_id=${process.env.ZOHO_CLIENT_ID}&client_secret=${process.env.ZOHO_CLIENT_SECRET}`, + method: "POST", + }); + + if (res.data.status && res.data.status.code != 200) { + console.dir(res.data, { depth: null }); + throw "error fetching token"; + } + + token.value = res.data.access_token; + token.expiry = new Date(Date.now() + res.data.expires_in * 1000); + + return token.value; +} + +export async function sendMail( + to: string, + subject: string, + body: string +): Promise { + try { + const token = await getToken(); + + const res = await axios({ + url: `https://mail.zoho.com/api/accounts/${process.env.ZOHO_ACCOUNT_ID}/messages`, + method: "POST", + headers: { + Authorization: `Zoho-oauthtoken ${token}`, + }, + data: { + fromAddress: "akhil.reddy@qualyval.com", + toAddress: to, + subject, + content: body, + mailFormat: "plaintext", + }, + }); + } catch (err) { + if (err.response) console.log(err.response.data); + else console.log(err); + + return false; + } + + return true; +} diff --git a/src/webauthn/webauthn.route.ts b/src/webauthn/webauthn.route.ts new file mode 100644 index 0000000..11a8538 --- /dev/null +++ b/src/webauthn/webauthn.route.ts @@ -0,0 +1,213 @@ +import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { + generateAuthenticationOptions, + generateRegistrationOptions, + RegistrationResponseJSON, + VerifiedAuthenticationResponse, + verifyAuthenticationResponse, + verifyRegistrationResponse, +} from "@simplewebauthn/server"; +import { isoUint8Array } from "@simplewebauthn/server/helpers"; +import { getUserByEmail } from "../user/user.service"; +import { createSession } from "../auth/auth.service"; +import { generateOTP } from "../utils/id"; +import { sendMail } from "../utils/mail"; + +const rpID = process.env.SERVER_DOMAIN; +const rpName = "Quicker Permits"; +const origin = `http://${rpID}:3000`; + +export async function webAuthnRoutes(fastify: FastifyInstance) { + fastify.post<{ Body: { email: string } }>( + "/register/request", + { + schema: { + body: { + type: "object", + properties: { + email: { type: "string" }, + }, + }, + }, + }, + async (req, res: FastifyReply) => { + const { email } = req.body; + + if (!email) { + return res.code(400).send({ error: "Email is required" }); + } + + const userInDB = await getUserByEmail(email); + if (!userInDB) return res.code(400).send({ error: "not allowed" }); + + const otp = generateOTP(); + + const userId = userInDB.id; + const options = await generateRegistrationOptions({ + rpName, + rpID, + userID: isoUint8Array.fromUTF8String(userId), + userName: email, + attestationType: "none", + excludeCredentials: userInDB.passKeys.map((cred) => ({ + id: cred.credentialID, + type: "public-key", + transports: cred.transports, + })), + }); + + userInDB.challenge = { + value: options.challenge, + expiry: new Date(Date.now() + 60 * 5 * 1000), + }; + + userInDB.otp = { + value: otp, + expiry: new Date(Date.now() + 60 * 5 * 1000), + }; + + await userInDB.save(); + + const sent = await sendMail( + email, + "Code for Quicker Permits Authentication", + `Your code is ${otp}` + ); + if (!sent) return res.code(500).send({ error: "server error" }); + + return res.send(options); + } + ); + + fastify.post<{ + Body: { + email: string; + code: string; + attestationResponse: RegistrationResponseJSON; + }; + }>("/register/verify", async (req, res: FastifyReply) => { + const { email, code, attestationResponse } = req.body; + + const userInDB = await getUserByEmail(email); + if (!userInDB) return res.code(400).send({ error: "not allowed" }); + + const challenge = userInDB.challenge; + const requiredOTP = userInDB.otp; + + if (!challenge || !requiredOTP) + return res.code(400).send({ error: "not allowed" }); + + if (new Date() > challenge.expiry || new Date() > requiredOTP.expiry) + return res.code(400).send({ error: "challenge expired" }); + + if (code != requiredOTP.value) + return res.code(400).send({ error: "invalid code" }); + + try { + const verification = await verifyRegistrationResponse({ + response: attestationResponse, + expectedChallenge: challenge.value as string, + expectedRPID: rpID, + expectedOrigin: origin, + }); + + if (!verification.verified) { + return res.code(400).send({ error: "registration failed" }); + } + + userInDB.passKeys.push({ + credentialID: verification.registrationInfo.credential.id, + credentialPublicKey: verification.registrationInfo.credential.publicKey, + counter: verification.registrationInfo.credential.counter, + transports: attestationResponse.response.transports, + }); + + await userInDB.save(); + + return res.code(200).send({ success: "registration complete" }); + } catch (error) { + return res.code(400).send({ error: error.message }); + } + }); + + fastify.post<{ Body: { email: string } }>( + "/login/request", + async (req, res) => { + const { email } = req.body; + + const userInDB = await getUserByEmail(email); + + if (!userInDB || userInDB.passKeys.length === 0) { + return res.code(400).send({ error: "user not registered" }); + } + + const options: PublicKeyCredentialRequestOptionsJSON = + await generateAuthenticationOptions({ + rpID, + allowCredentials: userInDB.passKeys.map((cred) => ({ + id: cred.credentialID, + type: "public-key", + transports: cred.transports, + })), + userVerification: "preferred", + }); + + userInDB.challenge.value = options.challenge; + userInDB.challenge.expiry = new Date(Date.now() + 60 * 5 * 1000); + + await userInDB.save(); + + return res.send(options); + } + ); + + // Authentication Verification (Step 4) + fastify.post<{ Body: { email: string; assertionResponse: any } }>( + "/login/verify", + async (req, res) => { + const { email, assertionResponse } = req.body; + + const userInDB = await getUserByEmail(email); + if (!userInDB) return res.code(400).send({ error: "not allowed" }); + + const challenge = userInDB.challenge; + if (!challenge) return res.code(400).send({ error: "not allowed" }); + + if (new Date() > challenge.expiry) + return res.code(400).send({ error: "challenge expired" }); + + try { + const credential = userInDB.passKeys.find( + (cred) => cred.credentialID === assertionResponse.id + ); + + if (!credential) + return res.code(400).send({ error: "credential not found" }); + + const verification: VerifiedAuthenticationResponse = + await verifyAuthenticationResponse({ + response: assertionResponse, + expectedChallenge: userInDB.challenge.value as string, + expectedRPID: rpID, + expectedOrigin: origin, + credential: credential, + }); + + if (!verification.verified) + return res.code(400).send({ error: "Authentication failed" }); + + credential.counter = verification.authenticationInfo.newCounter; + + const newSession = await createSession( + userInDB.id, + req.ip, + req.headers["user-agent"] + ); + + res.send({ session_token: newSession.sid }); + } catch (error) { + res.code(400).send({ error: (error as Error).message }); + } + } + ); +}