SimpleWebAuthn with multiple devices #588
Replies: 9 comments 1 reply
-
Guess that my understanding so far is that: WebAuthn: A standard for passwordless authentication using public-key cryptography. Private keys are stored locally on each device. Each device must be registered separately. Passkeys: An extension of WebAuthn that allows private keys to be securely synchronized across multiple devices using platform-specific keychain services, providing a more seamless user experience. This allows the same private key to be available on all devices that the user owns and has signed in with their platform account. So I can assume that the primary difference between passkeys and traditional WebAuthn implementations lies in the synchronization of the private key across devices (?) That means that the operating system and the underlying platform services (such as Apple's iCloud Keychain or Google's Password Manager) handle the synchronization of the private keys transparently. This means that as a developer, you don't need to change the flow of interactions between the browser and your server for handling WebAuthn and passkeys. The backend implementation for registration and authentication remains the same (?) That means that users typically know if their private keys are synced based on their platform settings and notifications. For example: iCloud Keychain: On Apple devices, users can check if iCloud Keychain is enabled in their device settings. If it is enabled, their credentials are synchronized across their Apple devices. Google Password Manager: On Android devices, users can check if they have Google Password Manager enabled. If the Key is Synchronized: If the Key is Not Synchronized: Does this mean that if the private key is not synchronized and you need to register a new device, verifying ownership of the account is crucial to ensure security. Leveraging a magic link sent to the user's email address is an effective way to verify that the person trying to register a new device is indeed the legitimate owner of the account? |
Beta Was this translation helpful? Give feedback.
-
Here is my progress so far. The first one is the start registration // @ts-check
import express from "express";
import { body, validationResult } from "express-validator";
import { verify } from "jsonwebtoken";
import { generateRegistrationOptions } from "@simplewebauthn/server";
import { User } from "../models/users.js";
var router = express.Router();
router.post(
"/register/start",
[
body("token")
.not()
.isEmpty()
.isString()
.withMessage("A valid 'token' must be provided with this request"),
],
/**
*
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
*/
async (req, res, next) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
var { token } = req.body;
var tokenPayload;
/**
* TODO: Store the magic tokens and mark them as 'used' after they were validated?
* TODO: Add secret to env var
*/
try {
tokenPayload = verify(decodeURIComponent(token), "SECRET");
} catch (error) {
return res.sendStatus(422);
}
if (
!tokenPayload ||
typeof tokenPayload === "string" ||
!tokenPayload.email
) {
return res.sendStatus(422);
}
var user = await User.findOne({ email: tokenPayload.email });
if (!user) {
user = new User({
email: tokenPayload.email,
devices: [],
});
await user.save();
}
var { devices, email } = user;
var options = await generateRegistrationOptions({
rpName: "localhost",
rpID: "localhost",
userName: email,
timeout: 300000, // 5 minutes
attestationType: "none",
/**
* Passing in a user's list of already-registered authenticator IDs here prevents users from
* registering the same device multiple times. The authenticator will simply throw an error in
* the browser if it's asked to perform registration when one of these ID's already resides
* on it.
*/
excludeCredentials: devices.map((dev) => ({
id: dev.credentialID,
type: "public-key",
transports: dev.transports,
})),
authenticatorSelection: {
residentKey: "discouraged",
/**
* Wondering why user verification isn't required? See here:
*
* https://passkeys.dev/docs/use-cases/bootstrapping/#a-note-about-user-verification
*/
userVerification: "preferred",
},
/**
* Support the two most common algorithms: ES256, and RS256
*/
supportedAlgorithmIDs: [-7, -257],
});
/**
* TODO: Store the expected challenge in Redis and retrieve it in the registration verification
*/
var expectedChallenge = options.challenge;
res.status(200).json({ options });
} catch (err) {
next(err);
}
}
);
export { router as registerRouter }; And this is the end registration endpoint // @ts-check
import express from "express";
import { body, validationResult } from "express-validator";
import { verify } from "jsonwebtoken";
import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { User } from "../models/users.js";
var router = express.Router();
router.post(
"/register/finish",
[
body("token")
.not()
.isEmpty()
.isString()
.withMessage("A valid 'token' must be provided with this request"),
body("registrationResponse")
.not()
.isEmpty()
.isObject()
.withMessage(
"A non empty 'registrationResponse' must be provided with this request"
),
],
/**
*
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
*/
async (req, res, next) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
/**
* @type {{
* registrationResponse: import('@simplewebauthn/types').RegistrationResponseJSON
* token: String
* }}
*/
var { registrationResponse, token } = req.body;
/**
* TODO: Retrieve the expected challenge from Redis
*/
var expectedChallenge = "";
var verifiedRegistrationResponse;
var tokenPayload;
try {
verifiedRegistrationResponse = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge: `${expectedChallenge}`,
expectedOrigin: "http://localhost",
expectedRPID: "localhost",
requireUserVerification: false,
});
} catch (error) {
return res.sendStatus(422);
}
try {
tokenPayload = verify(decodeURIComponent(token), "SECRET");
} catch (err) {
return res.sendStatus(422);
}
if (
!verifiedRegistrationResponse ||
!tokenPayload ||
typeof tokenPayload === "string" ||
!tokenPayload.email
) {
return res.sendStatus(422);
}
var { verified, registrationInfo } = verifiedRegistrationResponse;
if (verified && registrationInfo) {
var { credentialPublicKey, credentialID, counter } = registrationInfo;
var user = await User.findOne({ email: tokenPayload.email });
if (user) {
var existingDevice = user.devices.find(
(device) => device.credentialID === credentialID
);
if (!existingDevice) {
var newDevice = {
credentialPublicKey,
credentialID,
counter,
transports: registrationResponse.response.transports,
};
user.devices.push(newDevice);
await user.save();
}
}
}
// TODO: Remove challenge from Redis
res.sendStatus(200);
} catch (err) {
next(err);
}
}
);
export { router as registerFinishRouter }; |
Beta Was this translation helpful? Give feedback.
-
I'm using a |
Beta Was this translation helpful? Give feedback.
-
This is the confusing bit: https://github.com/MasterKale/SimpleWebAuthn/blob/master/example/index.ts#L222 How can I |
Beta Was this translation helpful? Give feedback.
-
I guess that I can simply pass the email, or account identifier when initiating the authentication flow, leveraging the WebAuthn protocol to ensure that only registered devices with the corresponding private keys can authenticate. If a device doesn't have the appropriate private key, it will be unable to produce a valid assertion, and the authentication attempt will fail. User Initiates Authentication: The user provides their email or account identifier on the login page. Server Generates Authentication Options: The server retrieves the user's registered credentials (public keys) from the database based on the provided email or account identifier. Client Receives Authentication Options: The client (user’s device) receives the authentication options from the server. Authenticator Checks Credentials: The authenticator on the user's device checks if it has any credentials (private keys) that match the allowed credentials provided in the authentication options. Client Sends Assertion to Server: If the authenticator signs the challenge, the client sends the signed challenge (assertion) back to the server. Server Verifies the Assertion: The server uses the public key associated with the credential ID to verify the signed challenge. |
Beta Was this translation helpful? Give feedback.
-
I decided to go with the following flow Signup Start: Signup Finish: WebAuthn Register Start: WebAuthn Register Finish: WebAuthn Authentication Start: WebAuthn Authentication Finish: |
Beta Was this translation helpful? Give feedback.
-
// @ts-check
import express from "express";
import { body, validationResult } from "express-validator";
import { sign } from "jsonwebtoken";
var router = express.Router();
router.post(
"/signup/start",
[
body("email")
.isEmail()
.withMessage("A valid 'email' must be provided with this request"),
],
/**
*
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
*/
async (req, res, next) => {
try {
var errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
var { email } = req.body;
var webauthnToken = sign({ email }, "WEBAUTHN_TOKEN_SECRET", {
expiresIn: "15m",
});
/**
* The URL should open the web app route or mobile app screen
* that can take over the magic webauthnToken and complete the signup
* and webauthn flow
*/
let url = new URL(`http://localhost`);
url.pathname = "/signup";
url.searchParams.set("webauthnToken", webauthnToken);
/**
* TODO: Send email
*/
res.status(200).json({
webauthnToken,
});
} catch (err) {
next(err);
}
}
);
export { router as signupStartRouter }; // @ts-check
import express from "express";
import { body, validationResult } from "express-validator";
import { sign, verify } from "jsonwebtoken";
import { User } from "../models/users";
var router = express.Router();
router.post(
"/signup/finish",
[
body("webauthnToken")
.not()
.isEmpty()
.isString()
.withMessage(
"A valid 'webauthnToken' must be provided with this request"
),
],
/**
*
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
*/
async (req, res, next) => {
try {
var errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
var { webauthnToken } = req.body;
var webauthnTokenPayload;
try {
webauthnTokenPayload = verify(webauthnToken, "WEBAUTHN_TOKEN_SECRET");
} catch (error) {
return res.sendStatus(422);
}
if (
!webauthnTokenPayload ||
typeof webauthnTokenPayload === "string" ||
!webauthnTokenPayload.email
) {
return res.sendStatus(422);
}
var user = await User.findOne({ email: webauthnTokenPayload.email });
if (!user) {
user = new User({
email: webauthnTokenPayload.email,
devices: [],
});
await user.save();
}
/**
* Reuse the token from the email for the webauthn registration
*/
res.sendStatus(200);
} catch (err) {
next(err);
}
}
);
export { router as signupFinishRouter }; // @ts-check
import express from "express";
import { body, validationResult } from "express-validator";
import { verify } from "jsonwebtoken";
import { generateRegistrationOptions } from "@simplewebauthn/server";
import { User } from "../models/users.js";
var router = express.Router();
router.post(
"/webauthn/register/start",
[
body("webauthnToken")
.not()
.isEmpty()
.isString()
.withMessage(
"A valid 'webauthnToken' must be provided with this request"
),
],
/**
*
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
*/
async (req, res, next) => {
try {
var errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
var { webauthnToken } = req.body;
var webauthnTokenPayload;
/**
* TODO: Store the magic tokens and mark them as 'used' after they were validated?
* TODO: Add secret to env var
*/
try {
webauthnTokenPayload = verify(
decodeURIComponent(webauthnToken),
"WEBAUTHN_TOKEN_SECRET"
);
} catch (error) {
return res.sendStatus(422);
}
if (
!webauthnTokenPayload ||
typeof webauthnTokenPayload === "string" ||
!webauthnTokenPayload.email
) {
return res.sendStatus(422);
}
var user = await User.findOne({ email: webauthnTokenPayload.email });
if (!user) {
return res.sendStatus(422);
}
var { devices, email } = user;
var options = await generateRegistrationOptions({
rpName: "localhost",
rpID: "localhost",
userName: email,
/**
* Optionals below
*/
timeout: 300000,
attestationType: "none",
/**
* Passing in a user's list of already-registered authenticator IDs here prevents users from
* registering the same device multiple times. The authenticator will simply throw an error in
* the browser if it's asked to perform registration when one of these ID's already resides
* on it.
*/
excludeCredentials: devices.map((dev) => ({
id: dev.credentialID,
type: "public-key",
transports: dev.transports,
})),
authenticatorSelection: {
residentKey: "discouraged",
/**
* Wondering why user verification isn't required? See here:
*
* https://passkeys.dev/docs/use-cases/bootstrapping/#a-note-about-user-verification
*/
userVerification: "preferred",
},
/**
* Support the two most common algorithms: ES256, and RS256
*/
supportedAlgorithmIDs: [-7, -257],
});
/**
* TODO: Store the expected challenge in Redis and retrieve it in the registration verification
*/
var expectedChallenge = options.challenge;
res.status(200).json({
options,
});
} catch (err) {
next(err);
}
}
);
export { router as webauthnRegisterStart }; // @ts-check
import express from "express";
import { body, validationResult } from "express-validator";
import { verify } from "jsonwebtoken";
import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { User } from "../models/users.js";
var router = express.Router();
router.post(
"/webauthn/register/finish",
[
body("webauthnToken")
.not()
.isEmpty()
.isString()
.withMessage(
"A valid 'webauthnToken' must be provided with this request"
),
body("registrationResponse")
.not()
.isEmpty()
.isObject()
.withMessage(
"A non empty 'registrationResponse' must be provided with this request"
),
],
/**
*
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
*/
async (req, res, next) => {
try {
var errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
/**
* @type {{
* registrationResponse: import('@simplewebauthn/types').RegistrationResponseJSON
* webauthnToken: String
* }}
*/
var { registrationResponse, webauthnToken } = req.body;
/**
* TODO: Retrieve the expected challenge from Redis
*/
var expectedChallenge = "";
/**
* TODO: Wish I could get rid of the token here and get the userName from somewhere else
*/
var webauthnTokenPayload;
try {
webauthnTokenPayload = verify(
decodeURIComponent(webauthnToken),
"WEBAUTHN_START_SECRET"
);
} catch (err) {
return res.sendStatus(422);
}
if (
!webauthnTokenPayload ||
typeof webauthnTokenPayload === "string" ||
!webauthnTokenPayload.email
) {
return res.sendStatus(422);
}
/**
* @type {import('@simplewebauthn/server').VerifiedRegistrationResponse}
*/
var verifiedRegistrationResponse;
try {
verifiedRegistrationResponse = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge: `${expectedChallenge}`,
expectedOrigin: "http://localhost",
expectedRPID: "localhost",
requireUserVerification: false,
});
} catch (error) {
return res.sendStatus(422);
}
var { verified, registrationInfo } = verifiedRegistrationResponse;
if (verified && registrationInfo) {
var { credentialPublicKey, credentialID, counter } = registrationInfo;
var user = await User.findOne({ email: webauthnTokenPayload.email });
if (user) {
var existingDevice = user.devices.find(
(device) => device.credentialID === credentialID
);
if (!existingDevice) {
var newDevice = {
credentialPublicKey,
credentialID,
counter,
transports: registrationResponse.response.transports,
};
user.devices.push(newDevice);
await user.save();
}
}
}
// TODO: Remove challenge from Redis
res.sendStatus(200);
} catch (err) {
next(err);
}
}
);
export { router as webauthnRegisterFinish }; // @ts-check
import express from "express";
import { body, validationResult } from "express-validator";
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { User } from "../models/users.js";
var router = express.Router();
router.post(
"/webauthn/authenticate/start",
[
body("email")
.isEmail()
.withMessage("A valid 'email' must be provided with this request"),
],
/**
*
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
*/
async (req, res, next) => {
try {
var errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
var user = await User.findOne({ email: req.body.email });
if (!user) {
return res.sendStatus(422);
}
var options = await generateAuthenticationOptions({
rpID: "localhost",
timeout: 300000,
allowCredentials: user.devices.map(({ credentialID, transports }) => ({
id: credentialID,
type: "public-key",
transports: transports,
})),
/**
* Wondering why user verification isn't required? See here:
*
* https://passkeys.dev/docs/use-cases/bootstrapping/#a-note-about-user-verification
*/
userVerification: "preferred",
});
/**
* TODO: Store the expected challenge in Redis and retrieve it in the registration verification
*/
var expectedChallenge = options.challenge;
res.status(200).json({
options,
});
} catch (err) {
next(err);
}
}
);
export { router as webauthnAuthStart }; // @ts-check
import express from "express";
import { body, validationResult } from "express-validator";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { User } from "../models/users.js";
var router = express.Router();
router.post(
"/webauthn/authenticate/finish",
[
body("email")
.isEmail()
.withMessage("A valid 'email' must be provided with this request"),
body("authenticationResponse")
.not()
.isEmpty()
.isObject()
.withMessage(
"A non empty 'authenticationResponse' must be provided with this request"
),
],
/**
*
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
*/
async (req, res, next) => {
try {
var errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
/**
* @type {{
* authenticationResponse: import('@simplewebauthn/types').AuthenticationResponseJSON
* email: String
* }}
*/
var { email, authenticationResponse } = req.body;
/**
* TODO: Retrieve the expected challenge from Redis
*/
var expectedChallenge = "";
var user = await User.findOne({ email });
if (!user) {
return res.sendStatus(422);
}
/**
* @type {import('@simplewebauthn/types').AuthenticatorDevice}
*/
var authenticator = user.devices.find(
({ credentialID }) => credentialID === authenticationResponse.id
);
if (!authenticator) {
return res.sendStatus(422);
}
/**
* @type {import("@simplewebauthn/server").VerifiedAuthenticationResponse | undefined}
*/
var verification;
try {
verification = await verifyAuthenticationResponse({
response: authenticationResponse,
expectedChallenge: `${expectedChallenge}`,
expectedOrigin: "localhost",
expectedRPID: "localhost",
authenticator,
requireUserVerification: false,
});
} catch (error) {}
if (!verification) {
return res.sendStatus(422);
}
var { verified, authenticationInfo } = verification;
if (verified) {
authenticator.counter = authenticationInfo.newCounter;
}
/**
* TODO: Remove challenge from Redis
*/
/**
* TODO: Generate access and refresh tokens based on subscription status
*/
res.sendStatus(200);
} catch (err) {
next(err);
}
}
);
export { router as webauthnAuthFinish }; |
Beta Was this translation helpful? Give feedback.
-
@mstaicu So you already have a valid jwt token authenticating the user, what prevents you from serving this jwt token, when registering a new device. Login to acount with old device and register new device with valid "session" informations, or am I missing something here? |
Beta Was this translation helpful? Give feedback.
-
Hey @lmarschall thanks for replying and sorry for the delay in my answer. What I am trying to understand, first and foremost, is if what I want to achieve is possible and if possible, if it is a good experience for the customers. I want to completely get rid of passwords and rely on the webauthn registration flows to generate a key pair and register the public key associated with that pair with a account. I would like to think that this replaces the Then I would use the webauthn authentication flow and rely on the platform's OS to sync up Passkeys and also do the discovery of the available passkeys associated with a domain in the customer's keychain So this is what I have so far, it relies, currently, on a JWT token that I send to the email in order to verify the ownership of that email. I want to completely eliminate the need for the email validation, as I would be creating the user accounts on webauthn registration against the provided public key. // @ts-check
import express from "express";
import { header, validationResult } from "express-validator";
import jwt from "jsonwebtoken";
import nconf from "nconf";
import { generateRegistrationOptions } from "@simplewebauthn/server";
import { User } from "../models/user.mjs";
import { redis } from "../services/index.mjs";
var router = express.Router();
router.post(
"/webauthn/register/start",
[
header("Authorization")
.not()
.isEmpty()
.withMessage("'Authorization' header must be provided"),
],
/**
*
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
*/
async (req, res, next) => {
try {
var errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
type: "https://example.com/probs/validation-error",
title: "Invalid Request",
status: 400,
detail: "There were validation errors with your request",
errors: errors.array(),
});
}
var header = req.headers.authorization || "";
var [type, token] = header.split(" ");
if (type !== "Bearer" || !token) {
return res.status(401).json({
type: "https://example.com/probs/unauthorized",
title: "Unauthorized",
status: 401,
detail: "Invalid or missing authorization token",
});
}
var tokenStatus = await redis.get(`registration:token:${token}`);
if (!tokenStatus) {
return res.status(401).json({
type: "https://example.com/probs/unauthorized",
title: "Unauthorized",
status: 401,
detail: "Token is either expired or has been used already",
});
}
var tokenPayload;
try {
tokenPayload = jwt.verify(token, nconf.get("REGISTRATION_ACCESS_TOKEN"));
} catch (error) {
return res.status(401).json({
type: "https://example.com/probs/unauthorized",
title: "Unauthorized",
status: 401,
detail: "Invalid or expired authorization token",
});
}
if (
!tokenPayload ||
typeof tokenPayload === "string" ||
!tokenPayload.email
) {
return res.status(401).json({
type: "https://example.com/probs/unauthorized",
title: "Unauthorized",
status: 401,
detail: "Invalid token payload",
});
}
var user = await User.findOne({ email: tokenPayload.email });
/**
* Email Enumeration: If the route reveals whether an email exists in the system,
* it can be used for enumeration attacks.
*
* Return the same options response regardless of the existence of the user
*/
/**
* @type {import("@simplewebauthn/server").GenerateRegistrationOptionsOpts}
*/
var options = {
rpName: nconf.get("DOMAIN"),
rpID: nconf.get("DOMAIN"),
userName: user ? user.email : "",
/**
* Optionals below
*/
timeout: 300000,
attestationType: "none",
excludeCredentials: user
? user.devices.map((dev) => ({
id: dev.credentialID,
type: "public-key",
transports: dev.transports,
}))
: [],
/**
* https://w3c.github.io/webauthn/#dictionary-authenticatorSelection
*/
authenticatorSelection: {
residentKey: "discouraged",
/**
* Wondering why user verification isn't required? See here:
*
* https://passkeys.dev/docs/use-cases/bootstrapping/#a-note-about-user-verification
*/
userVerification: "preferred",
},
/**
* Support the two most common algorithms: ES256, and RS256
*/
supportedAlgorithmIDs: [-7, -257],
};
var registrationOptions = await generateRegistrationOptions(options);
if (user) {
var expectedChallenge = registrationOptions.challenge;
redis.setex(
`webauthnChallenge:register:${user.email}`,
300,
expectedChallenge
);
}
res.status(200).json({
registrationOptions,
});
} catch (err) {
next(err);
}
}
);
export { router as webauthnRegisterStart };
// @ts-check
import express from "express";
import { header, body, validationResult } from "express-validator";
import jwt from "jsonwebtoken";
import nconf from "nconf";
import { verifyRegistrationResponse } from "@simplewebauthn/server";
import { User } from "../models/user.mjs";
import { redis } from "../services/index.mjs";
var router = express.Router();
router.post(
"/webauthn/register/finish",
[
header("Authorization")
.not()
.isEmpty()
.withMessage("'Authorization' header must be provided"),
body("registrationResponse")
.not()
.isEmpty()
.isObject()
.withMessage("'registrationResponse' must be provided"),
],
/**
*
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
*/
async (req, res, next) => {
try {
var errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
type: "https://example.com/probs/validation-error",
title: "Invalid Request",
status: 400,
detail: "There were validation errors with your request",
errors: errors.array(),
});
}
var header = req.headers.authorization || "";
var [type, token] = header.split(" ");
if (type !== "Bearer" || !token) {
return res.status(401).json({
type: "https://example.com/errors/unauthorized",
title: "Unauthorized",
status: 401,
detail: "Invalid or missing authorization token",
});
}
var tokenStatus = await redis.get(`registration:token:${token}`);
if (!tokenStatus) {
return res.status(401).json({
type: "https://example.com/probs/unauthorized",
title: "Unauthorized",
status: 401,
detail: "Token is either expired or has been used already",
});
}
var tokenPayload;
try {
tokenPayload = jwt.verify(token, nconf.get("REGISTRATION_ACCESS_TOKEN"));
} catch (err) {
return res.status(401).json({
type: "https://example.com/errors/unauthorized",
title: "Unauthorized",
status: 401,
detail: "Invalid or expired authorization token",
});
}
if (
!tokenPayload ||
typeof tokenPayload === "string" ||
!tokenPayload.email
) {
return res.status(401).json({
type: "https://example.com/errors/unauthorized",
title: "Unauthorized",
status: 401,
detail: "Invalid token payload",
});
}
/**
* @type {import('@simplewebauthn/types').RegistrationResponseJSON}
*/
var registrationResponse = req.body.registrationResponse;
/**
* Challenge
*/
var challengeKey = `webauthnChallenge:register:${tokenPayload.email}`;
var expectedChallenge;
try {
expectedChallenge = await redis.get(challengeKey);
} catch (err) {}
if (!expectedChallenge) {
return res.status(401).json({
type: "https://example.com/errors/unauthorized",
title: "Unauthorized",
status: 401,
detail: "Challenge expired or not found",
});
}
/**
* @type {import('@simplewebauthn/server').VerifiedRegistrationResponse}
*/
var verifiedRegistrationResponse;
try {
verifiedRegistrationResponse = await verifyRegistrationResponse({
response: registrationResponse,
expectedChallenge: `${expectedChallenge}`,
expectedOrigin: nconf.get("ORIGIN"),
expectedRPID: nconf.get("DOMAIN"),
requireUserVerification: false,
});
} catch (error) {
return res.status(401).json({
type: "https://example.com/errors/unauthorized",
title: "Unauthorized",
status: 401,
detail: "Registration verification failed",
});
}
var { verified, registrationInfo } = verifiedRegistrationResponse;
if (!verified || !registrationInfo) {
return res.status(401).json({
type: "https://example.com/errors/unauthorized",
title: "Unauthorized",
status: 401,
detail: "Verification failed",
});
}
var user = await User.findOne({ email: tokenPayload.email });
if (!user) {
return res.status(401).json({
type: "https://example.com/errors/unauthorized",
title: "Unauthorized",
status: 401,
detail: "User not found",
});
}
var { credentialPublicKey, credentialID, counter } = registrationInfo;
var existingDevice = user.devices.find(
(device) => device.credentialID === credentialID
);
if (!existingDevice) {
var newDevice = {
credentialPublicKey,
credentialID,
counter,
transports: registrationResponse.response.transports,
};
user.devices.push(newDevice);
await user.save();
try {
await redis.del(challengeKey);
await redis.del(`registration_token:${token}`);
} catch (err) {}
}
res.sendStatus(200);
} catch (err) {
next(err);
}
}
);
export { router as webauthnRegisterFinish };
// @ts-check
import express from "express";
import { body, validationResult } from "express-validator";
import nconf from "nconf";
import { generateAuthenticationOptions } from "@simplewebauthn/server";
import { User } from "../models/user.mjs";
import { redis } from "../services/index.mjs";
var router = express.Router();
router.post(
"/webauthn/authenticate/start",
[body("email").isEmail().withMessage("'email' must be provided")],
/**
*
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
*/
async (req, res, next) => {
try {
var errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
type: "https://example.com/probs/validation-error",
title: "Invalid Request",
status: 400,
detail: "There were validation errors with your request",
errors: errors.array(),
});
}
var { email } = req.body;
var user = await User.findOne({ email });
/**
* Email Enumeration: If the route reveals whether an email exists in the system,
* it can be used for enumeration attacks.
*
* Return the same options response regardless of the existence of the user
*/
/**
* @type {import("@simplewebauthn/server").GenerateAuthenticationOptionsOpts}
*/
var options = {
rpID: nconf.get("DOMAIN"),
timeout: 300000,
allowCredentials: user
? user.devices.map(({ credentialID, transports }) => ({
id: credentialID,
type: "public-key",
transports: transports,
}))
: [],
};
var authenticationOptions = await generateAuthenticationOptions(options);
if (user) {
var expectedChallenge = authenticationOptions.challenge;
await redis.setex(
`webauthnChallenge:authenticate:${email}`,
300,
expectedChallenge
);
}
res.status(200).json({
authenticationOptions,
});
} catch (err) {
next(err);
}
}
);
export { router as webauthnAuthStart };
// @ts-check
import express from "express";
import { body, validationResult } from "express-validator";
import nconf from "nconf";
import { verifyAuthenticationResponse } from "@simplewebauthn/server";
import { User } from "../models/user.mjs";
import { redis } from "../services/index.mjs";
var router = express.Router();
router.post(
"/webauthn/authenticate/finish",
[
body("email").isEmail().withMessage("'email' must be provided"),
body("authenticationResponse")
.not()
.isEmpty()
.isObject()
.withMessage("'authenticationResponse' must be provided"),
],
/**
*
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
*/
async (req, res, next) => {
try {
var errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
type: "https://example.com/probs/validation-error",
title: "Invalid Request",
status: 400,
detail: "There were validation errors with your request",
errors: errors.array(),
});
}
/**
* @type {{
* authenticationResponse: import('@simplewebauthn/types').AuthenticationResponseJSON
* email: String
* }}
*/
var { email, authenticationResponse } = req.body;
var user = await User.findOne({ email });
if (!user) {
return res.status(401).json({
type: "https://example.com/probs/unauthorized",
title: "Unauthorized",
status: 401,
detail: "Invalid or missing authorization",
});
}
/**
* @type {import('@simplewebauthn/types').AuthenticatorDevice}
*/
var authenticator = user.devices.find(
({ credentialID }) => credentialID === authenticationResponse.id
);
if (!authenticator) {
return res.status(401).json({
type: "https://example.com/probs/unauthorized",
title: "Unauthorized",
status: 401,
detail: "Authenticator device not found",
});
}
var challengeKey = `webauthnChallenge:authenticate:${email}`;
var expectedChallenge;
try {
expectedChallenge = await redis.get(challengeKey);
} catch (err) {}
if (!expectedChallenge) {
return res.status(401).json({
type: "https://example.com/errors/unauthorized",
title: "Unauthorized",
status: 401,
detail: "Challenge expired or not found",
});
}
/**
* @type {import("@simplewebauthn/server").VerifiedAuthenticationResponse}
*/
var verification;
try {
verification = await verifyAuthenticationResponse({
response: authenticationResponse,
expectedChallenge: `${expectedChallenge}`,
expectedOrigin: nconf.get("ORIGIN"),
expectedRPID: nconf.get("DOMAIN"),
authenticator,
requireUserVerification: false,
});
} catch (error) {
return res.status(401).json({
type: "https://example.com/probs/unauthorized",
title: "Unauthorized",
status: 401,
detail: "Authentication verification failed",
});
}
var { verified, authenticationInfo } = verification;
if (!verified) {
return res.status(401).json({
type: "https://example.com/probs/unauthorized",
title: "Unauthorized",
status: 401,
detail: "Authentication failed",
});
}
authenticator.counter = authenticationInfo.newCounter;
await user.save();
await redis.del(challengeKey);
/**
* TODO: Generate access and refresh tokens based on subscription status
*/
res.sendStatus(200);
} catch (err) {
next(err);
}
}
);
export { router as webauthnAuthFinish }; |
Beta Was this translation helpful? Give feedback.
-
Not sure if this was asked before but I am very interested in implementing this authentication flow in our internal projects, but I have a series of questions:
webauthn
orpasskeys
as a replacement for federated logins, username / password authentication flows, and others? I want a single experience?webauthn
orpasskeys
with multiple devices? What I mean is, if I register an account for an email address on a device, a laptop or mobile device, can I and how can I access the same account from a different device? I'm confused on how could this be achieved as I'm not sure I fully understand what would prevent a different user from accessing an account he did not create using his biometricsGladly appreciate if anyone can help me out with these questions
Beta Was this translation helpful? Give feedback.
All reactions