├── .env
├── CODE_OF_CONDUCT.md
├── .github
└── dependabot.yml
├── package.json
├── aws
├── CreateAuthChallenge.js
├── DefineAuthChallenge.js
├── VerifyAuthChallenge.js
└── UserPoolTemplate.yaml
├── LICENSE
├── server.js
├── views
└── webauthn.html
├── CONTRIBUTING.md
├── libs
└── authn.js
├── README.md
└── public
└── webauthn-client.js
/.env:
--------------------------------------------------------------------------------
1 | # Environment Config
2 | HOSTNAME=webauthn.cloud.mmatouk.net/
3 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | ## Code of Conduct
2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct).
3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact
4 | opensource-codeofconduct@amazon.com with any additional questions or comments.
5 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "npm" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "daily"
12 |
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "webauthn-with-amazon-cognito",
3 | "version": "0.0.1",
4 | "description": "Node app to demonestrate WebAuthn with Cognito",
5 | "main": "server.js",
6 | "scripts": {
7 | "start": "node server.js"
8 | },
9 | "dependencies": {
10 | "amazon-cognito-identity-js": "^5.2.10",
11 | "cookie-parser": "^1.4.6",
12 | "dotenv": "^16.0.1",
13 | "express": "^4.18.1",
14 | "fido2-lib": "agektmr/fido2-lib#android-compatible3",
15 | "hbs": "^4.2.0",
16 | "helmet": "^5.1.1"
17 | },
18 | "engines": {
19 | "node": "12.x"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/aws/CreateAuthChallenge.js:
--------------------------------------------------------------------------------
1 | const crypto = require("crypto");
2 |
3 | exports.handler = async (event) => {
4 | console.log(event);
5 |
6 | var publicKeyCred = event.request.userAttributes["custom:publicKeyCred"];
7 | var publicKeyCredJSON = Buffer.from(publicKeyCred, 'base64').toString('ascii');
8 | console.log(JSON.parse(publicKeyCredJSON));
9 |
10 | const challenge = crypto.randomBytes(64).toString('hex');
11 |
12 | event.response.publicChallengeParameters = {
13 | credId: JSON.parse(publicKeyCredJSON).id, //credetnial id
14 | challenge: challenge
15 | };
16 |
17 | event.response.privateChallengeParameters = { challenge : challenge};
18 | console.log("privateChallengeParameters="+event.response.privateChallengeParameters);
19 |
20 | return event;
21 | };
22 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of
4 | this software and associated documentation files (the "Software"), to deal in
5 | the Software without restriction, including without limitation the rights to
6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
7 | the Software, and to permit persons to whom the Software is furnished to do so.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
10 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
11 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
12 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
13 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
14 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
15 |
16 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | // init project
2 | const AmazonCognitoIdentity = require('amazon-cognito-identity-js');
3 | require('dotenv');
4 | const express = require('express');
5 | const cookieParser = require('cookie-parser');
6 | const hbs = require('hbs');
7 | const authn = require('./libs/authn');
8 | const helmet = require('helmet');
9 | const app = express();
10 | app.use(helmet());
11 |
12 |
13 | app.set('view engine', 'html');
14 | app.engine('html', hbs.__express);
15 | app.set('views', './views');
16 | app.use(cookieParser());
17 | app.use(express.json());
18 | app.use(express.static('public'));
19 |
20 | app.use((req, res, next) => {
21 | if (req.get('x-forwarded-proto') &&
22 | (req.get('x-forwarded-proto')).split(',')[0] !== 'https') {
23 | return res.redirect(301, `https://${req.get('host')}`);
24 | }
25 | req.schema = 'https';
26 | next();
27 | });
28 |
29 | // http://expressjs.com/en/starter/basic-routing.html
30 | app.get('/', (req, res) => {
31 | res.render('webauthn.html');
32 | });
33 |
34 | app.get('/webauthn', (req, res) => {
35 | res.render('webauthn.html');
36 | });
37 |
38 | app.use('/authn', authn);
39 |
40 | // listen for req :)
41 | const port = 8080;
42 | const listener = app.listen(port, () => {
43 | console.log('Your app is listening on port ' + listener.address().port);
44 | });
45 |
--------------------------------------------------------------------------------
/views/webauthn.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | WebAuthn with Cognito
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | WebAuthn with Cognito
27 |
28 |
29 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/aws/DefineAuthChallenge.js:
--------------------------------------------------------------------------------
1 | /**
2 | * 1- if user doesn't exist, throw exception
3 | * 2- if CUSTOM_CHALLENGE answer is correct, authentication successful
4 | * 3- if PASSWORD_VERIFIER challenge answer is correct, return custom challeneg (3,4 will be appliable if password+fido is selected)
5 | * 4- if challenge name is SRP_A, return PASSWORD_VERIFIER challenge (3,4 will be appliable if password+fido is selected)
6 | * 5- if 5 attempts with no correct answer, fail authentication
7 | * 6- default is to respond with CUSTOM_CHALLENEG --> password-less authentication
8 | * */
9 |
10 | exports.handler = (event, context, callback) => {
11 |
12 | console.log(event);
13 | console.log(event.request.session);
14 | console.log(context);
15 |
16 | // If user is not registered
17 | if (event.request.userNotFound) {
18 | event.response.issueToken = false;
19 | event.response.failAuthentication = true;
20 | throw new Error("User does not exist");
21 | }
22 |
23 | if (event.request.session &&
24 | event.request.session.length &&
25 | event.request.session.slice(-1)[0].challengeName === 'CUSTOM_CHALLENGE' &&
26 | event.request.session.slice(-1)[0].challengeResult === true) {
27 | // The user provided the right answer; succeed auth
28 | event.response.issueTokens = true;
29 | event.response.failAuthentication = false;
30 |
31 | }else if (event.request.session &&
32 | event.request.session.length &&
33 | event.request.session.slice(-1)[0].challengeName === 'PASSWORD_VERIFIER' &&
34 | event.request.session.slice(-1)[0].challengeResult === true){
35 |
36 | event.response.issueTokens = false;
37 | event.response.failAuthentication = false;
38 | event.response.challengeName = 'CUSTOM_CHALLENGE';
39 |
40 | }else if (event.request.session &&
41 | event.request.session.length &&
42 | event.request.session.slice(-1)[0].challengeName === 'SRP_A'){
43 |
44 | event.response.issueTokens = false;
45 | event.response.failAuthentication = false;
46 | event.response.challengeName = 'PASSWORD_VERIFIER';
47 |
48 | }else if(event.request.session.length >= 5 &&
49 | event.request.session.slice(-1)[0].challengeResult === false){
50 |
51 | event.response.issueToken = false;
52 | event.response.failAuthentication = true;
53 | throw new Error("Invalid credentials");
54 | }else{
55 |
56 | event.response.issueTokens = false;
57 | event.response.failAuthentication = false;
58 | event.response.challengeName = 'CUSTOM_CHALLENGE';
59 |
60 | }
61 |
62 | // Return to Amazon Cognito
63 | callback(null, event);
64 | }
65 |
--------------------------------------------------------------------------------
/aws/VerifyAuthChallenge.js:
--------------------------------------------------------------------------------
1 | var crypto = require("crypto");
2 |
3 | exports.handler = async (event) => {
4 | console.log(event);
5 |
6 | //--------get private challenge data
7 | const challenge = event.request.privateChallengeParameters.challenge;
8 | const credId = event.request.privateChallengeParameters.credId;
9 |
10 | //--------publickey information
11 | var publicKeyCred = event.request.userAttributes["custom:publicKeyCred"];
12 | var publicKeyCredJSON = JSON.parse(Buffer.from(publicKeyCred, 'base64').toString('ascii'));
13 |
14 | //-------get challenge ansower
15 | const challengeAnswerJSON = JSON.parse(event.request.challengeAnswer);
16 |
17 | const verificationResult = await validateAssertionSignature(publicKeyCredJSON, challengeAnswerJSON);
18 | console.log("Verification Results:"+verificationResult);
19 |
20 | if (verificationResult) {
21 | event.response.answerCorrect = true;
22 | } else {
23 | event.response.answerCorrect = false;
24 | }
25 | return event;
26 | };
27 |
28 | async function validateAssertionSignature(publicKeyCredJSON, challengeAnswerJSON) {
29 |
30 | var expectedSignature = toArrayBuffer(challengeAnswerJSON.response.signature, "signature");
31 | var publicKey = publicKeyCredJSON.publicKey;
32 | var rawAuthnrData = toArrayBuffer(challengeAnswerJSON.response.authenticatorData, "authenticatorData");
33 | var rawClientData = toArrayBuffer(challengeAnswerJSON.response.clientDataJSON, "clientDataJSON");
34 |
35 | const hash = crypto.createHash("SHA256");
36 | hash.update(Buffer.from(new Uint8Array(rawClientData)));
37 | var clientDataHashBuf = hash.digest();
38 | var clientDataHash = new Uint8Array(clientDataHashBuf).buffer;
39 |
40 | const verify = crypto.createVerify("SHA256");
41 | verify.write(Buffer.from(new Uint8Array(rawAuthnrData)));
42 | verify.write(Buffer.from(new Uint8Array(clientDataHash)));
43 | verify.end();
44 |
45 | var res = null;
46 | try {
47 | res = verify.verify(publicKey, Buffer.from(new Uint8Array(expectedSignature)));
48 | } catch (e) {console.error(e);}
49 |
50 | return res;
51 | }
52 |
53 | function toArrayBuffer(buf, name) {
54 | if (!name) {
55 | throw new TypeError("name not specified");
56 | }
57 |
58 | if (typeof buf === "string") {
59 | buf = buf.replace(/-/g, "+").replace(/_/g, "/");
60 | buf = Buffer.from(buf, "base64");
61 | }
62 |
63 | if (buf instanceof Buffer || Array.isArray(buf)) {
64 | buf = new Uint8Array(buf);
65 | }
66 |
67 | if (buf instanceof Uint8Array) {
68 | buf = buf.buffer;
69 | }
70 |
71 | if (!(buf instanceof ArrayBuffer)) {
72 | throw new TypeError(`could not convert '${name}' to ArrayBuffer`);
73 | }
74 |
75 | return buf;
76 | }
77 |
--------------------------------------------------------------------------------
/aws/UserPoolTemplate.yaml:
--------------------------------------------------------------------------------
1 | AWSTemplateFormatVersion: 2010-09-09
2 | Transform: AWS::Serverless-2016-10-31
3 | Resources:
4 | UserPool:
5 | Type: AWS::Cognito::UserPool
6 | Properties:
7 | AdminCreateUserConfig:
8 | AllowAdminCreateUserOnly: false
9 | UserPoolName: !Sub ${AWS::StackName}-UserPool
10 | AutoVerifiedAttributes:
11 | - email
12 | LambdaConfig:
13 | DefineAuthChallenge: !GetAtt DefineAuthChallenge.Arn
14 | CreateAuthChallenge: !GetAtt CreateAuthChallenge.Arn
15 | VerifyAuthChallengeResponse: !GetAtt VerifyAuthChallenge.Arn
16 | Schema:
17 | - AttributeDataType: String
18 | Name: publicKeyCred
19 | Mutable: true
20 | StringAttributeConstraints:
21 | MaxLength: 1024
22 |
23 | UserPoolClient:
24 | Type: AWS::Cognito::UserPoolClient
25 | Properties:
26 | ClientName: my-app
27 | GenerateSecret: false
28 | UserPoolId: !Ref UserPool
29 | ExplicitAuthFlows:
30 | - ALLOW_CUSTOM_AUTH
31 | - ALLOW_REFRESH_TOKEN_AUTH
32 | - ALLOW_USER_SRP_AUTH
33 | WriteAttributes:
34 | - custom:publicKeyCred
35 | - email
36 | - name
37 | ReadAttributes:
38 | - email
39 | - name
40 |
41 | DefineAuthChallenge:
42 | Type: AWS::Serverless::Function
43 | Properties:
44 | FunctionName: !Sub ${AWS::StackName}-DefineAuthChallenge
45 | CodeUri: s3://webauthn-with-amazon-cognito/DefineAuthChallenge
46 | Handler: index.handler
47 | Runtime: nodejs12.x
48 | MemorySize: 1024
49 | Timeout: 30
50 | Tracing: Active
51 | DefineAuthChallengePermission:
52 | Type: AWS::Lambda::Permission
53 | Properties:
54 | FunctionName: !GetAtt DefineAuthChallenge.Arn
55 | Principal: cognito-idp.amazonaws.com
56 | Action: lambda:InvokeFunction
57 | SourceArn: !GetAtt UserPool.Arn
58 |
59 | CreateAuthChallenge:
60 | Type: AWS::Serverless::Function
61 | Properties:
62 | FunctionName: !Sub ${AWS::StackName}-CreateAuthChallenge
63 | CodeUri: s3://webauthn-with-amazon-cognito/CreateAuthChallenge
64 | Handler: index.handler
65 | Runtime: nodejs12.x
66 | MemorySize: 1024
67 | Timeout: 30
68 | Tracing: Active
69 | CreateAuthChallengePermission:
70 | Type: AWS::Lambda::Permission
71 | Properties:
72 | FunctionName: !GetAtt CreateAuthChallenge.Arn
73 | Principal: cognito-idp.amazonaws.com
74 | Action: lambda:InvokeFunction
75 | SourceArn: !GetAtt UserPool.Arn
76 |
77 | VerifyAuthChallenge:
78 | Type: AWS::Serverless::Function
79 | Properties:
80 | FunctionName: !Sub ${AWS::StackName}-VerifyAuthChallenge
81 | CodeUri: s3://webauthn-with-amazon-cognito/VerifyAuthChallenge
82 | Handler: index.handler
83 | Runtime: nodejs12.x
84 | MemorySize: 1024
85 | Timeout: 30
86 | Tracing: Active
87 | VerifyAuthChallengePermission:
88 | Type: AWS::Lambda::Permission
89 | Properties:
90 | FunctionName: !GetAtt VerifyAuthChallenge.Arn
91 | Principal: cognito-idp.amazonaws.com
92 | Action: lambda:InvokeFunction
93 | SourceArn: !GetAtt UserPool.Arn
94 |
95 | Outputs :
96 | UserPoolId:
97 | Value: !Ref 'UserPool'
98 | AppClientID:
99 | Value: !Ref 'UserPoolClient'
100 |
101 |
102 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing Guidelines
2 |
3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional
4 | documentation, we greatly value feedback and contributions from our community.
5 |
6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary
7 | information to effectively respond to your bug report or contribution.
8 |
9 |
10 | ## Reporting Bugs/Feature Requests
11 |
12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features.
13 |
14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already
15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful:
16 |
17 | * A reproducible test case or series of steps
18 | * The version of our code being used
19 | * Any modifications you've made relevant to the bug
20 | * Anything unusual about your environment or deployment
21 |
22 |
23 | ## Contributing via Pull Requests
24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that:
25 |
26 | 1. You are working against the latest source on the *master* branch.
27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already.
28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted.
29 |
30 | To send us a pull request, please:
31 |
32 | 1. Fork the repository.
33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change.
34 | 3. Ensure local tests pass.
35 | 4. Commit to your fork using clear commit messages.
36 | 5. Send us a pull request, answering any default questions in the pull request interface.
37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation.
38 |
39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and
40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/).
41 |
42 |
43 | ## Finding contributions to work on
44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start.
45 |
46 |
47 | ## Code of Conduct
48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct).
49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact
50 | opensource-codeofconduct@amazon.com with any additional questions or comments.
51 |
52 |
53 | ## Security issue notifications
54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue.
55 |
56 |
57 | ## Licensing
58 |
59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution.
60 |
61 | We may ask you to sign a [Contributor License Agreement (CLA)](http://en.wikipedia.org/wiki/Contributor_License_Agreement) for larger changes.
62 |
--------------------------------------------------------------------------------
/libs/authn.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.
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 | * A copy of the License is located at
7 | *
8 | * http://aws.amazon.com/apache2.0/
9 | *
10 | * or in the "license" file accompanying this file.
11 | * This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12 | * express or implied. See the License for the specific language governing
13 | * permissions and limitations under the License.
14 | */
15 |
16 | const express = require('express');
17 | const router = express.Router();
18 | const { Fido2Lib } = require('fido2-lib');
19 | const { coerceToBase64Url, coerceToArrayBuffer } = require('fido2-lib/lib/utils');
20 |
21 | router.use(express.json());
22 |
23 | const f2l = new Fido2Lib({
24 | timeout: 30*1000*60,
25 | //rpId: process.env.HOSTNAME,
26 | rpName: "WebAuthn With Cognito",
27 | challengeSize: 32,
28 | cryptoParams: [-7]
29 | });
30 |
31 |
32 | /**
33 | * Respond with required information to call navigator.credential.create()
34 | * Response format:
35 | * {
36 | rp: {
37 | id: String,
38 | name: String
39 | },
40 | user: {
41 | displayName: String,
42 | id: String,
43 | name: String
44 | },
45 | publicKeyCredParams: [{
46 | type: 'public-key', alg: -7
47 | }],
48 | timeout: Number,
49 | challenge: String,
50 | allowCredentials : [{
51 | id: String,
52 | type: 'public-key',
53 | transports: [('ble'|'nfc'|'usb'|'internal'), ...]
54 | }, ...],
55 | authenticatorSelection: {
56 | authenticatorAttachment: ('platform'|'cross-platform'),
57 | requireResidentKey: Boolean,
58 | userVerification: ('required'|'preferred'|'discouraged')
59 | },
60 | attestation: ('none'|'indirect'|'direct')
61 | * }
62 | **/
63 | router.post('/createCredRequest', async (req, res) => {
64 | f2l.config.rpId = `${req.get('host')}`;
65 |
66 | try {
67 |
68 | const response = await f2l.attestationOptions();
69 | response.user = {
70 | displayName: req.body.name,
71 | id: req.body.username,
72 | name: req.body.username
73 | };
74 | response.challenge = coerceToBase64Url(response.challenge, 'challenge');
75 |
76 | response.excludeCredentials = [];
77 | response.pubKeyCredParams = [];
78 | // const params = [-7, -35, -36, -257, -258, -259, -37, -38, -39, -8];
79 | const params = [-7, -257];
80 | for (let param of params) {
81 | response.pubKeyCredParams.push({type:'public-key', alg: param});
82 | }
83 | const as = {}; // authenticatorSelection
84 | const aa = req.body.authenticatorSelection.authenticatorAttachment;
85 | const rr = req.body.authenticatorSelection.requireResidentKey;
86 | const uv = req.body.authenticatorSelection.userVerification;
87 | const cp = req.body.attestation; // attestationConveyancePreference
88 | let asFlag = false;
89 |
90 | if (aa && (aa == 'platform' || aa == 'cross-platform')) {
91 | asFlag = true;
92 | as.authenticatorAttachment = aa;
93 | }
94 | if (rr && typeof rr == 'boolean') {
95 | asFlag = true;
96 | as.requireResidentKey = rr;
97 | }
98 | if (uv && (uv == 'required' || uv == 'preferred' || uv == 'discouraged')) {
99 | asFlag = true;
100 | as.userVerification = uv;
101 | }
102 | if (asFlag) {
103 | response.authenticatorSelection = as;
104 | }
105 | if (cp && (cp == 'none' || cp == 'indirect' || cp == 'direct')) {
106 | response.attestation = cp;
107 | }
108 |
109 | res.json(response);
110 | } catch (e) {
111 | res.status(400).send({ error: e });
112 | }
113 | });
114 |
115 |
116 | /**
117 | * Register user credential.
118 | * Input format:
119 | * {
120 | id: String,
121 | type: 'public-key',
122 | rawId: String,
123 | response: {
124 | clientDataJSON: String,
125 | attestationObject: String,
126 | signature: String,
127 | userHandle: String
128 | }
129 | * }
130 | **/
131 | router.post('/parseCredResponse', async (req, res) => {
132 | f2l.config.rpId = `${req.get('host')}`;
133 |
134 | try {
135 | const clientAttestationResponse = { response: {} };
136 | clientAttestationResponse.rawId = coerceToArrayBuffer(req.body.rawId, "rawId");
137 | clientAttestationResponse.response.clientDataJSON = coerceToArrayBuffer(req.body.response.clientDataJSON, "clientDataJSON");
138 | clientAttestationResponse.response.attestationObject = coerceToArrayBuffer(req.body.response.attestationObject, "attestationObject");
139 |
140 | let origin = `https://${req.get('host')}`;
141 |
142 | const attestationExpectations = {
143 | challenge: req.body.challenge,
144 | origin: origin,
145 | factor: "either"
146 | };
147 |
148 | const regResult = await f2l.attestationResult(clientAttestationResponse, attestationExpectations);
149 |
150 | const credential = {
151 | credId: coerceToBase64Url(regResult.authnrData.get("credId"), 'credId'),
152 | publicKey: regResult.authnrData.get("credentialPublicKeyPem"),
153 | aaguid: coerceToBase64Url(regResult.authnrData.get("aaguid"), 'aaguid'),
154 | prevCounter: regResult.authnrData.get("counter"),
155 | flags: regResult.authnrData.get("flags")
156 | };
157 |
158 | // Respond with user info
159 | res.json(credential);
160 | } catch (e) {
161 | res.status(400).send({ error: e.message });
162 | }
163 | });
164 |
165 |
166 | module.exports = router;
167 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # WebAuthn with Amazon Cognito
2 |
3 | This project is a demonstration of how to implement FIDO-based authentication with Amazon Cognito user pools.
4 |
5 | ## Requirements
6 | - AWS account and permissions to create CloudFormation stacks, Cognito resources and lambda functions
7 | - Nodejs and NPM
8 | - Browser and security key that supports FIDO2. Refer to [FIDO Alliance]
9 |
10 | ## Deployment steps
11 | ###### Clone the project
12 | ```sh
13 | $ git clone https://github.com/aws-samples/webauthn-with-amazon-cognito.git
14 | $ cd webauthn-with-amazon-cognito
15 | ```
16 | ###### Create Cognito resaources and lambda triggers
17 | ```sh
18 | $ aws cloudformation create-stack --stack-name webauthn-cognito --template-body file://aws/UserPoolTemplate.yaml --capabilities CAPABILITY_AUTO_EXPAND CAPABILITY_IAM CAPABILITY_NAMED_IAM --region us-west-2
19 | ```
20 | Wait for the stack to be created successfully and then get the user-pool-id and app-client-id from outputs section. you can do this from CloudFromation console or using describe-stacks command
21 | ```sh
22 | $ aws cloudformation describe-stacks --stack-name webauthn-cognito --region us-west-2
23 | ```
24 | Edit the file public/webauthn-client.js to use the new user-pool that you just created.
25 | ```javascript
26 | var poolData = {
27 | UserPoolId: 'user_pool_id',
28 | ClientId: 'app_client_id'
29 | };
30 | ```
31 | ###### Install and run the application
32 | ```sh
33 | $ npm install
34 | $ node server.js
35 | ```
36 | ###### Note
37 | **WebAuthn APIs will be exposed by the user-agent only if secure transport is established without errors. This means you have to access the demo application via HTTPS.
38 | In the demo recording below, I used AWS Cloud9 which gives you a quick way to deploy and test the app. if you deploy this app on your own workstation or on a separate VM, you need to configure SSL.**
39 |
40 | Here is a quick demo of deploying and running this project in a fresh Cloud9 environment.
41 |
42 | [](https://webauthn-with-amazon-cognito.s3-us-west-2.amazonaws.com/WebAuthn.mp4)
43 |
44 | [FIDO Alliance]:
45 | [blog post]:
46 |
47 | ## User registration
48 | Registration starts by calling createCredential function in webauthn-client.js. This function will construct credentials options object and use it to create credentials with an available authenticator.
49 |
50 | Creating credentials will use `navigator.credentials.create` browser API, this API takes createCredentialOptions object as input and this object contains parameters about the relying party, the user and some flags to indicate which authenticators are allowed and whether user verification is required or not. In this demo, credentialOptions object is created server side using `createCredRequest` in libs/authn.js
51 |
52 | The dictionary structure of CreateCredentialOptions object could include parameters as below (note that not all parameters are required and this is an extension point that can be extended in the future to support additional parameters):
53 | ```javascript
54 | {
55 | rp: {
56 | id: String,
57 | name: String
58 | },
59 | user: {
60 | displayName: String,
61 | id: String,
62 | name: String
63 | },
64 | publicKeyCredParams: [{
65 | type: 'public-key', alg: -7
66 | }],
67 | timeout: Number,
68 | challenge: String,
69 | allowCredentials : [{
70 | id: String,
71 | type: 'public-key',
72 | transports: [('ble'|'nfc'|'usb'|'internal')]
73 | }],
74 | authenticatorSelection: {
75 | authenticatorAttachment: ('platform'|'cross-platform'),
76 | requireResidentKey: Boolean,
77 | userVerification: ('required'|'preferred'|'discouraged')
78 | },
79 | attestation: ('none'|'indirect'|'direct')
80 | }
81 | ```
82 | After creating credentials, `createCredential` function will parse response from authenticator to extract credential-id and public-key then it will call signUp function to start the signUp process with Cognito and will store the public-key and credential-id as custom attribute in cognito.
83 |
84 | ## User authentication
85 | This demo application includes multiple scenarios for demonestration and education purposes.
86 |
87 | Authentication starts by calling `signIn()` function in webauthn-client.js. This function will evaluate which sign-in option was chosen; e.g. sign-in with password only (for example to sign in with temp password for account recovery if authenticator device is lost), sign-in with FIDO only (this is the passwordless option) OR sign-in with password + FIDO (this is when using password as primary factor and using FIDO as second factor).
88 |
89 | Based on the selected option, `signIn()` will make a call to authentication the user with Cognito. Authentication flows that utilize FIDO will be sent to Cognito as CUSTOM_AUTH flows, this will trigger Define Auth Challenge and process the authentication with custom challenge.
90 |
91 | On client-side, FIDO challenge will be triggered when client receives a `customChallenge` response in the `authCallBack` function, this will use the challenge and credential-id returned in custom challenge to call `navigator.credentials.get` browser API which will ask the user to use the authenticator to sign-in. Authenticator will then validate inputs (relying party, credential-id ...etc. ) and after validation, authenticator response is sent to cognito using `cognitoUser.sendCustomChallengeAnswer` API and will be verified in Verify Auth Challenge lambda trigger.
92 |
93 | ## Lambda triggers
94 | The cloudformation template aws/UserPoolTemplate.yaml will deploy three lambda triggers to implement custom authentication flow.
95 |
96 | ###### Define Auth Challenge
97 | This lamda function is triggered when authentication flow is CUSTOM_AUTH to evaluate the authentication progress and decide what is the next step. For reference, the code for this lambda trigger is under aws/DefineAuthChallenge.js
98 |
99 | Define auth challenge will go through the logic below to decide next challenge:
100 |
101 | ```javascript
102 | /**
103 | * 1- if user doesn't exist, throw exception
104 | * 2- if CUSTOM_CHALLENGE answer is correct, authentication successful (issue-tokens will be set to true)
105 | * 3- if PASSWORD_VERIFIER challenge answer is correct, return custom challenge (steps 3,4 will be applicable if password+fido is selected and these steps handle SRP authentication)
106 | * 4- if challenge name is SRP_A, return PASSWORD_VERIFIER challenge (steps 3,4 will be appliable if password+fido is selected and these steps handle SRP authentication)
107 | * 5- if 5 attempts with no correct answer, fail authentication
108 | * 6- default is to respond with CUSTOM_CHALLENGE --> password-less authentication
109 | * */
110 | ```
111 |
112 | ###### Create Auth Challenge
113 | This lambda function is triggered when the next step (set from define auth challenge) is CUSTOM_CHALLENGE. For reference, the code of this lambda trigger is under aws/CreateAuthChallenge.js
114 |
115 | This function will do three things:
116 | 1- extract credential-id from user's profile (this is the credential-id created by authenticator during registration step)
117 | 2- create random string to be used as a chanllenge
118 | 3- return credential-id and challenge string to client as custom challenge
119 |
120 | ###### Verify Auth Challenge
121 | This lambda will be triggered when challenge response is passed on from client to Cognito service. challenge response includes the response generated from authenticator device, this response will be parsed and validated using the stored paublic-key in user's profile. For reference, the code of this lambda trigger is under aws/DefineAuthChallenge.js
122 |
123 | ## Security
124 |
125 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information.
126 |
127 | ## License
128 |
129 | This library is licensed under the MIT-0 License. See the LICENSE file.
130 |
131 |
--------------------------------------------------------------------------------
/public/webauthn-client.js:
--------------------------------------------------------------------------------
1 | /*
2 | * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.
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 | * A copy of the License is located at
7 | *
8 | * http://aws.amazon.com/apache2.0/
9 | *
10 | * or in the "license" file accompanying this file.
11 | * This file is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12 | * express or implied. See the License for the specific language governing
13 | * permissions and limitations under the License.
14 | */
15 |
16 |
17 | let globalRegisteredCredentials = "";
18 | let globalRegisteredCredentialsJSON = {};
19 |
20 | let poolData = {
21 | UserPoolId: 'user-pool-id', // Your user pool id here
22 | ClientId: 'client-id' //Your app client id here
23 |
24 | };
25 | let userPool = new AmazonCognitoIdentity.CognitoUserPool(poolData);
26 |
27 | //create credentials using platform or roaming authenticator
28 | createCredential = async () => {
29 |
30 | try {
31 |
32 | //build the credentials options requirements
33 | var credOptionsRequest = {
34 | attestation: 'none',
35 | username: $("#reg-username").val() ,
36 | name: $("#reg-username").val(),
37 | authenticatorSelection: {
38 | authenticatorAttachment: ['platform','cross-platform'],
39 | userVerification: 'preferred',
40 | requireResidentKey: false
41 | }
42 | };
43 |
44 | //generate credentials request to be sent to navigator.credentials.create
45 | var credOptions = await _fetch('/authn/createCredRequest' , credOptionsRequest);
46 | var challenge = credOptions.challenge;
47 | credOptions.user.id = base64url.decode(credOptions.user.id);
48 | credOptions.challenge = base64url.decode(credOptions.challenge);
49 |
50 | //----------create credentials using available authenticator
51 | const cred = await navigator.credentials.create({
52 | publicKey: credOptions
53 | });
54 |
55 | // parse credentials response to extract id and public-key, this is the information needed to register the user in Cognito
56 | const credential = {};
57 | credential.id = cred.id;
58 | credential.rawId = base64url.encode(cred.rawId);
59 | credential.type = cred.type;
60 | credential.challenge = challenge;
61 |
62 | if (cred.response) {
63 | const clientDataJSON = base64url.encode(cred.response.clientDataJSON);
64 | const attestationObject = base64url.encode(cred.response.attestationObject);
65 | credential.response = {
66 | clientDataJSON,
67 | attestationObject
68 | };
69 | }
70 |
71 | credResponse = await _fetch('/authn/parseCredResponse' , credential);
72 |
73 | globalRegisteredCredentialsJSON = {id: credResponse.credId,publicKey: credResponse.publicKey};
74 | globalRegisteredCredentials = JSON.stringify(globalRegisteredCredentialsJSON);
75 | console.log(globalRegisteredCredentials);
76 |
77 | //----------credentials have been created, now sign-up the user in Cognito
78 | signUp();
79 |
80 | } catch (e) {console.error(e);}
81 | };
82 |
83 | //---------------Cognito sign-up
84 | signUp = async () =>{
85 |
86 | var email = $("#reg-email").val();
87 | var username = $("#reg-username").val();
88 | var password =$("#reg-password").val();
89 | var name = $("#reg-name").val();
90 | var publicKeyCred = btoa(globalRegisteredCredentials);//base64 encode credentials json string (credId, public-key)
91 |
92 | var attributeList = [];
93 |
94 | var dataEmail = {Name: 'email',Value: email};
95 | var dataName = { Name: 'name',Value: name};
96 | var dataPublicKeyCred = { Name: 'custom:publicKeyCred',Value: publicKeyCred};
97 |
98 | var attributeEmail = new AmazonCognitoIdentity.CognitoUserAttribute(dataEmail);
99 | var attributePublicKeyCred = new AmazonCognitoIdentity.CognitoUserAttribute(dataPublicKeyCred);
100 | var attributeName = new AmazonCognitoIdentity.CognitoUserAttribute(dataName);
101 |
102 | attributeList.push(attributeEmail);
103 | attributeList.push(attributePublicKeyCred);
104 | attributeList.push(attributeName);
105 |
106 | userPool.signUp(username, password, attributeList, null, function(err, result ) {
107 | if (err) {
108 | console.log(err.message || JSON.stringify(err));
109 | return;
110 | }else{
111 | var cognitoUser = result.user;
112 |
113 | var confirmationCode = prompt("Please enter confirmation code:");
114 | cognitoUser.confirmRegistration(confirmationCode, true, function(err, result) {
115 | if (err) {
116 | alert(err.message || JSON.stringify(err));
117 | return;
118 | }
119 | console.log('call result: ' + result);
120 | alert("Registration successful, now sign-in.");
121 | });
122 |
123 | console.log('user name is ' + cognitoUser.getUsername());
124 | }
125 | });
126 | }
127 |
128 |
129 | //---------------Cognito sign-in user
130 | signIn = async () => {
131 |
132 | var username = $("#login-username").val();
133 | var password = $("#login-password").val();
134 | var flow = $("input[name='authentication']:checked").val();
135 |
136 | var authenticationData = {
137 | Username: username, //only username required since we will authenticate user using custom auth flow and will use security key
138 | Password: password
139 | };
140 |
141 | var userData = {
142 | Username: username,
143 | Pool: userPool,
144 | };
145 |
146 | var authenticationDetails = new AmazonCognitoIdentity.AuthenticationDetails(authenticationData);
147 | cognitoUser = new AmazonCognitoIdentity.CognitoUser(userData);
148 |
149 | if(flow === 'password'){ //sign-in using password only
150 |
151 | /**
152 | authenticateUser method will trigger authentication with USER_SRP_AUTH flow
153 | USER_SRP_AUTH doesn't trigger define auth challenge, this will just authenticate the user using password
154 | (if SMS/TOTP MFA is configured for the user it will also be triggered)
155 | **/
156 | cognitoUser.authenticateUser(authenticationDetails, authCallBack);
157 |
158 | }else if(flow === 'fido'){ // sign-in using FIDO authenticator only
159 | /**
160 | initiateAuth method will trigger authentication with CUSTOM_AUTH flow and will not provide any challenge data initially
161 | This will allow define auth challenge to respond with CUSTOM_CHALLENGE
162 | **/
163 |
164 | cognitoUser.setAuthenticationFlowType('CUSTOM_AUTH');
165 | cognitoUser.initiateAuth(authenticationDetails, authCallBack);
166 |
167 | }else{ //sign-in with password and use FIDO for 2nd factor
168 | /**
169 | authenticateUser method will trigger authentication with CUSTOM_AUTH flow and will provide SRP_A as the challenge
170 | This will allow define auth challenge to authenticate user using SRP first and then respond with CUSTOM_AUTH
171 | **/
172 |
173 | cognitoUser.setAuthenticationFlowType('CUSTOM_AUTH');
174 | cognitoUser.authenticateUser(authenticationDetails, authCallBack);
175 |
176 | }
177 | }
178 |
179 | authCallBack = {
180 |
181 | onSuccess: function(result) {
182 | var accessToken = result.getAccessToken().getJwtToken();
183 | var idToken = result.getIdToken().getJwtToken();
184 | var refreshToken = result.getRefreshToken().getToken();
185 |
186 | $("#idToken").html('ID Token
'+JSON.stringify(parseJwt(idToken),null, 2));
187 | $("#accessToken").html('Access Token
'+JSON.stringify(parseJwt(accessToken), null, 2));
188 | //$("#refreshToken").html('Refresh Token
'+refreshToken);
189 |
190 | },
191 | customChallenge: async function(challengeParameters) {
192 | // User authentication depends on challenge response
193 |
194 | console.log("Custom Challenge from Cognito:");console.log(challengeParameters);
195 |
196 |
197 | //----------get creds from security key or platform authenticator
198 | var signinOptions = {
199 | "challenge": base64url.decode(challengeParameters.challenge),//challenge was generated and sent from CreateAuthChallenge lambda trigger
200 | "timeout":1800000,
201 | "rpId":window.location.hostname,
202 | "userVerification":"preferred",
203 | "allowCredentials":[
204 | {
205 | "id": base64url.decode(challengeParameters.credId),
206 | "type":"public-key",
207 | "transports":["ble","nfc","usb","internal"]
208 | }
209 | ]
210 | }
211 |
212 | //get sign in credentials from authenticator
213 | const cred = await navigator.credentials.get({
214 | publicKey: signinOptions
215 | });
216 |
217 | //prepare credentials challenge response
218 | const credential = {};
219 | if (cred.response) {
220 | const clientDataJSON = base64url.encode(cred.response.clientDataJSON);
221 | const authenticatorData = base64url.encode(cred.response.authenticatorData);
222 | const signature = base64url.encode(cred.response.signature);
223 | const userHandle = base64url.encode(cred.response.userHandle);
224 |
225 | credential.response = {clientDataJSON, authenticatorData, signature, userHandle};
226 | }
227 |
228 | //send credentials to Cognito VerifyAuthChallenge lambda trigger for verification
229 | cognitoUser.sendCustomChallengeAnswer(JSON.stringify(credential), this);
230 |
231 | },
232 | onFailure: function(err) {
233 | console.error("Error authenticateUser:"+err);
234 | console.log(err.message || JSON.stringify(err));
235 | },
236 | }
237 |
238 | //---------------------Set of helper functions-----------------------//
239 | //------------------------------------------------------------------//
240 |
241 | //tabs UI
242 | $( function() {
243 | $( "#tabs" ).tabs();
244 | } );
245 |
246 | //helper function
247 | _fetch = async (path, payload = '') => {
248 | const headers = {'X-Requested-With': 'XMLHttpRequest'};
249 | if (payload && !(payload instanceof FormData)) {
250 | headers['Content-Type'] = 'application/json';
251 | payload = JSON.stringify(payload);
252 | }
253 | const res = await fetch(path, {
254 | method: 'POST',
255 | credentials: 'same-origin',
256 | headers: headers,
257 | body: payload
258 | });
259 | if (res.status === 200) {
260 | return res.json();
261 | } else {
262 | const result = await res.json();
263 | throw result.error;
264 | }
265 | };
266 |
267 | function parseJwt (token) {
268 | var base64Url = token.split('.')[1];
269 | var base64 = base64Url.replace('-', '+').replace('_', '/');
270 | return JSON.parse(window.atob(base64));
271 | };
272 |
--------------------------------------------------------------------------------