diff --git a/app/components/Input.tsx b/app/components/Input.tsx index 68b698137c..77b748bff1 100644 --- a/app/components/Input.tsx +++ b/app/components/Input.tsx @@ -125,11 +125,10 @@ export const LabelText = styled.div` display: inline-block; `; -export interface Props - extends Omit< - React.InputHTMLAttributes, - "prefix" - > { +export interface Props extends Omit< + React.InputHTMLAttributes, + "prefix" +> { type?: "text" | "email" | "checkbox" | "search" | "textarea" | "password"; labelHidden?: boolean; label?: string; diff --git a/app/components/Switch.tsx b/app/components/Switch.tsx index fa57059048..aeccfa720e 100644 --- a/app/components/Switch.tsx +++ b/app/components/Switch.tsx @@ -6,11 +6,10 @@ import { LabelText } from "~/components/Input"; import Text from "~/components/Text"; import { undraggableOnDesktop } from "~/styles"; -interface Props - extends Omit< - React.ComponentProps, - "checked" | "onCheckedChange" | "onChange" - > { +interface Props extends Omit< + React.ComponentProps, + "checked" | "onCheckedChange" | "onChange" +> { /** Width of the switch. Defaults to 32. */ width?: number; /** Height of the switch. Defaults to 18 */ diff --git a/app/hooks/useSettingsConfig.ts b/app/hooks/useSettingsConfig.ts index 7402ab5252..8829dedc4d 100644 --- a/app/hooks/useSettingsConfig.ts +++ b/app/hooks/useSettingsConfig.ts @@ -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 { diff --git a/app/models/Team.ts b/app/models/Team.ts index 77b24338cb..b853f2cb98 100644 --- a/app/models/Team.ts +++ b/app/models/Team.ts @@ -52,6 +52,10 @@ class Team extends Model { @observable guestSignin: boolean; + @Field + @observable + passkeysEnabled: boolean; + @Field @observable subdomain: string | null | undefined; diff --git a/app/scenes/Login/components/AuthenticationProvider.tsx b/app/scenes/Login/components/AuthenticationProvider.tsx index 2ee2306a5f..5445a1dcd4 100644 --- a/app/scenes/Login/components/AuthenticationProvider.tsx +++ b/app/scenes/Login/components/AuthenticationProvider.tsx @@ -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 & { id: string; @@ -26,6 +30,7 @@ function AuthenticationProvider(props: Props) { const [authState, setAuthState] = React.useState("initial"); const [isSubmitting, setSubmitting] = React.useState(false); const [email, setEmail] = React.useState(""); + const formRef = React.useRef(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 + ) => { + 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 ( + +
+ } + fullwidth + {...rest} + > + {t("Continue with Passkey")} + +
+
+ ); + } + if (id === "email") { if (isCreate) { return null; diff --git a/app/scenes/Settings/APIAndApps.tsx b/app/scenes/Settings/APIAndApps.tsx index 43fb82cbe6..3b89eccb01 100644 --- a/app/scenes/Settings/APIAndApps.tsx +++ b/app/scenes/Settings/APIAndApps.tsx @@ -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 ( } + icon={} actions={ <> {can.createApiKey && ( diff --git a/app/scenes/Settings/Authentication.tsx b/app/scenes/Settings/Authentication.tsx index e0a5391923..5fc2b606bd 100644 --- a/app/scenes/Settings/Authentication.tsx +++ b/app/scenes/Settings/Authentication.tsx @@ -190,6 +190,29 @@ function Authentication() { /> + + {t("Passkeys")} + + } + name="passkeysEnabled" + description={t("Allow members to sign-in with a WebAuthn passkey")} + > + { + try { + await team.save({ passkeysEnabled: checked }); + toast.success(t("Settings saved")); + } catch (err) { + toast.error(err.message); + } + }} + /> + + {t("Restrictions")} diff --git a/app/scenes/Settings/Security.tsx b/app/scenes/Settings/Security.tsx index 75a7b00478..98bf015d32 100644 --- a/app/scenes/Settings/Security.tsx +++ b/app/scenes/Settings/Security.tsx @@ -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() { )} + {t("Authentication")} + + + + {t("Behavior")} ; + 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 = { diff --git a/package.json b/package.json index 7510f26f5b..09a4677dc6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/plugins/passkeys/client/Settings.tsx b/plugins/passkeys/client/Settings.tsx new file mode 100644 index 0000000000..3cb5129d64 --- /dev/null +++ b/plugins/passkeys/client/Settings.tsx @@ -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([]); + 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: ( + { + await loadPasskeys(); + dialogs.closeAllModals(); + }} + /> + ), + }); + }; + + const handleDelete = (passkeyId: string) => { + dialogs.openModal({ + title: t("Delete passkey"), + content: ( + { + 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 + > + + Are you sure you want to delete this passkey? You will no longer be + able to use it to sign in. + + + ), + }); + }; + + return ( + } + actions={ + + + + } + > + {t("Passkeys")} + + + 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. + + + + {team.passkeysEnabled === false && ( + + {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.")} + + )} + + {isLoading ? ( + + + + ) : passkeys.length > 0 ? ( + <> + {passkeys.map((pk) => ( + handleRename(pk.id, pk.name)} + onDelete={() => handleDelete(pk.id)} + /> + ))} + + ) : ( + + {t("You don't have any passkeys yet.")} + + )} + + ); +} + +export default observer(PasskeysSettings); diff --git a/plugins/passkeys/client/components/PasskeyIcon.tsx b/plugins/passkeys/client/components/PasskeyIcon.tsx new file mode 100644 index 0000000000..9ab93d0867 --- /dev/null +++ b/plugins/passkeys/client/components/PasskeyIcon.tsx @@ -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 ( + + + + ); + } + + return ; +} + +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; diff --git a/plugins/passkeys/client/components/PasskeyListItem.tsx b/plugins/passkeys/client/components/PasskeyListItem.tsx new file mode 100644 index 0000000000..9670f707e2 --- /dev/null +++ b/plugins/passkeys/client/components/PasskeyListItem.tsx @@ -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: , + section: "Passkey", + perform: onRename, + }), + ActionSeparator, + createAction({ + name: `${t("Delete")}…`, + icon: , + section: "Passkey", + dangerous: true, + perform: onDelete, + }), + ], + [t, onRename, onDelete] + ); + + const rootAction = useMenuAction(actions); + + return ( + + + + ); +} + +/** + * 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 ( + } + title={passkey.name} + subtitle={ + passkey.lastActiveAt ? ( + + {t("Last used")} + ) : ( + + {t("Registered {{ timeAgo }}", { + timeAgo: dateToRelative(Date.parse(passkey.createdAt), { + addSuffix: true, + locale, + }), + })} + + ) + } + actions={ + + } + /> + ); +} + +export default PasskeyListItem; diff --git a/plugins/passkeys/client/components/RenamePasskeyDialog.tsx b/plugins/passkeys/client/components/RenamePasskeyDialog.tsx new file mode 100644 index 0000000000..38bd6033f1 --- /dev/null +++ b/plugins/passkeys/client/components/RenamePasskeyDialog.tsx @@ -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 ( +
+ + {t("Give your passkey a memorable name to easily identify it.")} + + setName(ev.target.value)} + placeholder={t("Enter passkey name")} + autoFocus + required + maxLength={255} + disabled={isSaving} + /> + + + +
+ ); +} + +export default observer(RenamePasskeyDialog); diff --git a/plugins/passkeys/client/index.tsx b/plugins/passkeys/client/index.tsx new file mode 100644 index 0000000000..44673f4390 --- /dev/null +++ b/plugins/passkeys/client/index.tsx @@ -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")), + }, + }, +]); diff --git a/plugins/passkeys/plugin.json b/plugins/passkeys/plugin.json new file mode 100644 index 0000000000..8c640e0d07 --- /dev/null +++ b/plugins/passkeys/plugin.json @@ -0,0 +1,6 @@ +{ + "id": "passkeys", + "name": "Passkeys", + "priority": 100, + "description": "Adds Passkeys authentication." +} diff --git a/plugins/passkeys/server/api/passkeys.ts b/plugins/passkeys/server/api/passkeys.ts new file mode 100644 index 0000000000..768ac4b774 --- /dev/null +++ b/plugins/passkeys/server/api/passkeys.ts @@ -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) => { + 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) => { + 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) => { + 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; diff --git a/plugins/passkeys/server/api/schema.ts b/plugins/passkeys/server/api/schema.ts new file mode 100644 index 0000000000..6a70f65d85 --- /dev/null +++ b/plugins/passkeys/server/api/schema.ts @@ -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; + +export const PasskeysDeleteSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + }), +}); + +export type PasskeysDeleteReq = z.infer; + +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; diff --git a/plugins/passkeys/server/auth/passkeys.ts b/plugins/passkeys/server/auth/passkeys.ts new file mode 100644 index 0000000000..87f256388f --- /dev/null +++ b/plugins/passkeys/server/auth/passkeys.ts @@ -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) => { + 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) => { + 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) => { + 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; diff --git a/plugins/passkeys/server/auth/schema.ts b/plugins/passkeys/server/auth/schema.ts new file mode 100644 index 0000000000..38d81ebd18 --- /dev/null +++ b/plugins/passkeys/server/auth/schema.ts @@ -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 +>; diff --git a/plugins/passkeys/server/email/templates/PasskeyCreatedEmail.tsx b/plugins/passkeys/server/email/templates/PasskeyCreatedEmail.tsx new file mode 100644 index 0000000000..d8701973a1 --- /dev/null +++ b/plugins/passkeys/server/email/templates/PasskeyCreatedEmail.tsx @@ -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 { + 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 ( + +
+ + + 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. +

+ +

+ +

+ +

+ If you have any concerns about your account security, please contact + a workspace admin. +

+ + +