feat: Passkey support (#11065)

closes #6930
This commit is contained in:
Tom Moor
2026-01-05 19:58:46 -05:00
committed by GitHub
parent 9f07607c05
commit 57b6e9aca4
52 changed files with 2260 additions and 49 deletions
+4 -5
View File
@@ -125,11 +125,10 @@ export const LabelText = styled.div`
display: inline-block;
`;
export interface Props
extends Omit<
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
"prefix"
> {
export interface Props extends Omit<
React.InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>,
"prefix"
> {
type?: "text" | "email" | "checkbox" | "search" | "textarea" | "password";
labelHidden?: boolean;
label?: string;
+4 -5
View File
@@ -6,11 +6,10 @@ import { LabelText } from "~/components/Input";
import Text from "~/components/Text";
import { undraggableOnDesktop } from "~/styles";
interface Props
extends Omit<
React.ComponentProps<typeof RadixSwitch.Root>,
"checked" | "onCheckedChange" | "onChange"
> {
interface Props extends Omit<
React.ComponentProps<typeof RadixSwitch.Root>,
"checked" | "onCheckedChange" | "onChange"
> {
/** Width of the switch. Defaults to 32. */
width?: number;
/** Height of the switch. Defaults to 18 */
+2 -1
View File
@@ -17,6 +17,7 @@ import {
PlusIcon,
InternetIcon,
SmileyIcon,
BuildingBlocksIcon,
} from "outline-icons";
import type { ComponentProps } from "react";
import { useEffect } from "react";
@@ -110,7 +111,7 @@ const useSettingsConfig = () => {
preload: APIAndApps.preload,
enabled: true,
group: t("Account"),
icon: PadlockIcon,
icon: BuildingBlocksIcon,
},
// Workspace
{
+4
View File
@@ -52,6 +52,10 @@ class Team extends Model {
@observable
guestSignin: boolean;
@Field
@observable
passkeysEnabled: boolean;
@Field
@observable
subdomain: string | null | undefined;
@@ -1,6 +1,8 @@
import { startAuthentication } from "@simplewebauthn/browser";
import { EmailIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { Client } from "@shared/types";
import ButtonLarge from "~/components/ButtonLarge";
@@ -9,6 +11,8 @@ import PluginIcon from "~/components/PluginIcon";
import { client } from "~/utils/ApiClient";
import Desktop from "~/utils/Desktop";
import { getRedirectUrl } from "../urls";
import { CSRF } from "@shared/constants";
import { getCookie } from "tiny-cookie";
type Props = React.ComponentProps<typeof ButtonLarge> & {
id: string;
@@ -26,6 +30,7 @@ function AuthenticationProvider(props: Props) {
const [authState, setAuthState] = React.useState<AuthState>("initial");
const [isSubmitting, setSubmitting] = React.useState(false);
const [email, setEmail] = React.useState("");
const formRef = React.useRef<HTMLFormElement>(null);
const { isCreate, id, name, authUrl, onEmailSuccess, ...rest } = props;
const clientType = Desktop.isElectron() ? Client.Desktop : Client.Web;
@@ -64,6 +69,78 @@ function AuthenticationProvider(props: Props) {
const href = getRedirectUrl(authUrl);
if (id === "passkeys") {
const handleSubmitPasskey = async (
event: React.SyntheticEvent<HTMLFormElement>
) => {
event.preventDefault();
try {
const resp = await client.post(
"/passkeys.generateAuthenticationOptions",
undefined,
{
baseUrl: "/auth",
}
);
const { challengeId, ...optionsData } = resp.data;
const authResp = await startAuthentication(optionsData);
// Populate hidden form fields with authentication data
if (formRef.current) {
const createInputs = (obj: any, prefix = "") => {
Object.entries(obj).forEach(([key, value]) => {
const fieldName = prefix ? `${prefix}[${key}]` : key;
if (value && typeof value === "object" && !Array.isArray(value)) {
createInputs(value, fieldName);
} else {
// Create hidden input for primitive values
const input = document.createElement("input");
input.type = "hidden";
input.name = fieldName;
input.value = String(value);
formRef.current?.appendChild(input);
}
});
};
createInputs({
...authResp,
challengeId,
[CSRF.fieldName]: getCookie(CSRF.cookieName),
client: clientType,
});
}
// Submit form natively to let browser handle redirect and cookies
formRef.current?.submit();
} catch (err) {
toast.error(err.message);
}
};
return (
<Wrapper>
<Form
ref={formRef}
method="POST"
action="/auth/passkeys.verifyAuthentication"
onSubmit={handleSubmitPasskey}
>
<ButtonLarge
type="submit"
icon={<PluginIcon id={id} color="currentColor" />}
fullwidth
{...rest}
>
{t("Continue with Passkey")}
</ButtonLarge>
</Form>
</Wrapper>
);
}
if (id === "email") {
if (isCreate) {
return null;
+2 -2
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import { PadlockIcon } from "outline-icons";
import { BuildingBlocksIcon } from "outline-icons";
import { useTranslation, Trans } from "react-i18next";
import type ApiKey from "~/models/ApiKey";
import type OAuthAuthentication from "~/models/oauth/OAuthAuthentication";
@@ -29,7 +29,7 @@ function APIAndApps() {
return (
<Scene
title={t("API & Apps")}
icon={<PadlockIcon />}
icon={<BuildingBlocksIcon />}
actions={
<>
{can.createApiKey && (
+23
View File
@@ -190,6 +190,29 @@ function Authentication() {
/>
</SettingRow>
<SettingRow
label={
<Flex gap={8} align="center">
<PadlockIcon /> {t("Passkeys")}
</Flex>
}
name="passkeysEnabled"
description={t("Allow members to sign-in with a WebAuthn passkey")}
>
<Switch
id="passkeysEnabled"
checked={team.passkeysEnabled}
onChange={async (checked) => {
try {
await team.save({ passkeysEnabled: checked });
toast.success(t("Settings saved"));
} catch (err) {
toast.error(err.message);
}
}}
/>
</SettingRow>
<Heading as="h2">{t("Restrictions")}</Heading>
<DomainManagement onSuccess={showSuccessMessage} />
</Scene>
+23
View File
@@ -30,6 +30,7 @@ function Security() {
memberCollectionCreate: team.memberCollectionCreate,
memberTeamCreate: team.memberTeamCreate,
inviteRequired: team.inviteRequired,
passkeysEnabled: team.passkeysEnabled,
});
const userRoleOptions: Option[] = React.useMemo(
@@ -91,6 +92,13 @@ function Security() {
[saveData]
);
const handlePasskeysEnabledChange = React.useCallback(
async (checked: boolean) => {
await saveData({ passkeysEnabled: checked });
},
[saveData]
);
const handleMemberCollectionCreateChange = React.useCallback(
async (checked: boolean) => {
await saveData({ memberCollectionCreate: checked });
@@ -231,6 +239,21 @@ function Security() {
</SettingRow>
)}
<Heading as="h2">{t("Authentication")}</Heading>
<SettingRow
label={t("Passkeys")}
name="passkeysEnabled"
description={t(
"Allow users to sign in with passkeys for passwordless authentication"
)}
>
<Switch
id="passkeysEnabled"
checked={data.passkeysEnabled}
onChange={handlePasskeysEnabledChange}
/>
</SettingRow>
<Heading as="h2">{t("Behavior")}</Heading>
<SettingRow
label={t("Public document sharing")}
+1 -4
View File
@@ -123,10 +123,7 @@ declare module "styled-components" {
}
export interface DefaultTheme
extends Colors,
Spacing,
Breakpoints,
EditorTheme {
extends Colors, Spacing, Breakpoints, EditorTheme {
background: string;
backgroundSecondary: string;
backgroundTertiary: string;
+2 -1
View File
@@ -34,6 +34,7 @@ interface FetchOptions {
retry?: boolean;
credentials?: "omit" | "same-origin" | "include";
headers?: Record<string, string>;
baseUrl?: string;
}
const fetchWithRetry = retry(fetch);
@@ -97,7 +98,7 @@ class ApiClient {
if (path.match(/^http/)) {
urlToFetch = modifiedPath || path;
} else {
urlToFetch = this.baseUrl + (modifiedPath || path);
urlToFetch = (options.baseUrl ?? this.baseUrl) + (modifiedPath || path);
}
const headerOptions: Record<string, string> = {
+4 -2
View File
@@ -11,7 +11,7 @@
"build": "yarn clean && yarn vite:build && yarn build:i18n && yarn build:server",
"start": "node ./build/server/index.js",
"dev": "NODE_ENV=development yarn concurrently -n api,collaboration -c \"blue,magenta\" \"node --inspect=0.0.0.0 build/server/index.js --services=cron,collaboration,websockets,admin,web,worker\"",
"dev:backend": "NODE_ENV=development nodemon --quiet --exec \"yarn build:server && yarn dev\" -e js,ts,tsx --watch server --watch shared --watch plugins --watch .env --watch .env.local --watch .env.development --ignore \"**/*.test.ts\" --ignore data/ --ignore build/ --ignore app/ --ignore shared/editor --ignore server/migrations",
"dev:backend": "NODE_ENV=development nodemon --quiet --exec \"yarn build:server && yarn dev\" -e js,ts,tsx --watch server --watch shared --watch plugins --watch .env --watch .env.local --watch .env.development --ignore \"plugins/client/**/*.ts\" --ignore \"**/*.test.ts\" --ignore data/ --ignore build/ --ignore app/ --ignore shared/editor --ignore server/migrations",
"dev:watch": "NODE_ENV=development yarn concurrently -n backend,frontend \"yarn dev:backend\" \"yarn vite:dev\"",
"lint": "oxlint --type-aware app server shared plugins",
"lint:changed": "git diff --name-only --diff-filter=ACMRTUXB | grep -E '\\.(js|jsx|ts|tsx)$' | xargs -r oxlint",
@@ -105,6 +105,8 @@
"@radix-ui/react-visually-hidden": "^1.2.4",
"@sentry/node": "^7.120.4",
"@sentry/react": "^7.120.4",
"@simplewebauthn/browser": "^13.2.2",
"@simplewebauthn/server": "^13.2.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.13.12",
"@types/form-data": "^2.5.2",
@@ -182,7 +184,7 @@
"node-fetch": "2.7.0",
"nodemailer": "^7.0.11",
"octokit": "^3.2.2",
"outline-icons": "^3.17.0",
"outline-icons": "^3.18.0",
"oy-vey": "^0.12.1",
"pako": "^2.1.0",
"passport": "^0.7.0",
+185
View File
@@ -0,0 +1,185 @@
import { startRegistration } from "@simplewebauthn/browser";
import { observer } from "mobx-react";
import { KeyIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import Button from "~/components/Button";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Heading from "~/components/Heading";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import PlaceholderList from "~/components/List/Placeholder";
import useStores from "~/hooks/useStores";
import { client } from "~/utils/ApiClient";
import PasskeyListItem from "./components/PasskeyListItem";
import RenamePasskeyDialog from "./components/RenamePasskeyDialog";
import { Action } from "~/components/Actions";
import usePolicy from "~/hooks/usePolicy";
import useCurrentTeam from "~/hooks/useCurrentTeam";
import Notice from "~/components/Notice";
import DelayedMount from "~/components/DelayedMount";
type Passkey = {
id: string;
name: string;
aaguid: string | null;
userAgent: string | null;
lastActiveAt: string | null;
createdAt: string;
updatedAt: string;
};
function PasskeysSettings() {
const { t } = useTranslation();
const { dialogs } = useStores();
const [passkeys, setPasskeys] = React.useState<Passkey[]>([]);
const [isLoading, setIsLoading] = React.useState(true);
const [isRegistering, setIsRegistering] = React.useState(false);
const team = useCurrentTeam();
const can = usePolicy(team);
const loadPasskeys = React.useCallback(async () => {
try {
const res = await client.post("/passkeys.list");
setPasskeys(res.data || []);
} catch (_err) {
toast.error(t("Failed to load passkeys"));
} finally {
setIsLoading(false);
}
}, [t]);
React.useEffect(() => {
void loadPasskeys();
}, [loadPasskeys]);
const handleRegister = async () => {
setIsRegistering(true);
try {
const resp = await client.post(
"/passkeys.generateRegistrationOptions",
undefined,
{
baseUrl: "/auth",
}
);
const attResp = await startRegistration(resp.data);
await client.post("/passkeys.verifyRegistration", attResp as any, {
baseUrl: "/auth",
});
toast.success(t("Passkey added successfully"));
await loadPasskeys();
} catch (err) {
toast.error(
err.message || t("Failed to register passkey. Please try again.")
);
} finally {
setIsRegistering(false);
}
};
const handleRename = (passkeyId: string, currentName: string | null) => {
dialogs.openModal({
title: t("Rename passkey"),
content: (
<RenamePasskeyDialog
passkeyId={passkeyId}
currentName={currentName}
onSuccess={async () => {
await loadPasskeys();
dialogs.closeAllModals();
}}
/>
),
});
};
const handleDelete = (passkeyId: string) => {
dialogs.openModal({
title: t("Delete passkey"),
content: (
<ConfirmationDialog
onSubmit={async () => {
try {
await client.post("/passkeys.delete", { id: passkeyId });
toast.success(t("Passkey deleted successfully"));
await loadPasskeys();
} catch (err) {
toast.error(
err.message || t("Failed to delete passkey. Please try again.")
);
}
}}
savingText={`${t("Deleting")}`}
danger
>
<Trans>
Are you sure you want to delete this passkey? You will no longer be
able to use it to sign in.
</Trans>
</ConfirmationDialog>
),
});
};
return (
<Scene
title={t("Passkeys")}
icon={<KeyIcon />}
actions={
<Action>
<Button
onClick={handleRegister}
disabled={isRegistering || !can.createUserPasskey}
icon={<PlusIcon />}
>
{isRegistering ? `${t("Registering")}` : `${t("Add Passkey")}`}
</Button>
</Action>
}
>
<Heading>{t("Passkeys")}</Heading>
<Text as="p" type="secondary">
<Trans>
Passkeys allow you to sign in safely without a password using your
device's biometric authentication (Face ID, Touch ID, Windows Hello)
or security key.
</Trans>
</Text>
{team.passkeysEnabled === false && (
<Notice>
{t("Sign-in with Passkey is currently disabled for this team.")}{" "}
{can.update
? t("Enable for all users in Settings -> Authentication.")
: t("Contact a workspace admin to enable it.")}
</Notice>
)}
{isLoading ? (
<DelayedMount>
<PlaceholderList count={5} />
</DelayedMount>
) : passkeys.length > 0 ? (
<>
{passkeys.map((pk) => (
<PasskeyListItem
key={pk.id}
passkey={pk}
onRename={() => handleRename(pk.id, pk.name)}
onDelete={() => handleDelete(pk.id)}
/>
))}
</>
) : (
<Text as="p" type="secondary">
{t("You don't have any passkeys yet.")}
</Text>
)}
</Scene>
);
}
export default observer(PasskeysSettings);
@@ -0,0 +1,173 @@
import {
faChrome,
faSafari,
faFirefoxBrowser,
faEdge,
faOpera,
faApple,
faAndroid,
faWindows,
faGoogle,
faLinux,
} from "@fortawesome/free-brands-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { KeyIcon } from "outline-icons";
import styled from "styled-components";
import { PasskeyBrand } from "@shared/utils/passkeys";
interface PasskeyIconProps {
passkey: {
name: string;
userAgent: string | null;
aaguid: string | null;
};
size?: number;
}
/**
* Displays an appropriate icon based on the passkey's AAGUID, browser, device, and authenticator type.
*
* @param passkey - passkey object with aaguid, name, and userAgent.
* @param size - optional icon size in pixels.
* @returns icon component representing the passkey type.
*/
function PasskeyIcon({ passkey, size = 24 }: PasskeyIconProps) {
const { aaguid, userAgent } = passkey;
// Detect icon based on AAGUID
const getAaguidIcon = () => {
if (!aaguid) {
return undefined;
}
switch (aaguid as PasskeyBrand) {
case PasskeyBrand.ApplePasswords:
case PasskeyBrand.ICloudKeychainManaged:
case PasskeyBrand.IPasswords:
return faApple;
case PasskeyBrand.WindowsHello1:
case PasskeyBrand.WindowsHello2:
case PasskeyBrand.WindowsHello3:
case PasskeyBrand.MicrosoftPasswordManager:
return faWindows;
case PasskeyBrand.GooglePasswordManager:
return faGoogle;
case PasskeyBrand.ChromeOnMac:
case PasskeyBrand.ChromiumBrowser:
return faChrome;
case PasskeyBrand.EdgeOnMac:
return faEdge;
case PasskeyBrand.SamsungPass:
return faAndroid;
default:
return undefined;
}
};
// Detect browser from user agent
const getBrowserIcon = () => {
if (!userAgent) {
return null;
}
const ua = userAgent.toLowerCase();
// Edge (must check before Chrome)
if (ua.includes("edg/") || ua.includes("edga/") || ua.includes("edgios/")) {
return faEdge;
}
// Opera (must check before Chrome)
if (ua.includes("opr/") || ua.includes("opera")) {
return faOpera;
}
// Chrome
if (ua.includes("chrome") && !ua.includes("edg")) {
return faChrome;
}
// Safari
if (ua.includes("safari") && !ua.includes("chrome")) {
return faSafari;
}
// Firefox
if (ua.includes("firefox")) {
return faFirefoxBrowser;
}
return null;
};
// Detect device from user agent
const getDeviceIcon = () => {
if (!userAgent) {
return null;
}
const ua = userAgent.toLowerCase();
// iPhone or iPad
if (ua.includes("iphone") || ua.includes("ipad")) {
return faApple;
}
// Android
if (ua.includes("android")) {
return faAndroid;
}
// Windows
if (ua.includes("windows")) {
return faWindows;
}
// Linux
if (ua.includes("linux") && !ua.includes("android")) {
return faLinux;
}
return null;
};
// Determine which icon to show
const aaguidIcon = getAaguidIcon();
const browserIcon = getBrowserIcon();
const deviceIcon = getDeviceIcon();
// Prioritize AAGUID icon, then browser icon, then device icon
const faIcon = aaguidIcon || browserIcon || deviceIcon;
if (faIcon) {
return (
<FontAwesomeWrapper size={size}>
<FontAwesomeIcon
icon={faIcon}
style={{
width: size * 0.8,
height: size * 0.8,
}}
/>
</FontAwesomeWrapper>
);
}
return <KeyIcon size={size} />;
}
const FontAwesomeWrapper = styled.span<{ size: number }>`
display: flex;
align-items: center;
justify-content: center;
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
`;
export default PasskeyIcon;
@@ -0,0 +1,105 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import { EditIcon, TrashIcon } from "outline-icons";
import ListItem from "~/components/List/Item";
import Text from "~/components/Text";
import { DropdownMenu } from "~/components/Menu/DropdownMenu";
import { OverflowMenuButton } from "~/components/Menu/OverflowMenuButton";
import { ActionSeparator, createAction } from "~/actions";
import { useMenuAction } from "~/hooks/useMenuAction";
import PasskeyIcon from "./PasskeyIcon";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import useUserLocale from "~/hooks/useUserLocale";
import Time from "~/components/Time";
type Passkey = {
id: string;
name: string;
aaguid: string | null;
userAgent: string | null;
lastActiveAt: string | null;
createdAt: string;
updatedAt: string;
};
type Props = {
/** The passkey to render. */
passkey: Passkey;
/** Callback fired when the rename action is triggered. */
onRename: () => void;
/** Callback fired when the delete action is triggered. */
onDelete: () => void;
};
function PasskeyMenu({ onRename, onDelete }: Props) {
const { t } = useTranslation();
const actions = React.useMemo(
() => [
createAction({
name: `${t("Rename")}`,
icon: <EditIcon />,
section: "Passkey",
perform: onRename,
}),
ActionSeparator,
createAction({
name: `${t("Delete")}`,
icon: <TrashIcon />,
section: "Passkey",
dangerous: true,
perform: onDelete,
}),
],
[t, onRename, onDelete]
);
const rootAction = useMenuAction(actions);
return (
<DropdownMenu action={rootAction} ariaLabel={t("Passkey options")}>
<OverflowMenuButton neutral />
</DropdownMenu>
);
}
/**
* Renders a single passkey list item with icon, metadata, and action menu.
*/
function PasskeyListItem({ passkey, onRename, onDelete }: Props) {
const { t } = useTranslation();
const userLocale = useUserLocale();
const locale = dateLocale(userLocale);
return (
<ListItem
image={<PasskeyIcon passkey={passkey} size={24} />}
title={passkey.name}
subtitle={
passkey.lastActiveAt ? (
<Text type="tertiary">
{t("Last used")} <Time dateTime={passkey.lastActiveAt} addSuffix />
</Text>
) : (
<Text type="tertiary">
{t("Registered {{ timeAgo }}", {
timeAgo: dateToRelative(Date.parse(passkey.createdAt), {
addSuffix: true,
locale,
}),
})}
</Text>
)
}
actions={
<PasskeyMenu
passkey={passkey}
onRename={onRename}
onDelete={onDelete}
/>
}
/>
);
}
export default PasskeyListItem;
@@ -0,0 +1,78 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import Text from "~/components/Text";
import { client } from "~/utils/ApiClient";
interface RenamePasskeyDialogProps {
passkeyId: string;
currentName: string | null;
onSuccess: () => void;
}
/**
* Dialog component for renaming a passkey.
*
* @param passkeyId - the ID of the passkey to rename.
* @param currentName - the current name of the passkey.
* @param onSuccess - callback to be called after successful rename.
* @returns dialog component for renaming passkeys.
*/
function RenamePasskeyDialog({
passkeyId,
currentName,
onSuccess,
}: RenamePasskeyDialogProps) {
const { t } = useTranslation();
const [name, setName] = React.useState(currentName || "");
const [isSaving, setIsSaving] = React.useState(false);
const handleSubmit = async (ev: React.FormEvent) => {
ev.preventDefault();
setIsSaving(true);
try {
await client.post("/passkeys.update", {
id: passkeyId,
name: name.trim(),
});
toast.success(t("Passkey updated"));
onSuccess();
} catch (err) {
toast.error(
err.message || t("Failed to update passkey. Please try again.")
);
} finally {
setIsSaving(false);
}
};
return (
<form onSubmit={handleSubmit}>
<Text as="p" type="secondary">
{t("Give your passkey a memorable name to easily identify it.")}
</Text>
<Input
type="text"
value={name}
onChange={(ev) => setName(ev.target.value)}
placeholder={t("Enter passkey name")}
autoFocus
required
maxLength={255}
disabled={isSaving}
/>
<Flex justify="flex-end" gap={8}>
<Button type="submit" disabled={isSaving || !name.trim()}>
{t("Save")}
</Button>
</Flex>
</form>
);
}
export default observer(RenamePasskeyDialog);
+24
View File
@@ -0,0 +1,24 @@
import { createLazyComponent } from "~/components/LazyLoad";
import { PluginManager, Hook } from "~/utils/PluginManager";
import { KeyIcon } from "outline-icons";
import config from "../plugin.json";
PluginManager.add([
{
...config,
type: Hook.Icon,
value: KeyIcon,
},
{
...config,
type: Hook.Settings,
value: {
group: "Account",
after: "Notifications",
icon: KeyIcon,
description:
"Manage your passkeys for passwordless authentication using biometrics or security keys.",
component: createLazyComponent(() => import("./Settings")),
},
},
]);
+6
View File
@@ -0,0 +1,6 @@
{
"id": "passkeys",
"name": "Passkeys",
"priority": 100,
"description": "Adds Passkeys authentication."
}
+85
View File
@@ -0,0 +1,85 @@
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
import { UserPasskey } from "@server/models";
import type { APIContext } from "@server/types";
import Router from "koa-router";
import * as T from "./schema";
import { authorize } from "@server/policies";
import { transaction } from "@server/middlewares/transaction";
import pagination from "@server/routes/api/middlewares/pagination";
import presentUserPasskey from "../presenters/userPasskey";
const router = new Router();
router.post(
"passkeys.list",
auth(),
pagination(),
validate(T.PasskeysListSchema),
async (ctx: APIContext<T.PasskeysListReq>) => {
const { user } = ctx.state.auth;
const { pagination } = ctx.state;
const passkeys = await UserPasskey.findAll({
where: { userId: user.id },
order: [["createdAt", "DESC"]],
offset: pagination.offset,
limit: pagination.limit,
});
ctx.body = {
pagination,
data: passkeys.map(presentUserPasskey),
};
}
);
router.post(
"passkeys.update",
auth(),
validate(T.PasskeysUpdateSchema),
transaction(),
async (ctx: APIContext<T.PasskeysUpdateReq>) => {
const { id, name } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const passkey = await UserPasskey.findByPk(id, {
rejectOnEmpty: true,
lock: transaction.LOCK.UPDATE,
});
authorize(user, "update", passkey);
await passkey.updateWithCtx(ctx, {
name,
});
ctx.body = { data: presentUserPasskey(passkey) };
}
);
router.post(
"passkeys.delete",
auth(),
validate(T.PasskeysDeleteSchema),
transaction(),
async (ctx: APIContext<T.PasskeysDeleteReq>) => {
const { id } = ctx.input.body;
const { user } = ctx.state.auth;
const { transaction } = ctx.state;
const passkey = await UserPasskey.findByPk(id, {
rejectOnEmpty: true,
lock: transaction.LOCK.UPDATE,
});
authorize(user, "delete", passkey);
await passkey.destroyWithCtx(ctx);
ctx.body = {
success: true,
};
}
);
export default router;
+30
View File
@@ -0,0 +1,30 @@
import { z } from "zod";
import { BaseSchema } from "@server/routes/api/schema";
import { UserPasskeyValidation } from "@shared/validations";
export const PasskeysListSchema = BaseSchema.extend({
body: z.object({}),
});
export type PasskeysListReq = z.infer<typeof PasskeysListSchema>;
export const PasskeysDeleteSchema = BaseSchema.extend({
body: z.object({
id: z.string().uuid(),
}),
});
export type PasskeysDeleteReq = z.infer<typeof PasskeysDeleteSchema>;
export const PasskeysUpdateSchema = BaseSchema.extend({
body: z.object({
id: z.string().uuid(),
name: z
.string()
.trim()
.min(UserPasskeyValidation.minNameLength)
.max(UserPasskeyValidation.maxNameLength),
}),
});
export type PasskeysUpdateReq = z.infer<typeof PasskeysUpdateSchema>;
+268
View File
@@ -0,0 +1,268 @@
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from "@simplewebauthn/server";
import { isoBase64URL } from "@simplewebauthn/server/helpers";
import type { AuthenticatorTransportFuture } from "@simplewebauthn/server";
import Router from "koa-router";
import { randomBytes } from "crypto";
import { User, UserPasskey, Team } from "@server/models";
import auth from "@server/middlewares/authentication";
import validate from "@server/middlewares/validate";
import env from "@server/env";
import { ValidationError } from "@server/errors";
import type { APIContext } from "@server/types";
import Logger from "@server/logging/Logger";
import Redis from "@server/storage/redis";
import { signIn } from "@server/utils/authentication";
import { generatePasskeyName } from "@shared/utils/passkeys";
import * as T from "./schema";
import { Client } from "@shared/types";
import { Minute } from "@shared/utils/time";
import { authorize } from "@server/policies";
const router = new Router();
const rpName = env.APP_NAME;
const CHALLENGE_EXPIRY_MS = Minute.ms * 5;
// Helper to get RP ID (domain) - for simplicity, we can use the hostname but strip port.
const getRpID = (ctx: APIContext) => ctx.request.hostname;
/**
* Generate Redis key for registration challenge.
*
* @param userId - the user ID.
* @returns the Redis key.
*/
const getRegistrationChallengeKey = (userId: string): string =>
`passkey:reg-challenge:${userId}`;
/**
* Generate Redis key for authentication challenge.
*
* @param challengeId - the challenge ID.
* @returns the Redis key.
*/
const getAuthenticationChallengeKey = (challengeId: string): string =>
`passkey:auth-challenge:${challengeId}`;
router.post(
"passkeys.generateRegistrationOptions",
auth(),
async (ctx: APIContext) => {
const { user } = ctx.state.auth;
authorize(user, "createUserPasskey", user.team);
const options = await generateRegistrationOptions({
rpName,
rpID: getRpID(ctx),
userID: isoBase64URL.toBuffer(user.id),
userName: user.email || user.name,
// Don't exclude credentials, so we can detect if one is already registered (optional)
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
authenticatorAttachment: "platform",
},
});
// Save challenge to Redis with user ID as key
await Redis.defaultClient.set(
getRegistrationChallengeKey(user.id),
options.challenge,
"PX",
CHALLENGE_EXPIRY_MS
);
ctx.body = { data: options };
}
);
router.post(
"passkeys.verifyRegistration",
auth(),
validate(T.PasskeysVerifyRegistrationSchema),
async (ctx: APIContext<T.PasskeysVerifyRegistrationReq>) => {
const { user } = ctx.state.auth;
const body = ctx.input.body;
authorize(user, "createUserPasskey", user.team);
// Retrieve challenge from Redis
const expectedChallenge = await Redis.defaultClient.get(
getRegistrationChallengeKey(user.id)
);
if (!expectedChallenge) {
throw ValidationError(
"No registration challenge found or challenge expired"
);
}
let verification;
try {
verification = await verifyRegistrationResponse({
response: body,
expectedChallenge,
expectedOrigin: `${ctx.protocol}://${ctx.request.host}`, // Origin includes port
expectedRPID: getRpID(ctx),
});
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
Logger.error("passkeys: Registration verification failed", err);
throw ValidationError(err.message);
}
const { verified, registrationInfo } = verification;
if (verified && registrationInfo) {
const { credential, aaguid } = registrationInfo;
// credential.id is already a Base64URL string
const credentialIdBase64 = credential.id;
const credentialPublicKey = credential.publicKey;
const counter = credential.counter;
// Capture user agent and generate friendly name
const userAgent = ctx.request.get("user-agent");
const transports = body.response.transports || [];
// Check if already exists
const existing = await UserPasskey.findOne({
where: { credentialId: credentialIdBase64 },
});
if (existing) {
if (existing.userId !== user.id) {
throw ValidationError("Passkey already registered to another user");
}
await existing.updateWithCtx(ctx, {
credentialPublicKey: Buffer.from(credentialPublicKey),
counter,
userAgent,
aaguid,
});
} else {
await UserPasskey.createWithCtx(ctx, {
userId: user.id,
credentialId: credentialIdBase64,
credentialPublicKey: Buffer.from(credentialPublicKey),
counter,
transports,
name: generatePasskeyName(aaguid, userAgent, transports),
userAgent,
aaguid,
});
}
// Delete challenge from Redis
await Redis.defaultClient.del(getRegistrationChallengeKey(user.id));
ctx.body = { data: { verified: true } };
} else {
throw ValidationError("Verification failed");
}
}
);
router.post(
"passkeys.generateAuthenticationOptions",
validate(T.PasskeysGenerateAuthenticationOptionsSchema),
async (ctx: APIContext<T.PasskeysGenerateAuthenticationOptionsReq>) => {
const options = await generateAuthenticationOptions({
rpID: getRpID(ctx),
userVerification: "preferred",
});
// Generate a unique challenge ID for this authentication attempt
const challengeId = randomBytes(32).toString("hex");
await Redis.defaultClient.set(
getAuthenticationChallengeKey(challengeId),
options.challenge,
"PX",
CHALLENGE_EXPIRY_MS
);
ctx.body = { data: { ...options, challengeId } };
}
);
router.post(
"passkeys.verifyAuthentication",
validate(T.PasskeysVerifyAuthenticationSchema),
async (ctx: APIContext<T.PasskeysVerifyAuthenticationReq>) => {
const body = ctx.input.body;
const { challengeId, client = Client.Web } = body;
// Retrieve challenge from Redis
const expectedChallenge = await Redis.defaultClient.get(
getAuthenticationChallengeKey(challengeId)
);
if (!expectedChallenge) {
throw ValidationError(
"No authentication challenge found or challenge expired"
);
}
const credentialId = body.id;
const passkey = await UserPasskey.findOne({
where: { credentialId },
include: [
{
model: User,
as: "user",
include: [{ model: Team, as: "team", required: true }],
},
],
rejectOnEmpty: true,
});
const user = passkey.user;
const team = user.team;
let verification;
try {
verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge,
expectedOrigin: `${ctx.protocol}://${ctx.request.host}`,
expectedRPID: getRpID(ctx),
credential: {
id: passkey.credentialId,
publicKey: new Uint8Array(passkey.credentialPublicKey),
counter: passkey.counter,
transports: passkey.transports as AuthenticatorTransportFuture[],
},
});
} catch (err) {
Logger.error("passkeys: Authentication verification failed", err);
throw ValidationError("Passkey authentication failed. Please try again.");
}
const { verified, authenticationInfo } = verification;
if (verified && authenticationInfo) {
// Update counter
passkey.counter = authenticationInfo.newCounter;
passkey.lastActiveAt = new Date();
await passkey.save({ silent: true });
// Delete challenge from Redis
await Redis.defaultClient.del(getAuthenticationChallengeKey(challengeId));
// Use the signIn utility which handles all sign-in logic
await signIn(ctx, "passkeys", {
user,
team,
isNewUser: false,
isNewTeam: false,
client,
});
} else {
throw ValidationError("Verification failed");
}
}
);
export default router;
+49
View File
@@ -0,0 +1,49 @@
import { z } from "zod";
import { Client } from "@shared/types";
import { BaseSchema } from "@server/routes/api/schema";
export const PasskeysGenerateAuthenticationOptionsSchema = BaseSchema.extend({
body: z.object({}),
query: z.object({
client: z.nativeEnum(Client).optional(),
}),
});
export type PasskeysGenerateAuthenticationOptionsReq = z.infer<
typeof PasskeysGenerateAuthenticationOptionsSchema
>;
export const PasskeysVerifyAuthenticationSchema = BaseSchema.extend({
body: z.object({
challengeId: z.string(),
client: z.nativeEnum(Client).optional(),
id: z.string(),
rawId: z.string(),
response: z.object({
authenticatorData: z.string(),
clientDataJSON: z.string(),
signature: z.string(),
userHandle: z.string().optional(),
}),
type: z.literal("public-key"),
authenticatorAttachment: z.enum(["cross-platform", "platform"]).optional(),
clientExtensionResults: z
.object({
appid: z.boolean().optional(),
hmacCreateSecret: z.boolean().optional(),
})
.default({}),
}),
});
export type PasskeysVerifyAuthenticationReq = z.infer<
typeof PasskeysVerifyAuthenticationSchema
>;
export const PasskeysVerifyRegistrationSchema = BaseSchema.extend({
body: z.any(), // WebAuthn RegistrationResponseJSON from @simplewebauthn/browser
});
export type PasskeysVerifyRegistrationReq = z.infer<
typeof PasskeysVerifyRegistrationSchema
>;
@@ -0,0 +1,91 @@
import env from "@server/env";
import type { EmailProps } from "@server/emails/templates/BaseEmail";
import BaseEmail, {
EmailMessageCategory,
} from "@server/emails/templates/BaseEmail";
import Body from "@server/emails/templates/components/Body";
import Button from "@server/emails/templates/components/Button";
import EmailTemplate from "@server/emails/templates/components/EmailLayout";
import EmptySpace from "@server/emails/templates/components/EmptySpace";
import Footer from "@server/emails/templates/components/Footer";
import Header from "@server/emails/templates/components/Header";
import Heading from "@server/emails/templates/components/Heading";
type InputProps = EmailProps & {
userId: string;
passkeyId: string;
passkeyName: string;
teamUrl: string;
};
type Props = InputProps;
/**
* Email sent to a user when a new passkey is created on their account.
*/
export class PasskeyCreatedEmail extends BaseEmail<InputProps> {
protected get category() {
return EmailMessageCategory.Notification;
}
protected subject() {
return `New passkey added to your ${env.APP_NAME} account`;
}
protected preview() {
return "A new passkey was created for your account.";
}
protected renderAsText({ passkeyName, teamUrl }: Props) {
return `
New Passkey Created
A new passkey has been added to your ${env.APP_NAME} account:
${passkeyName}
Passkeys provide a secure, passwordless way to sign in to your account. If you did not create this passkey, please review your account security settings immediately.
You can manage your passkeys at any time:
${teamUrl}/settings/passkeys
---
If you have any concerns about your account security, please contact a workspace admin.
`;
}
protected render({ passkeyName, teamUrl }: Props) {
const securityUrl = `${teamUrl}/settings/passkeys`;
return (
<EmailTemplate previewText={this.preview()}>
<Header />
<Body>
<Heading>New Passkey Created</Heading>
<p>A new passkey has been added to your {env.APP_NAME} account:</p>
<p>
<strong>{passkeyName}</strong>
</p>
<p>
Passkeys provide a secure, passwordless way to sign in to your
account. If you did not create this passkey, please review your
account security settings immediately.
</p>
<EmptySpace height={10} />
<p>
<Button href={securityUrl}>Manage Passkeys</Button>
</p>
<EmptySpace height={10} />
<p style={{ fontSize: "14px", color: "#666" }}>
If you have any concerns about your account security, please contact
a workspace admin.
</p>
</Body>
<Footer />
</EmailTemplate>
);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { PluginManager, Hook } from "@server/utils/PluginManager";
import config from "../plugin.json";
import router from "./auth/passkeys";
import api from "./api/passkeys";
import { PasskeyCreatedProcessor } from "./processors/PasskeyCreatedProcessor";
import { PasskeyCreatedEmail } from "./email/templates/PasskeyCreatedEmail";
PluginManager.add([
{
...config,
type: Hook.AuthProvider,
value: { router, id: config.id },
},
{
...config,
type: Hook.API,
value: api,
},
{
type: Hook.Processor,
value: PasskeyCreatedProcessor,
},
{
type: Hook.EmailTemplate,
value: PasskeyCreatedEmail,
},
]);
@@ -0,0 +1,13 @@
import type UserPasskey from "@server/models/UserPasskey";
export default function presentUserPasskey(userPasskey: UserPasskey) {
return {
id: userPasskey.id,
createdAt: userPasskey.createdAt,
updatedAt: userPasskey.updatedAt,
lastActiveAt: userPasskey.lastActiveAt,
name: userPasskey.name,
userAgent: userPasskey.userAgent,
aaguid: userPasskey.aaguid,
};
}
@@ -0,0 +1,28 @@
import { UserPasskey, User } from "@server/models";
import type { Event, UserPasskeyEvent } from "@server/types";
import BaseProcessor from "@server/queues/processors/BaseProcessor";
import { PasskeyCreatedEmail } from "../email/templates/PasskeyCreatedEmail";
export class PasskeyCreatedProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = ["passkeys.create"];
async perform(event: UserPasskeyEvent) {
const userPasskey = await UserPasskey.findByPk(event.modelId);
if (!userPasskey) {
return;
}
const user = await User.scope("withTeam").findByPk(event.userId);
if (!user) {
return;
}
await new PasskeyCreatedEmail({
to: user.email,
userId: user.id,
passkeyId: userPasskey.id,
passkeyName: userPasskey.name,
teamUrl: user.team.url,
}).schedule();
}
}
@@ -246,6 +246,11 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
case "oauthClients.delete":
// Ignored
return;
case "passkeys.create":
case "passkeys.update":
case "passkeys.delete":
// Ignored
return;
default:
assertUnreachable(event);
}
@@ -95,7 +95,7 @@ View Document: ${teamUrl}${collection.path}
<Heading>{collection.name}</Heading>
<p>
{actorName} invited you to {permission} documents in the{" "}
<a href={collectionUrl}>{collection.name}</a>{" "}collection.
<a href={collectionUrl}>{collection.name}</a> collection.
</p>
<p>
<Button href={collectionUrl}>View Collection</Button>
@@ -94,7 +94,7 @@ View Document: ${teamUrl}${document.path}
<Heading>{document.titleWithDefault}</Heading>
<p>
{actorName} invited you to {permission} the{" "}
<a href={documentUrl}>{document.titleWithDefault}</a>{" "}document.
<a href={documentUrl}>{document.titleWithDefault}</a> document.
</p>
<p>
<Button href={documentUrl}>View Document</Button>
+1 -1
View File
@@ -2,7 +2,7 @@ import { Hook, PluginManager } from "@server/utils/PluginManager";
import { requireDirectory } from "@server/utils/fs";
import type BaseEmail from "./BaseEmail";
const emails: Record<string, typeof BaseEmail> = {};
const emails: Record<string, typeof BaseEmail<any>> = {};
requireDirectory<{ default: typeof BaseEmail }>(__dirname).forEach(
([module, id]) => {
@@ -0,0 +1,16 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("teams", "passkeysEnabled", {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
});
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn("teams", "passkeysEnabled");
},
};
@@ -0,0 +1,81 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
return queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.createTable(
"user_passkeys",
{
id: {
type: Sequelize.UUID,
allowNull: false,
primaryKey: true,
},
name: {
type: Sequelize.TEXT,
allowNull: false,
},
userAgent: {
type: Sequelize.TEXT,
allowNull: true,
},
credentialId: {
type: Sequelize.TEXT,
allowNull: false,
unique: true,
},
credentialPublicKey: {
type: Sequelize.BLOB,
allowNull: false,
},
aaguid: {
type: Sequelize.TEXT,
allowNull: true,
},
counter: {
type: Sequelize.BIGINT,
allowNull: false,
defaultValue: 0,
},
transports: {
type: Sequelize.ARRAY(Sequelize.STRING),
allowNull: true,
},
lastActiveAt: {
type: Sequelize.DATE,
allowNull: true,
},
userId: {
type: Sequelize.UUID,
allowNull: false,
references: {
model: "users",
key: "id",
},
onDelete: "CASCADE",
},
createdAt: {
type: Sequelize.DATE,
allowNull: false,
},
updatedAt: {
type: Sequelize.DATE,
allowNull: false,
},
},
{ transaction }
);
await queryInterface.addIndex("user_passkeys", ["userId"], {
transaction,
});
});
},
async down(queryInterface) {
return queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.dropTable("user_passkeys", { transaction });
});
},
};
+4
View File
@@ -153,6 +153,10 @@ class Team extends ParanoidModel<
@Column
guestSignin: boolean;
@Default(true)
@Column
passkeysEnabled: boolean;
@Default(true)
@Column
documentEmbeds: boolean;
+4
View File
@@ -61,6 +61,7 @@ import Group from "./Group";
import Team from "./Team";
import UserAuthentication from "./UserAuthentication";
import UserMembership from "./UserMembership";
import UserPasskey from "./UserPasskey";
import ParanoidModel from "./base/ParanoidModel";
import Encrypted from "./decorators/Encrypted";
import Fix from "./decorators/Fix";
@@ -249,6 +250,9 @@ class User extends ParanoidModel<
@HasMany(() => UserAuthentication)
authentications: UserAuthentication[];
@HasMany(() => UserPasskey)
passkeys: UserPasskey[];
// getters
get isSuspended(): boolean {
+70
View File
@@ -0,0 +1,70 @@
import type { InferAttributes, InferCreationAttributes } from "sequelize";
import {
BelongsTo,
Column,
DataType,
ForeignKey,
IsDate,
Length,
Table,
} from "sequelize-typescript";
import User from "./User";
import IdModel from "./base/IdModel";
import Fix from "./decorators/Fix";
import { UserPasskeyValidation } from "@shared/validations";
import NotContainsUrl from "./validators/NotContainsUrl";
import { SkipChangeset } from "./decorators/Changeset";
@Table({ tableName: "user_passkeys", modelName: "user_passkey" })
@Fix
class UserPasskey extends IdModel<
InferAttributes<UserPasskey>,
Partial<InferCreationAttributes<UserPasskey>>
> {
static eventNamespace = "passkeys";
@Column
credentialId: string;
@Column(DataType.BLOB)
@SkipChangeset
credentialPublicKey: Buffer;
@Column
aaguid: string | null;
@Column(DataType.BIGINT)
@SkipChangeset
counter: number;
@Column(DataType.ARRAY(DataType.STRING))
transports: string[];
@Length({
min: UserPasskeyValidation.minNameLength,
max: UserPasskeyValidation.maxNameLength,
msg: `Name must be between ${UserPasskeyValidation.minNameLength} and ${UserPasskeyValidation.maxNameLength} characters`,
})
@NotContainsUrl
@Column
name: string;
@Column
userAgent: string | null;
@IsDate
@Column
@SkipChangeset
lastActiveAt: Date | null;
// associations
@BelongsTo(() => User, "userId")
user: User;
@ForeignKey(() => User)
@Column(DataType.UUID)
userId: string;
}
export default UserPasskey;
+10 -2
View File
@@ -26,7 +26,9 @@ export default class AuthenticationHelper {
const isCloudHosted = env.isCloudHosted;
return AuthenticationHelper.providers
.sort((hook) => (hook.value.id === "email" ? 1 : -1))
.sort((hook) =>
hook.value.id === "email" || hook.value.id === "passkeys" ? 1 : -1
)
.filter((hook) => {
// Email sign-in is an exception as it does not have an authentication
// provider using passport, instead it exists as a boolean option.
@@ -34,7 +36,13 @@ export default class AuthenticationHelper {
return team?.emailSigninEnabled;
}
// If no team return all possible authentication providers except email.
// Passkeys is an exception as it does not have an authentication
// provider using passport, instead it exists as a boolean option.
if (hook.value.id === "passkeys") {
return team?.passkeysEnabled;
}
// If no team return all possible authentication providers except email and passkeys.
if (!team) {
return true;
}
+1
View File
@@ -69,3 +69,4 @@ export { default as WebhookDelivery } from "./WebhookDelivery";
export { default as Subscription } from "./Subscription";
export { default as Emoji } from "./Emoji";
export { default as UserPasskey } from "./UserPasskey";
+1
View File
@@ -26,4 +26,5 @@ import "./team";
import "./group";
import "./webhookSubscription";
import "./userMembership";
import "./userPasskey";
import "./emoji";
+14
View File
@@ -0,0 +1,14 @@
import { UserPasskey, User, Team } from "@server/models";
import { allow } from "./cancan";
import { and, isTeamModel, isTeamMutable } from "./utils";
allow(User, "createUserPasskey", Team, (actor, team) =>
and(isTeamModel(actor, team), isTeamMutable(actor), !!team?.passkeysEnabled)
);
allow(
User,
["read", "update", "delete"],
UserPasskey,
(actor, passkey) => passkey?.userId === actor.id
);
+1
View File
@@ -12,6 +12,7 @@ export default function presentTeam(team: Team) {
defaultCollectionId: team.defaultCollectionId,
documentEmbeds: team.documentEmbeds,
guestSignin: team.emailSigninEnabled,
passkeysEnabled: team.passkeysEnabled,
subdomain: team.subdomain,
domain: team.domain,
url: team.url,
@@ -5,13 +5,7 @@ import {
} from "@shared/types";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { createContext } from "@server/context";
import {
Comment,
Document,
Group,
Notification,
User,
} from "@server/models";
import { Comment, Document, Group, Notification, User } from "@server/models";
import NotificationHelper from "@server/models/helpers/NotificationHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { sequelize } from "@server/storage/database";
@@ -1,13 +1,7 @@
import invariant from "invariant";
import { Op } from "sequelize";
import { MentionType, NotificationEventType } from "@shared/types";
import {
Comment,
Document,
Group,
Notification,
User,
} from "@server/models";
import { Comment, Document, Group, Notification, User } from "@server/models";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import type { CommentEvent, CommentUpdateEvent } from "@server/types";
import { canUserAccessDocument } from "@server/utils/permissions";
+6 -1
View File
@@ -1,10 +1,15 @@
import { z } from "zod";
import { BaseSchema } from "@server/routes/api/schema";
import { ApiKeyValidation } from "@shared/validations";
export const APIKeysCreateSchema = BaseSchema.extend({
body: z.object({
/** API Key name */
name: z.string(),
name: z
.string()
.trim()
.min(ApiKeyValidation.minNameLength)
.max(ApiKeyValidation.maxNameLength),
/** API Key expiry date */
expiresAt: z.coerce.date().optional(),
/** A list of scopes that this API key has access to */
@@ -105,7 +105,7 @@ router.post(
)) as AuthenticationProvider[];
const data = AuthenticationHelper.providers
.filter((p) => p.value.id !== "email")
.filter((p) => p.value.id !== "email" && p.value.id !== "passkeys")
.map((p) => {
const row = teamAuthenticationProviders.find(
(t) => t.name === p.value.id
+3 -1
View File
@@ -14,8 +14,10 @@ export const TeamsUpdateSchema = BaseSchema.extend({
subdomain: z.string().nullish(),
/** Whether public sharing is enabled */
sharing: z.boolean().optional(),
/** Whether siginin with email is enabled */
/** Whether signin with email is enabled */
guestSignin: z.boolean().optional(),
/** Whether signin with passkeys is enabled */
passkeysEnabled: z.boolean().optional(),
/** Whether third-party document embeds are enabled */
documentEmbeds: z.boolean().optional(),
/** Whether team members are able to create new collections */
+1
View File
@@ -153,6 +153,7 @@ export function buildTeam(
return Team.create(
{
name: faker.company.name(),
passkeysEnabled: false,
authenticationProviders: [
{
name: "slack",
+8
View File
@@ -23,6 +23,7 @@ import type {
Team,
User,
UserMembership,
UserPasskey,
WebhookSubscription,
Pin,
Star,
@@ -440,6 +441,12 @@ export type OAuthClientEvent = BaseEvent<OAuthClient> & {
modelId: string;
};
export type UserPasskeyEvent = BaseEvent<UserPasskey> & {
name: "passkeys.create" | "passkeys.update" | "passkeys.delete";
modelId: string;
userId: string;
};
// oxlint-disable-next-line @typescript-eslint/no-explicit-any
export type ImportEvent = BaseEvent<Import<any>> & {
name:
@@ -477,6 +484,7 @@ export type Event =
| WebhookSubscriptionEvent
| NotificationEvent
| OAuthClientEvent
| UserPasskeyEvent
| EmptyTrashEvent
| ImportEvent;
+1 -1
View File
@@ -40,7 +40,7 @@ export enum Hook {
type PluginValueMap = {
[Hook.API]: Router;
[Hook.AuthProvider]: { router: Router | Promise<Router>; id: string };
[Hook.EmailTemplate]: typeof BaseEmail;
[Hook.EmailTemplate]: typeof BaseEmail<any>;
[Hook.IssueProvider]: BaseIssueProvider;
[Hook.Processor]: typeof BaseProcessor;
[Hook.Task]: typeof BaseTask<any>;
@@ -897,6 +897,7 @@
"Triggers": "Triggers",
"Mention users and more": "Mention users and more",
"Insert block": "Insert block",
"Continue with Passkey": "Continue with Passkey",
"Sign In": "Sign In",
"Continue with Email": "Continue with Email",
"Continue with {{ authProviderName }}": "Continue with {{ authProviderName }}",
@@ -1028,6 +1029,8 @@
"Connect": "Connect",
"Allow members to sign-in using their email address": "Allow members to sign-in using their email address",
"The server must have SMTP configured to enable this setting": "The server must have SMTP configured to enable this setting",
"Passkeys": "Passkeys",
"Allow members to sign-in with a WebAuthn passkey": "Allow members to sign-in with a WebAuthn passkey",
"Restrictions": "Restrictions",
"by {{ name }}": "by {{ name }}",
"Expired": "Expired",
@@ -1237,6 +1240,7 @@
"Require members to be invited to the workspace before they can create an account using SSO.": "Require members to be invited to the workspace before they can create an account using SSO.",
"Default role": "Default role",
"The default user role for new accounts. Changing this setting does not affect existing user accounts.": "The default user role for new accounts. Changing this setting does not affect existing user accounts.",
"Allow users to sign in with passkeys for passwordless authentication": "Allow users to sign in with passkeys for passwordless authentication",
"When enabled, documents can be shared publicly on the internet by any member of the workspace": "When enabled, documents can be shared publicly on the internet by any member of the workspace",
"Viewer document exports": "Viewer document exports",
"When enabled, viewers can see download options for documents": "When enabled, viewers can see download options for documents",
@@ -1375,6 +1379,27 @@
"An ID that uniquely identifies the website in your Matomo instance.": "An ID that uniquely identifies the website in your Matomo instance.",
"Whoops, you need to accept the permissions in Notion to connect {{ appName }} to your workspace. Try again?": "Whoops, you need to accept the permissions in Notion to connect {{ appName }} to your workspace. Try again?",
"Import pages from Notion": "Import pages from Notion",
"Passkey options": "Passkey options",
"Registered {{ timeAgo }}": "Registered {{ timeAgo }}",
"Passkey updated": "Passkey updated",
"Failed to update passkey. Please try again.": "Failed to update passkey. Please try again.",
"Give your passkey a memorable name to easily identify it.": "Give your passkey a memorable name to easily identify it.",
"Enter passkey name": "Enter passkey name",
"Failed to load passkeys": "Failed to load passkeys",
"Passkey added successfully": "Passkey added successfully",
"Failed to register passkey. Please try again.": "Failed to register passkey. Please try again.",
"Rename passkey": "Rename passkey",
"Delete passkey": "Delete passkey",
"Passkey deleted successfully": "Passkey deleted successfully",
"Failed to delete passkey. Please try again.": "Failed to delete passkey. Please try again.",
"Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.": "Are you sure you want to delete this passkey? You will no longer be able to use it to sign in.",
"Registering": "Registering",
"Add Passkey": "Add Passkey",
"Passkeys allow you to sign in safely without a password using your device's biometric authentication (Face ID, Touch ID, Windows Hello) or security key.": "Passkeys allow you to sign in safely without a password using your device's biometric authentication (Face ID, Touch ID, Windows Hello) or security key.",
"Sign-in with Passkey is currently disabled for this team.": "Sign-in with Passkey is currently disabled for this team.",
"Enable for all users in Settings -> Authentication.": "Enable for all users in Settings -> Authentication.",
"Contact a workspace admin to enable it.": "Contact a workspace admin to enable it.",
"You don't have any passkeys yet.": "You don't have any passkeys yet.",
"Add to Slack": "Add to Slack",
"document published": "document published",
"document updated": "document updated",
+202
View File
@@ -0,0 +1,202 @@
import { generatePasskeyName } from "./passkeys";
describe("generatePasskeyName", () => {
describe("AAGUID-based brand detection", () => {
it("detects 1Password", () => {
const aaguid = "bada5566-a7aa-401f-bd96-45619a55120d";
const name = generatePasskeyName(aaguid);
expect(name).toBe("1Password");
});
it("detects Google Password Manager", () => {
const aaguid = "ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4";
const name = generatePasskeyName(aaguid);
expect(name).toBe("Google Password Manager");
});
it("detects Windows Hello", () => {
const aaguid = "08987058-cadc-4b81-b6e1-30de50dcbe96";
const name = generatePasskeyName(aaguid);
expect(name).toBe("Windows Hello");
});
it("detects iCloud Keychain (Managed)", () => {
const aaguid = "dd4ec289-e01d-41c9-bb89-70fa845d4bf2";
const name = generatePasskeyName(aaguid);
expect(name).toBe("iCloud Keychain (Managed)");
});
it("detects Bitwarden", () => {
const aaguid = "d548826e-79b4-db40-a3d8-11116f7e8349";
const name = generatePasskeyName(aaguid);
expect(name).toBe("Bitwarden");
});
it("prefers AAGUID over user agent", () => {
const aaguid = "bada5566-a7aa-401f-bd96-45619a55120d";
const ua =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const name = generatePasskeyName(aaguid, ua, ["internal"]);
expect(name).toBe("1Password");
expect(name).not.toContain("Chrome");
});
it("falls back to user agent when AAGUID is unknown", () => {
const aaguid = "00000000-0000-0000-0000-000000000000";
const ua =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const name = generatePasskeyName(aaguid, ua, ["internal"]);
expect(name).toBe("Chrome on macOS");
});
});
describe("browser detection", () => {
it("detects Chrome on macOS", () => {
const ua =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const name = generatePasskeyName(undefined, ua, ["internal"]);
expect(name).toBe("Chrome on macOS");
});
it("detects Safari on macOS", () => {
const ua =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15";
const name = generatePasskeyName(undefined, ua, ["internal"]);
expect(name).toBe("Safari on macOS");
});
it("detects Firefox on Windows", () => {
const ua =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0";
const name = generatePasskeyName(undefined, ua, ["internal"]);
expect(name).toBe("Firefox on Windows");
});
it("detects Edge on Windows", () => {
const ua =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0";
const name = generatePasskeyName(undefined, ua, ["internal"]);
expect(name).toBe("Edge on Windows");
});
it("detects Opera on Linux", () => {
const ua =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0";
const name = generatePasskeyName(undefined, ua, ["internal"]);
expect(name).toBe("Opera on Linux");
});
});
describe("device detection", () => {
it("detects iPhone", () => {
const ua =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
const name = generatePasskeyName(undefined, ua, ["internal"]);
expect(name).toBe("iPhone");
});
it("detects iPad", () => {
const ua =
"Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
const name = generatePasskeyName(undefined, ua, ["internal"]);
expect(name).toBe("iPad");
});
it("detects Android Phone", () => {
const ua =
"Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36";
const name = generatePasskeyName(undefined, ua, ["internal"]);
expect(name).toBe("Android Phone");
});
it("detects Android Tablet", () => {
const ua =
"Mozilla/5.0 (Linux; Android 13; Pixel Tablet) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const name = generatePasskeyName(undefined, ua, ["internal"]);
expect(name).toBe("Android Tablet");
});
});
describe("OS detection", () => {
it("detects Chrome OS", () => {
const ua =
"Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const name = generatePasskeyName(undefined, ua, ["internal"]);
expect(name).toBe("Chrome on Chrome OS");
});
it("detects Linux", () => {
const ua =
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0";
const name = generatePasskeyName(undefined, ua, ["internal"]);
expect(name).toBe("Firefox on Linux");
});
});
describe("authenticator type detection", () => {
it("detects security key (USB)", () => {
const ua =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const name = generatePasskeyName(undefined, ua, ["usb"]);
expect(name).toContain("Security Key");
});
it("detects security key (NFC)", () => {
const ua =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const name = generatePasskeyName(undefined, ua, ["nfc"]);
expect(name).toContain("Security Key");
});
it("detects security key (BLE)", () => {
const ua =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const name = generatePasskeyName(undefined, ua, ["ble"]);
expect(name).toContain("Security Key");
});
it("detects hybrid authenticator (phone)", () => {
const ua =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const name = generatePasskeyName(undefined, ua, ["hybrid"]);
expect(name).toContain("Phone");
});
});
describe("edge cases", () => {
it("handles missing user agent", () => {
const name = generatePasskeyName(undefined, undefined, ["internal"]);
expect(name).toBe("Passkey");
});
it("handles empty user agent", () => {
const name = generatePasskeyName(undefined, "", ["usb"]);
expect(name).toBe("Security Key");
});
it("handles unknown user agent", () => {
const name = generatePasskeyName(undefined, "Unknown/1.0");
expect(name).toBe("Passkey");
});
it("handles missing transports", () => {
const ua =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
const name = generatePasskeyName(undefined, ua, []);
expect(name).toBe("Chrome on macOS");
});
it("handles no parameters", () => {
const name = generatePasskeyName();
expect(name).toBe("Passkey");
});
it("prioritizes device name over browser/OS", () => {
const ua =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
const name = generatePasskeyName(undefined, ua, ["internal"]);
expect(name).toBe("iPhone");
expect(name).not.toContain("Safari");
});
});
});
+244
View File
@@ -0,0 +1,244 @@
/**
* Utility functions for working with passkeys and WebAuthn.
*/
interface ParsedUserAgent {
browser?: string;
os?: string;
device?: string;
}
export enum PasskeyBrand {
AliasVault = "a11a5faa-9f32-4b8c-8c5d-2f7d13e8c942",
GooglePasswordManager = "ea9b8d66-4d01-1d21-3ce4-b6b48cb575d4",
ChromeOnMac = "adce0002-35bc-c60a-648b-0b25f1f05503",
WindowsHello1 = "08987058-cadc-4b81-b6e1-30de50dcbe96",
WindowsHello2 = "9ddd1817-af5a-4672-a2b9-3e3dd95000a9",
WindowsHello3 = "6028b017-b1d4-4c02-b4b3-afcdafc96bb2",
ICloudKeychainManaged = "dd4ec289-e01d-41c9-bb89-70fa845d4bf2",
Dashlane = "531126d6-e717-415c-9320-3d9aa6981239",
OnePassword = "bada5566-a7aa-401f-bd96-45619a55120d",
NordPass = "b84e4048-15dc-4dd0-8640-f4f60813c8af",
Keeper = "0ea242b4-43c4-4a1b-8b17-dd6d0b6baec6",
Sesame = "891494da-2c90-4d31-a9cd-4eab0aed1309",
Enpass = "f3809540-7f14-49c1-a8b3-8f813b225541",
ChromiumBrowser = "b5397666-4885-aa6b-cebf-e52262a439a2",
EdgeOnMac = "771b48fd-d3d4-4f74-9232-fc157ab0507a",
IDmelon = "39a5647e-1853-446c-a1f6-a79bae9f5bc7",
Bitwarden = "d548826e-79b4-db40-a3d8-11116f7e8349",
ApplePasswords = "fbfc3007-154e-4ecc-8c0b-6e020557d7bd",
SamsungPass = "53414d53-554e-4700-0000-000000000000",
ThalesBioIOS = "66a0ccb3-bd6a-191f-ee06-e375c50b9846",
ThalesBioAndroid = "8836336a-f590-0921-301d-46427531eee6",
ThalesPINAndroid = "cd69adb5-3c7a-deb9-3177-6800ea6cb72a",
ThalesPINIOS = "17290f1e-c212-34d0-1423-365d729f09d9",
ProtonPass = "50726f74-6f6e-5061-7373-50726f746f6e",
KeePassXC = "fdb141b2-5d84-443e-8a35-4698c205a502",
KeePassDX = "eaecdef2-1c31-5634-8639-f1cbd9c00a08",
ToothPicPasskeyProvider = "cc45f64e-52a2-451b-831a-4edd8022a202",
IPasswords = "bfc748bb-3429-4faa-b9f9-7cfa9f3b76d0",
ZohoVault = "b35a26b2-8f6e-4697-ab1d-d44db4da28c6",
LastPass = "b78a0a55-6ef8-d246-a042-ba0f6d55050c",
Devolutions = "de503f9c-21a4-4f76-b4b7-558eb55c6f89",
LogMeOnce = "22248c4c-7a12-46e2-9a41-44291b373a4d",
KasperskyPasswordManager = "a10c6dd9-465e-4226-8198-c7c44b91c555",
PwSafe = "d350af52-0351-4ba2-acd3-dfeeadc3f764",
MicrosoftPasswordManager = "d3452668-01fd-4c12-926c-83a4204853aa",
Initial = "6d212b28-a2c1-4638-b375-5932070f62e9",
HeimlaneVault = "d49b2120-b865-4191-8cea-be84a52b0485",
}
export const PasskeyBrandNames: Record<PasskeyBrand, string> = {
[PasskeyBrand.AliasVault]: "AliasVault",
[PasskeyBrand.GooglePasswordManager]: "Google Password Manager",
[PasskeyBrand.ChromeOnMac]: "Chrome on Mac",
[PasskeyBrand.WindowsHello1]: "Windows Hello",
[PasskeyBrand.WindowsHello2]: "Windows Hello",
[PasskeyBrand.WindowsHello3]: "Windows Hello",
[PasskeyBrand.ICloudKeychainManaged]: "iCloud Keychain (Managed)",
[PasskeyBrand.Dashlane]: "Dashlane",
[PasskeyBrand.OnePassword]: "1Password",
[PasskeyBrand.NordPass]: "NordPass",
[PasskeyBrand.Keeper]: "Keeper",
[PasskeyBrand.Sesame]: "Sésame",
[PasskeyBrand.Enpass]: "Enpass",
[PasskeyBrand.ChromiumBrowser]: "Chromium Browser",
[PasskeyBrand.EdgeOnMac]: "Edge on Mac",
[PasskeyBrand.IDmelon]: "IDmelon",
[PasskeyBrand.Bitwarden]: "Bitwarden",
[PasskeyBrand.ApplePasswords]: "Apple Passwords",
[PasskeyBrand.SamsungPass]: "Samsung Pass",
[PasskeyBrand.ThalesBioIOS]: "Thales Bio iOS SDK",
[PasskeyBrand.ThalesBioAndroid]: "Thales Bio Android SDK",
[PasskeyBrand.ThalesPINAndroid]: "Thales PIN Android SDK",
[PasskeyBrand.ThalesPINIOS]: "Thales PIN iOS SDK",
[PasskeyBrand.ProtonPass]: "Proton Pass",
[PasskeyBrand.KeePassXC]: "KeePassXC",
[PasskeyBrand.KeePassDX]: "KeePassDX",
[PasskeyBrand.ToothPicPasskeyProvider]: "ToothPic Passkey Provider",
[PasskeyBrand.IPasswords]: "iPasswords",
[PasskeyBrand.ZohoVault]: "Zoho Vault",
[PasskeyBrand.LastPass]: "LastPass",
[PasskeyBrand.Devolutions]: "Devolutions",
[PasskeyBrand.LogMeOnce]: "LogMeOnce",
[PasskeyBrand.KasperskyPasswordManager]: "Kaspersky Password Manager",
[PasskeyBrand.PwSafe]: "pwSafe",
[PasskeyBrand.MicrosoftPasswordManager]: "Microsoft Password Manager",
[PasskeyBrand.Initial]: "initial",
[PasskeyBrand.HeimlaneVault]: "Heimlane Vault",
};
/**
* Parses a user agent string to extract browser, OS, and device information.
*
* @param userAgent the user agent string to parse.
* @returns parsed information about the browser, OS, and device.
*/
function parseUserAgent(userAgent: string): ParsedUserAgent {
if (!userAgent) {
return {};
}
const ua = userAgent.toLowerCase();
const result: ParsedUserAgent = {};
// Detect browser
if (ua.includes("edg/") || ua.includes("edga/") || ua.includes("edgios/")) {
result.browser = "Edge";
} else if (ua.includes("opr/") || ua.includes("opera")) {
result.browser = "Opera";
} else if (ua.includes("chrome") && !ua.includes("edg")) {
result.browser = "Chrome";
} else if (ua.includes("safari") && !ua.includes("chrome")) {
result.browser = "Safari";
} else if (ua.includes("firefox")) {
result.browser = "Firefox";
}
// Detect OS (check iPhone/iPad before macOS since they contain "Mac OS X")
if (ua.includes("windows")) {
result.os = "Windows";
} else if (ua.includes("iphone")) {
result.os = "iOS";
result.device = "iPhone";
} else if (ua.includes("ipad")) {
result.os = "iOS";
result.device = "iPad";
} else if (ua.includes("mac os x") || ua.includes("macos")) {
result.os = "macOS";
} else if (ua.includes("android")) {
result.os = "Android";
if (ua.includes("mobile")) {
result.device = "Android Phone";
} else {
result.device = "Android Tablet";
}
} else if (ua.includes("linux")) {
result.os = "Linux";
} else if (ua.includes("cros")) {
result.os = "Chrome OS";
}
return result;
}
/**
* Determines the type of authenticator based on transports.
*
* @param transports array of authenticator transports.
* @returns a human-readable authenticator type or undefined.
*/
function getAuthenticatorType(transports?: string[]): string | undefined {
if (!transports || transports.length === 0) {
return undefined;
}
// Platform authenticators typically use "internal"
if (transports.includes("internal")) {
return undefined;
}
// Security keys typically use USB, NFC, or BLE
if (
transports.includes("usb") ||
transports.includes("nfc") ||
transports.includes("ble")
) {
return "Security Key";
}
// Hybrid authenticators use hybrid transport (phone as authenticator)
if (transports.includes("hybrid")) {
return "Phone";
}
return undefined;
}
/**
* Generates a default passkey name when user agent parsing fails.
*
* @param transports optional array of authenticator transports.
* @returns a default passkey name.
*/
function generateDefaultName(transports?: string[]): string {
const authenticatorType = getAuthenticatorType(transports);
if (authenticatorType) {
return authenticatorType;
}
return "Passkey";
}
/**
* Generates a friendly name for a passkey based on AAGUID, user agent, and transport information.
*
* @param aaguid the authenticator AAGUID from the registration response.
* @param userAgent the user agent string from the registration request.
* @param transports optional array of authenticator transports.
* @returns a human-readable name for the passkey.
*/
export function generatePasskeyName(
aaguid?: string | PasskeyBrand,
userAgent?: string,
transports?: string[]
): string {
// Prefer known brand from AAGUID over user agent sniffing
if (aaguid && PasskeyBrandNames[aaguid as PasskeyBrand]) {
return PasskeyBrandNames[aaguid as PasskeyBrand];
}
// Fall back to user agent parsing if AAGUID is not recognized
if (!userAgent) {
return generateDefaultName(transports);
}
const parsed = parseUserAgent(userAgent);
const parts: string[] = [];
// Prioritize device name if available (e.g., "iPhone", "iPad")
if (parsed.device) {
parts.push(parsed.device);
} else {
// Otherwise use browser and OS
if (parsed.browser) {
parts.push(parsed.browser);
}
if (parsed.os) {
parts.push(`on ${parsed.os}`);
}
}
// Add authenticator type hint if available
const authenticatorType = getAuthenticatorType(transports);
if (authenticatorType) {
parts.push(`(${authenticatorType})`);
}
if (parts.length === 0) {
return generateDefaultName(transports);
}
return parts.join(" ");
}
+5
View File
@@ -104,6 +104,11 @@ export const RevisionValidation = {
maxNameLength: 255,
};
export const UserPasskeyValidation = {
minNameLength: 1,
maxNameLength: 255,
};
export const PinValidation = {
/** The maximum number of pinned documents on an individual collection or home screen */
max: 8,
+244 -6
View File
@@ -3549,6 +3549,13 @@ __metadata:
languageName: node
linkType: hard
"@hexagon/base64@npm:^1.1.27":
version: 1.1.28
resolution: "@hexagon/base64@npm:1.1.28"
checksum: 10c0/f90876938cda7c369444f9abcf268a9d93fe26269ae9a16a95fa1f294a5e8f9d172a77905fac205d315b4538ab4da90c866ba73cc035cc0fcf5a6062bb6691ca
languageName: node
linkType: hard
"@hocuspocus/common@npm:^1.1.2":
version: 1.1.3
resolution: "@hocuspocus/common@npm:1.1.3"
@@ -4368,6 +4375,13 @@ __metadata:
languageName: node
linkType: hard
"@levischuck/tiny-cbor@npm:^0.2.2":
version: 0.2.11
resolution: "@levischuck/tiny-cbor@npm:0.2.11"
checksum: 10c0/b34b4b2df5d601f0b260f2cae012168439e6128963959a716ea264586d621d4c411a120219a6abd8c96482de6f11cb34e77365fb6d9f61d99a89c1db5f43ddf3
languageName: node
linkType: hard
"@lifeomic/attempt@npm:^3.0.2":
version: 3.1.0
resolution: "@lifeomic/attempt@npm:3.1.0"
@@ -5176,6 +5190,162 @@ __metadata:
languageName: node
linkType: hard
"@peculiar/asn1-android@npm:^2.3.10":
version: 2.5.0
resolution: "@peculiar/asn1-android@npm:2.5.0"
dependencies:
"@peculiar/asn1-schema": "npm:^2.5.0"
asn1js: "npm:^3.0.6"
tslib: "npm:^2.8.1"
checksum: 10c0/06c5ac26542886a5254ebb58c6c0f31e1fe355881a32c8e724adb46b16dc7312dcaba9d430e31df48239a3d92e176ae2673872077d5fb5cf17cd0e4901b7e22a
languageName: node
linkType: hard
"@peculiar/asn1-cms@npm:^2.5.0":
version: 2.5.0
resolution: "@peculiar/asn1-cms@npm:2.5.0"
dependencies:
"@peculiar/asn1-schema": "npm:^2.5.0"
"@peculiar/asn1-x509": "npm:^2.5.0"
"@peculiar/asn1-x509-attr": "npm:^2.5.0"
asn1js: "npm:^3.0.6"
tslib: "npm:^2.8.1"
checksum: 10c0/4e7b7d9ab67f628d8d9ea49001c1a377c44e50e5c75d8472a49a2e757f34de629b9ae00ee5b1ab974f4c401f6a827d1787ac7eb63e1a3d2ec18d7015ba551739
languageName: node
linkType: hard
"@peculiar/asn1-csr@npm:^2.5.0":
version: 2.5.0
resolution: "@peculiar/asn1-csr@npm:2.5.0"
dependencies:
"@peculiar/asn1-schema": "npm:^2.5.0"
"@peculiar/asn1-x509": "npm:^2.5.0"
asn1js: "npm:^3.0.6"
tslib: "npm:^2.8.1"
checksum: 10c0/30d19363f34e073bca034db2106fa3f9c6981bc3f1da7b47dcebc463e491a18459efe0e7b771eed82c64d97636c70a94cb16112492b88346d2d9946f4ebf274e
languageName: node
linkType: hard
"@peculiar/asn1-ecc@npm:^2.3.8, @peculiar/asn1-ecc@npm:^2.5.0":
version: 2.5.0
resolution: "@peculiar/asn1-ecc@npm:2.5.0"
dependencies:
"@peculiar/asn1-schema": "npm:^2.5.0"
"@peculiar/asn1-x509": "npm:^2.5.0"
asn1js: "npm:^3.0.6"
tslib: "npm:^2.8.1"
checksum: 10c0/447c4a03e45ecfbd9775704d9cec594fbdb234a2280e26373284d7959c3b15a216241b7bf85262e78775dd8509689a64d7e783d44745a8b2fe68863c31315985
languageName: node
linkType: hard
"@peculiar/asn1-pfx@npm:^2.5.0":
version: 2.5.0
resolution: "@peculiar/asn1-pfx@npm:2.5.0"
dependencies:
"@peculiar/asn1-cms": "npm:^2.5.0"
"@peculiar/asn1-pkcs8": "npm:^2.5.0"
"@peculiar/asn1-rsa": "npm:^2.5.0"
"@peculiar/asn1-schema": "npm:^2.5.0"
asn1js: "npm:^3.0.6"
tslib: "npm:^2.8.1"
checksum: 10c0/f1f8db89dfa53b74f59ecc83cb153f3f3c5576c528c0fc484d7c6318e05dc849efaa3c9efa94d147caa7ad7648afce4b5f9cec7346077b50200ef3a4581ddf59
languageName: node
linkType: hard
"@peculiar/asn1-pkcs8@npm:^2.5.0":
version: 2.5.0
resolution: "@peculiar/asn1-pkcs8@npm:2.5.0"
dependencies:
"@peculiar/asn1-schema": "npm:^2.5.0"
"@peculiar/asn1-x509": "npm:^2.5.0"
asn1js: "npm:^3.0.6"
tslib: "npm:^2.8.1"
checksum: 10c0/f799eafdb3167a3d0409d4aa740a28d67c8aa41ce39b9c73e5ddb8b5934e665a2029ee96d6f09f59980a5374bb14f892e317c7c9d370b78cee5d67c04eacea6f
languageName: node
linkType: hard
"@peculiar/asn1-pkcs9@npm:^2.5.0":
version: 2.5.0
resolution: "@peculiar/asn1-pkcs9@npm:2.5.0"
dependencies:
"@peculiar/asn1-cms": "npm:^2.5.0"
"@peculiar/asn1-pfx": "npm:^2.5.0"
"@peculiar/asn1-pkcs8": "npm:^2.5.0"
"@peculiar/asn1-schema": "npm:^2.5.0"
"@peculiar/asn1-x509": "npm:^2.5.0"
"@peculiar/asn1-x509-attr": "npm:^2.5.0"
asn1js: "npm:^3.0.6"
tslib: "npm:^2.8.1"
checksum: 10c0/a12a2c2eb874bfbce23e65b7ebed35abfc329b7ed505854b8b1f54e7ffc6c25638472b289993066acb9723c6af64e4734284ce47cf46b9a00c16d346756ab98d
languageName: node
linkType: hard
"@peculiar/asn1-rsa@npm:^2.3.8, @peculiar/asn1-rsa@npm:^2.5.0":
version: 2.5.0
resolution: "@peculiar/asn1-rsa@npm:2.5.0"
dependencies:
"@peculiar/asn1-schema": "npm:^2.5.0"
"@peculiar/asn1-x509": "npm:^2.5.0"
asn1js: "npm:^3.0.6"
tslib: "npm:^2.8.1"
checksum: 10c0/ca11e9512e27e12760cf4cccd60830f4ac0c06766cbbe52adf9f6384310366e5c5f6da2460da164d140ce6ccee071ceab0ec29646a7953210633ccfd4aff0fd6
languageName: node
linkType: hard
"@peculiar/asn1-schema@npm:^2.3.8, @peculiar/asn1-schema@npm:^2.5.0":
version: 2.5.0
resolution: "@peculiar/asn1-schema@npm:2.5.0"
dependencies:
asn1js: "npm:^3.0.6"
pvtsutils: "npm:^1.3.6"
tslib: "npm:^2.8.1"
checksum: 10c0/17a3a68b9ac631beeea6fa6a86b5b95e2d91602d6c477f18374beadfbc71fd4cdbec3290233bf8eae0b216595229450b3cff8ba9c7b96b4a56d57cbbd41ff62f
languageName: node
linkType: hard
"@peculiar/asn1-x509-attr@npm:^2.5.0":
version: 2.5.0
resolution: "@peculiar/asn1-x509-attr@npm:2.5.0"
dependencies:
"@peculiar/asn1-schema": "npm:^2.5.0"
"@peculiar/asn1-x509": "npm:^2.5.0"
asn1js: "npm:^3.0.6"
tslib: "npm:^2.8.1"
checksum: 10c0/bbae84dfb676297d137dcf8e67d673ff74a7c26de829e89a338023ec1de8a0759a33c786b7cfb3f7b43c41da4f127c6c9a0a17f0d4dc12495c0457a908a749f0
languageName: node
linkType: hard
"@peculiar/asn1-x509@npm:^2.3.8, @peculiar/asn1-x509@npm:^2.5.0":
version: 2.5.0
resolution: "@peculiar/asn1-x509@npm:2.5.0"
dependencies:
"@peculiar/asn1-schema": "npm:^2.5.0"
asn1js: "npm:^3.0.6"
pvtsutils: "npm:^1.3.6"
tslib: "npm:^2.8.1"
checksum: 10c0/278c81f8a9c025a4276d5d88d955f5374e03cd4181b6aaf4d16ac369f323aff118da531c41de17abd8903c149b4811e3ad457e445ce91ad3d1ac30785d01686e
languageName: node
linkType: hard
"@peculiar/x509@npm:^1.13.0":
version: 1.14.0
resolution: "@peculiar/x509@npm:1.14.0"
dependencies:
"@peculiar/asn1-cms": "npm:^2.5.0"
"@peculiar/asn1-csr": "npm:^2.5.0"
"@peculiar/asn1-ecc": "npm:^2.5.0"
"@peculiar/asn1-pkcs9": "npm:^2.5.0"
"@peculiar/asn1-rsa": "npm:^2.5.0"
"@peculiar/asn1-schema": "npm:^2.5.0"
"@peculiar/asn1-x509": "npm:^2.5.0"
pvtsutils: "npm:^1.3.6"
reflect-metadata: "npm:^0.2.2"
tslib: "npm:^2.8.1"
tsyringe: "npm:^4.10.0"
checksum: 10c0/87f6da2965bfab0a898f2ed91724db2c171ed40542bc0df281456675f05193f398800d93a012ee2c9d0d4e16217ba37ab951105e17b2f300232044ab2d8ea883
languageName: node
linkType: hard
"@pkgjs/parseargs@npm:^0.11.0":
version: 0.11.0
resolution: "@pkgjs/parseargs@npm:0.11.0"
@@ -6643,6 +6813,29 @@ __metadata:
languageName: node
linkType: hard
"@simplewebauthn/browser@npm:^13.2.2":
version: 13.2.2
resolution: "@simplewebauthn/browser@npm:13.2.2"
checksum: 10c0/07cd7b71fbe975504c2de2d81b5e3d0174ce495e51795af002ff0f38cc55fd58d023abd914c35477d4bb28952de385d9948b7e2d872343ebadcbcdb9ddc05e1c
languageName: node
linkType: hard
"@simplewebauthn/server@npm:^13.2.2":
version: 13.2.2
resolution: "@simplewebauthn/server@npm:13.2.2"
dependencies:
"@hexagon/base64": "npm:^1.1.27"
"@levischuck/tiny-cbor": "npm:^0.2.2"
"@peculiar/asn1-android": "npm:^2.3.10"
"@peculiar/asn1-ecc": "npm:^2.3.8"
"@peculiar/asn1-rsa": "npm:^2.3.8"
"@peculiar/asn1-schema": "npm:^2.3.8"
"@peculiar/asn1-x509": "npm:^2.3.8"
"@peculiar/x509": "npm:^1.13.0"
checksum: 10c0/4dbcf25fe9b33ae37add96f3c8be3489eee8cf4126f9dab0f6637c4f6c1fabc9ac3b2d337452be3e6c9908616322eb07c7f782d316c39c6f6a9567f01c6241c6
languageName: node
linkType: hard
"@sinclair/typebox@npm:^0.27.8":
version: 0.27.8
resolution: "@sinclair/typebox@npm:0.27.8"
@@ -9302,6 +9495,17 @@ __metadata:
languageName: node
linkType: hard
"asn1js@npm:^3.0.6":
version: 3.0.6
resolution: "asn1js@npm:3.0.6"
dependencies:
pvtsutils: "npm:^1.3.6"
pvutils: "npm:^1.1.3"
tslib: "npm:^2.8.1"
checksum: 10c0/96d35e65e3df819ad9cc2d91d1150a3041fd84687a62faa73405e72a6b4c655bc2450e779fad524969e14eeac1f69db2559f27ef6d06ddeeddada28f72ad9b89
languageName: node
linkType: hard
"async-function@npm:^1.0.0":
version: 1.0.0
resolution: "async-function@npm:1.0.0"
@@ -17467,12 +17671,12 @@ __metadata:
languageName: node
linkType: hard
"outline-icons@npm:^3.17.0":
version: 3.17.0
resolution: "outline-icons@npm:3.17.0"
"outline-icons@npm:^3.18.0":
version: 3.18.0
resolution: "outline-icons@npm:3.18.0"
peerDependencies:
react: ^17.0.0 || ^18.0.0
checksum: 10c0/9d2d293d830d77ce3865693734b1c4f9669a70ec21f04f7a3ebf0393c513525117fbe79cf691cb22e4232a8a579f5aa6fa794f6da506862bad0eab45f84d07bd
checksum: 10c0/af7c3c8766294de7eedd3c202488f02462e9db96914759c23ccc58ab66bfe6b1a0469eefd590992e684f54fc968fd27a0658e29113b3963f853a5a693deb1702
languageName: node
linkType: hard
@@ -17538,6 +17742,8 @@ __metadata:
"@relative-ci/agent": "npm:^4.3.1"
"@sentry/node": "npm:^7.120.4"
"@sentry/react": "npm:^7.120.4"
"@simplewebauthn/browser": "npm:^13.2.2"
"@simplewebauthn/server": "npm:^13.2.2"
"@tanstack/react-table": "npm:^8.21.3"
"@tanstack/react-virtual": "npm:^3.13.12"
"@types/addressparser": "npm:^1.0.3"
@@ -17695,7 +17901,7 @@ __metadata:
nodemailer: "npm:^7.0.11"
nodemon: "npm:^3.1.11"
octokit: "npm:^3.2.2"
outline-icons: "npm:^3.17.0"
outline-icons: "npm:^3.18.0"
oxlint: "npm:1.11.2"
oxlint-tsgolint: "npm:^0.1.6"
oy-vey: "npm:^0.12.1"
@@ -18982,6 +19188,22 @@ __metadata:
languageName: node
linkType: hard
"pvtsutils@npm:^1.3.6":
version: 1.3.6
resolution: "pvtsutils@npm:1.3.6"
dependencies:
tslib: "npm:^2.8.1"
checksum: 10c0/b1b42646370505ccae536dcffa662303b2c553995211330c8e39dec9ab8c197585d7751c2c5b9ab2f186feda0219d9bb23c34ee1e565573be96450f79d89a13c
languageName: node
linkType: hard
"pvutils@npm:^1.1.3":
version: 1.1.5
resolution: "pvutils@npm:1.1.5"
checksum: 10c0/e968b07b78a58fec9377fe7aa6342c8cfa21c8fb4afc4e51e1489bd42bec6dc71b8a52541d0aede0aea17adec7ca3f89f29f56efdc31d0083cc02e9bb5721bcf
languageName: node
linkType: hard
"qs@npm:6.14.1":
version: 6.14.1
resolution: "qs@npm:6.14.1"
@@ -21600,13 +21822,20 @@ __metadata:
languageName: node
linkType: hard
"tslib@npm:2.8.1, tslib@npm:^2.0.0, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.6.2":
"tslib@npm:2.8.1, tslib@npm:^2.0.0, tslib@npm:^2.0.3, tslib@npm:^2.1.0, tslib@npm:^2.3.1, tslib@npm:^2.4.0, tslib@npm:^2.6.2, tslib@npm:^2.8.1":
version: 2.8.1
resolution: "tslib@npm:2.8.1"
checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62
languageName: node
linkType: hard
"tslib@npm:^1.9.3":
version: 1.14.1
resolution: "tslib@npm:1.14.1"
checksum: 10c0/69ae09c49eea644bc5ebe1bca4fa4cc2c82b7b3e02f43b84bd891504edf66dbc6b2ec0eef31a957042de2269139e4acff911e6d186a258fb14069cd7f6febce2
languageName: node
linkType: hard
"tsscmp@npm:1.0.6":
version: 1.0.6
resolution: "tsscmp@npm:1.0.6"
@@ -21614,6 +21843,15 @@ __metadata:
languageName: node
linkType: hard
"tsyringe@npm:^4.10.0":
version: 4.10.0
resolution: "tsyringe@npm:4.10.0"
dependencies:
tslib: "npm:^1.9.3"
checksum: 10c0/918594b4dfac97beb8be2c041c6ec45f078ef3768ed4edfe35ae2c709ab503e2e6b454b2b37e692c658572d1972a428fbfdbc0a2b42fee727a83c1c685fbe5e1
languageName: node
linkType: hard
"ttl-set@npm:^1.0.0":
version: 1.0.0
resolution: "ttl-set@npm:1.0.0"