diff --git a/backend/internal/token.js b/backend/internal/token.js index 126283e2dd..665409b641 100644 --- a/backend/internal/token.js +++ b/backend/internal/token.js @@ -5,6 +5,7 @@ import authModel from "../models/auth.js"; import TokenModel from "../models/token.js"; import userModel from "../models/user.js"; import twoFactor from "./2fa.js"; +import webauthn from "./webauthn.js"; const ERROR_MESSAGE_INVALID_AUTH = "Invalid email or password"; const ERROR_MESSAGE_INVALID_AUTH_I18N = "error.invalid-auth"; @@ -210,6 +211,39 @@ export default { }; }, + /** + * Verify passkey authentication and return full token + * @param {string} challengeToken + * @param {Object} credential + * @param {string} [expiry] + * @returns {Promise} + */ + getTokenFromPasskey: async (challengeToken, credential, expiry, req) => { + const Token = TokenModel(); + const tokenExpiry = expiry || "1d"; + + const userId = await webauthn.verifyAuthentication(challengeToken, credential, req); + + const expiryDate = parseDatePeriod(tokenExpiry); + if (expiryDate === null) { + throw new errs.AuthError(`Invalid expiry time: ${tokenExpiry}`); + } + + const signed = await Token.create({ + iss: "api", + attrs: { + id: userId, + }, + scope: ["user"], + expiresIn: tokenExpiry, + }); + + return { + token: signed.token, + expires: expiryDate.toISOString(), + }; + }, + /** * @param {Object} user * @returns {Promise} diff --git a/backend/internal/user.js b/backend/internal/user.js index d13931d54a..796398e7f0 100644 --- a/backend/internal/user.js +++ b/backend/internal/user.js @@ -5,6 +5,7 @@ import utils from "../lib/utils.js"; import authModel from "../models/auth.js"; import userModel from "../models/user.js"; import userPermissionModel from "../models/user_permission.js"; +import webauthnCredentialModel from "../models/webauthn_credential.js"; import internalAuditLog from "./audit-log.js"; import internalToken from "./token.js"; @@ -170,19 +171,30 @@ const internalUser = { return query.then(utils.omitRow(omissions())); }) - .then((row) => { + .then(async (row) => { if (!row || !row.id) { throw new errs.ItemNotFoundError(thisData.id); } - // Custom omissions - if (typeof thisData.omit !== "undefined" && thisData.omit !== null) { - return _.omit(row, thisData.omit); - } if (row.avatar === "") { row.avatar = DEFAULT_AVATAR; } + // Include has_password when user is fetching themselves or is an admin + if (row.id === access.token.getUserId(0) || access.token.hasScope("admin")) { + const passwordAuth = await authModel + .query() + .where("user_id", row.id) + .andWhere("type", "password") + .first(); + row.has_password = !!passwordAuth; + } + + // Custom omissions + if (typeof thisData.omit !== "undefined" && thisData.omit !== null) { + return _.omit(row, thisData.omit); + } + return row; }); }, @@ -350,7 +362,7 @@ const internalUser = { .then(() => { return internalUser.get(access, { id: data.id }); }) - .then((user) => { + .then(async (user) => { if (user.id !== data.id) { // Sanity check that something crazy hasn't happened throw new errs.InternalValidationError( @@ -359,19 +371,25 @@ const internalUser = { } if (user.id === access.token.getUserId(0)) { - // they're setting their own password. Make sure their current password is correct - if (typeof data.current === "undefined" || !data.current) { - throw new errs.ValidationError("Current password was not supplied"); - } + // Check if this user already has a password set + const existingAuth = await authModel + .query() + .where("user_id", user.id) + .andWhere("type", "password") + .first(); + + if (existingAuth) { + // Has password — require current password + if (typeof data.current === "undefined" || !data.current) { + throw new errs.ValidationError("Current password was not supplied"); + } - return internalToken - .getTokenFromEmail({ + await internalToken.getTokenFromEmail({ identity: user.email, secret: data.current, - }) - .then(() => { - return user; }); + } + // No password — skip current password check, allow setting a new one } return user; @@ -423,6 +441,65 @@ const internalUser = { * @param {Object} data * @return {Promise} */ + /** + * @param {Access} access + * @param {Object} data + * @param {Integer} data.id + * @param {String} [data.current] + * @return {Promise} + */ + removePassword: async (access, data) => { + await access.can("users:password", data.id); + + const user = await internalUser.get(access, { id: data.id }); + if (user.id !== data.id) { + throw new errs.InternalValidationError( + `User could not be updated, IDs do not match: ${user.id} !== ${data.id}`, + ); + } + + // Verify user has at least one passkey as an alternative auth method + const passkeys = await webauthnCredentialModel + .query() + .where("user_id", user.id) + .andWhere("is_deleted", 0); + + if (passkeys.length === 0) { + throw new errs.ValidationError("Cannot remove password without an alternative authentication method (passkey)"); + } + + // If user is removing their own password, verify current password + if (user.id === access.token.getUserId(0)) { + if (typeof data.current === "undefined" || !data.current) { + throw new errs.ValidationError("Current password was not supplied"); + } + + await internalToken.getTokenFromEmail({ + identity: user.email, + secret: data.current, + }); + } + + // Delete the password auth row + await authModel + .query() + .where("user_id", user.id) + .andWhere("type", "password") + .delete(); + + await internalAuditLog.add(access, { + action: "updated", + object_type: "user", + object_id: user.id, + meta: { + name: user.name, + password_removed: true, + }, + }); + + return true; + }, + setPermissions: (access, data) => { return access .can("users:permissions", data.id) diff --git a/backend/internal/webauthn.js b/backend/internal/webauthn.js new file mode 100644 index 0000000000..9eb02a4427 --- /dev/null +++ b/backend/internal/webauthn.js @@ -0,0 +1,397 @@ +import { + generateRegistrationOptions, + verifyRegistrationResponse, + generateAuthenticationOptions, + verifyAuthenticationResponse, +} from "@simplewebauthn/server"; +import errs from "../lib/error.js"; +import TokenModel from "../models/token.js"; +import userModel from "../models/user.js"; +import webauthnCredentialModel from "../models/webauthn_credential.js"; + +/** + * Derive WebAuthn relying party settings from the request. + * Environment variables override auto-detection if set. + * + * @param {Object} req Express request object + * @returns {{ rpID: string, rpName: string, origin: string }} + */ +const getRP = (req) => { + const rpID = process.env.WEBAUTHN_RP_ID || req.hostname; + const rpName = process.env.WEBAUTHN_RP_NAME || "Nginx Proxy Manager"; + const origin = process.env.WEBAUTHN_ORIGIN + || req.get("origin") + || `${req.protocol}://${req.get("host")}`; + return { rpID, rpName, origin }; +}; + +const internalWebauthn = { + /** + * Generate registration options for a user + * + * @param {Access} access + * @param {number} userId + * @param {Object} req Express request object + * @returns {Promise<{options: Object, challenge_token: string}>} + */ + generateRegOptions: async (access, userId, req) => { + await access.can("users:password", userId); + + const user = await userModel + .query() + .where("id", userId) + .andWhere("is_deleted", 0) + .first(); + + if (!user) { + throw new errs.ItemNotFoundError("User not found"); + } + + const existingCreds = await webauthnCredentialModel + .query() + .where("user_id", userId) + .andWhere("is_deleted", 0); + + const { rpID, rpName } = getRP(req); + + const options = await generateRegistrationOptions({ + rpName, + rpID, + userName: user.email, + userDisplayName: user.name || user.nickname || user.email, + excludeCredentials: existingCreds.map((cred) => ({ + id: cred.credential_id, + transports: cred.transports || [], + })), + authenticatorSelection: { + residentKey: "preferred", + userVerification: "preferred", + }, + }); + + const Token = TokenModel(); + const challengeToken = await Token.create({ + iss: "api", + attrs: { + challenge: options.challenge, + userId: userId, + }, + scope: ["webauthn-reg-challenge"], + expiresIn: "5m", + }); + + return { + options, + challenge_token: challengeToken.token, + }; + }, + + /** + * Verify registration response and store credential + * + * @param {Access} access + * @param {number} userId + * @param {string} challengeToken + * @param {Object} credential + * @param {string} friendlyName + * @param {Object} req Express request object + * @returns {Promise} + */ + verifyRegistration: async (access, userId, challengeToken, credential, friendlyName, req) => { + await access.can("users:password", userId); + + const Token = TokenModel(); + let tokenData; + try { + tokenData = await Token.load(challengeToken); + } catch { + throw new errs.AuthError("Invalid or expired challenge token"); + } + + if (!tokenData.scope || tokenData.scope[0] !== "webauthn-reg-challenge") { + throw new errs.AuthError("Invalid challenge token"); + } + + if (tokenData.attrs?.userId !== userId) { + throw new errs.AuthError("Challenge token does not match user"); + } + + const expectedChallenge = tokenData.attrs?.challenge; + if (!expectedChallenge) { + throw new errs.AuthError("Invalid challenge token"); + } + + const { rpID, origin } = getRP(req); + + let verification; + try { + verification = await verifyRegistrationResponse({ + response: credential, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpID, + }); + } catch (err) { + throw new errs.AuthError(`Registration verification failed: ${err.message}`); + } + + if (!verification.verified || !verification.registrationInfo) { + throw new errs.AuthError("Registration verification failed"); + } + + const { credential: regCredential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo; + + const record = await webauthnCredentialModel.query().insertAndFetch({ + user_id: userId, + credential_id: regCredential.id, + public_key: Buffer.from(regCredential.publicKey).toString("base64url"), + counter: regCredential.counter, + transports: credential.response?.transports || [], + device_type: credentialDeviceType, + backed_up: credentialBackedUp ? 1 : 0, + friendly_name: friendlyName || "", + }); + + return { + id: record.id, + friendly_name: record.friendly_name, + created_on: record.created_on, + device_type: record.device_type, + backed_up: record.backed_up, + }; + }, + + /** + * Generate authentication options + * + * @param {string|null} email Optional email to filter credentials + * @param {Object} req Express request object + * @returns {Promise<{options: Object, challenge_token: string}>} + */ + generateAuthOptions: async (email, req) => { + const { rpID } = getRP(req); + + let allowCredentials; + if (email) { + const user = await userModel + .query() + .where("email", email.toLowerCase().trim()) + .andWhere("is_deleted", 0) + .andWhere("is_disabled", 0) + .first(); + + if (!user) { + throw new errs.AuthError("Invalid credentials"); + } + + const creds = await webauthnCredentialModel + .query() + .where("user_id", user.id) + .andWhere("is_deleted", 0); + + if (creds.length === 0) { + throw new errs.AuthError("No passkeys registered for this account"); + } + + allowCredentials = creds.map((cred) => ({ + id: cred.credential_id, + transports: cred.transports || [], + })); + } + + const options = await generateAuthenticationOptions({ + rpID, + userVerification: "preferred", + ...(allowCredentials ? { allowCredentials } : {}), + }); + + const Token = TokenModel(); + const challengeToken = await Token.create({ + iss: "api", + attrs: { + challenge: options.challenge, + }, + scope: ["webauthn-auth-challenge"], + expiresIn: "5m", + }); + + return { + options, + challenge_token: challengeToken.token, + }; + }, + + /** + * Verify authentication response + * + * @param {string} challengeToken + * @param {Object} credential + * @param {Object} req Express request object + * @returns {Promise} userId + */ + verifyAuthentication: async (challengeToken, credential, req) => { + const Token = TokenModel(); + let tokenData; + try { + tokenData = await Token.load(challengeToken); + } catch { + throw new errs.AuthError("Invalid or expired challenge token"); + } + + if (!tokenData.scope || tokenData.scope[0] !== "webauthn-auth-challenge") { + throw new errs.AuthError("Invalid challenge token"); + } + + const expectedChallenge = tokenData.attrs?.challenge; + if (!expectedChallenge) { + throw new errs.AuthError("Invalid challenge token"); + } + + // Look up credential by ID + const dbCredential = await webauthnCredentialModel + .query() + .where("credential_id", credential.id) + .andWhere("is_deleted", 0) + .first(); + + if (!dbCredential) { + throw new errs.AuthError("Passkey not recognized"); + } + + // Verify the user is active + const user = await userModel + .query() + .where("id", dbCredential.user_id) + .andWhere("is_deleted", 0) + .andWhere("is_disabled", 0) + .first(); + + if (!user) { + throw new errs.AuthError("User account is not available"); + } + + const { rpID, origin } = getRP(req); + + let verification; + try { + verification = await verifyAuthenticationResponse({ + response: credential, + expectedChallenge, + expectedOrigin: origin, + expectedRPID: rpID, + credential: { + id: dbCredential.credential_id, + publicKey: Buffer.from(dbCredential.public_key, "base64url"), + counter: dbCredential.counter, + transports: dbCredential.transports || [], + }, + }); + } catch (err) { + throw new errs.AuthError(`Passkey verification failed: ${err.message}`); + } + + if (!verification.verified) { + throw new errs.AuthError("Passkey verification failed"); + } + + // Update counter + await webauthnCredentialModel + .query() + .findById(dbCredential.id) + .patch({ counter: verification.authenticationInfo.newCounter }); + + return user.id; + }, + + /** + * List passkeys for a user + * + * @param {Access} access + * @param {number} userId + * @returns {Promise} + */ + list: async (access, userId) => { + await access.can("users:password", userId); + + const creds = await webauthnCredentialModel + .query() + .where("user_id", userId) + .andWhere("is_deleted", 0) + .orderBy("created_on", "desc"); + + return creds.map((cred) => ({ + id: cred.id, + friendly_name: cred.friendly_name, + created_on: cred.created_on, + device_type: cred.device_type, + backed_up: cred.backed_up, + })); + }, + + /** + * Rename a passkey + * + * @param {Access} access + * @param {number} userId + * @param {number} credentialId + * @param {string} friendlyName + * @returns {Promise} + */ + rename: async (access, userId, credentialId, friendlyName) => { + await access.can("users:password", userId); + + const cred = await webauthnCredentialModel + .query() + .where("id", credentialId) + .andWhere("user_id", userId) + .andWhere("is_deleted", 0) + .first(); + + if (!cred) { + throw new errs.ItemNotFoundError("Passkey not found"); + } + + await webauthnCredentialModel + .query() + .findById(credentialId) + .patch({ friendly_name: friendlyName }); + + return { + id: cred.id, + friendly_name: friendlyName, + created_on: cred.created_on, + device_type: cred.device_type, + backed_up: cred.backed_up, + }; + }, + + /** + * Soft-delete a passkey + * + * @param {Access} access + * @param {number} userId + * @param {number} credentialId + * @returns {Promise} + */ + remove: async (access, userId, credentialId) => { + await access.can("users:password", userId); + + const cred = await webauthnCredentialModel + .query() + .where("id", credentialId) + .andWhere("user_id", userId) + .andWhere("is_deleted", 0) + .first(); + + if (!cred) { + throw new errs.ItemNotFoundError("Passkey not found"); + } + + await webauthnCredentialModel + .query() + .findById(credentialId) + .patch({ is_deleted: 1 }); + }, +}; + +export default internalWebauthn; diff --git a/backend/migrations/20260203120000_webauthn_credentials.js b/backend/migrations/20260203120000_webauthn_credentials.js new file mode 100644 index 0000000000..9b775f7e05 --- /dev/null +++ b/backend/migrations/20260203120000_webauthn_credentials.js @@ -0,0 +1,51 @@ +import { migrate as logger } from "../logger.js"; + +const migrateName = "webauthn-credentials"; + +/** + * Migrate + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @returns {Promise} + */ +const up = (knex) => { + logger.info(`[${migrateName}] Migrating Up...`); + + return knex.schema + .createTable("webauthn_credential", (table) => { + table.increments().primary(); + table.dateTime("created_on").notNull(); + table.dateTime("modified_on").notNull(); + table.integer("user_id").notNull().unsigned(); + table.string("credential_id", 512).notNull(); + table.text("public_key").notNull(); + table.bigInteger("counter").notNull().unsigned().defaultTo(0); + table.json("transports").notNull(); + table.string("device_type", 30).notNull().defaultTo("singleDevice"); + table.integer("backed_up").notNull().unsigned().defaultTo(0); + table.string("friendly_name", 255).notNull().defaultTo(""); + table.integer("is_deleted").notNull().unsigned().defaultTo(0); + table.unique("credential_id"); + table.index("user_id"); + }) + .then(() => { + logger.info(`[${migrateName}] webauthn_credential Table created`); + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @returns {Promise} + */ +const down = (knex) => { + logger.info(`[${migrateName}] Migrating Down...`); + return knex.schema.dropTableIfExists("webauthn_credential").then(() => { + logger.info(`[${migrateName}] webauthn_credential Table dropped`); + }); +}; + +export { up, down }; diff --git a/backend/models/webauthn_credential.js b/backend/models/webauthn_credential.js new file mode 100644 index 0000000000..28524ec62e --- /dev/null +++ b/backend/models/webauthn_credential.js @@ -0,0 +1,67 @@ +// Objection Docs: +// http://vincit.github.io/objection.js/ + +import { Model } from "objection"; +import db from "../db.js"; +import { convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.js"; +import now from "./now_helper.js"; +import User from "./user.js"; + +Model.knex(db()); + +const boolFields = ["is_deleted", "backed_up"]; + +class WebauthnCredential extends Model { + $beforeInsert() { + this.created_on = now(); + this.modified_on = now(); + + if (typeof this.transports === "undefined") { + this.transports = []; + } + } + + $beforeUpdate() { + this.modified_on = now(); + } + + $parseDatabaseJson(json) { + const thisJson = super.$parseDatabaseJson(json); + return convertIntFieldsToBool(thisJson, boolFields); + } + + $formatDatabaseJson(json) { + const thisJson = convertBoolFieldsToInt(json, boolFields); + return super.$formatDatabaseJson(thisJson); + } + + static get name() { + return "WebauthnCredential"; + } + + static get tableName() { + return "webauthn_credential"; + } + + static get jsonAttributes() { + return ["transports"]; + } + + static get relationMappings() { + return { + user: { + relation: Model.BelongsToOneRelation, + modelClass: User, + join: { + from: "webauthn_credential.user_id", + to: "user.id", + }, + filter: { + is_deleted: 0, + }, + }, + }; + } +} + +export default WebauthnCredential; diff --git a/backend/package.json b/backend/package.json index 8fd6f58b26..056d9bcde6 100644 --- a/backend/package.json +++ b/backend/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@apidevtools/json-schema-ref-parser": "^15.2.2", + "@simplewebauthn/server": "^13.2.2", "ajv": "^8.18.0", "archiver": "^7.0.1", "batchflow": "^0.4.0", diff --git a/backend/routes/tokens.js b/backend/routes/tokens.js index 8a6a1bc0fb..13c0949a7c 100644 --- a/backend/routes/tokens.js +++ b/backend/routes/tokens.js @@ -1,5 +1,6 @@ import express from "express"; import internalToken from "../internal/token.js"; +import internalWebauthn from "../internal/webauthn.js"; import jwtdecode from "../lib/express/jwt-decode.js"; import apiValidator from "../lib/validator/api.js"; import { debug, express as logger } from "../logger.js"; @@ -75,4 +76,49 @@ router } }); +router + .route("/passkey/options") + .options((_, res) => { + res.sendStatus(204); + }) + + /** + * POST /tokens/passkey/options + * + * Generate passkey authentication options + */ + .post(async (req, res, next) => { + try { + const data = await apiValidator(getValidationSchema("/tokens/passkey/options", "post"), req.body); + const result = await internalWebauthn.generateAuthOptions(data.email || null, req); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +router + .route("/passkey/verify") + .options((_, res) => { + res.sendStatus(204); + }) + + /** + * POST /tokens/passkey/verify + * + * Verify passkey authentication and get full token + */ + .post(async (req, res, next) => { + try { + const data = await apiValidator(getValidationSchema("/tokens/passkey/verify", "post"), req.body); + const credential = JSON.parse(data.credential); + const result = await internalToken.getTokenFromPasskey(data.challenge_token, credential, undefined, req); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + export default router; diff --git a/backend/routes/users.js b/backend/routes/users.js index 7c3da8c1d1..2ef600db80 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -1,6 +1,8 @@ import express from "express"; import internal2FA from "../internal/2fa.js"; +import internalToken from "../internal/token.js"; import internalUser from "../internal/user.js"; +import internalWebauthn from "../internal/webauthn.js"; import Access from "../lib/access.js"; import { isCI } from "../lib/config.js"; import errs from "../lib/error.js"; @@ -100,7 +102,14 @@ router body, ); const user = await internalUser.create(res.locals.access, payload); - res.status(201).send(user); + + if (!setup) { + // In setup mode, include a token so the frontend can authenticate immediately + const tokenResult = await internalToken.getTokenFromUser(user); + res.status(201).send({ ...user, token: tokenResult.token, expires: tokenResult.expires }); + } else { + res.status(201).send(user); + } } catch (err) { debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); next(err); @@ -259,6 +268,24 @@ router debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); next(err); } + }) + + /** + * DELETE /api/users/123/auth + * + * Remove password for a user (requires passkey as alternative auth) + */ + .delete(async (req, res, next) => { + try { + const result = await internalUser.removePassword(res.locals.access, { + id: req.params.user_id, + current: req.body?.current, + }); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } }); /** @@ -452,4 +479,148 @@ router } }); +/** + * User passkeys + * + * /api/users/123/passkeys + */ +router + .route("/:user_id/passkeys") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + .all(userIdFromMe) + + /** + * GET /api/users/123/passkeys + * + * List user's registered passkeys + */ + .get(async (req, res, next) => { + try { + const result = await internalWebauthn.list(res.locals.access, req.params.user_id); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * User passkey registration options + * + * /api/users/123/passkeys/register/options + */ +router + .route("/:user_id/passkeys/register/options") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + .all(userIdFromMe) + + /** + * POST /api/users/123/passkeys/register/options + * + * Generate passkey registration options + */ + .post(async (req, res, next) => { + try { + const result = await internalWebauthn.generateRegOptions(res.locals.access, req.params.user_id, req); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * User passkey registration verify + * + * /api/users/123/passkeys/register/verify + */ +router + .route("/:user_id/passkeys/register/verify") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + .all(userIdFromMe) + + /** + * POST /api/users/123/passkeys/register/verify + * + * Verify passkey registration + */ + .post(async (req, res, next) => { + try { + const credential = JSON.parse(req.body.credential); + const result = await internalWebauthn.verifyRegistration( + res.locals.access, + req.params.user_id, + req.body.challenge_token, + credential, + req.body.friendly_name || "", + req, + ); + res.status(201).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + +/** + * Specific user passkey + * + * /api/users/123/passkeys/456 + */ +router + .route("/:user_id/passkeys/:passkey_id") + .options((_, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + .all(userIdFromMe) + + /** + * PUT /api/users/123/passkeys/456 + * + * Rename a passkey + */ + .put(async (req, res, next) => { + try { + const result = await internalWebauthn.rename( + res.locals.access, + req.params.user_id, + Number.parseInt(req.params.passkey_id, 10), + req.body.friendly_name, + ); + res.status(200).send(result); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }) + + /** + * DELETE /api/users/123/passkeys/456 + * + * Delete a passkey + */ + .delete(async (req, res, next) => { + try { + await internalWebauthn.remove( + res.locals.access, + req.params.user_id, + Number.parseInt(req.params.passkey_id, 10), + ); + res.status(200).send(true); + } catch (err) { + debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`); + next(err); + } + }); + export default router; diff --git a/backend/schema/components/user-object.json b/backend/schema/components/user-object.json index 7acd0a4290..6fd4728710 100644 --- a/backend/schema/components/user-object.json +++ b/backend/schema/components/user-object.json @@ -55,6 +55,11 @@ "type": "string" } }, + "has_password": { + "type": "boolean", + "description": "Whether the user has a password set (only included for own user or admin requests)", + "example": true + }, "permissions": { "type": "object", "description": "Permissions if expanded in request", @@ -111,6 +116,16 @@ "pattern": "^(manage|view|hidden)$" } } + }, + "token": { + "type": "string", + "description": "JWT Token, only present when user is created during initial setup", + "example": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...xaHKYr3Kk6MvkUjcC4" + }, + "expires": { + "type": "string", + "description": "Token Expiry Timestamp, only present when user is created during initial setup", + "example": "2025-02-04T20:40:46.340Z" } } } diff --git a/backend/schema/paths/tokens/passkey/options/post.json b/backend/schema/paths/tokens/passkey/options/post.json new file mode 100644 index 0000000000..f05675cf3d --- /dev/null +++ b/backend/schema/paths/tokens/passkey/options/post.json @@ -0,0 +1,64 @@ +{ + "operationId": "requestPasskeyAuthOptions", + "summary": "Generate passkey authentication options", + "tags": ["tokens"], + "requestBody": { + "description": "Optional email to filter credentials", + "required": false, + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "email": { + "type": "string", + "format": "email", + "example": "me@example.com" + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "options": { + "challenge": "dGVzdC1jaGFsbGVuZ2U", + "timeout": 60000, + "rpId": "example.com", + "allowCredentials": [] + }, + "challenge_token": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNzA3MjQwMDAwfQ.signature" + } + } + }, + "schema": { + "type": "object", + "properties": { + "options": { + "type": "object", + "example": { + "challenge": "dGVzdC1jaGFsbGVuZ2U", + "timeout": 60000, + "rpId": "example.com", + "allowCredentials": [] + } + }, + "challenge_token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNzA3MjQwMDAwfQ.signature" + } + } + } + } + }, + "description": "200 response" + } + } +} diff --git a/backend/schema/paths/tokens/passkey/verify/post.json b/backend/schema/paths/tokens/passkey/verify/post.json new file mode 100644 index 0000000000..8727f92a9f --- /dev/null +++ b/backend/schema/paths/tokens/passkey/verify/post.json @@ -0,0 +1,43 @@ +{ + "operationId": "verifyPasskeyAuth", + "summary": "Verify passkey authentication and get full token", + "tags": ["tokens"], + "requestBody": { + "description": "Passkey authentication response", + "required": true, + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "challenge_token": { + "minLength": 1, + "type": "string", + "example": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNzA3MjQwMDAwfQ.signature" + }, + "credential": { + "minLength": 1, + "type": "string", + "description": "JSON-stringified WebAuthn credential response", + "example": "{\"id\":\"abc123\",\"rawId\":\"abc123\",\"type\":\"public-key\",\"response\":{\"authenticatorData\":\"data\",\"clientDataJSON\":\"json\",\"signature\":\"sig\"}}" + } + }, + "required": ["challenge_token", "credential"], + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "../../../../components/token-object.json" + } + } + }, + "description": "200 response" + } + } +} diff --git a/backend/schema/paths/users/userID/auth/delete.json b/backend/schema/paths/users/userID/auth/delete.json new file mode 100644 index 0000000000..6f6d08d46c --- /dev/null +++ b/backend/schema/paths/users/userID/auth/delete.json @@ -0,0 +1,67 @@ +{ + "operationId": "deleteUserAuth", + "summary": "Remove a User's Password Authentication", + "tags": ["users"], + "security": [ + { + "bearerAuth": ["admin"] + } + ], + "parameters": [ + { + "in": "path", + "name": "userID", + "schema": { + "oneOf": [ + { + "type": "string", + "pattern": "^me$" + }, + { + "type": "integer", + "minimum": 1 + } + ] + }, + "required": true, + "description": "User ID or 'me' for yourself", + "example": 2 + } + ], + "requestBody": { + "description": "Optional current password for verification", + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "current": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "example": "changeme" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "200 response", + "content": { + "application/json": { + "examples": { + "default": { + "value": true + } + }, + "schema": { + "type": "boolean" + } + } + } + } + } +} diff --git a/backend/schema/paths/users/userID/passkeys/get.json b/backend/schema/paths/users/userID/passkeys/get.json new file mode 100644 index 0000000000..f1f80b54aa --- /dev/null +++ b/backend/schema/paths/users/userID/passkeys/get.json @@ -0,0 +1,55 @@ +{ + "operationId": "listPasskeys", + "summary": "List user's registered passkeys", + "tags": ["users"], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "userID", + "schema": { "type": "integer", "minimum": 1 }, + "required": true, + "description": "User ID", + "example": 1 + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "default": { + "value": [ + { + "id": 1, + "friendly_name": "My Security Key", + "created_on": "2024-01-15T10:30:00.000Z", + "device_type": "multi_device", + "backed_up": true + } + ] + } + }, + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "integer", "example": 1 }, + "friendly_name": { "type": "string", "example": "My Security Key" }, + "created_on": { "type": "string", "example": "2024-01-15T10:30:00.000Z" }, + "device_type": { "type": "string", "example": "multi_device" }, + "backed_up": { "type": "boolean", "example": true } + } + } + } + } + }, + "description": "200 response" + } + } +} diff --git a/backend/schema/paths/users/userID/passkeys/passkeyID/delete.json b/backend/schema/paths/users/userID/passkeys/passkeyID/delete.json new file mode 100644 index 0000000000..3d15ef2ed0 --- /dev/null +++ b/backend/schema/paths/users/userID/passkeys/passkeyID/delete.json @@ -0,0 +1,45 @@ +{ + "operationId": "deletePasskey", + "summary": "Delete a passkey", + "tags": ["users"], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "userID", + "schema": { "type": "integer", "minimum": 1 }, + "required": true, + "description": "User ID", + "example": 1 + }, + { + "in": "path", + "name": "passkeyID", + "schema": { "type": "integer", "minimum": 1 }, + "required": true, + "description": "Passkey ID", + "example": 1 + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "default": { + "value": true + } + }, + "schema": { + "type": "boolean" + } + } + }, + "description": "200 response" + } + } +} diff --git a/backend/schema/paths/users/userID/passkeys/passkeyID/put.json b/backend/schema/paths/users/userID/passkeys/passkeyID/put.json new file mode 100644 index 0000000000..d60c314d2b --- /dev/null +++ b/backend/schema/paths/users/userID/passkeys/passkeyID/put.json @@ -0,0 +1,79 @@ +{ + "operationId": "renamePasskey", + "summary": "Rename a passkey", + "tags": ["users"], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "userID", + "schema": { "type": "integer", "minimum": 1 }, + "required": true, + "description": "User ID", + "example": 1 + }, + { + "in": "path", + "name": "passkeyID", + "schema": { "type": "integer", "minimum": 1 }, + "required": true, + "description": "Passkey ID", + "example": 1 + } + ], + "requestBody": { + "description": "Passkey rename payload", + "required": true, + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "friendly_name": { + "minLength": 1, + "maxLength": 255, + "type": "string", + "example": "My Security Key" + } + }, + "required": ["friendly_name"], + "type": "object" + } + } + } + }, + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "id": 1, + "friendly_name": "My Security Key", + "created_on": "2024-01-15T10:30:00.000Z", + "device_type": "multi_device", + "backed_up": true + } + } + }, + "schema": { + "type": "object", + "properties": { + "id": { "type": "integer", "example": 1 }, + "friendly_name": { "type": "string", "example": "My Security Key" }, + "created_on": { "type": "string", "example": "2024-01-15T10:30:00.000Z" }, + "device_type": { "type": "string", "example": "multi_device" }, + "backed_up": { "type": "boolean", "example": true } + } + } + } + }, + "description": "200 response" + } + } +} diff --git a/backend/schema/paths/users/userID/passkeys/register/options/post.json b/backend/schema/paths/users/userID/passkeys/register/options/post.json new file mode 100644 index 0000000000..b4ceb3bbb0 --- /dev/null +++ b/backend/schema/paths/users/userID/passkeys/register/options/post.json @@ -0,0 +1,60 @@ +{ + "operationId": "getPasskeyRegOptions", + "summary": "Generate passkey registration options", + "tags": ["users"], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "userID", + "schema": { "type": "integer", "minimum": 1 }, + "required": true, + "description": "User ID", + "example": 1 + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "options": { + "challenge": "dGVzdC1jaGFsbGVuZ2U", + "rp": { "name": "Nginx Proxy Manager", "id": "example.com" }, + "user": { "id": "dXNlci1pZA", "name": "user@example.com", "displayName": "User" }, + "timeout": 60000, + "attestation": "none" + }, + "challenge_token": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNzA3MjQwMDAwfQ.signature" + } + } + }, + "schema": { + "type": "object", + "properties": { + "options": { + "type": "object", + "example": { + "challenge": "dGVzdC1jaGFsbGVuZ2U", + "rp": { "name": "Nginx Proxy Manager", "id": "example.com" }, + "timeout": 60000 + } + }, + "challenge_token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNzA3MjQwMDAwfQ.signature" + } + } + } + } + }, + "description": "200 response" + } + } +} diff --git a/backend/schema/paths/users/userID/passkeys/register/verify/post.json b/backend/schema/paths/users/userID/passkeys/register/verify/post.json new file mode 100644 index 0000000000..974478f9f2 --- /dev/null +++ b/backend/schema/paths/users/userID/passkeys/register/verify/post.json @@ -0,0 +1,81 @@ +{ + "operationId": "verifyPasskeyRegistration", + "summary": "Verify passkey registration and store credential", + "tags": ["users"], + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "userID", + "schema": { "type": "integer", "minimum": 1 }, + "required": true, + "description": "User ID", + "example": 1 + } + ], + "requestBody": { + "description": "Passkey registration response", + "required": true, + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "challenge_token": { + "minLength": 1, + "type": "string", + "example": "eyJhbGciOiJIUzI1NiJ9.eyJpZCI6MSwiZXhwIjoxNzA3MjQwMDAwfQ.signature" + }, + "credential": { + "minLength": 1, + "type": "string", + "description": "JSON-stringified WebAuthn credential response", + "example": "{\"id\":\"abc123\",\"rawId\":\"abc123\",\"type\":\"public-key\",\"response\":{\"attestationObject\":\"data\",\"clientDataJSON\":\"json\"}}" + }, + "friendly_name": { + "type": "string", + "maxLength": 255, + "example": "My Security Key" + } + }, + "required": ["challenge_token", "credential"], + "type": "object" + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "examples": { + "default": { + "value": { + "id": 1, + "friendly_name": "My Security Key", + "created_on": "2024-01-15T10:30:00.000Z", + "device_type": "multi_device", + "backed_up": true + } + } + }, + "schema": { + "type": "object", + "properties": { + "id": { "type": "integer", "example": 1 }, + "friendly_name": { "type": "string", "example": "My Security Key" }, + "created_on": { "type": "string", "example": "2024-01-15T10:30:00.000Z" }, + "device_type": { "type": "string", "example": "multi_device" }, + "backed_up": { "type": "boolean", "example": true } + } + } + } + }, + "description": "201 response" + } + } +} diff --git a/backend/schema/swagger.json b/backend/schema/swagger.json index 4222f19ddd..ba5a2e0cf2 100644 --- a/backend/schema/swagger.json +++ b/backend/schema/swagger.json @@ -298,6 +298,17 @@ "$ref": "./paths/tokens/2fa/post.json" } }, + "/tokens/passkey/options": { + "x-lint-ignore": ["no-http-verbs-in-path"], + "post": { + "$ref": "./paths/tokens/passkey/options/post.json" + } + }, + "/tokens/passkey/verify": { + "post": { + "$ref": "./paths/tokens/passkey/verify/post.json" + } + }, "/version/check": { "get": { "$ref": "./paths/version/check/get.json" @@ -343,6 +354,30 @@ "$ref": "./paths/users/userID/2fa/backup-codes/post.json" } }, + "/users/{userID}/passkeys": { + "get": { + "$ref": "./paths/users/userID/passkeys/get.json" + } + }, + "/users/{userID}/passkeys/register/options": { + "x-lint-ignore": ["no-http-verbs-in-path"], + "post": { + "$ref": "./paths/users/userID/passkeys/register/options/post.json" + } + }, + "/users/{userID}/passkeys/register/verify": { + "post": { + "$ref": "./paths/users/userID/passkeys/register/verify/post.json" + } + }, + "/users/{userID}/passkeys/{passkeyID}": { + "put": { + "$ref": "./paths/users/userID/passkeys/passkeyID/put.json" + }, + "delete": { + "$ref": "./paths/users/userID/passkeys/passkeyID/delete.json" + } + }, "/users/{userID}/auth": { "put": { "$ref": "./paths/users/userID/auth/put.json" diff --git a/backend/yarn.lock b/backend/yarn.lock index 84e2bd7740..89aa616313 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -4,7 +4,7 @@ "@apidevtools/json-schema-ref-parser@14.0.1": version "14.0.1" - resolved "https://registry.yarnpkg.com/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz#3bc445ed2eddf72bc2f9eb2e295c696bdc5be725" + resolved "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-14.0.1.tgz" integrity sha512-Oc96zvmxx1fqoSEdUmfmvvb59/KDOnUoJ7s2t7bISyAn0XEz57LCCw8k2Y4Pf3mwKaZLMciESALORLgfe2frCw== dependencies: "@types/json-schema" "^7.0.15" @@ -19,17 +19,17 @@ "@apidevtools/openapi-schemas@^2.1.0": version "2.1.0" - resolved "https://registry.yarnpkg.com/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz#9fa08017fb59d80538812f03fc7cac5992caaa17" + resolved "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz" integrity sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ== "@apidevtools/swagger-methods@^3.0.2": version "3.0.2" - resolved "https://registry.yarnpkg.com/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz#b789a362e055b0340d04712eafe7027ddc1ac267" + resolved "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz" integrity sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg== "@apidevtools/swagger-parser@^12.1.0": version "12.1.0" - resolved "https://registry.yarnpkg.com/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz#ef73e5f9e32c2becef6d95b90fb4481b0fec8fe4" + resolved "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-12.1.0.tgz" integrity sha512-e5mJoswsnAX0jG+J09xHFYQXb/bUc5S3pLpMxUuRUA2H8T2kni3yEoyz2R3Dltw5f4A6j6rPNMpWTK+iVDFlng== dependencies: "@apidevtools/json-schema-ref-parser" "14.0.1" @@ -95,12 +95,17 @@ "@gar/promisify@^1.0.1": version "1.1.3" - resolved "https://registry.yarnpkg.com/@gar/promisify/-/promisify-1.1.3.tgz#555193ab2e3bb3b6adc3d551c9c030d9e860daf6" + resolved "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz" integrity sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw== +"@hexagon/base64@^1.1.27": + version "1.1.28" + resolved "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz" + integrity sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw== + "@isaacs/cliui@^8.0.2": version "8.0.2" - resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz" integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== dependencies: string-width "^5.1.2" @@ -110,6 +115,11 @@ wrap-ansi "^8.1.0" wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" +"@levischuck/tiny-cbor@^0.2.2": + version "0.2.11" + resolved "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz" + integrity sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow== + "@noble/hashes@^2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.0.1.tgz#fc1a928061d1232b0a52bb754393c37a5216c89e" @@ -117,7 +127,7 @@ "@npmcli/fs@^1.0.0": version "1.1.1" - resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-1.1.1.tgz#72f719fe935e687c56a4faecf3c03d06ba593257" + resolved "https://registry.npmjs.org/@npmcli/fs/-/fs-1.1.1.tgz" integrity sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ== dependencies: "@gar/promisify" "^1.0.1" @@ -125,7 +135,7 @@ "@npmcli/move-file@^1.0.1": version "1.1.2" - resolved "https://registry.yarnpkg.com/@npmcli/move-file/-/move-file-1.1.2.tgz#1a82c3e372f7cae9253eb66d72543d6b8685c674" + resolved "https://registry.npmjs.org/@npmcli/move-file/-/move-file-1.1.2.tgz" integrity sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg== dependencies: mkdirp "^1.0.4" @@ -176,9 +186,141 @@ dependencies: "@otplib/core" "13.3.0" +"@peculiar/asn1-android@^2.3.10": + version "2.6.0" + resolved "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.6.0.tgz" + integrity sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ== + dependencies: + "@peculiar/asn1-schema" "^2.6.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-cms@^2.6.0": + version "2.6.0" + resolved "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.0.tgz" + integrity sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA== + dependencies: + "@peculiar/asn1-schema" "^2.6.0" + "@peculiar/asn1-x509" "^2.6.0" + "@peculiar/asn1-x509-attr" "^2.6.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-csr@^2.6.0": + version "2.6.0" + resolved "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.0.tgz" + integrity sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ== + dependencies: + "@peculiar/asn1-schema" "^2.6.0" + "@peculiar/asn1-x509" "^2.6.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-ecc@^2.3.8", "@peculiar/asn1-ecc@^2.6.0": + version "2.6.0" + resolved "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.0.tgz" + integrity sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw== + dependencies: + "@peculiar/asn1-schema" "^2.6.0" + "@peculiar/asn1-x509" "^2.6.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-pfx@^2.6.0": + version "2.6.0" + resolved "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.0.tgz" + integrity sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ== + dependencies: + "@peculiar/asn1-cms" "^2.6.0" + "@peculiar/asn1-pkcs8" "^2.6.0" + "@peculiar/asn1-rsa" "^2.6.0" + "@peculiar/asn1-schema" "^2.6.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-pkcs8@^2.6.0": + version "2.6.0" + resolved "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.0.tgz" + integrity sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA== + dependencies: + "@peculiar/asn1-schema" "^2.6.0" + "@peculiar/asn1-x509" "^2.6.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-pkcs9@^2.6.0": + version "2.6.0" + resolved "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.0.tgz" + integrity sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw== + dependencies: + "@peculiar/asn1-cms" "^2.6.0" + "@peculiar/asn1-pfx" "^2.6.0" + "@peculiar/asn1-pkcs8" "^2.6.0" + "@peculiar/asn1-schema" "^2.6.0" + "@peculiar/asn1-x509" "^2.6.0" + "@peculiar/asn1-x509-attr" "^2.6.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-rsa@^2.3.8", "@peculiar/asn1-rsa@^2.6.0": + version "2.6.0" + resolved "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.0.tgz" + integrity sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w== + dependencies: + "@peculiar/asn1-schema" "^2.6.0" + "@peculiar/asn1-x509" "^2.6.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-schema@^2.3.8", "@peculiar/asn1-schema@^2.6.0": + version "2.6.0" + resolved "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz" + integrity sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg== + dependencies: + asn1js "^3.0.6" + pvtsutils "^1.3.6" + tslib "^2.8.1" + +"@peculiar/asn1-x509-attr@^2.6.0": + version "2.6.0" + resolved "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.0.tgz" + integrity sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA== + dependencies: + "@peculiar/asn1-schema" "^2.6.0" + "@peculiar/asn1-x509" "^2.6.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-x509@^2.3.8", "@peculiar/asn1-x509@^2.6.0": + version "2.6.0" + resolved "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.0.tgz" + integrity sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA== + dependencies: + "@peculiar/asn1-schema" "^2.6.0" + asn1js "^3.0.6" + pvtsutils "^1.3.6" + tslib "^2.8.1" + +"@peculiar/x509@^1.13.0": + version "1.14.3" + resolved "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz" + integrity sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA== + dependencies: + "@peculiar/asn1-cms" "^2.6.0" + "@peculiar/asn1-csr" "^2.6.0" + "@peculiar/asn1-ecc" "^2.6.0" + "@peculiar/asn1-pkcs9" "^2.6.0" + "@peculiar/asn1-rsa" "^2.6.0" + "@peculiar/asn1-schema" "^2.6.0" + "@peculiar/asn1-x509" "^2.6.0" + pvtsutils "^1.3.6" + reflect-metadata "^0.2.2" + tslib "^2.8.1" + tsyringe "^4.10.0" + "@pkgjs/parseargs@^0.11.0": version "0.11.0" - resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== "@scure/base@^2.0.0": @@ -186,36 +328,50 @@ resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.0.0.tgz#ba6371fddf92c2727e88ad6ab485db6e624f9a98" integrity sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w== +"@simplewebauthn/server@^13.2.2": + version "13.2.2" + resolved "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.2.tgz" + integrity sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA== + dependencies: + "@hexagon/base64" "^1.1.27" + "@levischuck/tiny-cbor" "^0.2.2" + "@peculiar/asn1-android" "^2.3.10" + "@peculiar/asn1-ecc" "^2.3.8" + "@peculiar/asn1-rsa" "^2.3.8" + "@peculiar/asn1-schema" "^2.3.8" + "@peculiar/asn1-x509" "^2.3.8" + "@peculiar/x509" "^1.13.0" + "@tootallnate/once@1": version "1.1.2" - resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" + resolved "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== "@tootallnate/quickjs-emscripten@^0.23.0": version "0.23.0" - resolved "https://registry.yarnpkg.com/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz#db4ecfd499a9765ab24002c3b696d02e6d32a12c" + resolved "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz" integrity sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA== "@types/json-schema@^7.0.15": version "7.0.15" - resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== abbrev@1: version "1.1.1" - resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" + resolved "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== abort-controller@^3.0.0: version "3.0.0" - resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + resolved "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz" integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== dependencies: event-target-shim "^5.0.0" accepts@^2.0.0: version "2.0.0" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" + resolved "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz" integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== dependencies: mime-types "^3.0.0" @@ -223,26 +379,26 @@ accepts@^2.0.0: agent-base@6, agent-base@^6.0.2: version "6.0.2" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz" integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== dependencies: debug "4" agent-base@^7.1.0, agent-base@^7.1.2: version "7.1.4" - resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" + resolved "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz" integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== agentkeepalive@^4.1.3: version "4.6.0" - resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.6.0.tgz#35f73e94b3f40bf65f105219c623ad19c136ea6a" + resolved "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz" integrity sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ== dependencies: humanize-ms "^1.2.1" aggregate-error@^3.0.0: version "3.1.0" - resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" + resolved "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz" integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== dependencies: clean-stack "^2.0.0" @@ -250,12 +406,12 @@ aggregate-error@^3.0.0: ajv-draft-04@^1.0.0: version "1.0.0" - resolved "https://registry.yarnpkg.com/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz#3b64761b268ba0b9e668f0b41ba53fce0ad77fc8" + resolved "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz" integrity sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw== ajv-formats@^2.1.1: version "2.1.1" - resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" + resolved "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz" integrity sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA== dependencies: ajv "^8.0.0" @@ -272,36 +428,36 @@ ajv@^8.0.0, ajv@^8.17.1, ajv@^8.18.0: ansi-regex@^5.0.1: version "5.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== -ansi-regex@^6.0.1: +ansi-regex@^6.2.2: version "6.2.2" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== ansi-styles@^3.2.1: version "3.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz" integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== dependencies: color-convert "^1.9.0" ansi-styles@^4.0.0: version "4.3.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz" integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== dependencies: color-convert "^2.0.1" ansi-styles@^6.1.0: version "6.2.3" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz" integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== anymatch@~3.1.2: version "3.1.3" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz" integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== dependencies: normalize-path "^3.0.0" @@ -309,12 +465,12 @@ anymatch@~3.1.2: "aproba@^1.0.3 || ^2.0.0": version "2.1.0" - resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.1.0.tgz#75500a190313d95c64e871e7e4284c6ac219f0b1" + resolved "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz" integrity sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew== archiver-utils@^5.0.0, archiver-utils@^5.0.2: version "5.0.2" - resolved "https://registry.yarnpkg.com/archiver-utils/-/archiver-utils-5.0.2.tgz#63bc719d951803efc72cf961a56ef810760dd14d" + resolved "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz" integrity sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA== dependencies: glob "^10.0.0" @@ -327,7 +483,7 @@ archiver-utils@^5.0.0, archiver-utils@^5.0.2: archiver@^7.0.1: version "7.0.1" - resolved "https://registry.yarnpkg.com/archiver/-/archiver-7.0.1.tgz#c9d91c350362040b8927379c7aa69c0655122f61" + resolved "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz" integrity sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ== dependencies: archiver-utils "^5.0.2" @@ -340,7 +496,7 @@ archiver@^7.0.1: are-we-there-yet@^3.0.0: version "3.0.1" - resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz#679df222b278c64f2cdba1175cdc00b0d96164bd" + resolved "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz" integrity sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg== dependencies: delegates "^1.0.0" @@ -348,31 +504,40 @@ are-we-there-yet@^3.0.0: argparse@^2.0.1: version "2.0.1" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== asn1@^0.2.4: version "0.2.6" - resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" + resolved "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz" integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== dependencies: safer-buffer "~2.1.0" +asn1js@^3.0.6: + version "3.0.7" + resolved "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz" + integrity sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ== + dependencies: + pvtsutils "^1.3.6" + pvutils "^1.1.3" + tslib "^2.8.1" + ast-types@^0.13.4: version "0.13.4" - resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.13.4.tgz#ee0d77b343263965ecc3fb62da16e7222b2b6782" + resolved "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz" integrity sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w== dependencies: tslib "^2.0.1" async@^3.2.4: version "3.2.6" - resolved "https://registry.yarnpkg.com/async/-/async-3.2.6.tgz#1b0728e14929d51b85b449b7f06e27c1145e38ce" + resolved "https://registry.npmjs.org/async/-/async-3.2.6.tgz" integrity sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA== aws-ssl-profiles@^1.1.2: version "1.1.2" - resolved "https://registry.yarnpkg.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz#157dd77e9f19b1d123678e93f120e6f193022641" + resolved "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz" integrity sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g== b4a@^1.6.4: @@ -382,7 +547,7 @@ b4a@^1.6.4: balanced-match@^1.0.0: version "1.0.2" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== balanced-match@^4.0.2: @@ -392,12 +557,12 @@ balanced-match@^4.0.2: bare-events@^2.7.0: version "2.8.2" - resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.8.2.tgz#7b3e10bd8e1fc80daf38bb516921678f566ab89f" + resolved "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz" integrity sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ== base64-js@^1.3.1: version "1.5.1" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== basic-ftp@^5.0.2: @@ -475,7 +640,7 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" -brace-expansion@^2.0.1: +brace-expansion@^2.0.1, brace-expansion@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== @@ -483,9 +648,9 @@ brace-expansion@^2.0.1: balanced-match "^1.0.0" brace-expansion@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.2.tgz#b6c16d0791087af6c2bc463f52a8142046c06b6f" - integrity sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw== + version "5.0.4" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.4.tgz#614daaecd0a688f660bbbc909a8748c3d80d4336" + integrity sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg== dependencies: balanced-match "^4.0.2" @@ -1751,32 +1916,32 @@ mimic-response@^3.1.0: integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== minimatch@^10.2.1: - version "10.2.2" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.2.tgz#361603ee323cfb83496fea2ae17cc44ea4e1f99f" - integrity sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw== + version "10.2.4" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.4.tgz#465b3accbd0218b8281f5301e27cedc697f96fde" + integrity sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg== dependencies: brace-expansion "^5.0.2" minimatch@^3.1.1: - version "3.1.2" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" - integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + version "3.1.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" + integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== dependencies: brace-expansion "^1.1.7" minimatch@^5.1.0: - version "5.1.6" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" - integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== + version "5.1.9" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.9.tgz#1293ef15db0098b394540e8f9f744f9fda8dee4b" + integrity sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw== dependencies: brace-expansion "^2.0.1" minimatch@^9.0.4: - version "9.0.5" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" - integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + version "9.0.9" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.9.tgz#9b0cb9fcb78087f6fd7eababe2511c4d3d60574e" + integrity sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg== dependencies: - brace-expansion "^2.0.1" + brace-expansion "^2.0.2" minimist@^1.2.0, minimist@^1.2.3: version "1.2.8" @@ -1878,9 +2043,9 @@ ms@^2.0.0, ms@^2.1.1, ms@^2.1.3: integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== mysql2@^3.17.5: - version "3.17.5" - resolved "https://registry.yarnpkg.com/mysql2/-/mysql2-3.17.5.tgz#c0a0fdd73812ba65d08d47ee639df5ec46409087" - integrity sha512-Qb5kOObI10PUTjMRrvJhegiu2i7EmbCm2F2tbYHp9gCfpbLWjk39+TeoA6ususBc8xZa3pmRI6z0kPQldpcA8A== + version "3.18.2" + resolved "https://registry.yarnpkg.com/mysql2/-/mysql2-3.18.2.tgz#11d30fbc03a456d076760bd60e6ebf17abc6323d" + integrity sha512-UfEShBFAZZEAKjySnTUuE7BgqkYT4mx+RjoJ5aqtmwSSvNcJ/QxQPXz/y3jSxNiVRedPfgccmuBtiPCSiEEytw== dependencies: aws-ssl-profiles "^1.1.2" denque "^2.1.0" @@ -1931,9 +2096,9 @@ node-addon-api@^7.0.0: integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== node-addon-api@^8.3.0: - version "8.5.0" - resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-8.5.0.tgz#c91b2d7682fa457d2e1c388150f0dff9aafb8f3f" - integrity sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A== + version "8.6.0" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-8.6.0.tgz#b22497201b465cd0a92ef2c01074ee5068c79a6d" + integrity sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q== node-gyp-build@^4.8.4: version "4.8.4" @@ -2197,15 +2362,15 @@ pg-int8@1.0.1: resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== -pg-pool@^3.11.0: - version "3.11.0" - resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.11.0.tgz#adf9a6651a30c839f565a3cc400110949c473d69" - integrity sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w== +pg-pool@^3.12.0: + version "3.12.0" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.12.0.tgz#798c84ec7d42ba03fff056ebe575daa6e14feab8" + integrity sha512-eIJ0DES8BLaziFHW7VgJEBPi5hg3Nyng5iKpYtj3wbcAUV9A1wLgWiY7ajf/f/oO1wfxt83phXPY8Emztg7ITg== -pg-protocol@^1.11.0: - version "1.11.0" - resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.11.0.tgz#2502908893edaa1e8c0feeba262dd7b40b317b53" - integrity sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g== +pg-protocol@^1.12.0: + version "1.12.0" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.12.0.tgz#e9827f3e1dae6cdcb78d009cba5bb699d88ae998" + integrity sha512-uOANXNRACNdElMXJ0tPz6RBM0XQ61nONGAwlt8da5zs/iUOOCLBQOHSXnrC6fMsvtjxbOJrZZl5IScGv+7mpbg== pg-types@2.2.0: version "2.2.0" @@ -2219,13 +2384,13 @@ pg-types@2.2.0: postgres-interval "^1.1.0" pg@^8.18.0: - version "8.18.0" - resolved "https://registry.yarnpkg.com/pg/-/pg-8.18.0.tgz#e9ee214206f5d9231240f1b82f22d2fa9de5cb75" - integrity sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ== + version "8.19.0" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.19.0.tgz#2cb45322471c1ed05786ee7ec09bd91abdfe3eeb" + integrity sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ== dependencies: pg-connection-string "^2.11.0" - pg-pool "^3.11.0" - pg-protocol "^1.11.0" + pg-pool "^3.12.0" + pg-protocol "^1.12.0" pg-types "2.2.0" pgpass "1.0.5" optionalDependencies: @@ -2359,6 +2524,18 @@ pump@^3.0.0: end-of-stream "^1.1.0" once "^1.3.1" +pvtsutils@^1.3.6: + version "1.3.6" + resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.3.6.tgz#ec46e34db7422b9e4fdc5490578c1883657d6001" + integrity sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg== + dependencies: + tslib "^2.8.1" + +pvutils@^1.1.3: + version "1.1.5" + resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.5.tgz#84b0dea4a5d670249aa9800511804ee0b7c2809c" + integrity sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA== + qs@^6.14.0, qs@^6.14.1: version "6.15.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.0.tgz#db8fd5d1b1d2d6b5b33adaf87429805f1909e7b3" @@ -2450,6 +2627,11 @@ rechoir@^0.8.0: dependencies: resolve "^1.20.0" +reflect-metadata@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz#400c845b6cba87a21f2c65c4aeb158f4fa4d9c5b" + integrity sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q== + require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" @@ -2791,11 +2973,11 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: ansi-regex "^5.0.1" strip-ansi@^7.0.1: - version "7.1.2" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.2.tgz#132875abde678c7ea8d691533f2e7e22bb744dba" - integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== + version "7.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" + integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== dependencies: - ansi-regex "^6.0.1" + ansi-regex "^6.2.2" strip-bom@^3.0.0: version "3.0.0" @@ -2909,11 +3091,23 @@ touch@^3.1.0: resolved "https://registry.yarnpkg.com/touch/-/touch-3.1.1.tgz#097a23d7b161476435e5c1344a95c0f75b4a5694" integrity sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA== -tslib@^2.0.1: +tslib@^1.9.3: + version "1.14.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" + integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== + +tslib@^2.0.1, tslib@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== +tsyringe@^4.10.0: + version "4.10.0" + resolved "https://registry.yarnpkg.com/tsyringe/-/tsyringe-4.10.0.tgz#d0c95815d584464214060285eaaadd94aa03299c" + integrity sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw== + dependencies: + tslib "^1.9.3" + tunnel-agent@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" diff --git a/frontend/package.json b/frontend/package.json index 179e5484f1..797ea2ba4c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "test": "vitest" }, "dependencies": { + "@simplewebauthn/browser": "^13.2.2", "@tabler/core": "^1.4.0", "@tabler/icons-react": "^3.37.1", "@tanstack/react-query": "^5.90.21", diff --git a/frontend/src/api/backend/base.ts b/frontend/src/api/backend/base.ts index d56f120c7f..7072fdcc0a 100644 --- a/frontend/src/api/backend/base.ts +++ b/frontend/src/api/backend/base.ts @@ -150,14 +150,20 @@ export async function put({ url, params, data }: PutArgs, abortController?: Abor interface DeleteArgs { url: string; params?: queryString.StringifiableRecord; + data?: Record; } -export async function del({ url, params }: DeleteArgs, abortController?: AbortController) { +export async function del({ url, params, data }: DeleteArgs, abortController?: AbortController) { const apiUrl = buildUrl({ url, params }); const method = "DELETE"; - const headers = { + const headers: Record = { ...buildAuthHeader(), }; const signal = abortController?.signal; - const response = await fetch(apiUrl, { method, headers, signal }); + let body: string | undefined; + if (data) { + headers[contentTypeHeader] = "application/json"; + body = buildBody(data); + } + const response = await fetch(apiUrl, { method, headers, body, signal }); return processResponse(response); } diff --git a/frontend/src/api/backend/index.ts b/frontend/src/api/backend/index.ts index 40cb4142fc..ce1ec17a5f 100644 --- a/frontend/src/api/backend/index.ts +++ b/frontend/src/api/backend/index.ts @@ -61,3 +61,5 @@ export * from "./updateUser"; export * from "./uploadCertificate"; export * from "./validateCertificate"; export * from "./twoFactor"; +export * from "./passkeys"; +export * from "./removeAuth"; diff --git a/frontend/src/api/backend/models.ts b/frontend/src/api/backend/models.ts index 2ae0b08348..638f6615cd 100644 --- a/frontend/src/api/backend/models.ts +++ b/frontend/src/api/backend/models.ts @@ -29,6 +29,7 @@ export interface User { avatar: string; roles: string[]; permissions?: UserPermissions; + hasPassword?: boolean; } export interface AuditLog { diff --git a/frontend/src/api/backend/passkeys.ts b/frontend/src/api/backend/passkeys.ts new file mode 100644 index 0000000000..c48460802e --- /dev/null +++ b/frontend/src/api/backend/passkeys.ts @@ -0,0 +1,80 @@ +import * as api from "./base"; +import type { + PasskeyAuthOptionsResponse, + PasskeyAuthVerifyResponse, + PasskeyCredential, + PasskeyRegOptionsResponse, +} from "./responseTypes"; + +// === Authentication (login) === + +export async function getPasskeyAuthOptions( + email?: string, +): Promise { + return await api.post({ + url: "/tokens/passkey/options", + data: email ? { email } : {}, + noAuth: true, + }); +} + +export async function verifyPasskeyAuth( + challengeToken: string, + credential: string, +): Promise { + return await api.post({ + url: "/tokens/passkey/verify", + data: { challengeToken, credential }, + noAuth: true, + }); +} + +// === Registration (management, requires auth) === + +export async function getPasskeyRegOptions( + userId: number | "me", +): Promise { + return await api.post({ + url: `/users/${userId}/passkeys/register/options`, + }); +} + +export async function verifyPasskeyRegistration( + userId: number | "me", + challengeToken: string, + credential: string, + friendlyName: string, +): Promise { + return await api.post({ + url: `/users/${userId}/passkeys/register/verify`, + data: { challengeToken, credential, friendlyName }, + }); +} + +export async function listPasskeys( + userId: number | "me", +): Promise { + return await api.get({ + url: `/users/${userId}/passkeys`, + }); +} + +export async function renamePasskey( + userId: number | "me", + passkeyId: number, + friendlyName: string, +): Promise { + return await api.put({ + url: `/users/${userId}/passkeys/${passkeyId}`, + data: { friendlyName }, + }); +} + +export async function deletePasskey( + userId: number | "me", + passkeyId: number, +): Promise { + return await api.del({ + url: `/users/${userId}/passkeys/${passkeyId}`, + }); +} diff --git a/frontend/src/api/backend/removeAuth.ts b/frontend/src/api/backend/removeAuth.ts new file mode 100644 index 0000000000..309d5143c1 --- /dev/null +++ b/frontend/src/api/backend/removeAuth.ts @@ -0,0 +1,8 @@ +import * as api from "./base"; + +export async function removeAuth(userId: number | "me", current?: string): Promise { + return await api.del({ + url: `/users/${userId}/auth`, + data: current ? { current } : undefined, + }); +} diff --git a/frontend/src/api/backend/responseTypes.ts b/frontend/src/api/backend/responseTypes.ts index 2f88ede547..68010c2c90 100644 --- a/frontend/src/api/backend/responseTypes.ts +++ b/frontend/src/api/backend/responseTypes.ts @@ -44,3 +44,26 @@ export interface TwoFactorSetupResponse { export interface TwoFactorEnableResponse { backupCodes: string[]; } + +export interface PasskeyAuthOptionsResponse { + options: any; + challengeToken: string; +} + +export interface PasskeyAuthVerifyResponse { + token: string; + expires: number; +} + +export interface PasskeyRegOptionsResponse { + options: any; + challengeToken: string; +} + +export interface PasskeyCredential { + id: number; + friendlyName: string; + createdOn: string; + deviceType: string; + backedUp: boolean; +} diff --git a/frontend/src/components/SiteHeader.tsx b/frontend/src/components/SiteHeader.tsx index f00d38d6ed..5ceacd7824 100644 --- a/frontend/src/components/SiteHeader.tsx +++ b/frontend/src/components/SiteHeader.tsx @@ -1,14 +1,22 @@ -import { IconLock, IconLogout, IconShieldLock, IconUser } from "@tabler/icons-react"; +import { IconFingerprint, IconLock, IconLogout, IconShieldLock, IconUser } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; +import { listPasskeys } from "src/api/backend"; import { LocalePicker, NavLink, ThemeSwitcher } from "src/components"; import { useAuthState } from "src/context"; import { useUser } from "src/hooks"; import { T } from "src/locale"; -import { showChangePasswordModal, showTwoFactorModal, showUserModal } from "src/modals"; +import { showChangePasswordModal, showPasskeyModal, showTwoFactorModal, showUserModal } from "src/modals"; import styles from "./SiteHeader.module.css"; export function SiteHeader() { const { data: currentUser } = useUser("me"); + const { data: passkeys } = useQuery({ + queryKey: ["passkeys", "me"], + queryFn: () => listPasskeys("me"), + enabled: !!currentUser, + }); const isAdmin = currentUser?.roles.includes("admin"); + const hasPasskeys = (passkeys?.length ?? 0) > 0; const { logout } = useAuthState(); return ( @@ -102,11 +110,11 @@ export function SiteHeader() { className="dropdown-item" onClick={(e) => { e.preventDefault(); - showChangePasswordModal("me"); + showChangePasswordModal("me", currentUser?.hasPassword ?? true, hasPasskeys); }} > - + + { + e.preventDefault(); + showPasskeyModal("me"); + }} + > + + +
Promise; + loginWithPasskey: () => Promise; + authenticateWithToken: (response: TokenResponse) => void; verifyTwoFactor: (code: string) => Promise; cancelTwoFactor: () => void; loginAs: (id: number) => Promise; @@ -56,6 +61,13 @@ function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props) handleTokenUpdate(response); }; + const loginWithPasskey = async () => { + const { options, challengeToken } = await getPasskeyAuthOptions(); + const credential = await startAuthentication({ optionsJSON: options }); + const response = await verifyPasskeyAuth(challengeToken, JSON.stringify(credential)); + handleTokenUpdate(response); + }; + const verifyTwoFactor = async (code: string) => { if (!twoFactorChallenge) { throw new Error("No 2FA challenge pending"); @@ -102,10 +114,16 @@ function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props) true, ); + const authenticateWithToken = (response: TokenResponse) => { + handleTokenUpdate(response); + }; + const value = { authenticated, twoFactorChallenge, login, + loginWithPasskey, + authenticateWithToken, verifyTwoFactor, cancelTwoFactor, loginAs, diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index bb00ac3322..c42b5ce993 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -476,6 +476,12 @@ "login.2fa-verify": { "defaultMessage": "Verify" }, + "login.or": { + "defaultMessage": "or" + }, + "login.passkey-sign-in": { + "defaultMessage": "Sign in with Passkey" + }, "login.title": { "defaultMessage": "Login to your account" }, @@ -563,6 +569,39 @@ "options": { "defaultMessage": "Options" }, + "passkey.backed-up": { + "defaultMessage": "Backed Up" + }, + "passkey.delete": { + "defaultMessage": "Delete" + }, + "passkey.delete-confirm": { + "defaultMessage": "Are you sure you want to delete this passkey? You will no longer be able to use it to sign in." + }, + "passkey.device-type.multi": { + "defaultMessage": "Multi-Device" + }, + "passkey.device-type.single": { + "defaultMessage": "Single Device" + }, + "passkey.friendly-name": { + "defaultMessage": "Passkey Name" + }, + "passkey.no-passkeys": { + "defaultMessage": "No passkeys registered" + }, + "passkey.no-passkeys-description": { + "defaultMessage": "Register a passkey to enable passwordless sign-in." + }, + "passkey.register": { + "defaultMessage": "Register New Passkey" + }, + "passkey.rename": { + "defaultMessage": "Rename" + }, + "passkey.title": { + "defaultMessage": "Passkeys" + }, "password": { "defaultMessage": "Password" }, @@ -680,6 +719,18 @@ "settings.default-site.redirect": { "defaultMessage": "Redirect" }, + "setup.choose-auth-method": { + "defaultMessage": "Choose how to secure your account." + }, + "setup.create-with-passkey": { + "defaultMessage": "Set up with Passkey" + }, + "setup.create-with-password": { + "defaultMessage": "Set up with Password" + }, + "setup.next": { + "defaultMessage": "Next" + }, "setup.preamble": { "defaultMessage": "Get started by creating your admin account." }, @@ -752,6 +803,12 @@ "user.nickname": { "defaultMessage": "Nickname" }, + "user.passkeys": { + "defaultMessage": "Passkeys" + }, + "user.remove-password": { + "defaultMessage": "Remove Password" + }, "user.set-password": { "defaultMessage": "Set Password" }, diff --git a/frontend/src/modals/ChangePasswordModal.tsx b/frontend/src/modals/ChangePasswordModal.tsx index 48221e2812..1a365a6919 100644 --- a/frontend/src/modals/ChangePasswordModal.tsx +++ b/frontend/src/modals/ChangePasswordModal.tsx @@ -3,19 +3,21 @@ import { Field, Form, Formik } from "formik"; import { type ReactNode, useState } from "react"; import { Alert } from "react-bootstrap"; import Modal from "react-bootstrap/Modal"; -import { updateAuth } from "src/api/backend"; +import { removeAuth, updateAuth } from "src/api/backend"; import { Button } from "src/components"; import { intl, T } from "src/locale"; import { validateString } from "src/modules/Validations"; -const showChangePasswordModal = (id: number | "me") => { - EasyModal.show(ChangePasswordModal, { id }); +const showChangePasswordModal = (id: number | "me", hasPassword = true, hasPasskeys = false) => { + EasyModal.show(ChangePasswordModal, { userId: id, hasPassword, hasPasskeys }); }; interface Props extends InnerModalProps { - id: number | "me"; + userId: number | "me"; + hasPassword: boolean; + hasPasskeys: boolean; } -const ChangePasswordModal = EasyModal.create(({ id, visible, remove }: Props) => { +const ChangePasswordModal = EasyModal.create(({ userId, hasPassword, hasPasskeys, visible, remove }: Props) => { const [error, setError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); @@ -31,7 +33,7 @@ const ChangePasswordModal = EasyModal.create(({ id, visible, remove }: Props) => setError(null); try { - await updateAuth(id, values.new, values.current); + await updateAuth(userId, values.new, hasPassword ? values.current : undefined); remove(); } catch (err: any) { setError(); @@ -40,6 +42,20 @@ const ChangePasswordModal = EasyModal.create(({ id, visible, remove }: Props) => setSubmitting(false); }; + const onRemovePassword = async (values: any) => { + if (isSubmitting) return; + setIsSubmitting(true); + setError(null); + + try { + await removeAuth(userId, values.current); + remove(); + } catch (err: any) { + setError(); + } + setIsSubmitting(false); + }; + return ( } onSubmit={onSubmit} > - {() => ( + {({ values }) => (
- + setError(null)} dismissible> {error} + {hasPassword && (
{({ field, form }: any) => ( @@ -92,6 +109,7 @@ const ChangePasswordModal = EasyModal.create(({ id, visible, remove }: Props) => )}
+ )}
{({ field, form }: any) => ( @@ -149,6 +167,16 @@ const ChangePasswordModal = EasyModal.create(({ id, visible, remove }: Props) => + {hasPassword && hasPasskeys && ( + + )} + +
+
+ ); + } + + if (step === "register-name") { + return ( +
+ + {() => ( + + + {({ field, form }: any) => ( + + )} + +
+ + +
+ + )} +
+
+ ); + } + + // step === "list" + return ( +
+ {passkeys.length === 0 ? ( +
+

+ +

+

+ +

+
+ ) : ( +
+ {passkeys.map((passkey) => ( +
+
+
+ {editingId === passkey.id ? ( +
+ setEditingName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleRename(passkey.id); + if (e.key === "Escape") { + setEditingId(null); + setEditingName(""); + } + }} + /> + +
+ ) : ( +
+ {passkey.friendlyName || `Passkey #${passkey.id}`} +
+ )} +
+ + {passkey.deviceType === "multiDevice" + ? + : } + + {passkey.backedUp && ( + + + + )} + + {new Date(passkey.createdOn).toLocaleDateString()} + +
+
+ {editingId !== passkey.id && ( +
+ + +
+ )} +
+
+ ))} +
+ )} + +
+ ); + }; + + return ( + + + + + + + + setError(null)} dismissible> + {error} + + {renderContent()} + + + ); +}); + +export { showPasskeyModal }; diff --git a/frontend/src/modals/index.ts b/frontend/src/modals/index.ts index a06a0c0d71..4a7d12fa3f 100644 --- a/frontend/src/modals/index.ts +++ b/frontend/src/modals/index.ts @@ -14,4 +14,5 @@ export * from "./RenewCertificateModal"; export * from "./SetPasswordModal"; export * from "./StreamModal"; export * from "./TwoFactorModal"; +export * from "./PasskeyModal"; export * from "./UserModal"; diff --git a/frontend/src/pages/Login/index.tsx b/frontend/src/pages/Login/index.tsx index ebf7eeb376..1be15c1c65 100644 --- a/frontend/src/pages/Login/index.tsx +++ b/frontend/src/pages/Login/index.tsx @@ -80,7 +80,9 @@ function TwoFactorForm() { function LoginForm() { const emailRef = useRef(null); const [formErr, setFormErr] = useState(""); - const { login } = useAuthState(); + const [passkeyLoading, setPasskeyLoading] = useState(false); + const [passkeySupported] = useState(() => typeof window.PublicKeyCredential !== "undefined"); + const { login, loginWithPasskey } = useAuthState(); const onSubmit = async (values: any, { setSubmitting }: any) => { setFormErr(""); @@ -94,6 +96,19 @@ function LoginForm() { setSubmitting(false); }; + const onPasskeyLogin = async () => { + setFormErr(""); + setPasskeyLoading(true); + try { + await loginWithPasskey(); + } catch (err) { + if (err instanceof Error) { + setFormErr(err.message); + } + } + setPasskeyLoading(false); + }; + useEffect(() => { emailRef.current?.focus(); }, []); @@ -162,6 +177,21 @@ function LoginForm() { )} + {passkeySupported && ( + <> +
+ +
+ + + )} ); } diff --git a/frontend/src/pages/Setup/index.tsx b/frontend/src/pages/Setup/index.tsx index 4becf5faec..c0fa8bfce2 100644 --- a/frontend/src/pages/Setup/index.tsx +++ b/frontend/src/pages/Setup/index.tsx @@ -1,63 +1,305 @@ import { useQueryClient } from "@tanstack/react-query"; +import { startRegistration } from "@simplewebauthn/browser"; import cn from "classnames"; import { Field, Form, Formik } from "formik"; import { useState } from "react"; import { Alert } from "react-bootstrap"; -import { createUser } from "src/api/backend"; +import { + createUser, + getPasskeyRegOptions, + verifyPasskeyRegistration, +} from "src/api/backend"; import { Button, LocalePicker, Page, ThemeSwitcher } from "src/components"; import { useAuthState } from "src/context"; import { intl, T } from "src/locale"; import { validateEmail, validateString } from "src/modules/Validations"; import styles from "./index.module.css"; -interface Payload { +interface UserInfo { name: string; email: string; - password: string; } +type Step = "user-info" | "choose-method" | "password"; + export default function Setup() { const queryClient = useQueryClient(); - const { login } = useAuthState(); + const { authenticateWithToken } = useAuthState(); const [errorMsg, setErrorMsg] = useState(null); + const [userInfo, setUserInfo] = useState(null); + const [step, setStep] = useState("user-info"); + const [passkeyLoading, setPasskeyLoading] = useState(false); + const [passwordLoading, setPasswordLoading] = useState(false); + const [passkeySupported] = useState(() => typeof window.PublicKeyCredential !== "undefined"); - const onSubmit = async (values: Payload, { setSubmitting }: any) => { + const onUserInfoSubmit = async (values: UserInfo, { setSubmitting }: any) => { setErrorMsg(null); + setUserInfo(values); + setStep(passkeySupported ? "choose-method" : "password"); + setSubmitting(false); + }; - // Set a nickname, which is the first word of the name - const nickname = values.name.split(" ")[0]; + const onPasswordSubmit = async (values: { password: string }, { setSubmitting }: any) => { + if (!userInfo) return; + setErrorMsg(null); + setPasswordLoading(true); - const { password, ...payload } = { - ...values, - ...{ - nickname, - auth: { - type: "password", - secret: values.password, - }, - }, - }; + const nickname = userInfo.name.split(" ")[0]; try { - const user = await createUser(payload, true); - if (user?.id) { - try { - await login(user.email, password); - // Trigger a Health change - await queryClient.refetchQueries({ queryKey: ["health"] }); - // window.location.reload(); - } catch (err: any) { - setErrorMsg(err.message); - } + const result = await createUser( + { + name: userInfo.name, + email: userInfo.email, + nickname, + auth: { type: "password", secret: values.password }, + }, + true, + ); + if (result?.id) { + authenticateWithToken({ token: (result as any).token, expires: (result as any).expires }); + await queryClient.refetchQueries({ queryKey: ["health"] }); } else { setErrorMsg("cannot_create_user"); } } catch (err: any) { setErrorMsg(err.message); } + setPasswordLoading(false); setSubmitting(false); }; + const onPasskeySetup = async () => { + if (!userInfo) return; + setErrorMsg(null); + setPasskeyLoading(true); + + const nickname = userInfo.name.split(" ")[0]; + + try { + const result = await createUser( + { name: userInfo.name, email: userInfo.email, nickname }, + true, + ); + + if (!result?.id) { + setErrorMsg("cannot_create_user"); + setPasskeyLoading(false); + return; + } + + // Store the token so passkey registration API calls are authenticated + authenticateWithToken({ token: (result as any).token, expires: (result as any).expires }); + + // Now register a passkey + try { + const regOptions = await getPasskeyRegOptions(result.id); + const credential = await startRegistration({ optionsJSON: regOptions.options }); + await verifyPasskeyRegistration( + result.id, + regOptions.challengeToken, + JSON.stringify(credential), + "Setup passkey", + ); + } catch { + // If passkey registration fails (user cancels, device error, etc.), + // the user is already created and authenticated. They can configure + // auth from the settings menu. + } + + await queryClient.refetchQueries({ queryKey: ["health"] }); + } catch (err: any) { + setErrorMsg(err.message); + } + setPasskeyLoading(false); + }; + + const renderUserInfo = () => ( + + {({ isSubmitting }) => ( +
+
+

+ +

+

+ +

+
+
+
+ + {({ field, form }: any) => ( +
+ + + {form.errors.name ? ( +
+ {form.errors.name && form.touched.name + ? form.errors.name + : null} +
+ ) : null} +
+ )} +
+ + {({ field, form }: any) => ( +
+ + + {form.errors.email ? ( +
+ {form.errors.email && form.touched.email + ? form.errors.email + : null} +
+ ) : null} +
+ )} +
+
+
+ +
+
+ )} +
+ ); + + const renderChooseMethod = () => ( + <> +
+

+ +

+

+ +

+
+
+
+ +
+ +
+ +
+ + ); + + const renderPassword = () => ( + + {({ isSubmitting }) => ( +
+
+

+ +

+

+ +

+
+
+
+ + {({ field, form }: any) => ( +
+ + + {form.errors.password ? ( +
+ {form.errors.password && form.touched.password + ? form.errors.password + : null} +
+ ) : null} +
+ )} +
+
+
+ +
+
+ )} +
+ ); + + const renderStep = () => { + switch (step) { + case "choose-method": + return renderChooseMethod(); + case "password": + return renderPassword(); + default: + return renderUserInfo(); + } + }; + return (
@@ -76,119 +318,7 @@ export default function Setup() { setErrorMsg(null)} dismissible> {errorMsg} - - {({ isSubmitting }) => ( -
-
-

- -

-

- -

-
-
-
-
- - {({ field, form }: any) => ( -
- - - {form.errors.name ? ( -
- {form.errors.name && form.touched.name - ? form.errors.name - : null} -
- ) : null} -
- )} -
-
-
- - {({ field, form }: any) => ( -
- - - {form.errors.email ? ( -
- {form.errors.email && form.touched.email - ? form.errors.email - : null} -
- ) : null} -
- )} -
-
-
- - {({ field, form }: any) => ( -
- - - {form.errors.password ? ( -
- {form.errors.password && form.touched.password - ? form.errors.password - : null} -
- ) : null} -
- )} -
-
-
-
- -
-
- )} -
+ {renderStep()}
diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 4beafdf1f5..f190346922 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -9,7 +9,7 @@ "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.27.1", "@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" + resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz" integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== dependencies: "@babel/helper-validator-identifier" "^7.28.5" @@ -18,12 +18,12 @@ "@babel/compat-data@^7.28.6": version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.0.tgz#00d03e8c0ac24dd9be942c5370990cbe1f17d88d" + resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz" integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg== "@babel/core@^7.29.0": version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.0.tgz#5286ad785df7f79d656e88ce86e650d16ca5f322" + resolved "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz" integrity sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA== dependencies: "@babel/code-frame" "^7.29.0" @@ -55,7 +55,7 @@ "@babel/helper-compilation-targets@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz#32c4a3f41f12ed1532179b108a4d746e105c2b25" + resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz" integrity sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA== dependencies: "@babel/compat-data" "^7.28.6" @@ -71,7 +71,7 @@ "@babel/helper-module-imports@^7.16.7", "@babel/helper-module-imports@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz#60632cbd6ffb70b22823187201116762a03e2d5c" + resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz" integrity sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw== dependencies: "@babel/traverse" "^7.28.6" @@ -79,7 +79,7 @@ "@babel/helper-module-transforms@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz#9312d9d9e56edc35aeb6e95c25d4106b50b9eb1e" + resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz" integrity sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA== dependencies: "@babel/helper-module-imports" "^7.28.6" @@ -108,7 +108,7 @@ "@babel/helpers@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.28.6.tgz#fca903a313ae675617936e8998b814c415cbf5d7" + resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz" integrity sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw== dependencies: "@babel/template" "^7.28.6" @@ -116,7 +116,7 @@ "@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.28.6", "@babel/parser@^7.29.0": version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.0.tgz#669ef345add7d057e92b7ed15f0bac07611831b6" + resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz" integrity sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww== dependencies: "@babel/types" "^7.29.0" @@ -142,7 +142,7 @@ "@babel/template@^7.28.6": version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57" + resolved "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz" integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ== dependencies: "@babel/code-frame" "^7.28.6" @@ -151,7 +151,7 @@ "@babel/traverse@^7.28.6", "@babel/traverse@^7.29.0": version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.0.tgz#f323d05001440253eead3c9c858adbe00b90310a" + resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz" integrity sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA== dependencies: "@babel/code-frame" "^7.29.0" @@ -164,7 +164,7 @@ "@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.28.2", "@babel/types@^7.28.6", "@babel/types@^7.29.0": version "7.29.0" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7" + resolved "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz" integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== dependencies: "@babel/helper-string-parser" "^7.27.1" @@ -469,7 +469,7 @@ "@formatjs/ecma402-abstract@3.1.1": version "3.1.1" - resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-3.1.1.tgz#329fa5eed8024ee389e9c82be8c798315631b11d" + resolved "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-3.1.1.tgz" integrity sha512-jhZbTwda+2tcNrs4kKvxrPLPjx8QsBCLCUgrrJ/S+G9YrGHWLhAyFMMBHJBnBoOwuLHd7L14FgYudviKaxkO2Q== dependencies: "@formatjs/fast-memoize" "3.1.0" @@ -479,14 +479,14 @@ "@formatjs/fast-memoize@3.1.0": version "3.1.0" - resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-3.1.0.tgz#f8643d44803df8f579506d40804f4faeba6f5da2" + resolved "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.0.tgz" integrity sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg== dependencies: tslib "^2.8.1" "@formatjs/icu-messageformat-parser@3.5.1": version "3.5.1" - resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.1.tgz#a08faadbbb333d9e4fa7276c92a2b66a37788aa1" + resolved "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.1.tgz" integrity sha512-sSDmSvmmoVQ92XqWb499KrIhv/vLisJU8ITFrx7T7NZHUmMY7EL9xgRowAosaljhqnj/5iufG24QrdzB6X3ItA== dependencies: "@formatjs/ecma402-abstract" "3.1.1" @@ -495,7 +495,7 @@ "@formatjs/icu-skeleton-parser@2.1.1": version "2.1.1" - resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.1.tgz#a05b45733bd0f277c50f31b32e4a73375569d3c8" + resolved "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.1.tgz" integrity sha512-PSFABlcNefjI6yyk8f7nyX1DC7NHmq6WaCHZLySEXBrXuLOB2f935YsnzuPjlz+ibhb9yWTdPeVX1OVcj24w2Q== dependencies: "@formatjs/ecma402-abstract" "3.1.1" @@ -503,7 +503,7 @@ "@formatjs/intl-localematcher@0.8.1": version "0.8.1" - resolved "https://registry.yarnpkg.com/@formatjs/intl-localematcher/-/intl-localematcher-0.8.1.tgz#554fe5f8c746ba52d430674de9a242fae64f67ff" + resolved "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.1.tgz" integrity sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA== dependencies: "@formatjs/fast-memoize" "3.1.0" @@ -511,7 +511,7 @@ "@formatjs/intl@4.1.2": version "4.1.2" - resolved "https://registry.yarnpkg.com/@formatjs/intl/-/intl-4.1.2.tgz#4a0d487e6290067462f93d034ef36a8aaf6f671b" + resolved "https://registry.npmjs.org/@formatjs/intl/-/intl-4.1.2.tgz" integrity sha512-V60fNY/X/7zqmRffr7qPwscGmVGYDmlKF069mSQ2a/7fE22q602NtIfOQY8vzRA63Gr/O/U6vjRVBHMabrnA9A== dependencies: "@formatjs/ecma402-abstract" "3.1.1" @@ -814,6 +814,11 @@ resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz#4584a8a87b29188a4c1fe987a9fcf701e256d86c" integrity sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA== +"@simplewebauthn/browser@^13.2.2": + version "13.2.2" + resolved "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz" + integrity sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA== + "@standard-schema/spec@^1.0.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" @@ -848,17 +853,17 @@ "@tanstack/query-core@5.90.20": version "5.90.20" - resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.90.20.tgz#e12128e39210715d4ce4fb299c33498ac297771e" + resolved "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz" integrity sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg== "@tanstack/query-devtools@5.93.0": version "5.93.0" - resolved "https://registry.yarnpkg.com/@tanstack/query-devtools/-/query-devtools-5.93.0.tgz#517f61d4e2cfb9af671e34ad5e7e871052bca814" + resolved "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.93.0.tgz" integrity sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg== "@tanstack/react-query-devtools@^5.91.3": version "5.91.3" - resolved "https://registry.yarnpkg.com/@tanstack/react-query-devtools/-/react-query-devtools-5.91.3.tgz#0f65340fa3f7e7d5575de928ad70cfa6b5f74ff1" + resolved "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.3.tgz" integrity sha512-nlahjMtd/J1h7IzOOfqeyDh5LNfG0eULwlltPEonYy0QL+nqrBB+nyzJfULV+moL7sZyxc2sHdNJki+vLA9BSA== dependencies: "@tanstack/query-devtools" "5.93.0" @@ -910,7 +915,7 @@ "@testing-library/react@^16.3.2": version "16.3.2" - resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.3.2.tgz#672883b7acb8e775fc0492d9e9d25e06e89786d0" + resolved "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz" integrity sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g== dependencies: "@babel/runtime" "^7.12.5" @@ -1096,7 +1101,7 @@ "@types/ws@^8.18.1": version "8.18.1" - resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9" + resolved "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz" integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg== dependencies: "@types/node" "*" @@ -1129,7 +1134,7 @@ "@vitest/expect@4.0.18": version "4.0.18" - resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-4.0.18.tgz#361510d99fbf20eb814222e4afcb8539d79dc94d" + resolved "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz" integrity sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ== dependencies: "@standard-schema/spec" "^1.0.0" @@ -1141,7 +1146,7 @@ "@vitest/mocker@4.0.18": version "4.0.18" - resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-4.0.18.tgz#b9735da114ef65ea95652c5bdf13159c6fab4865" + resolved "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz" integrity sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ== dependencies: "@vitest/spy" "4.0.18" @@ -1150,14 +1155,14 @@ "@vitest/pretty-format@4.0.18": version "4.0.18" - resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-4.0.18.tgz#fbccd4d910774072ec15463553edb8ca5ce53218" + resolved "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz" integrity sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw== dependencies: tinyrainbow "^3.0.3" "@vitest/runner@4.0.18": version "4.0.18" - resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-4.0.18.tgz#c2c0a3ed226ec85e9312f9cc8c43c5b3a893a8b1" + resolved "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz" integrity sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw== dependencies: "@vitest/utils" "4.0.18" @@ -1165,7 +1170,7 @@ "@vitest/snapshot@4.0.18": version "4.0.18" - resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-4.0.18.tgz#bcb40fd6d742679c2ac927ba295b66af1c6c34c5" + resolved "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz" integrity sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA== dependencies: "@vitest/pretty-format" "4.0.18" @@ -1174,12 +1179,12 @@ "@vitest/spy@4.0.18": version "4.0.18" - resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-4.0.18.tgz#ba0f20503fb6d08baf3309d690b3efabdfa88762" + resolved "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz" integrity sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw== "@vitest/utils@4.0.18": version "4.0.18" - resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-4.0.18.tgz#9636b16d86a4152ec68a8d6859cff702896433d4" + resolved "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz" integrity sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA== dependencies: "@vitest/pretty-format" "4.0.18" @@ -1277,7 +1282,7 @@ ccount@^2.0.0: chai@^6.2.1: version "6.2.2" - resolved "https://registry.yarnpkg.com/chai/-/chai-6.2.2.tgz#ae41b52c9aca87734505362717f3255facda360e" + resolved "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz" integrity sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg== character-entities-html4@^2.0.0: @@ -1377,7 +1382,7 @@ debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1: decimal.js@^10.6.0: version "10.6.0" - resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.6.0.tgz#e649a43e3ab953a72192ff5983865e509f37ed9a" + resolved "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz" integrity sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg== decode-named-character-reference@^1.0.0: @@ -1550,7 +1555,7 @@ find-root@^1.1.0: formik@^2.4.9: version "2.4.9" - resolved "https://registry.yarnpkg.com/formik/-/formik-2.4.9.tgz#7e5b81e9c9e215d0ce2ac8fed808cf7fba0cd204" + resolved "https://registry.npmjs.org/formik/-/formik-2.4.9.tgz" integrity sha512-5nI94BMnlFDdQRBY4Sz39WkhxajZJ57Fzs8wVbtsQlm5ScKIR1QLYqv/ultBnobObtlUyxpxoLodpixrsf36Og== dependencies: "@types/hoist-non-react-statics" "^3.3.1" @@ -1775,7 +1780,7 @@ inline-style-parser@0.2.7: intl-messageformat@11.1.2: version "11.1.2" - resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-11.1.2.tgz#c72879165d15633f38b092479822c5d831c21ac5" + resolved "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.1.2.tgz" integrity sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg== dependencies: "@formatjs/ecma402-abstract" "3.1.1" @@ -1869,7 +1874,7 @@ lines-and-columns@^1.1.6: lodash-es@^4.17.21: version "4.17.23" - resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.23.tgz#58c4360fd1b5d33afc6c0bbd3d1149349b1138e0" + resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz" integrity sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg== lodash.debounce@^4.0.8: @@ -1879,7 +1884,7 @@ lodash.debounce@^4.0.8: lodash@^4.17.21: version "4.17.23" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a" + resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz" integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== longest-streak@^3.0.0: @@ -1908,7 +1913,7 @@ lz-string@^1.5.0: magic-string@^0.30.21: version "0.30.21" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91" + resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz" integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== dependencies: "@jridgewell/sourcemap-codec" "^1.5.5" @@ -2257,7 +2262,7 @@ object-assign@^4.1.1: obug@^2.1.1: version "2.1.1" - resolved "https://registry.yarnpkg.com/obug/-/obug-2.1.1.tgz#2cba74ff241beb77d63055ddf4cd1e9f90b538be" + resolved "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz" integrity sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ== parent-module@^1.0.0: @@ -2431,7 +2436,7 @@ react-bootstrap@^2.10.10: react-dom@^19.2.4: version "19.2.4" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.4.tgz#6fac6bd96f7db477d966c7ec17c1a2b1ad8e6591" + resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz" integrity sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ== dependencies: scheduler "^0.27.0" @@ -2493,14 +2498,14 @@ react-refresh@^0.18.0: react-router-dom@^7.13.0: version "7.13.0" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-7.13.0.tgz#8b5f7204fadca680f0e94f207c163f0dcd1cfdf5" + resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz" integrity sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g== dependencies: react-router "7.13.0" react-router@7.13.0: version "7.13.0" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-7.13.0.tgz#de9484aee764f4f65b93275836ff5944d7f5bd3b" + resolved "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz" integrity sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw== dependencies: cookie "^1.0.1" @@ -2540,7 +2545,7 @@ react-transition-group@^4.3.0, react-transition-group@^4.4.5: react@^19.2.4: version "19.2.4" - resolved "https://registry.yarnpkg.com/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a" + resolved "https://registry.npmjs.org/react/-/react-19.2.4.tgz" integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ== readdirp@^4.0.1: @@ -2677,7 +2682,7 @@ rollup@^4.43.0: rooks@^9.5.0: version "9.5.0" - resolved "https://registry.yarnpkg.com/rooks/-/rooks-9.5.0.tgz#bbe15fdf64fff44b9a1750cc913de3401572f012" + resolved "https://registry.npmjs.org/rooks/-/rooks-9.5.0.tgz" integrity sha512-AtmaX8yjQkJAW7EXW+UU481bpGwuk455hjD/aEUuy7N7VjvXlNmO8BErQ+jEUQp1DRA/PTWonv+Dq1nEkJdgkw== dependencies: fast-deep-equal "^3.1.3" @@ -2692,7 +2697,7 @@ safe-buffer@^5.1.0: sass@^1.97.3: version "1.97.3" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.3.tgz#9cb59339514fa7e2aec592b9700953ac6e331ab2" + resolved "https://registry.npmjs.org/sass/-/sass-1.97.3.tgz" integrity sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg== dependencies: chokidar "^4.0.0" @@ -2748,7 +2753,7 @@ stackback@0.0.2: std-env@^3.10.0: version "3.10.0" - resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.10.0.tgz#d810b27e3a073047b2b5e40034881f5ea6f9c83b" + resolved "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz" integrity sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg== stringify-entities@^4.0.0: @@ -2807,7 +2812,7 @@ tinybench@^2.9.0: tinyexec@^1.0.2: version "1.0.2" - resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.0.2.tgz#bdd2737fe2ba40bd6f918ae26642f264b99ca251" + resolved "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz" integrity sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg== tinyglobby@^0.2.15: @@ -2982,7 +2987,7 @@ vfile@^6.0.0: vite-plugin-checker@^0.12.0: version "0.12.0" - resolved "https://registry.yarnpkg.com/vite-plugin-checker/-/vite-plugin-checker-0.12.0.tgz#1e9688a5a10f5de1fd833bc1351618eed54db3bc" + resolved "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.12.0.tgz" integrity sha512-CmdZdDOGss7kdQwv73UyVgLPv0FVYe5czAgnmRX2oKljgEvSrODGuClaV3PDR2+3ou7N/OKGauDDBjy2MB07Rg== dependencies: "@babel/code-frame" "^7.27.1" @@ -3005,7 +3010,7 @@ vite-tsconfig-paths@^6.1.1: "vite@^6.0.0 || ^7.0.0", vite@^7.3.1: version "7.3.1" - resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.1.tgz#7f6cfe8fb9074138605e822a75d9d30b814d6507" + resolved "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz" integrity sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA== dependencies: esbuild "^0.27.0" @@ -3019,7 +3024,7 @@ vite-tsconfig-paths@^6.1.1: vitest@^4.0.18: version "4.0.18" - resolved "https://registry.yarnpkg.com/vitest/-/vitest-4.0.18.tgz#56f966353eca0b50f4df7540cd4350ca6d454a05" + resolved "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz" integrity sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ== dependencies: "@vitest/expect" "4.0.18" @@ -3075,7 +3080,7 @@ why-is-node-running@^2.3.0: ws@^8.18.3: version "8.19.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.19.0.tgz#ddc2bdfa5b9ad860204f5a72a4863a8895fd8c8b" + resolved "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz" integrity sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg== yallist@^3.0.2: