Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
8 changes: 4 additions & 4 deletions passkeys-backend/.env.example
Original file line number Diff line number Diff line change
@@ -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
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

ANDROID_APP_KEYS=
SERVICE_SID=
15 changes: 13 additions & 2 deletions passkeys-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |

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, 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 {
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