The code to follow along the webinar can be found on this repo:
- Initial state: Branch
step1-starter
(GitHub Link) - Final code: Branch
step1-final
(GitHub Link)
- You are going to need nodejs. Install it following the instructions here.
- Get the starter code for the demo application we are going to use:
git clone -b step1-starter https://github.com/transmitsecurity/workshop-latam
- Install dependencies
cd workshop-latam
npm install
- Create configuration file
cp dotenv.example .env
- Start the server
npm start
- Browse to
http://localhost:3001
, you should see the login page for our brand new (and fake) "Artificial Intelligence-created NFT Art Site" (aka AI NFT Art)
Your hosts in the session will provide the instructions for this step.
First, edit .env
file at the root folder and modify the following lines with the right values provided for you in step 1:
### Transmit configuration ###
VITE_TS_CLIENT_ID=<your_transmit_app_client_id>
TS_CLIENT_SECRET=<your_transmit_app_client_secret>
VITE_TS_BASE_URL=https://api.transmitsecurity.io
Restart the application so that it gets the new values in .env
<Ctrl>+C
npm start
Include Transmit SDK and initialize it in login and sign-up pages (index.html
and sign-up.html
respectively), located in webinar-vanilla-js/src
folder.
Edit index.html
and sign-up.html
and include the sdk script at the bottom of the page, just before </body>
.
<!-- This loads the latest SDK within the major version 1. -->
<script
src="https://platform-websdk.transmitsecurity.io/platform-websdk/latest/ts-platform-websdk.js"
defer="true"
id="ts-platform-script"></script>
Move to webinar-server
.
Let's start creating the file src/services/transmitService.js
with the calls to the Transmit APIs we will need for passkeys registration.
import { ERROR_CLIENT_ACCESS_TOKEN, ERROR_PASSKEY_REGISTRATION } from '../helpers/constants.js';
/**
* Get ClientAccessToken using client credentials flow
* @returns client access_token
*/
export const getClientAccessToken = async () => {
const formData = {
client_id: process.env.VITE_TS_CLIENT_ID,
client_secret: process.env.TS_CLIENT_SECRET,
grant_type: 'client_credentials',
};
try {
const resp = await fetch(`${process.env.VITE_TS_BASE_URL}/oidc/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(formData).toString(),
});
const data = await resp.json();
return data.access_token;
} catch (error) {
console.error(`${ERROR_CLIENT_ACCESS_TOKEN}: ${error.message}`);
throw new Error(ERROR_CLIENT_ACCESS_TOKEN);
}
};
/**
* Register Passkey
* @param {String} webauthnEncodedResult Returned by register() SDK call
* @param {String} externalUserId Identifier of the user in your system
* @param {String} clientAccessToken Transmit Platform Client Access Token
*/
export const registerPasskey = async (webauthnEncodedResult, externalUserId, clientAccessToken) => {
try {
const resp = await fetch(`${process.env.VITE_TS_BASE_URL}/cis/v1/auth/webauthn/external/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${clientAccessToken}`, // Client access token
},
body: JSON.stringify({
webauthn_encoded_result: webauthnEncodedResult, // Returned by register() SDK call
external_user_id: externalUserId, // Identifier of the user in your system
}),
});
const data = await resp.json();
console.log(`User registered: ${JSON.stringify(data, null, 2)}`);
return data;
} catch (error) {
console.error(`${ERROR_PASSKEY_REGISTRATION}: ${error.message}`);
throw new Error(ERROR_PASSKEY_REGISTRATION);
}
};
Now we need to add an endpoint on our server to allow user registration with Passkeys. We will create a controller to take care of the flow and update the router with a new endpoint for this.
First, let's create a new controller for this:
Create the file src/controllers/passkeysController.js
and add the following code:
import jwt from 'jsonwebtoken';
import dbService from '../services/dbService.js';
import { ERROR_PASSKEY_REGISTRATION, ERROR_USER_EXISTS } from '../helpers/constants.js';
import { getClientAccessToken, registerPasskey } from '../services/transmitService.js';
const registerUserPasskey = async (webauthnEncodedResult, userid) => {
try {
// Check if user already exists
if (dbService.getUserByUserId(userid)) {
console.log(`User ${userid} already exists`);
throw new Error(ERROR_USER_EXISTS);
}
// Register Passkey
const clientAccessToken = await getClientAccessToken();
const passkeyInfo = await registerPasskey(webauthnEncodedResult, userid, clientAccessToken);
// Save user in database
console.log({ userid, passkeyInfo });
await dbService.addUser({ userid, passkeyInfo });
const loginData = {
userid,
signInTime: Date.now(),
};
const token = jwt.sign(loginData, process.env.JWT_SECRET_KEY);
console.log(`User created successfully: ${userid}, token: ${token}`);
return token;
} catch (error) {
console.error(`${ERROR_PASSKEY_REGISTRATION}: ${error.message}`);
throw new Error(`${ERROR_PASSKEY_REGISTRATION}: ${error.message}`);
}
};
export default { registerUserPasskey };
This method takes care of checking if the user already exists, register the passkey and save the user in the database. Finally, in the same way as the password based registration, it creates a JWT for the user to login and keep browsing the site.
The server side is almost done, we only need to "publish" the new endpoint so that the client can register a user with Passkeys. To do that, open src/routes/defaultRouter.js
, add the following import at the beginning of the file:
import passkeysController from '../controllers/passkeysController.js';
to import the recently created "passkeysController", and add a POST endpoint for /webauthn/register
:
// The register-passkeys endpoint that registers a new passkey
defaultRouter.post('/webauthn/register', async (req, res) => {
const { webauthnEncodedResult, userId } = req.body;
if (!webauthnEncodedResult || !userId) return res.status(400).json({ message: 'Invalid request' });
try {
const token = await passkeysController.registerUserPasskey(webauthnEncodedResult, userId);
return res.status(200).json({ message: 'success', token });
} catch (error) {
return res.status(401).json({ message: error.message });
}
});
We have everything we need, now let's make some changes on client side to call this new endpoint to register a user with Passkeys.
Let's start with the registration page: sign-up.html
After adding the action to the signup button, we are going to initialize the Transmit SDK, check if Passkeys can be used on this device, and in this case, make some UI changes to the page. We are going to copy 2 snippets, one below the other.
Add the following snippet to the page just below the following line (in the script declaration at the bottom):
btnSignup.addEventListener('click', signUpWithPassword);
First snippet:
/**
* Initialize SDK and make required changes in the UI for passwordless
*/
const letsGoPasswordless = async () => {
// Initialize and configure the SDK with your client.
try {
await window.tsPlatform.initialize({
clientId: import.meta.env.VITE_TS_CLIENT_ID,
webauthn: { serverPath: import.meta.env.VITE_TS_BASE_URL },
});
console.log('TS Platform SDK initialized');
// Check if biometrics is supported
const isBiometricsSupported = await window.tsPlatform.webauthn.isPlatformAuthenticatorSupported();
console.log('isBiometricsSupported', isBiometricsSupported);
if (isBiometricsSupported) {
btnSignup.removeEventListener('click', signUpWithPassword);
// Remove password input and add biometrics
document.querySelector('input[type="password"]').classList.add('hidden');
btnSignup.getElementsByTagName('img')[0].classList.remove('hidden');
// Add Passkey registration logic to the button
btnSignup.addEventListener('click', signUpWithPasskey);
}
} catch (error) {
console.error('Failed to initialize TS Platform SDK', error);
showToastSync('Failed to initialize TS Platform SDK', 'error');
}
};
// Initialize the SDK once it's loaded
document.getElementById('ts-platform-script').addEventListener('load', () => {
letsGoPasswordless();
});
The missing part on the above script is the signUpWithPasskey
method that we also have to implement. This method is the one that makes a request to our own backend to finish the Passkey registration. Paste the second snippet below the above code:
/**
* Registers a user with a passkey
*/
async function signUpWithPasskey() {
const email = document.querySelector('input[type="email"]').value;
if (!email) {
showToastSync('Please enter email', 'warning');
return;
}
// Registers WebAuthn credentials on the device and returns an encoded result
// that should be passed to your backend to complete the registration flow
const encodedResult = await window.tsPlatform.webauthn.register(email);
const resp = await fetch(`${import.meta.env.VITE_BACKEND_BASE_URL}/webauthn/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
webauthnEncodedResult: encodedResult,
userId: email,
}),
});
if (resp.status !== 200) {
console.error('Failed to register');
const error = await resp.json();
showToastSync(error.message || 'Registration failed', 'warning');
return;
}
const data = await resp.json();
storeSaveUser(email, data.token);
window.location.href = '/home.html';
}
We are done for the registration part. Let's test how it works!
Browse to: http://localhost:3001/sign-up.html, add your email and try to Sign Up. If everything went well, that it should, you have created your Passkey for this demo site. Congratulations! π₯
Move to webinar-server
.
You remember that we created src/services/transmitService.js
with the calls to the Transmit Platform APIs that we require to register Passkeys. Let's now add the call to authenticate with Passkeys.
Copy the following snippet in the service:
/**
* Authenticate Passkey
* @param {String} webauthnEncodedResult Returned by authenticate() SDK call
* @param {String} clientAccessToken Transmit Platform Client Access Token
*/
export const authPasskey = async (webauthnEncodedResult, clientAccessToken) => {
try {
const resp = await fetch(`${process.env.VITE_TS_BASE_URL}/cis/v1/auth/webauthn/authenticate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${clientAccessToken}`, // Client access token
},
body: JSON.stringify({
webauthn_encoded_result: webauthnEncodedResult, // Returned by authenticate() SDK call
}),
});
const data = await resp.json();
console.log(`User authenticated: ${JSON.stringify(data, null, 2)}`);
return data;
} catch (error) {
console.error(`${ERROR_PASSKEY_AUTHENTICATION}: ${error.message}`);
throw new Error(ERROR_PASSKEY_AUTHENTICATION);
}
};
Now we can go to the controller and add the functionality to log in a user. Open src/controllers/passkeysController.js
and add this new function:
/**
* Authenticate a user with Passkey credentials
* @param {String} webauthnEncodedResult Webauthn encoded result returned by authenticate() SDK call
* @param {String} userid User identifier
* @returns JWT token for the user
*/
const authUserPasskey = async (webauthnEncodedResult, userid) => {
// Look up the user entry in the database
const user = dbService.getUserByUserId(userid);
// If found, go ahead with passkey authentication
if (user) {
try {
// Authenticate with Passkey
const clientAccessToken = await getClientAccessToken();
const authInfo = await authPasskey(webauthnEncodedResult, clientAccessToken);
console.log(`User authenticated: ${JSON.stringify(authInfo, null, 2)}`);
const loginData = {
userid,
signInTime: Date.now(),
};
const token = jwt.sign(loginData, process.env.JWT_SECRET_KEY);
return token;
} catch (error) {
console.error(`${ERROR_PASSKEY_AUTHENTICATION}: ${error.message}`);
throw new Error(ERROR_PASSKEY_AUTHENTICATION);
}
} else {
console.log('Error authenticating user: user not found');
throw new Error(ERROR_AUTHENTICATION);
}
};
You also need to import the authPasskey
method we created above, so at the beginning, in the import section, change:
import { getClientAccessToken, registerPasskey } from '../services/transmitService.js';
by
import { getClientAccessToken, registerPasskey, authPasskey } from '../services/transmitService.js';
Don't forget to export the new method at the end of the file. Change
export default { registerUserPasskey };
by
export default { registerUserPasskey, authUserPasskey };
And finally, we only need to publish the endpoint to be consumed from the server. Open src/routes/defaultRouter.js
and paste the following route definition:
// The auth-passkeys endpoint that authenticates a passkey
defaultRouter.post('/webauthn/auth', async (req, res) => {
const { webauthnEncodedResult, userId } = req.body;
if (!webauthnEncodedResult || !userId) return res.status(400).json({ message: 'Invalid request' });
try {
const token = await passkeysController.authUserPasskey(webauthnEncodedResult, userId);
return res.status(200).json({ message: 'success', token });
} catch (error) {
return res.status(401).json({ message: error.message });
}
});
Come on, cheer up, we almost finished π¦Ύ We've already created everything we need in the backend to authenticate a user with a Passkey, so we are only missing to allow the user to use the Passkey to authenticate, so let's get down to business.
Open index.html
and make sure that, at the bottom of the file, just before <\body>
, you have already included:
<!-- This loads the latest SDK within the major version 1. -->
<script
src="https://platform-websdk.transmitsecurity.io/platform-websdk/latest/ts-platform-websdk.js"
defer="true"
id="ts-platform-script"></script>
First thing, we are going to replicate what we did in the Sign Up page, and create a method to be called once the Transmit Platform SDK has been initialized. Paste the following code just after add the click event listener to the button, so, after this line:
btnLogin.addEventListener('click', loginWithPassword);
Paste:
/**
* Initialize SDK and make required changes in the UI for passwordless
*/
const letsGoPasswordless = async () => {
// Initialize and configure the SDK with your client.
try {
await window.tsPlatform.initialize({
clientId: import.meta.env.VITE_TS_CLIENT_ID,
webauthn: { serverPath: import.meta.env.VITE_TS_BASE_URL },
});
console.log('TS Platform SDK initialized');
// Check if biometrics is supported
const isBiometricsSupported = await window.tsPlatform.webauthn.isPlatformAuthenticatorSupported();
console.log('isBiometricsSupported', isBiometricsSupported);
if (isBiometricsSupported) {
btnLogin.removeEventListener('click', loginWithPassword);
// Remove password input and add biometrics
document.querySelector('input[type="password"]').classList.add('hidden');
btnLogin.getElementsByTagName('img')[0].classList.remove('hidden');
// Add Passkey registration logic to the button
btnLogin.addEventListener('click', loginWithPasskeyModal);
}
} catch (error) {
console.error('Failed to initialize TS Platform SDK', error);
showToastSync('Failed to initialize TS Platform SDK', 'error');
}
};
// Initialize the SDK once it's loaded
document.getElementById('ts-platform-script').addEventListener('load', () => {
letsGoPasswordless();
});
As you can see, the last 3 lines call the method after loading the script. The method itself initializes the SDK, checks that Passkeys are supported on this device and make the same changes in the UI that we did in the Sign Up (not the best approach, but enough to understand what we are doing).
Let's now implement the new action for the Sign In button so that users can use their Passkeys to log in. Right after the code you pasted, add the following methods:
/**
* Login with passkey
*/
async function loginWithPasskeyModal() {
const email = document.querySelector('input[type="email"]').value;
if (!email) {
showToastSync('Please enter email', 'warning');
return;
}
try {
const webauthn_encoded_result = await window.tsPlatform.webauthn.authenticate.modal(email); // Optional username
await completePasskeyLogin(webauthn_encoded_result, email);
}
catch (e) {
console.error(e);
showToastSync('Invalid user or passkey', 'warning');
}
}
/**
* Complete login with passkey
* @param {string} webauthnEncodedResult Encoded result from the SDK
* @param {string} email User email
*/
async function completePasskeyLogin(webauthnEncodedResult, email) {
const resp = await fetch(`${import.meta.env.VITE_BACKEND_BASE_URL}/webauthn/auth`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ userId: email, webauthnEncodedResult }),
});
if (resp.status !== 200) {
console.error('Failed to login with passkey');
showToastSync('Invalid user or passkey', 'warning');
return;
}
const data = await resp.json();
storeSaveUser(email, data.token);
window.location.href = '/home.html';
}
You can see we have separated the code into 2 methods, one that calls Transmit SDK to authenticate the user using Passkeys and a second one that completes the authentication calling our backend (the endpoint /webauthn/authn
that we built in order to finish authentication, generate user token, etc.). There is a reason behind separating the code that we will see soon, but for now, let's try authenticate with the passkey you created during the registration: Go to Login page, set our email and click the Sign In button. Follow the steps and...
Congratulations!!!! You have a Passkeys-based authentication on your website, your customers will love you! π»
Let's test that it works, not because we are not sure, just to realize how easy it is to log in now π₯³.
Browse to http://localhost:3001
, write the email address you used to register the Passkey and click Sign In button.
There it is, we have just moved from a password based authentication to a passkeys based authentication (easier, much more secure, nothing to remember...) π π π
But wait, we are typing our email and clicking a button, but you have already seen other sites where, when you click on the input to type the email, a drop down appears offering you to choose a Passkey (or a password), and I like it even more than what we have already implemented! (many people, many minds π½)
Okie Dokie! Let's do that too π
First thing first, add this function to index.html
:
/**
* Enable passkey autofill
*/
async function initPasskeyAutofill() {
// Start autofill/conditionalUI if supported
const isAutofillSupported = await window.tsPlatform.webauthn.isAutofillSupported();
if (!isAutofillSupported) {
console.log('Autofill not supported');
return;
}
await window.tsPlatform.webauthn.authenticate.autofill.activate({
onSuccess: async (webauthn_encoded_result) => {
try {
await completePasskeyLogin(webauthn_encoded_result);
} catch (error) {
console.error(error);
showToastSync('Sorry, couldn\'t sign you in', 'warning');
}
},
onError: (error) => {
console.log('Passkey autofill error: ', error);
if (error.errorCode !== "autofill_authentication_aborted") {
// The user doesn't exists or cannot login, redirect to register
console.log("User doesn't exist. Sign up first");
showToastSync('User doesn\'t exist. Sign up first', 'warning');
}
},
});
}
This code is what we need to start using autofill (only if supported, that is the first thing we check in the function).
Now we have to call this method. Add the following lines in the letsGoPasswordless
method after the line
btnLogin.addEventListener('click', loginWithPasskeyModal);
paste:
// Start autofill/conditionalUI
initPasskeyAutofill();
We also have to indicate what is the input field that will get the autofill behavior in.
Look in the html for the input with type="email"
and change it into:
<input class="ina-inp-txt-primary" autocomplete="username webauthn" type="email" placeholder="Email" />
Finally, a small detail: we built the functionality to allow a user to type the email and press the button, so let's keep it so that the user can skip the autofill functionality and use the button. To achieve that, we need to stop (or abort) the autofill when the user clicks the button:
Go to the loginWithPasskeyModal
method and before the try
add the following line that will abort autofill:
// Abort autofill first
await window.tsPlatform.webauthn.authenticate.autofill.abort();
and just in case there is any issue while trying to login using the button, start the autofill again. In the same method, at the end of the catch
block, add the following:
// Re-start autofill/conditionalUI
initPasskeyAutofill();
Kudos! Autofill is now working, you only have to click on the input email, but unfortunately we get an error when trying to login. If you remember, the server is expecting a userid (we are using the email in this example application), but when we enabled autofill, we are just sending the proof that the user has a Passkey for this application. Let's change the server-side part to allow not only passwordless but also userid-less.
In the defaultRouter.js
, let's change the /webauthn/auth
endpoint to do not fail when there is no userId
:
// The auth-passkeys endpoint that authenticates a passkey
defaultRouter.post('/webauthn/auth', async (req, res) => {
const { webauthnEncodedResult, userId } = req.body;
if (!webauthnEncodedResult) return res.status(400).json({ message: 'Invalid request' });
try {
const token = await passkeysController.authUserPasskey(webauthnEncodedResult, userId);
return res.status(200).json({ message: 'success', token });
} catch (error) {
return res.status(401).json({ message: error.message });
}
});
Now let's move to passkeysController.js
, concretely to the authUserPasskey
function.
Here, the first thing we are checking is whether the user exists or not, and we are doing it based on the userid
param, but now this parameter is kinda optional.
Let's rebuild the function so that it can use the userid
when it's provided and get it from the passkey authentication response if not:
/**
* Authenticate a user with Passkey credentials
* @param {String} webauthnEncodedResult Webauthn encoded result returned by authenticate() SDK call
* @param {String} userid User identifier (optional)
* @returns JWT token for the user
*/
const authUserPasskey = async (webauthnEncodedResult, userid) => {
try {
// Authenticate with Passkey first
const clientAccessToken = await getClientAccessToken();
const authInfo = await authPasskey(webauthnEncodedResult, clientAccessToken);
console.log(`User authenticated: ${JSON.stringify(authInfo, null, 2)}`);
// If no userid is provided, look up the userid from the authInfo (id_token)
if (!userid) {
const { id_token } = authInfo;
const decodedToken = await validateToken(id_token);
console.log(`Decoded id_token: ${JSON.stringify(decodedToken, null, 2)}`);
userid = decodedToken?.webauthn?.username;
if (!userid) {
console.error('Error authenticating user: user not found');
throw new Error(ERROR_AUTHENTICATION);
}
}
// Look up the user entry in the database
const user = dbService.getUserByUserId(userid);
if (!user) {
console.error('Error authenticating user: user not found');
throw new Error(ERROR_AUTHENTICATION);
}
const loginData = {
userid,
signInTime: Date.now(),
};
const token = jwt.sign(loginData, process.env.JWT_SECRET_KEY);
return { token, userid };
} catch (error) {
console.error(`${ERROR_PASSKEY_AUTHENTICATION}: ${error.message}`);
throw new Error(ERROR_PASSKEY_AUTHENTICATION);
}
};
To decode the token, we have decided to make a simple validation (to make sure it's a token generated by Transmit Platform, and not from someone else π).
Add the validation code to the transmitService.js
file:
Import the following at the top:
import jwt from 'jsonwebtoken';
import jwkToPem from 'jwk-to-pem';
And add these two methods:
/**
* Get Transmit Platform JWKS
* @returns JKWS
*/
const getJwks = async () => {
// No error handling for the sake of simplicity
const resp = await fetch(`${process.env.VITE_TS_BASE_URL}/oidc/jwks`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const data = await resp.json();
return data;
};
/**
* Validates a JWT token
* @param {String} token token to validate
* @returns decoded validated token
*/
export const validateToken = async (token) => {
const jwks = await getJwks();
const { header } = jwt.decode(token, { complete: true });
const kid = header.kid;
const key = jwks.keys.find((key) => key.kid === kid);
const publicKey = jwkToPem(key);
return jwt.verify(token, publicKey);
};
and don't forget to import the validateToken
function in passkeysController.js
, so open it again and, in the import section at the beginning, change:
import { getClientAccessToken, registerPasskey, authPasskey } from '../services/transmitService.js';
by:
import { getClientAccessToken, registerPasskey, authPasskey, validateToken } from '../services/transmitService.js';
The last detail (promised), is the fact that when using autofill we do not have the email
of the user in the client app, so we are going to send it from the server together with the authentication token.
If you look at the return
in authUserPasskey
function you just revised, we are now returning both, the token
and the userid
.
Let's modify the code in defaultRouter.js
to also return both values to the client app:
// The auth-passkeys endpoint that authenticates a passkey
defaultRouter.post('/webauthn/auth', async (req, res) => {
const { webauthnEncodedResult, userId } = req.body;
if (!webauthnEncodedResult) return res.status(400).json({ message: 'Invalid request' });
try {
const { token, userid } = await passkeysController.authUserPasskey(webauthnEncodedResult, userId);
return res.status(200).json({ message: 'success', token, email: userid });
} catch (error) {
return res.status(401).json({ message: error.message });
}
});
And now, in the client app, go to index.html
, look for completePasskeyLogin
function and replace:
storeSaveUser(email, data.token);
by
storeSaveUser(data.email, data.token);
This way we use the userid
value from server.
Browse to http://localhost:3001
, but this time, click on the input where you can type your email and a modal should appear with a break down of the passkeys you have already created for this application. Click the one you created and marvel at the great experience π
And now yes, you have your passwordless site (with the password login still active for the devices that cannot use Passkeys). Congratulations!!! π»π»π»π»π»π»
_ _ _ _ _ _
_ _ ___ ___| | _ _ ___ _ _ __| (_) __| | (_) |_
| | | |/ _ \/ __| | | | | |/ _ \| | | | / _` | |/ _` | | | __|
| |_| | __/\__ \_| | |_| | (_) | |_| | | (_| | | (_| | | | |_
\__, |\___||___(_) \__, |\___/ \__,_| \__,_|_|\__,_| |_|\__|
|___/ |___/