Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions passkeys-backend/.env.example
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
# 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: Namespace UUID for generating deterministic UUIDs with the uuid library
# format: text
# required: false
ANDROID_APP_KEYS=
NAMESPACE=
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the purpose of this value? What will be the format of the value? Could there be a default value?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The purpose is to create a uuid based on the username, this is described in the readme, I think the UUID as is described is self explanatory, but yes we can add a default value


# description: [Optional] SID of the service created in Twilio verify
# format: sid
# required: false
Copy link
Contributor

@yafuquen yafuquen Sep 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be true?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if I set this to true the code exchange will take it as a mandatory when creating the function, but this is created with one of the endpoints

SERVICE_SID=
16 changes: 14 additions & 2 deletions passkeys-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ 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 |
| `NAMESPACE` | UUID for generating deterministic UUIDs with the uuid library for username conversion | Yes |
| `SERVICE_SID` | Service created in Twilio verify | No |

## Create a new project with the template

Expand Down Expand Up @@ -89,8 +90,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 |
Expand Down Expand Up @@ -121,4 +134,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 |

8 changes: 8 additions & 0 deletions passkeys-backend/assets/origins.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const origins = (context) => {
const { DOMAIN_NAME } = context;
return [`https://${DOMAIN_NAME}`, 'android:apk-key-hash:{base64_hash}'];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this for Android & Web? What about iOS?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iOS only need the apple-app-site-association, it uses backend domain as its origin

};

module.exports = {
origins,
};
11 changes: 11 additions & 0 deletions passkeys-backend/functions/.well-known/webauthn.js
Original file line number Diff line number Diff line change
@@ -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);
};
32 changes: 16 additions & 16 deletions passkeys-backend/functions/authentication/start.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we have a sense of when these endpoints will be available in the Node Helper Library?


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);
Expand Down
30 changes: 20 additions & 10 deletions passkeys-backend/functions/authentication/verification.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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, {
Expand All @@ -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;
Expand Down
48 changes: 48 additions & 0 deletions passkeys-backend/functions/registration/service.js
Original file line number Diff line number Diff line change
@@ -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, {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should already be available in the Node Helper Library - I vote we add that as an import instead of using axios.

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);
};
47 changes: 17 additions & 30 deletions passkeys-backend/functions/registration/start.js
Original file line number Diff line number Diff line change
@@ -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, NAMESPACE } = context;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need the NAMESPACE?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed


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);
Expand All @@ -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, NAMESPACE);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could it be possible to use something already included as library (to prevent adding a new library)? Could it be possible to use a hash of the value?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API specifies a UUID format, it reject the request if this specification is not fulfil


// 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 {
Expand All @@ -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);
Expand Down
27 changes: 18 additions & 9 deletions passkeys-backend/functions/registration/verification.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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, {
Expand Down
3 changes: 2 additions & 1 deletion passkeys-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"private": true,
"dependencies": {
"twilio": "^5.3.3",
"axios": "^1.7.7"
"axios": "^1.7.7",
"uuid": "^11.0.4"
}
}
Loading