diff --git a/package.json b/package.json index f62a02c0..9a03e5ea 100644 --- a/package.json +++ b/package.json @@ -141,6 +141,7 @@ "verify-sna", "flex-dialpad", "verify-prefill", - "reminder-message" + "reminder-message", + "passkeys-backend" ] } diff --git a/passkeys-backend/.env.example b/passkeys-backend/.env.example new file mode 100644 index 00000000..acbe3973 --- /dev/null +++ b/passkeys-backend/.env.example @@ -0,0 +1,14 @@ +# description: The URL of the comms API for passkeys +# format: url +# required: true +API_URL= + +# description: The domain of the relying party +# format: url +# required: true +RELYING_PARTY= + +# description: The domain of the adroid identity provider +# format: list(text) +# required: false +ANDROID_APP_KEYS= diff --git a/passkeys-backend/.owners b/passkeys-backend/.owners new file mode 100644 index 00000000..9ff1ceb7 --- /dev/null +++ b/passkeys-backend/.owners @@ -0,0 +1,4 @@ +dkundel +alisontanu +pthirumurthi +nicolas-camacho diff --git a/passkeys-backend/CHANGELOG.md b/passkeys-backend/CHANGELOG.md new file mode 100644 index 00000000..3982d461 --- /dev/null +++ b/passkeys-backend/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## [Unreleased] + +## [1.0.0] +### Added +- Initial release. + diff --git a/passkeys-backend/README.md b/passkeys-backend/README.md new file mode 100644 index 00000000..6d03f52d --- /dev/null +++ b/passkeys-backend/README.md @@ -0,0 +1,92 @@ +# passkeys-backend + +Verify enables developers to easily add Passkeys into their existing authentication flows, similar to Verify TOTP and Push. The Verify API supports passkey registration, public key storage, and auth flows. On the client-side, developers can optionally embed an open-source library (SDK) that handles interactions with operating systems and customizable UI widgets that maximize conversion. + +## How to use the template + +The best way to use the Function templates is through the Twilio CLI as described below. If you'd like to use the template without the Twilio CLI, [check out our usage docs](../docs/USING_FUNCTIONS.md). + +Make sure befores you use the template you have to set up your enviroment variables and +customize the associated files with your client applications origins you can find this +customization [here](#service-customization). + +## Pre-requisites + +### Environment variables + +This project requires some environment variables to be set. A file named `.env` is used to store the values for those environment variables. To keep your tokens and secrets secure, make sure to not commit the `.env` file in git. When setting up the project with `twilio serverless:init ...` the Twilio CLI will create a `.gitignore` file that excludes `.env` from the version history. + +- Enable ACCOUNT_SID and AUTH_TOKEN in your functions configuration (https://www.twilio.com/console/functions/configure) + +You can find a `.env.example` file to copy for creating your own `.env` file + +In your `.env` file, set the following values: + +| Variable | Description | Required | +| :------- | :---------- | :------- | +| API_URL | Passkeys API to point at | yes | +| RELYING_PARTY | Customer app or client | yes +| ANDROID_APP_KEYS | The domain of the adroid identity providers hash | yes | +| ACCOUNT_SID | Twilio account where the service belong | yes | +| AUTH_TOKEN | Authentication token for twilio account | yes | + +### Service customization + +Besides the enviroment variables files, the project also contain two files called `assetlink.json` and `apple-app-site-association` inside `./assets/.well-know/`, that is a public file that contains the identificators for the apps that will be connecting the service. + +`apple-app-site-association` contains identificator hash for the origin app in iOS: + +| Variable | Description | Required | +| :------- | :---------- | :------- | +| ORIGIN_IOS_APP_HASH | Replace it with the identificator of the iOS app | yes | + +`assetlink.json` contains identificator hash for the origin apps in android and web: + +| Variable | Description | Required | +| :------- | :---------- | :------- | +| RELYING_PARTY | Replace it with the value of the relaying party | yes | +| FINGERPRINT_CERTIFICATION_HASH | Replace it with the hash fingerprint given by android app in format SHA256 | yes | + + +### Function Parameters + +`/registration/start` expects the following parameters: + +| Parameter | Description | Required | +| :-------- | :---------- | :------- | +| username | user identification name | yes + + +`/registration/verification` expects the following parameters: + +| Parameter | Description | Required | +| :-------- | :---------- | :------- | +| id | A base64url encoded representation of `rawId`. | yes | +| rawId | The globally unique identifier for this `PublicKeyCredential`. | yes | +| attestationObject | A base64url encoded object given by the `AuthenticatorAttestationResponse` | yes | +| clientDataJSON | A base64url encoded object given by the `AuthenticatorAttestationResponse` | yes | +| transports | An Array with the transport methods given by the `AuthenticatorAttestationResponse` | yes | + + +`/authentication/start` a GET request, does not expect parameters + +`/authentication/verification` expects the following parameters: + +| Parameter | Description | Required | +| :-------- | :---------- | :------- | +| id | A base64url encoded representation of `rawId`. | yes | +| rawId | The globally unique identifier for this `PublicKeyCredential`. | yes | +| authenticatorData | A base64url encoded object given by the `AuthenticatorAttestationResponse` | yes | +| clientDataJSON | A base64url encoded object given by the `AuthenticatorAttestationResponse` | yes | +| signature | A base64url encoded object given by the `AuthenticatorAttestationResponse` | yes | +| userHandle | A base64url encoded object given by the `AuthenticatorAttestationResponse` | yes | + +## Deploying + +Deploy your functions and assets with either of the following commands. Note: you must run these commands from inside your project folder. [More details in the docs.](https://www.twilio.com/docs/labs/serverless-toolkit) + +With the [Twilio CLI](https://www.twilio.com/docs/twilio-cli/quickstart): + +``` +twilio serverless:deploy +``` diff --git a/passkeys-backend/assets/.well-know/apple-app-site-association b/passkeys-backend/assets/.well-know/apple-app-site-association new file mode 100644 index 00000000..8f962624 --- /dev/null +++ b/passkeys-backend/assets/.well-know/apple-app-site-association @@ -0,0 +1,7 @@ +{ + "webcredentials": { + "apps": [ + "{ORIGIN_IOS_APP_HASH}" + ] + } +} \ No newline at end of file diff --git a/passkeys-backend/assets/.well-know/assetlinks.json b/passkeys-backend/assets/.well-know/assetlinks.json new file mode 100644 index 00000000..04a4e0bb --- /dev/null +++ b/passkeys-backend/assets/.well-know/assetlinks.json @@ -0,0 +1,23 @@ +[ + { + "relation": [ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.get_login_creds" + ], + "target": { + "namespace": "web", + "site": "{RELYING_PARTY}" + } + }, + { + "relation": [ + "delegate_permission/common.handle_all_urls", + "delegate_permission/common.get_login_creds" + ], + "target": { + "namespace": "android_app", + "package_name": "com.twilio.passkeys.android", + "sha256_cert_fingerprints": ["{FINGERPRINT_CERTIFICATION_HASH}"] + } + } +] diff --git a/passkeys-backend/assets/index.html b/passkeys-backend/assets/index.html new file mode 100644 index 00000000..f447ad71 --- /dev/null +++ b/passkeys-backend/assets/index.html @@ -0,0 +1,285 @@ + + + + + + + Passkeys Demo + + + + + + + + + +
+

