diff --git a/.env.example b/.env.example index 08c44c5c3b46b..3f5ba2cd033cf 100644 --- a/.env.example +++ b/.env.example @@ -116,7 +116,7 @@ NEXT_PUBLIC_FRESHCHAT_HOST= # @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications GOOGLE_LOGIN_ENABLED=false -# - GOOGLE CALENDAR/MEET/LOGIN +# GOOGLE CALENDAR/MEET/LOGIN # Needed to enable Google Calendar integration and Login with Google # @see https://github.com/calcom/cal.com#obtaining-the-google-api-credentials GOOGLE_API_CREDENTIALS= @@ -124,7 +124,7 @@ GOOGLE_API_CREDENTIALS= # Inbox to send user feedback SEND_FEEDBACK_EMAIL= -# Sengrid +# SendGrid # Used for email reminders in workflows and internal sync services SENDGRID_API_KEY= SENDGRID_EMAIL= @@ -361,5 +361,21 @@ UNKEY_ROOT_KEY= # https://retellai.com RETELL_AI_KEY= +# Hanko Passkeys Config +# +# To quickly set these up: +# 1. Go to https://cloud.hanko.io/login +# 2. Create a "Passkey API" project. +# Enter a URL, e.g. http://localhost:3000 +# You'll find the tenant ID on the resulting page. Paste it below. +# 3. Click on "Create API Key". +# Copy the API key "Secret" (you can safely ignore the "ID") and paste it below. +# +# Then set `PASSKEY_LOGIN_ENABLED` to `true`. +NEXT_PUBLIC_HANKO_PASSKEYS_TENANT_ID= +# Make sure this API key is surrounded by quotes: +HANKO_PASSKEYS_API_KEY="" +PASSKEY_LOGIN_ENABLED=false + # Used to disallow emails as being added as guests on bookings BLACKLISTED_GUEST_EMAILS= diff --git a/apps/web/app/future/settings/security/passkeys/layout.tsx b/apps/web/app/future/settings/security/passkeys/layout.tsx new file mode 100644 index 0000000000000..230bfea4d1bac --- /dev/null +++ b/apps/web/app/future/settings/security/passkeys/layout.tsx @@ -0,0 +1,5 @@ +import { WithLayout } from "app/layoutHOC"; + +import { getLayout } from "@calcom/features/settings/layouts/SettingsLayoutAppDir"; + +export default WithLayout({ getLayout }); diff --git a/apps/web/app/future/settings/security/passkeys/page.tsx b/apps/web/app/future/settings/security/passkeys/page.tsx new file mode 100644 index 0000000000000..af6cbf0b95965 --- /dev/null +++ b/apps/web/app/future/settings/security/passkeys/page.tsx @@ -0,0 +1,10 @@ +import Page from "@pages/settings/security/passkeys"; +import { _generateMetadata } from "app/_utils"; + +export const generateMetadata = async () => + await _generateMetadata( + (t) => t("passkeys"), + (t) => t("passkeys_description") + ); + +export default Page; diff --git a/apps/web/components/auth/PasskeyIcon.tsx b/apps/web/components/auth/PasskeyIcon.tsx new file mode 100644 index 0000000000000..94ed261835767 --- /dev/null +++ b/apps/web/components/auth/PasskeyIcon.tsx @@ -0,0 +1,26 @@ +import type { ComponentProps } from "react"; + +export default function PasskeyIcon(props: ComponentProps<"svg">) { + return ( + + + + + + + + ); +} diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 0b5a9395bf76e..4e8dfb59ac0d0 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -205,6 +205,7 @@ const nextConfig = { "@calcom/prisma", "@calcom/trpc", "@calcom/ui", + "@teamhanko/passkeys-next-auth-provider", "lucide-react", ], modularizeImports: { diff --git a/apps/web/package.json b/apps/web/package.json index 93ba8016c392f..639839656e547 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -44,6 +44,7 @@ "@daily-co/daily-js": "^0.59.0", "@daily-co/daily-react": "^0.17.2", "@formkit/auto-animate": "1.0.0-beta.5", + "@github/webauthn-json": "^2.1.1", "@glidejs/glide": "^3.5.2", "@hookform/error-message": "^2.0.0", "@hookform/resolvers": "^2.9.7", @@ -65,6 +66,7 @@ "@stripe/react-stripe-js": "^1.10.0", "@stripe/stripe-js": "^1.35.0", "@tanstack/react-query": "^5.17.15", + "@teamhanko/passkeys-next-auth-provider": "^0.2.7", "@tremor/react": "^2.0.0", "@types/turndown": "^5.0.1", "@unkey/ratelimit": "^0.1.1", diff --git a/apps/web/pages/api/auth/passkeys/list.ts b/apps/web/pages/api/auth/passkeys/list.ts new file mode 100644 index 0000000000000..39b2cabc5c6f0 --- /dev/null +++ b/apps/web/pages/api/auth/passkeys/list.ts @@ -0,0 +1,48 @@ +import { tenant } from "@teamhanko/passkeys-next-auth-provider"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import prisma from "@calcom/prisma"; + +const tenantId = process.env.NEXT_PUBLIC_HANKO_PASSKEYS_TENANT_ID ?? ""; +const apiKey = process.env.HANKO_PASSKEYS_API_KEY ?? ""; + +const passkeyApi = tenant({ tenantId, apiKey }); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!tenantId || !apiKey) { + return res.status(501).json({ message: "Passkey API not configured" }); + } + + if (req.method !== "GET") { + return res.status(405).json({ message: "Method not allowed" }); + } + + const session = await getServerSession({ req, res }); + if (!session) { + return res.status(401).json({ message: "Not authenticated" }); + } + + if (!session.user?.id) { + console.error("Session is missing a user id."); + return res.status(500).json({ error: ErrorCode.InternalServerError }); + } + + const user = await prisma.user.findUnique({ where: { id: session.user.id } }); + if (!user) { + console.error(`Session references user that no longer exists.`); + return res.status(401).json({ message: "Not authenticated" }); + } + + const credentials = await passkeyApi.user(user.id.toString()).credentials(); + + return res.status(200).json({ + credentials: credentials.map((c) => ({ + id: c.id, + name: c.name, + createdAt: c.created_at, + lastUsedAt: c.last_used_at, + })), + }); +} diff --git a/apps/web/pages/api/auth/passkeys/register/finalize.ts b/apps/web/pages/api/auth/passkeys/register/finalize.ts new file mode 100644 index 0000000000000..be842372deca7 --- /dev/null +++ b/apps/web/pages/api/auth/passkeys/register/finalize.ts @@ -0,0 +1,41 @@ +import { tenant } from "@teamhanko/passkeys-next-auth-provider"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import prisma from "@calcom/prisma"; + +const tenantId = process.env.NEXT_PUBLIC_HANKO_PASSKEYS_TENANT_ID ?? ""; +const apiKey = process.env.HANKO_PASSKEYS_API_KEY ?? ""; + +const passkeyApi = tenant({ tenantId, apiKey }); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!tenantId || !apiKey) { + return res.status(501).json({ message: "Passkey API not configured" }); + } + + if (req.method !== "POST") { + return res.status(405).json({ message: "Method not allowed" }); + } + + const session = await getServerSession({ req, res }); + if (!session) { + return res.status(401).json({ message: "Not authenticated" }); + } + + if (!session.user?.id) { + console.error("Session is missing a user id."); + return res.status(500).json({ error: ErrorCode.InternalServerError }); + } + + const user = await prisma.user.findUnique({ where: { id: session.user.id } }); + if (!user) { + console.error(`Session references user that no longer exists.`); + return res.status(401).json({ message: "Not authenticated" }); + } + + await passkeyApi.registration.finalize(req.body); + + return res.status(200).json({ message: "Passkey registered" }); +} diff --git a/apps/web/pages/api/auth/passkeys/register/initialize.ts b/apps/web/pages/api/auth/passkeys/register/initialize.ts new file mode 100644 index 0000000000000..306dd92e52caf --- /dev/null +++ b/apps/web/pages/api/auth/passkeys/register/initialize.ts @@ -0,0 +1,44 @@ +import { tenant } from "@teamhanko/passkeys-next-auth-provider"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import prisma from "@calcom/prisma"; + +const tenantId = process.env.NEXT_PUBLIC_HANKO_PASSKEYS_TENANT_ID ?? ""; +const apiKey = process.env.HANKO_PASSKEYS_API_KEY ?? ""; + +const passkeyApi = tenant({ tenantId, apiKey }); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!tenantId || !apiKey) { + return res.status(501).json({ message: "Passkey API not configured" }); + } + + if (req.method !== "POST") { + return res.status(405).json({ message: "Method not allowed" }); + } + + const session = await getServerSession({ req, res }); + if (!session) { + return res.status(401).json({ message: "Not authenticated" }); + } + + if (!session.user?.id) { + console.error("Session is missing a user id."); + return res.status(500).json({ error: ErrorCode.InternalServerError }); + } + + const user = await prisma.user.findUnique({ where: { id: session.user.id } }); + if (!user) { + console.error(`Session references user that no longer exists.`); + return res.status(401).json({ message: "Not authenticated" }); + } + + const createOptions = await passkeyApi.registration.initialize({ + userId: session.user.id.toString(), + username: session.user.username ?? "", + }); + + return res.status(200).json(createOptions); +} diff --git a/apps/web/pages/api/auth/passkeys/remove.ts b/apps/web/pages/api/auth/passkeys/remove.ts new file mode 100644 index 0000000000000..2a8b36ba682a6 --- /dev/null +++ b/apps/web/pages/api/auth/passkeys/remove.ts @@ -0,0 +1,46 @@ +import { tenant } from "@teamhanko/passkeys-next-auth-provider"; +import type { NextApiRequest, NextApiResponse } from "next"; + +import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; +import prisma from "@calcom/prisma"; + +const tenantId = process.env.NEXT_PUBLIC_HANKO_PASSKEYS_TENANT_ID ?? ""; +const apiKey = process.env.HANKO_PASSKEYS_API_KEY ?? ""; + +const passkeyApi = tenant({ tenantId, apiKey }); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!tenantId || !apiKey) { + return res.status(501).json({ message: "Passkey API not configured" }); + } + + if (req.method !== "DELETE") { + return res.status(405).json({ message: "Method not allowed" }); + } + + const credentialId = req.body?.credentialId; + if (!credentialId) { + return res.status(400).json({ message: "Missing credential id" }); + } + + const session = await getServerSession({ req, res }); + if (!session) { + return res.status(401).json({ message: "Not authenticated" }); + } + + if (!session.user?.id) { + console.error("Session is missing a user id."); + return res.status(500).json({ error: ErrorCode.InternalServerError }); + } + + const user = await prisma.user.findUnique({ where: { id: session.user.id } }); + if (!user) { + console.error(`Session references user that no longer exists.`); + return res.status(401).json({ message: "Not authenticated" }); + } + + await passkeyApi.credential(credentialId).remove(); + + return res.status(200).json({}); +} diff --git a/apps/web/pages/auth/login.tsx b/apps/web/pages/auth/login.tsx index 9df9c88c70f32..467e893aa09ff 100644 --- a/apps/web/pages/auth/login.tsx +++ b/apps/web/pages/auth/login.tsx @@ -1,6 +1,7 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; +import { signInWithPasskey } from "@teamhanko/passkeys-next-auth-provider/client"; import classNames from "classnames"; import { signIn } from "next-auth/react"; import Link from "next/link"; @@ -25,6 +26,7 @@ import type { WithNonceProps } from "@lib/withNonce"; import AddToHomescreen from "@components/AddToHomescreen"; import PageWrapper from "@components/PageWrapper"; import BackupCode from "@components/auth/BackupCode"; +import PasskeyIcon from "@components/auth/PasskeyIcon"; import TwoFactor from "@components/auth/TwoFactor"; import AuthContainer from "@components/ui/AuthContainer"; @@ -44,6 +46,7 @@ const GoogleIcon = () => ( export default function Login({ csrfToken, isGoogleLoginEnabled, + isPasskeyLoginEnabled, isSAMLLoginEnabled, samlTenantID, samlProductID, @@ -238,7 +241,9 @@ inferSSRProps & WithNonceProps<{}>) { {!twoFactorRequired && ( <> - {(isGoogleLoginEnabled || displaySSOLogin) &&
} + {(isGoogleLoginEnabled || displaySSOLogin || isPasskeyLoginEnabled) && ( +
+ )}
{isGoogleLoginEnabled && ( )} + {isPasskeyLoginEnabled && ( + + )} {displaySSOLogin && ( { + return ( + + +
+ + +
+
+ ); +}; + +interface Passkey { + id: string; + name?: string; + createdAt: string; + lastUsedAt?: string; +} + +const PasskeyListItem = (props: Passkey & { onRemoved?: () => void }) => { + const { t } = useLocale(); + + const remove = useMutation({ + mutationFn: () => + fetch("/api/auth/passkeys/remove", { + method: "DELETE", + body: JSON.stringify({ credentialId: props.id }), + headers: { "Content-Type": "application/json" }, + }).then((res) => res.json()), + onSuccess: props.onRemoved, + onError: (e: any) => { + showToast(e.message || t("something_went_wrong"), "error"); + }, + }); + + return ( +
+
+
+

{props.name}

+
+
+

+ {t("passkey_created", { when: dayjs(props?.createdAt).fromNow() })} + { + // We also check for lastUsedAt != createdAt because new passkeys' lastUsedAt is set to createdAt + props.lastUsedAt && props.lastUsedAt !== props.createdAt && ( + <> • {t("passkey_last_used", { when: dayjs(props.lastUsedAt).fromNow() })} + ) + } +

+
+
+
+ + +
+
+ ); +}; + +async function registerPasskey() { + const createOptions = await fetch("/api/auth/passkeys/register/initialize", { + method: "POST", + }).then((res) => res.json()); + + const credential = await create(createOptions); + + await fetch("/api/auth/passkeys/register/finalize", { + method: "POST", + body: JSON.stringify(credential), + headers: { + "Content-Type": "application/json", + }, + }); +} + +const PasskeysView = () => { + const { t } = useLocale(); + + const { + data: data, + isLoading, + refetch, + } = useQuery({ + queryKey: ["passkeys", "list"], + queryFn: () => + fetch("/api/auth/passkeys/list").then((res) => res.json()) as Promise<{ credentials: Passkey[] }>, + }); + + const register = useMutation({ + mutationFn: registerPasskey, + onSuccess: () => { + refetch(); + }, + onError: () => { + showToast(t("passkey_registration_failed"), "error"); + }, + }); + + const AddPasskeyButton = () => { + return ( + + ); + }; + + if (isLoading || !data) { + return ( + + ); + } + + return ( + <> + } + borderInShellHeader={true} + /> + +
+ {data?.credentials.length ? ( + <> +
+ {data.credentials.map((pk) => ( + + ))} +
+ + ) : ( + + +
+ } + headline={t("register_first_passkey")} + description={t("register_first_passkey_description", { appName: APP_NAME })} + className="rounded-b-lg rounded-t-none border-t-0" + buttonRaw={} + /> + )} +
+ + ); +}; + +PasskeysView.getLayout = getLayout; +PasskeysView.PageWrapper = PageWrapper; + +export default PasskeysView; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index e66163489212a..e87dbdb810c46 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -524,6 +524,7 @@ "password_updated_successfully": "Password updated successfully", "password_has_been_changed": "Your password has been successfully changed.", "error_changing_password": "Error changing password", + "passkeys": "Passkeys", "session_timeout_changed": "Your session configuration has been updated successfully.", "session_timeout_change_error": "Error updating session configuration", "something_went_wrong": "Something went wrong.", @@ -945,6 +946,7 @@ "account_managed_by_identity_provider": "Your account is managed by {{provider}}", "account_managed_by_identity_provider_description": "To change your email, password, enable two-factor authentication and more, please visit your {{provider}} account settings.", "signin_with_google": "Sign in with Google", + "signin_with_passkey": "Sign in with a passkey", "signin_with_saml": "Sign in with SAML", "signin_with_saml_oidc": "Sign in with SAML/OIDC", "you_will_need_to_generate": "You will need to generate an access token from your old scheduling tool.", @@ -1414,7 +1416,13 @@ "conferencing_description": "Add your favourite video conferencing apps for your meetings", "add_conferencing_app": "Add Conferencing App", "password_description": "Manage settings for your account passwords", + "passkeys_description": "Manage settings for your account passkeys", "set_up_two_factor_authentication": "Set up your Two-factor authentication", + "register_and_manage_passkeys": "Register and manage your passkeys", + "passkey_login_failed": "We couldn't log you in with this passkey. It may have been removed from the account. Please try another passkey or a different login method.", + "passkey_registration_failed": "Failed to register passkey", + "passkey_created": "Created {{when}}", + "passkey_last_used": "Last used {{when}}", "we_just_need_basic_info": "We just need some basic info to get your profile setup.", "skip": "Skip", "do_this_later": "Do this later", @@ -1536,6 +1544,8 @@ "embeds_description": "Embed all your event types on your website", "create_first_api_key": "Create your first API key", "create_first_api_key_description": "API keys allow other apps to communicate with {{appName}}", + "register_first_passkey": "Register your first passkey", + "register_first_passkey_description": "Passkeys allow you to quickly log in without a password", "back_to_signin": "Back to sign in", "reset_link_sent": "Reset link sent", "password_reset_email": "An email is on it’s way to {{email}} with instructions to reset your password.", diff --git a/apps/web/server/lib/auth/login/getServerSideProps.tsx b/apps/web/server/lib/auth/login/getServerSideProps.tsx index 64144dec37c70..083a6a6aa5320 100644 --- a/apps/web/server/lib/auth/login/getServerSideProps.tsx +++ b/apps/web/server/lib/auth/login/getServerSideProps.tsx @@ -8,7 +8,7 @@ import { WEBSITE_URL } from "@calcom/lib/constants"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import prisma from "@calcom/prisma"; -import { IS_GOOGLE_LOGIN_ENABLED } from "@server/lib/constants"; +import { IS_GOOGLE_LOGIN_ENABLED, IS_PASSKEY_LOGIN_ENABLED } from "@server/lib/constants"; import { ssrInit } from "@server/lib/ssr"; export async function getServerSideProps(context: GetServerSidePropsContext) { @@ -93,6 +93,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { csrfToken: await getCsrfToken(context), trpcState: ssr.dehydrate(), isGoogleLoginEnabled: IS_GOOGLE_LOGIN_ENABLED, + isPasskeyLoginEnabled: IS_PASSKEY_LOGIN_ENABLED, isSAMLLoginEnabled, samlTenantID, samlProductID, diff --git a/apps/web/server/lib/constants.ts b/apps/web/server/lib/constants.ts index 3332dd8a009c7..6f4113e51f18f 100644 --- a/apps/web/server/lib/constants.ts +++ b/apps/web/server/lib/constants.ts @@ -1,6 +1,16 @@ +// Google export const GOOGLE_API_CREDENTIALS = process.env.GOOGLE_API_CREDENTIALS || "{}"; export const { client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET } = JSON.parse(GOOGLE_API_CREDENTIALS)?.web || {}; export const GOOGLE_LOGIN_ENABLED = process.env.GOOGLE_LOGIN_ENABLED === "true"; export const IS_GOOGLE_LOGIN_ENABLED = !!(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET && GOOGLE_LOGIN_ENABLED); + +// Passkeys +export const IS_PASSKEY_LOGIN_ENABLED = !!( + process.env.PASSKEY_LOGIN_ENABLED === "true" && + process.env.NEXT_PUBLIC_HANKO_PASSKEYS_TENANT_ID && + process.env.HANKO_PASSKEYS_API_KEY +); + +// SAML export const IS_SAML_LOGIN_ENABLED = !!(process.env.SAML_DATABASE_URL && process.env.SAML_ADMINS); diff --git a/packages/features/auth/lib/next-auth-options.ts b/packages/features/auth/lib/next-auth-options.ts index f5a12f2cb9c28..463dc67eb9074 100644 --- a/packages/features/auth/lib/next-auth-options.ts +++ b/packages/features/auth/lib/next-auth-options.ts @@ -1,4 +1,5 @@ import type { Membership, Team, UserPermissionRole } from "@prisma/client"; +import { PasskeyProvider, tenant } from "@teamhanko/passkeys-next-auth-provider"; import type { AuthOptions, Session } from "next-auth"; import type { JWT } from "next-auth/jwt"; import { encode } from "next-auth/jwt"; @@ -13,8 +14,12 @@ import ImpersonationProvider from "@calcom/features/ee/impersonation/lib/Imperso import { getOrgFullOrigin, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains"; import { clientSecretVerifier, hostedCal, isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml"; import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError"; -import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants"; -import { ENABLE_PROFILE_SWITCHER, IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; +import { + ENABLE_PROFILE_SWITCHER, + HOSTED_CAL_FEATURES, + IS_TEAM_BILLING_ENABLED, + WEBAPP_URL, +} from "@calcom/lib/constants"; import { symmetricDecrypt, symmetricEncrypt } from "@calcom/lib/crypto"; import { defaultCookies } from "@calcom/lib/default-cookies"; import { isENVDev } from "@calcom/lib/env"; @@ -34,11 +39,19 @@ import CalComAdapter from "./next-auth-custom-adapter"; import { verifyPassword } from "./verifyPassword"; const log = logger.getSubLogger({ prefix: ["next-auth-options"] }); + const GOOGLE_API_CREDENTIALS = process.env.GOOGLE_API_CREDENTIALS || "{}"; const { client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET } = JSON.parse(GOOGLE_API_CREDENTIALS)?.web || {}; const GOOGLE_LOGIN_ENABLED = process.env.GOOGLE_LOGIN_ENABLED === "true"; const IS_GOOGLE_LOGIN_ENABLED = !!(GOOGLE_CLIENT_ID && GOOGLE_CLIENT_SECRET && GOOGLE_LOGIN_ENABLED); + +const IS_PASSKEY_LOGIN_ENABLED = !!( + process.env.PASSKEY_LOGIN_ENABLED === "true" && + process.env.NEXT_PUBLIC_HANKO_PASSKEYS_TENANT_ID && + process.env.HANKO_PASSKEYS_API_KEY +); + const ORGANIZATIONS_AUTOLINK = process.env.ORGANIZATIONS_AUTOLINK === "1" || process.env.ORGANIZATIONS_AUTOLINK === "true"; @@ -251,6 +264,61 @@ if (IS_GOOGLE_LOGIN_ENABLED) { ); } +if (IS_PASSKEY_LOGIN_ENABLED) { + providers.push( + PasskeyProvider({ + tenant: tenant({ + apiKey: process.env.HANKO_PASSKEYS_API_KEY!, + tenantId: process.env.NEXT_PUBLIC_HANKO_PASSKEYS_TENANT_ID!, + }), + async authorize(data) { + /** The passkey API stores userIds as strings + * In "/web/pages/api/auth/passkeys/register/finalize.ts", we `.toString()` the userId + * so simply converting it back with `Number()` should be good + * + * The `!userId` check below makes sure it's not NaN + */ + const userId = Number(data.userId); + + // If removing this check, make sure userId is valid (e.g. not NaN) + if (!userId) { + console.error(`For some reason credentials are missing`); + throw new Error(ErrorCode.InternalServerError); + } + + const user = await UserRepository.findByIdAndIncludeProfilesAndPassword({ id: userId }); + + if (!user) { + throw new Error(ErrorCode.IncorrectEmailPassword); + } + + // Locked users cannot login + if (user.locked) { + throw new Error(ErrorCode.UserAccountLocked); + } + + await checkRateLimitAndThrowError({ + identifier: user.email, + }); + + // Check if the user you are logging into has any active teams + const hasActiveTeams = checkIfUserBelongsToActiveTeam(user); + + return { + id: user.id, + username: user.username, + email: user.email, + name: user.name, + role: user.role, + belongsToActiveTeam: hasActiveTeams, + locale: user.locale, + profile: user.allProfiles[0], + }; + }, + }) + ); +} + if (isSAMLLoginEnabled) { providers.push({ id: "saml", diff --git a/packages/features/auth/package.json b/packages/features/auth/package.json index 9f451d5709778..a39ebe8fa9bb6 100644 --- a/packages/features/auth/package.json +++ b/packages/features/auth/package.json @@ -12,6 +12,7 @@ "@calcom/prisma": "*", "@calcom/trpc": "*", "@calcom/ui": "*", + "@teamhanko/passkeys-next-auth-provider": "^0.2.7", "bcryptjs": "^2.4.3", "handlebars": "^4.7.7", "jose": "^4.13.1", diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index c5edf1069062e..4362d7772f9a3 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -42,6 +42,7 @@ const tabs: VerticalTabItemProps[] = [ { name: "password", href: "/settings/security/password" }, { name: "impersonation", href: "/settings/security/impersonation" }, { name: "2fa_auth", href: "/settings/security/two-factor-auth" }, + { name: "passkeys", href: "/settings/security/passkeys" }, ], }, { diff --git a/packages/features/settings/layouts/SettingsLayoutAppDir.tsx b/packages/features/settings/layouts/SettingsLayoutAppDir.tsx index 178fad4dd5514..766f390a0b1e6 100644 --- a/packages/features/settings/layouts/SettingsLayoutAppDir.tsx +++ b/packages/features/settings/layouts/SettingsLayoutAppDir.tsx @@ -43,6 +43,7 @@ const tabs: VerticalTabItemProps[] = [ { name: "password", href: "/settings/security/password" }, { name: "impersonation", href: "/settings/security/impersonation" }, { name: "2fa_auth", href: "/settings/security/two-factor-auth" }, + { name: "passkeys", href: "/settings/security/passkeys" }, ], }, { diff --git a/packages/lib/server/repository/user.ts b/packages/lib/server/repository/user.ts index 57c4c96b37fd8..afcb62505ee02 100644 --- a/packages/lib/server/repository/user.ts +++ b/packages/lib/server/repository/user.ts @@ -250,6 +250,46 @@ export class UserRepository { return user; } + static async findByIdAndIncludeProfilesAndPassword({ id }: { id: number }) { + const user = await prisma.user.findUnique({ + where: { + id, + }, + select: { + locked: true, + role: true, + id: true, + username: true, + name: true, + email: true, + metadata: true, + identityProvider: true, + password: true, + twoFactorEnabled: true, + twoFactorSecret: true, + backupCodes: true, + locale: true, + teams: { + include: { + team: { + select: teamSelect, + }, + }, + }, + }, + }); + + if (!user) { + return null; + } + + const allProfiles = await ProfileRepository.findAllProfilesForUserIncludingMovedUser(user); + return { + ...user, + allProfiles, + }; + } + static async findManyByOrganization({ organizationId }: { organizationId: number }) { const profiles = await ProfileRepository.findManyForOrg({ organizationId }); return profiles.map((profile) => profile.user); diff --git a/packages/types/environment.d.ts b/packages/types/environment.d.ts index 9706f44b08ea7..df2a4d6da22b4 100644 --- a/packages/types/environment.d.ts +++ b/packages/types/environment.d.ts @@ -58,6 +58,8 @@ declare namespace NodeJS { readonly NEXT_PUBLIC_APP_NAME: string | "Cal"; readonly NEXT_PUBLIC_SUPPORT_MAIL_ADDRESS: string | "help@cal.com"; readonly NEXT_PUBLIC_COMPANY_NAME: string | "Cal.com, Inc."; + readonly NEXT_PUBLIC_HANKO_PASSKEYS_TENANT_ID: string | undefined; + readonly HANKO_PASSKEYS_API_KEY: string | undefined; /** * "strict" -> Strict CSP * "non-strict" -> Strict CSP except the usage of unsafe-inline for `style-src` diff --git a/turbo.json b/turbo.json index 91cd8b5f7e22b..8ec05750a7eb2 100644 --- a/turbo.json +++ b/turbo.json @@ -292,6 +292,7 @@ "GITHUB_API_REPO_TOKEN", "GOOGLE_API_CREDENTIALS", "GOOGLE_LOGIN_ENABLED", + "HANKO_PASSKEYS_API_KEY", "HEROKU_APP_NAME", "HUBSPOT_CLIENT_ID", "HUBSPOT_CLIENT_SECRET", @@ -317,6 +318,7 @@ "NEXT_PUBLIC_EMBED_LIB_URL", "NEXT_PUBLIC_FORMBRICKS_HOST_URL", "NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID", + "NEXT_PUBLIC_HANKO_PASSKEYS_TENANT_ID", "NEXT_PUBLIC_HOSTED_CAL_FEATURES", "NEXT_PUBLIC_MINUTES_TO_BOOK", "NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED", @@ -339,6 +341,7 @@ "NODE_ENV", "ORGANIZATIONS_ENABLED", "ORGANIZATIONS_AUTOLINK", + "PASSKEY_LOGIN_ENABLED", "PAYMENT_FEE_FIXED", "PAYMENT_FEE_PERCENTAGE", "PLAYWRIGHT_HEADLESS",