diff --git a/passkeys-backend/.env.example b/passkeys-backend/.env.example index 147ce3eab..fee990b9c 100644 --- a/passkeys-backend/.env.example +++ b/passkeys-backend/.env.example @@ -1,9 +1,9 @@ # description: The URL of the API for passkeys # format: url # required: true -API_URL=https://comms.twilio.com/preview +API_URL=https://verify.twilio.com/v2/Services -# description: [Optional] Comma separated domains for Android application -# format: list(text) +# description: [Optional] SID of the service created in Twilio verify +# format: sid # required: false -ANDROID_APP_KEYS= +SERVICE_SID= diff --git a/passkeys-backend/README.md b/passkeys-backend/README.md index 4bff13ee0..3f2bd7bcb 100644 --- a/passkeys-backend/README.md +++ b/passkeys-backend/README.md @@ -23,7 +23,7 @@ In your `.env` file, set the following values: | `API_URL` | Passkeys API to point at | Yes | | `ACCOUNT_SID` | Find in the [console](https://www.twilio.com/console) | Yes | | `AUTH_TOKEN` | Find in the [console](https://www.twilio.com/console) | Yes | -| `ANDROID_APP_KEYS` | The domain of the Android identity providers hash | No | +| `SERVICE_SID` | Service created in Twilio verify | No | ## Create a new project with the template @@ -89,8 +89,20 @@ Besides the enviroment variables files, the project also contain two files calle | 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 | +`origins.js` contains the origins from where passkeys creation and authentication will be allowed + +##### Obtaining the SERVICE_SID + +In order to start working with the rest of The Twilio Verify Passkeys API, you will need to create a Verify Service. You can do this through calling one time the `/registration/service` endpoint. + +This will create a new Verify Service and return the `SERVICE_SID` that you will need to set in your environment variables. + +Inside that function you can modify the parameters of the service creation, like `friendlyName` or `Passkeys.RelyingParty.Name` to customize it to your needs. + ### Function Parameters +`/registration/service` a POST request, does not expect parameters + `/registration/start` expects the following parameters: | Parameter | Description | Required | @@ -121,4 +133,3 @@ Besides the enviroment variables files, the project also contain two files calle | 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 | - diff --git a/passkeys-backend/assets/.well-know/apple-app-site-association b/passkeys-backend/assets/.well-known/apple-app-site-association similarity index 100% rename from passkeys-backend/assets/.well-know/apple-app-site-association rename to passkeys-backend/assets/.well-known/apple-app-site-association diff --git a/passkeys-backend/assets/.well-know/assetlinks.json b/passkeys-backend/assets/.well-known/assetlinks.json similarity index 100% rename from passkeys-backend/assets/.well-know/assetlinks.json rename to passkeys-backend/assets/.well-known/assetlinks.json diff --git a/passkeys-backend/assets/origins.js b/passkeys-backend/assets/origins.js new file mode 100644 index 000000000..dfdd619d8 --- /dev/null +++ b/passkeys-backend/assets/origins.js @@ -0,0 +1,8 @@ +const origins = (context) => { + const { DOMAIN_NAME } = context; + return [`https://${DOMAIN_NAME}`, 'android:apk-key-hash:{base64_hash}']; +}; + +module.exports = { + origins, +}; diff --git a/passkeys-backend/functions/.well-known/webauthn.js b/passkeys-backend/functions/.well-known/webauthn.js new file mode 100644 index 000000000..d226f07d7 --- /dev/null +++ b/passkeys-backend/functions/.well-known/webauthn.js @@ -0,0 +1,11 @@ +const assets = Runtime.getAssets(); +const { origins } = require(assets['/origins.js'].path); + +exports.handler = function (context, event, callback) { + const response = new Twilio.Response(); + response.appendHeader('Content-Type', 'application/json'); + + response.setBody({ origins: origins(context) }); + + return callback(null, response); +}; diff --git a/passkeys-backend/functions/authentication/start.js b/passkeys-backend/functions/authentication/start.js index 7b1c27465..b0b74fd8f 100644 --- a/passkeys-backend/functions/authentication/start.js +++ b/passkeys-backend/functions/authentication/start.js @@ -2,32 +2,32 @@ const axios = require('axios'); // eslint-disable-next-line consistent-return exports.handler = async (context, _, callback) => { - const { DOMAIN_NAME, API_URL } = context; + const { API_URL, SERVICE_SID } = context; const response = new Twilio.Response(); response.appendHeader('Content-Type', 'application/json'); + response.appendHeader('Access-Control-Allow-Origin', '*'); + response.appendHeader('Access-Control-Allow-Methods', 'OPTIONS, POST, GET'); + response.appendHeader('Access-Control-Allow-Headers', 'Content-Type'); const { username, password } = context.getTwilioClient(); - const requestBody = { - content: { - // eslint-disable-next-line camelcase - rp_id: DOMAIN_NAME, - }, - }; - - const challengeURL = `${API_URL}/Verifications`; + const challengeURL = `${API_URL}/${SERVICE_SID}/Passkeys/Challenges`; try { - const APIResponse = await axios.post(challengeURL, requestBody, { - auth: { - username, - password, - }, - }); + const APIResponse = await axios.post( + challengeURL, + {}, + { + auth: { + username, + password, + }, + } + ); response.setStatusCode(200); - response.setBody(APIResponse.data.next_step); + response.setBody(APIResponse.data.options); } catch (error) { const statusCode = error.status || 400; response.setStatusCode(statusCode); diff --git a/passkeys-backend/functions/authentication/verification.js b/passkeys-backend/functions/authentication/verification.js index d69d463ef..1d8af5b1a 100644 --- a/passkeys-backend/functions/authentication/verification.js +++ b/passkeys-backend/functions/authentication/verification.js @@ -4,10 +4,13 @@ const assets = Runtime.getAssets(); const { isEmpty } = require(assets['/services/helpers.js'].path); exports.handler = async (context, event, callback) => { - const { API_URL } = context; + const { API_URL, SERVICE_SID } = context; const response = new Twilio.Response(); response.appendHeader('Content-Type', 'application/json'); + response.appendHeader('Access-Control-Allow-Origin', '*'); + response.appendHeader('Access-Control-Allow-Methods', 'OPTIONS, POST, GET'); + response.appendHeader('Access-Control-Allow-Headers', 'Content-Type'); if (isEmpty(event)) { response.setStatusCode(400); @@ -19,17 +22,24 @@ exports.handler = async (context, event, callback) => { const { username, password } = context.getTwilioClient(); + const responseData = event.response + ? event.response + : { + clientDataJSON: event.clientDataJSON, + authenticatorData: event.authenticatorData, + signature: event.signature, + userHandle: event.userHandle, + }; + const requestBody = { - content: { - rawId: event.rawId, - id: event.id, - authenticatorAttachment: event.authenticatorAttachment, - type: event.type, - response: event.response, - }, + id: event.id, + rawId: event.rawId, + authenticatorAttachment: event.authenticatorAttachment || 'platform', + type: event.type || 'public-key', + response: responseData, }; - const verifyChallengeURL = `${API_URL}/Verifications/Check`; + const verifyChallengeURL = `${API_URL}/${SERVICE_SID}/Passkeys/ApproveChallenge`; try { const APIresponse = await axios.post(verifyChallengeURL, requestBody, { @@ -42,7 +52,7 @@ exports.handler = async (context, event, callback) => { response.setStatusCode(200); response.setBody({ status: APIresponse.data.status, - identity: APIresponse.data.to.user_identifier, + identity: APIresponse.data.identity, }); } catch (error) { const statusCode = error.status || 400; diff --git a/passkeys-backend/functions/registration/service.js b/passkeys-backend/functions/registration/service.js new file mode 100644 index 000000000..89e524e68 --- /dev/null +++ b/passkeys-backend/functions/registration/service.js @@ -0,0 +1,48 @@ +const axios = require('axios'); + +const assets = Runtime.getAssets(); +const { origins } = require(assets['/origins.js'].path); + +exports.handler = async function (context, event, callback) { + const { DOMAIN_NAME, API_URL } = context; + + const response = new Twilio.Response(); + response.appendHeader('Content-Type', 'application/json'); + response.appendHeader('Access-Control-Allow-Origin', '*'); + response.appendHeader('Access-Control-Allow-Methods', 'OPTIONS, POST, GET'); + response.appendHeader('Access-Control-Allow-Headers', 'Content-Type'); + + const { username, password } = context.getTwilioClient(); + + const data = new URLSearchParams(); + data.append('FriendlyName', 'Passkeys Sample Backend'); + data.append('Passkeys.RelyingParty.Id', DOMAIN_NAME); + data.append('Passkeys.RelyingParty.Name', 'Passkeys Sample Backend'); + data.append('Passkeys.RelyingParty.Origins', origins(context).join(',')); + data.append('Passkeys.AuthenticatorAttachment', 'platform'); + data.append('Passkeys.DiscoverableCredentials', 'preferred'); + data.append('Passkeys.UserVerification', 'preferred'); + + const createServiceURL = `${API_URL}`; + + try { + const APIResponse = await axios.post(createServiceURL, data, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + auth: { + username, + password, + }, + }); + + response.setStatusCode(200); + response.setBody(APIResponse.data); + } 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 index 5b6aadb1a..9582519f8 100644 --- a/passkeys-backend/functions/registration/start.js +++ b/passkeys-backend/functions/registration/start.js @@ -1,13 +1,17 @@ const axios = require('axios'); +const { v5 } = require('uuid'); const assets = Runtime.getAssets(); const { detectMissingParams } = require(assets['/services/helpers.js'].path); exports.handler = async (context, event, callback) => { - const { DOMAIN_NAME, API_URL, ANDROID_APP_KEYS } = context; + const { API_URL, SERVICE_SID } = context; const response = new Twilio.Response(); response.appendHeader('Content-Type', 'application/json'); + response.appendHeader('Access-Control-Allow-Origin', '*'); + response.appendHeader('Access-Control-Allow-Methods', 'OPTIONS, POST, GET'); + response.appendHeader('Access-Control-Allow-Headers', 'Content-Type'); // Verify request comes with username const missingParams = detectMissingParams(['username'], event); @@ -22,40 +26,21 @@ exports.handler = async (context, event, callback) => { const { username, password } = context.getTwilioClient(); - const androidOrigins = (keys) => { - if (!keys || keys.trim() === '""') return []; - return keys.split(','); - }; + const uuidIdentity = v5(event.username, v5.URL); - // 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: DOMAIN_NAME, - name: 'PasskeySample', - origins: [ - `https://${DOMAIN_NAME}`, - ...androidOrigins(ANDROID_APP_KEYS), - ], - }, - user: { - display_name: event.username, - }, - authenticator_criteria: { - authenticator_attachment: 'platform', - discoverable_credentials: 'preferred', - user_verification: 'preferred', - }, + friendly_name: event.username, + identity: uuidIdentity, + config: { + authenticator_attachment: 'platform', + discoverable_credentials: 'preferred', + user_verification: 'preferred', }, }; // Factor URL of the passkeys service - const factorURL = `${API_URL}/Factors`; + const factorURL = `${API_URL}/${SERVICE_SID}/Passkeys/Factors`; // Call made to the passkeys service try { @@ -67,9 +52,11 @@ exports.handler = async (context, event, callback) => { }); response.setStatusCode(200); - response.setBody(APIResponse.data.next_step); + response.setBody({ + ...APIResponse.data.options.publicKey, + identity: uuidIdentity, + }); } catch (error) { - console.error('Error in passkeys registration start:', error.message); const statusCode = error.status || 400; response.setStatusCode(statusCode); response.setBody(error.message); diff --git a/passkeys-backend/functions/registration/verification.js b/passkeys-backend/functions/registration/verification.js index ec71d0769..f8c5e0ca8 100644 --- a/passkeys-backend/functions/registration/verification.js +++ b/passkeys-backend/functions/registration/verification.js @@ -5,10 +5,13 @@ 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 { API_URL, SERVICE_SID } = context; const response = new Twilio.Response(); response.appendHeader('Content-Type', 'application/json'); + response.appendHeader('Access-Control-Allow-Origin', '*'); + response.appendHeader('Access-Control-Allow-Methods', 'OPTIONS, POST, GET'); + response.appendHeader('Access-Control-Allow-Headers', 'Content-Type'); if (isEmpty(event)) { response.setStatusCode(400); @@ -21,17 +24,23 @@ exports.handler = async (context, event, callback) => { const { username, password } = context.getTwilioClient(); + const responseData = event.response + ? event.response + : { + attestationObject: event.attestationObject, + clientDataJSON: event.clientDataJSON, + transports: event.transports, + }; + const requestBody = { - content: { - id: event.id, - rawId: event.rawId, - authenticatorAttachment: event.authenticatorAttachment, - type: event.type, - response: event.response, - }, + id: event.id, + rawId: event.rawId, + authenticatorAttachment: event.authenticatorAttachment || 'platform', + type: event.type || 'public-key', + response: responseData, }; - const verifyFactorURL = `${API_URL}/Factors/Approve`; + const verifyFactorURL = `${API_URL}/${SERVICE_SID}/Passkeys/VerifyFactor`; try { const APIResponse = await axios.post(verifyFactorURL, requestBody, { diff --git a/passkeys-backend/package.json b/passkeys-backend/package.json index 856c82930..aabd0d6e3 100644 --- a/passkeys-backend/package.json +++ b/passkeys-backend/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "twilio": "^5.3.3", - "axios": "^1.7.7" + "axios": "^1.7.7", + "uuid": "^11.0.4" } } diff --git a/passkeys-backend/tests/registration-start.test.js b/passkeys-backend/tests/registration-start.test.js index ecfc9d580..a5ee7cd40 100644 --- a/passkeys-backend/tests/registration-start.test.js +++ b/passkeys-backend/tests/registration-start.test.js @@ -1,6 +1,7 @@ /* eslint-disable camelcase */ const axios = require('axios'); +const { v5 } = require('uuid'); const helpers = require('../../test/test-helper'); jest.mock('axios'); @@ -8,7 +9,7 @@ jest.mock('axios'); const mockContext = { API_URL: 'https://api.com', DOMAIN_NAME: 'example.com', - ANDROID_APP_KEYS: 'key1,key2,key3', + SERVICE_SID: 'mockServiceSid', getTwilioClient: () => ({ username: 'mockUsername', password: 'mockPassword', @@ -16,24 +17,12 @@ const mockContext = { }; const mockRequestBody = { - friendly_name: 'Passkey Example', - to: { - user_identifier: 'user001', - }, - content: { - relying_party: { - id: 'example.com', - name: 'PasskeySample', - origins: [`https://example.com`], - }, - user: { - display_name: 'user001', - }, - authenticator_criteria: { - authenticator_attachment: 'platform', - discoverable_credentials: 'preferred', - user_verification: 'preferred', - }, + friendly_name: 'user001', + identity: v5('user001', v5.URL), + config: { + authenticator_attachment: 'platform', + discoverable_credentials: 'preferred', + user_verification: 'preferred', }, }; @@ -83,12 +72,12 @@ describe('registration/start', () => { it('works with a phone number as a username', (done) => { const modifiedBody = structuredClone(mockRequestBody); - modifiedBody.to.user_identifier = '+14151234567'; - modifiedBody.content.user.display_name = '+14151234567'; + modifiedBody.friendly_name = '+14151234567'; + modifiedBody.identity = v5('+14151234567', v5.URL); const callback = (_, { _body }) => { expect(axios.post).toHaveBeenCalledWith( - 'https://api.com/Factors', + 'https://api.com/mockServiceSid/Passkeys/Factors', modifiedBody, { auth: { password: 'mockPassword', username: 'mockUsername' } } ); @@ -98,6 +87,7 @@ describe('registration/start', () => { const mockContextWithoutAndroidKeys = { API_URL: 'https://api.com', DOMAIN_NAME: 'example.com', + SERVICE_SID: 'mockServiceSid', getTwilioClient: () => ({ username: 'mockUsername', password: 'mockPassword', @@ -111,67 +101,12 @@ describe('registration/start', () => { ); }); - it('works with empty ANDROID_APP_KEYS', (done) => { - const callback = (_, { _body }) => { - expect(axios.post).toHaveBeenCalledWith( - 'https://api.com/Factors', - mockRequestBody, - { auth: { password: 'mockPassword', username: 'mockUsername' } } - ); - done(); - }; - - const mockContextWithoutAndroidKeys = { - API_URL: 'https://api.com', - DOMAIN_NAME: 'example.com', - getTwilioClient: () => ({ - username: 'mockUsername', - password: 'mockPassword', - }), - }; - - handlerFunction( - mockContextWithoutAndroidKeys, - { username: 'user001' }, - callback - ); - }); - - // This is how the CodeExchange is populating the optional field if left empty - it('works with ANDROID_APP_KEYS empty string', (done) => { - const callback = (_, { _body }) => { - expect(axios.post).toHaveBeenCalledWith( - 'https://api.com/Factors', - mockRequestBody, - { auth: { password: 'mockPassword', username: 'mockUsername' } } - ); - done(); - }; - - const mockContextWithoutAndroidKeys = { - API_URL: 'https://api.com', - ANDROID_APP_KEYS: '""', - DOMAIN_NAME: 'example.com', - getTwilioClient: () => ({ - username: 'mockUsername', - password: 'mockPassword', - }), - }; - - handlerFunction( - mockContextWithoutAndroidKeys, - { username: 'user001' }, - callback - ); - }); - it('calls the API with the expected request body', (done) => { const modifiedRequest = structuredClone(mockRequestBody); - modifiedRequest.content.relying_party.origins.push('key1', 'key2', 'key3'); const callback = (_, result) => { expect(axios.post).toHaveBeenCalledWith( - 'https://api.com/Factors', + 'https://api.com/mockServiceSid/Passkeys/Factors', modifiedRequest, { auth: { password: 'mockPassword', username: 'mockUsername' } } );