Sign up or sign in

+
+
+ +
+ + ― or ― + +
+
+ + + + + diff --git a/passkeys-backend/assets/services/helpers.private.js b/passkeys-backend/assets/services/helpers.private.js new file mode 100644 index 00000000..400d66d6 --- /dev/null +++ b/passkeys-backend/assets/services/helpers.private.js @@ -0,0 +1,15 @@ +const detectMissingParams = (paramNames, event) => { + const missingParams = paramNames.filter( + (param) => !event.hasOwnProperty(param) + ); + return missingParams.length > 0 ? missingParams : null; +}; + +const isEmpty = (requestBody) => { + return Object.keys(requestBody).length === 0; +}; + +module.exports = { + detectMissingParams, + isEmpty, +}; diff --git a/passkeys-backend/functions/authentication/start.js b/passkeys-backend/functions/authentication/start.js new file mode 100644 index 00000000..22778efe --- /dev/null +++ b/passkeys-backend/functions/authentication/start.js @@ -0,0 +1,38 @@ +const axios = require('axios'); + +// eslint-disable-next-line consistent-return +exports.handler = async (context, _, callback) => { + const { RELYING_PARTY, API_URL } = context; + + const response = new Twilio.Response(); + response.appendHeader('Content-Type', 'application/json'); + + const { username, password } = context.getTwilioClient(); + + const requestBody = { + content: { + // eslint-disable-next-line camelcase + rp_id: RELYING_PARTY, + }, + }; + + const challengeURL = `${API_URL}/Verifications`; + + try { + const APIResponse = await axios.post(challengeURL, requestBody, { + auth: { + username, + password, + }, + }); + + response.setStatusCode(200); + response.setBody(APIResponse.data.next_step); + } catch (error) { + const statusCode = error.status || 400; + response.setStatusCode(statusCode); + response.setBody(error.message); + } + + return callback(null, response); +}; diff --git a/passkeys-backend/functions/authentication/verification.js b/passkeys-backend/functions/authentication/verification.js new file mode 100644 index 00000000..d69d463e --- /dev/null +++ b/passkeys-backend/functions/authentication/verification.js @@ -0,0 +1,54 @@ +const axios = require('axios'); + +const assets = Runtime.getAssets(); +const { isEmpty } = require(assets['/services/helpers.js'].path); + +exports.handler = async (context, event, callback) => { + const { API_URL } = context; + + const response = new Twilio.Response(); + response.appendHeader('Content-Type', 'application/json'); + + if (isEmpty(event)) { + response.setStatusCode(400); + response.setBody( + `Something is wrong with the request. Please check the parameters.` + ); + return callback(null, response); + } + + const { username, password } = context.getTwilioClient(); + + const requestBody = { + content: { + rawId: event.rawId, + id: event.id, + authenticatorAttachment: event.authenticatorAttachment, + type: event.type, + response: event.response, + }, + }; + + const verifyChallengeURL = `${API_URL}/Verifications/Check`; + + try { + const APIresponse = await axios.post(verifyChallengeURL, requestBody, { + auth: { + username, + password, + }, + }); + + response.setStatusCode(200); + response.setBody({ + status: APIresponse.data.status, + identity: APIresponse.data.to.user_identifier, + }); + } catch (error) { + const statusCode = error.status || 400; + response.setStatusCode(statusCode); + response.setBody(error.message); + } + + return callback(null, response); +}; diff --git a/passkeys-backend/functions/registration/start.js b/passkeys-backend/functions/registration/start.js new file mode 100644 index 00000000..5b8e7980 --- /dev/null +++ b/passkeys-backend/functions/registration/start.js @@ -0,0 +1,70 @@ +const axios = require('axios'); + +const assets = Runtime.getAssets(); +const { detectMissingParams } = require(assets['/services/helpers.js'].path); + +exports.handler = async (context, event, callback) => { + const { RELYING_PARTY, API_URL, ANDROID_APP_KEYS } = context; + + const response = new Twilio.Response(); + response.appendHeader('Content-Type', 'application/json'); + + // Verify request comes with username + const missingParams = detectMissingParams(['username'], event); + if (missingParams) { + response.setStatusCode(400); + response.setBody( + `Missing parameters; please provide: '${missingParams.join(', ')}'.` + ); + + return callback(null, response); + } + + const { username, password } = context.getTwilioClient(); + + // Request body sent to passkeys verify URL call + /* eslint-disable camelcase */ + const requestBody = { + friendly_name: 'Passkey Example', + to: { + user_identifier: event.username, + }, + content: { + relying_party: { + id: RELYING_PARTY, + name: 'PasskeySample', + origins: [ + `https://${RELYING_PARTY}`, + ...(ANDROID_APP_KEYS.split(',') || []), + ], + }, + authenticator_criteria: { + authenticator_attachment: 'platform', + discoverable_credentials: 'preferred', + user_verification: 'preferred', + }, + }, + }; + + // Factor URL of the passkeys service + const factorURL = `${API_URL}/Factors`; + + // Call made to the passkeys service + try { + const APIResponse = await axios.post(factorURL, requestBody, { + auth: { + username, + password, + }, + }); + + response.setStatusCode(200); + response.setBody(APIResponse.data.next_step); + } catch (error) { + const statusCode = error.status || 400; + response.setStatusCode(statusCode); + response.setBody(error.message); + } + + return callback(null, response); +}; diff --git a/passkeys-backend/functions/registration/verification.js b/passkeys-backend/functions/registration/verification.js new file mode 100644 index 00000000..ec71d076 --- /dev/null +++ b/passkeys-backend/functions/registration/verification.js @@ -0,0 +1,57 @@ +const axios = require('axios'); + +const assets = Runtime.getAssets(); +const { isEmpty } = require(assets['/services/helpers.js'].path); + +// eslint-disable-next-line consistent-return +exports.handler = async (context, event, callback) => { + const { API_URL } = context; + + const response = new Twilio.Response(); + response.appendHeader('Content-Type', 'application/json'); + + if (isEmpty(event)) { + response.setStatusCode(400); + response.setBody( + `Something is wrong with the request. Please check the parameters.` + ); + + return callback(null, response); + } + + const { username, password } = context.getTwilioClient(); + + const requestBody = { + content: { + id: event.id, + rawId: event.rawId, + authenticatorAttachment: event.authenticatorAttachment, + type: event.type, + response: event.response, + }, + }; + + const verifyFactorURL = `${API_URL}/Factors/Approve`; + + try { + const APIResponse = await axios.post(verifyFactorURL, requestBody, { + auth: { + username, + password, + }, + }); + response.setStatusCode(200); + response.setBody({ + status: + APIResponse.data.status === 'approved' + ? 'verified' + : APIResponse.data.status, + }); + } catch (error) { + const statusCode = error.status || 400; + response.setStatusCode(statusCode); + response.setBody(error.message); + } + + return callback(null, response); +}; diff --git a/passkeys-backend/package.json b/passkeys-backend/package.json new file mode 100644 index 00000000..66a26eac --- /dev/null +++ b/passkeys-backend/package.json @@ -0,0 +1,9 @@ +{ + "name": "passkeys-backend", + "version": "1.0.0", + "private": true, + "dependencies": { + "@twilio-labs/runtime-helpers": "^0.1.2", + "twilio": "^3.61.0" + } +} diff --git a/passkeys-backend/tests/authentication-start.test.js b/passkeys-backend/tests/authentication-start.test.js new file mode 100644 index 00000000..7de7adcd --- /dev/null +++ b/passkeys-backend/tests/authentication-start.test.js @@ -0,0 +1,45 @@ +const axios = require('axios'); +const helpers = require('../../test/test-helper'); + +jest.mock('axios'); + +const mockContext = { + getTwilioClient: () => ({ + username: 'mockUsername', + password: 'mockPassword', + }), +}; + +describe('authentication/start', () => { + beforeAll(() => { + jest.clearAllMocks(); + const runtime = new helpers.MockRuntime(); + runtime._addAsset( + '/services/helpers.js', + '../assets/services/helpers.private.js' + ); + helpers.setup({}, runtime); + handlerFunction = require('../functions/authentication/start').handler; + }); + afterAll(() => { + helpers.teardown(); + }); + beforeEach(() => { + jest.resetModules(); + axios.post.mockClear(); + }); + + it('returns error with unsuccesfull request', (done) => { + const expectedError = new Error('something bad happened'); + axios.post = jest.fn(() => Promise.reject(expectedError)); + + const callback = (_, { _body }) => { + expect(_body).toBeDefined(); + expect(axios.post).toHaveBeenCalledTimes(1); + expect(_body).toEqual(expectedError.message); + done(); + }; + + handlerFunction(mockContext, {}, callback); + }); +}); diff --git a/passkeys-backend/tests/authentication-verification.test.js b/passkeys-backend/tests/authentication-verification.test.js new file mode 100644 index 00000000..11bdc2d5 --- /dev/null +++ b/passkeys-backend/tests/authentication-verification.test.js @@ -0,0 +1,74 @@ +const axios = require('axios'); +const helpers = require('../../test/test-helper'); + +jest.mock('axios'); + +const mockContext = { + getTwilioClient: () => ({ + username: 'mockUsername', + password: 'mockPassword', + }), +}; + +const testEvent = { + id: '12345', + rawId: 'randomRawId', + response: { + clientDataJSON: {}, + authenticatorData: {}, + signature: 'test-signature', + userHandle: {}, + }, +}; + +describe('authentication/verification', () => { + beforeAll(() => { + jest.clearAllMocks(); + const runtime = new helpers.MockRuntime(); + runtime._addAsset( + '/services/helpers.js', + '../assets/services/helpers.private.js' + ); + helpers.setup({}, runtime); + handlerFunction = + require('../functions/authentication/verification').handler; + }); + afterAll(() => { + helpers.teardown(); + }); + beforeEach(() => { + jest.resetModules(); + axios.post.mockClear(); + }); + + describe('when multiple required parameters are missing', () => { + it('returns an error indicating multiple missing parameters', (done) => { + const callback = (_, { _body, _statusCode }) => { + expect(_statusCode).toBeDefined(); + expect(_body).toBeDefined(); + expect(_statusCode).toEqual(400); + expect(_body).toEqual( + `Something is wrong with the request. Please check the parameters.` + ); + done(); + }; + handlerFunction(mockContext, {}, callback); + }); + }); + + describe('When response are unsuccesfull', () => { + it('returns error with unsuccesfull request', (done) => { + const expectedError = new Error('something bad happened'); + axios.post = jest.fn(() => Promise.reject(expectedError)); + + const callback = (_, { _body }) => { + expect(_body).toBeDefined(); + expect(axios.post).toHaveBeenCalledTimes(1); + expect(_body).toEqual(expectedError.message); + done(); + }; + + handlerFunction(mockContext, testEvent, callback); + }); + }); +}); diff --git a/passkeys-backend/tests/registration-start.test.js b/passkeys-backend/tests/registration-start.test.js new file mode 100644 index 00000000..097df955 --- /dev/null +++ b/passkeys-backend/tests/registration-start.test.js @@ -0,0 +1,63 @@ +const axios = require('axios'); +const helpers = require('../../test/test-helper'); + +jest.mock('axios'); + +const mockContext = { + ANDROID_APP_KEYS: 'key1,key2,key3', + getTwilioClient: () => ({ + username: 'mockUsername', + password: 'mockPassword', + }), +}; + +describe('registration/start', () => { + beforeAll(() => { + jest.clearAllMocks(); + const runtime = new helpers.MockRuntime(); + runtime._addAsset( + '/services/helpers.js', + '../assets/services/helpers.private.js' + ); + helpers.setup(mockContext, runtime); + handlerFunction = require('../functions/registration/start').handler; + }); + afterAll(() => { + helpers.teardown(); + }); + beforeEach(() => { + jest.resetModules(); + axios.post.mockClear(); + }); + + it('returns an error response indicating the missing parameters', (done) => { + const callback = (_, { _body, _statusCode }) => { + expect(_statusCode).toBeDefined(); + expect(_body).toBeDefined(); + expect(_statusCode).toEqual(400); + expect(_body).toEqual(`Missing parameters; please provide: 'username'.`); + done(); + }; + handlerFunction(mockContext, {}, callback); + }); + + it('returns error with unsuccesfull request', (done) => { + const expectedError = new Error('something bad happened'); + axios.post = jest.fn(() => Promise.reject(expectedError)); + + const callback = (_, { _body }) => { + expect(_body).toBeDefined(); + expect(axios.post).toHaveBeenCalledTimes(1); + expect(_body).toEqual(expectedError.message); + done(); + }; + + handlerFunction( + mockContext, + { + username: 'test-username', + }, + callback + ); + }); +}); diff --git a/passkeys-backend/tests/registration-verification.test.js b/passkeys-backend/tests/registration-verification.test.js new file mode 100644 index 00000000..df65f94e --- /dev/null +++ b/passkeys-backend/tests/registration-verification.test.js @@ -0,0 +1,72 @@ +const axios = require('axios'); +const helpers = require('../../test/test-helper'); + +jest.mock('axios'); + +const testEvent = { + id: '12345', + rawId: 'randomRawId', + response: { + attestationObject: {}, + clientDataJSON: {}, + transports: 'test-transport', + }, +}; + +const mockContext = { + getTwilioClient: () => ({ + username: 'mockUsername', + password: 'mockPassword', + }), +}; + +describe('registration/verification', () => { + beforeAll(() => { + jest.clearAllMocks(); + const runtime = new helpers.MockRuntime(); + runtime._addAsset( + '/services/helpers.js', + '../assets/services/helpers.private.js' + ); + helpers.setup({}, runtime); + handlerFunction = require('../functions/registration/verification').handler; + }); + afterAll(() => { + helpers.teardown(); + }); + beforeEach(() => { + jest.resetModules(); + axios.post.mockClear(); + }); + + describe('when multiple required parameters are missing', () => { + it('returns an error indicating multiple missing parameters', (done) => { + const callback = (_, { _body, _statusCode }) => { + expect(_statusCode).toBeDefined(); + expect(_body).toBeDefined(); + expect(_statusCode).toEqual(400); + expect(_body).toEqual( + `Something is wrong with the request. Please check the parameters.` + ); + done(); + }; + handlerFunction(mockContext, {}, callback); + }); + }); + + describe('When response are unsuccesfull', () => { + it('returns error with unsuccesfull request', (done) => { + const expectedError = new Error('something bad happened'); + axios.post = jest.fn(() => Promise.reject(expectedError)); + + const callback = (_, { _body }) => { + expect(_body).toBeDefined(); + expect(axios.post).toHaveBeenCalledTimes(1); + expect(_body).toEqual(expectedError.message); + done(); + }; + + handlerFunction(mockContext, testEvent, callback); + }); + }); +}); diff --git a/templates.json b/templates.json index 1762c785..58d00ba1 100644 --- a/templates.json +++ b/templates.json @@ -349,6 +349,11 @@ "id": "reminder-message", "name": "Send scheduled reminder messages", "description": "Schedule a reminder message to be sent a specified time after the initial message" + }, + { + "id": "passkeys-backend", + "name": "Backend for passkeys app", + "description": "Connect applications with the passkeys service" } ] }