diff --git a/apps/login/src/app/api/otp/set/route.ts b/apps/login/src/app/api/otp/set/route.ts deleted file mode 100644 index 750b0d5b6fb..00000000000 --- a/apps/login/src/app/api/otp/set/route.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { - getMostRecentSessionCookie, - getSessionCookieById, - getSessionCookieByLoginName, -} from "@zitadel/next"; -import { setSessionAndUpdateCookie } from "@/utils/session"; -import { NextRequest, NextResponse } from "next/server"; -import { - CheckOTPSchema, - ChecksSchema, - CheckTOTPSchema, -} from "@zitadel/proto/zitadel/session/v2/session_service_pb"; -import { create } from "@zitadel/client"; - -export async function POST(request: NextRequest) { - const body = await request.json(); - - if (body) { - const { loginName, sessionId, organization, authRequestId, code, method } = - body; - - const recentPromise = sessionId - ? getSessionCookieById({ sessionId }).catch((error) => { - return Promise.reject(error); - }) - : loginName - ? getSessionCookieByLoginName({ loginName, organization }).catch( - (error) => { - return Promise.reject(error); - }, - ) - : getMostRecentSessionCookie().catch((error) => { - return Promise.reject(error); - }); - - return recentPromise - .then((recent) => { - const checks = create(ChecksSchema, {}); - - if (method === "time-based") { - checks.totp = create(CheckTOTPSchema, { - code, - }); - } else if (method === "sms") { - checks.otpSms = create(CheckOTPSchema, { - code, - }); - } else if (method === "email") { - checks.otpEmail = create(CheckOTPSchema, { - code, - }); - } - - return setSessionAndUpdateCookie( - recent, - checks, - undefined, - authRequestId, - ).then((session) => { - return NextResponse.json({ - sessionId: session.id, - factors: session.factors, - challenges: session.challenges, - }); - }); - }) - .catch((error) => { - return NextResponse.json({ details: error }, { status: 500 }); - }); - } else { - return NextResponse.json( - { details: "Request body is missing" }, - { status: 400 }, - ); - } -} diff --git a/apps/login/src/app/api/passkeys/route.ts b/apps/login/src/app/api/passkeys/route.ts deleted file mode 100644 index 1583df298b2..00000000000 --- a/apps/login/src/app/api/passkeys/route.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - createPasskeyRegistrationLink, - getSession, - registerPasskey, -} from "@/lib/zitadel"; -import { getSessionCookieById } from "@zitadel/next"; -import { NextRequest, NextResponse } from "next/server"; - -export async function POST(request: NextRequest) { - const body = await request.json(); - if (body) { - const { sessionId } = body; - - const sessionCookie = await getSessionCookieById({ sessionId }); - - const session = await getSession(sessionCookie.id, sessionCookie.token); - - const domain: string = request.nextUrl.hostname; - - const userId = session?.session?.factors?.user?.id; - - if (userId) { - // TODO: add org context - return createPasskeyRegistrationLink(userId) - .then((resp) => { - const code = resp.code; - if (!code) { - throw new Error("Missing code in response"); - } - return registerPasskey(userId, code, domain).then((resp) => { - return NextResponse.json(resp); - }); - }) - .catch((error) => { - console.error("error on creating passkey registration link"); - return NextResponse.json(error, { status: 500 }); - }); - } else { - return NextResponse.json( - { details: "could not get session" }, - { status: 500 }, - ); - } - } else { - return NextResponse.json({}, { status: 400 }); - } -} diff --git a/apps/login/src/app/api/passkeys/verify/route.ts b/apps/login/src/app/api/passkeys/verify/route.ts deleted file mode 100644 index 08f5f390c4c..00000000000 --- a/apps/login/src/app/api/passkeys/verify/route.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { getSession, verifyPasskeyRegistration } from "@/lib/zitadel"; -import { getSessionCookieById } from "@zitadel/next"; -import { NextRequest, NextResponse, userAgent } from "next/server"; -import { create } from "@zitadel/client"; -import { VerifyPasskeyRegistrationRequestSchema } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; - -export async function POST(request: NextRequest) { - const body = await request.json(); - if (body) { - let { passkeyId, passkeyName, publicKeyCredential, sessionId } = body; - - if (!!!passkeyName) { - const { browser, device, os } = userAgent(request); - passkeyName = `${device.vendor ?? ""} ${device.model ?? ""}${ - device.vendor || device.model ? ", " : "" - }${os.name}${os.name ? ", " : ""}${browser.name}`; - } - const sessionCookie = await getSessionCookieById({ sessionId }); - - const session = await getSession(sessionCookie.id, sessionCookie.token); - - const userId = session?.session?.factors?.user?.id; - console.log("payload", { - passkeyId, - passkeyName, - publicKeyCredential, - userId, - }); - if (userId) { - return verifyPasskeyRegistration( - create(VerifyPasskeyRegistrationRequestSchema, { - passkeyId, - passkeyName, - publicKeyCredential, - userId, - }), - ) - .then((resp) => { - return NextResponse.json(resp); - }) - .catch((error) => { - return NextResponse.json(error, { status: 500 }); - }); - } else { - return NextResponse.json( - { details: "could not get session" }, - { status: 500 }, - ); - } - } else { - return NextResponse.json({}, { status: 400 }); - } -} diff --git a/apps/login/src/lib/server/otp.ts b/apps/login/src/lib/server/otp.ts new file mode 100644 index 00000000000..b64da9996dd --- /dev/null +++ b/apps/login/src/lib/server/otp.ts @@ -0,0 +1,72 @@ +import { + getMostRecentSessionCookie, + getSessionCookieById, + getSessionCookieByLoginName, +} from "@zitadel/next"; +import { setSessionAndUpdateCookie } from "@/utils/session"; +import { NextRequest, NextResponse } from "next/server"; +import { + CheckOTPSchema, + ChecksSchema, + CheckTOTPSchema, +} from "@zitadel/proto/zitadel/session/v2/session_service_pb"; +import { create } from "@zitadel/client"; + +export type SetOTPCommand = { + loginName?: string; + sessionId?: string; + organization?: string; + authRequestId?: string; + code: string; + method: string; +}; + +export async function setOTP(command: SetOTPCommand) { + const { loginName, sessionId, organization, authRequestId, code, method } = + command; + + const recentPromise = sessionId + ? getSessionCookieById({ sessionId }).catch((error) => { + return Promise.reject(error); + }) + : loginName + ? getSessionCookieByLoginName({ loginName, organization }).catch( + (error) => { + return Promise.reject(error); + }, + ) + : getMostRecentSessionCookie().catch((error) => { + return Promise.reject(error); + }); + + return recentPromise.then((recent) => { + const checks = create(ChecksSchema, {}); + + if (method === "time-based") { + checks.totp = create(CheckTOTPSchema, { + code, + }); + } else if (method === "sms") { + checks.otpSms = create(CheckOTPSchema, { + code, + }); + } else if (method === "email") { + checks.otpEmail = create(CheckOTPSchema, { + code, + }); + } + + return setSessionAndUpdateCookie( + recent, + checks, + undefined, + authRequestId, + ).then((session) => { + return { + sessionId: session.id, + factors: session.factors, + challenges: session.challenges, + }; + }); + }); +} diff --git a/apps/login/src/lib/server/passkeys.ts b/apps/login/src/lib/server/passkeys.ts new file mode 100644 index 00000000000..c34f63028b5 --- /dev/null +++ b/apps/login/src/lib/server/passkeys.ts @@ -0,0 +1,84 @@ +import { + createPasskeyRegistrationLink, + getSession, + registerPasskey, + verifyPasskeyRegistration, +} from "@/lib/zitadel"; +import { getSessionCookieById } from "@zitadel/next"; +import { userAgent } from "next/server"; +import { create } from "@zitadel/client"; +import { VerifyPasskeyRegistrationRequestSchema } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { headers } from "next/headers"; +import { RegisterPasskeyResponse } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; + +type VerifyPasskeyCommand = { + passkeyId: string; + passkeyName?: string; + publicKeyCredential: any; + sessionId: string; +}; + +type RegisterPasskeyCommand = { + sessionId: string; +}; + +export async function registerPasskeyLink( + command: RegisterPasskeyCommand, +): Promise { + const { sessionId } = command; + + const sessionCookie = await getSessionCookieById({ sessionId }); + const session = await getSession(sessionCookie.id, sessionCookie.token); + + const domain = headers().get("host"); + + if (!domain) { + throw new Error("Could not get domain"); + } + + const userId = session?.session?.factors?.user?.id; + + if (!userId) { + throw new Error("Could not get session"); + } + // TODO: add org context + const registerLink = await createPasskeyRegistrationLink(userId); + + if (!registerLink.code) { + throw new Error("Missing code in response"); + } + + return registerPasskey(userId, registerLink.code, domain); +} + +export async function verifyPasskey(command: VerifyPasskeyCommand) { + let { passkeyId, passkeyName, publicKeyCredential, sessionId } = command; + + // if no name is provided, try to generate one from the user agent + if (!!!passkeyName) { + const headersList = headers(); + const userAgentStructure = { headers: headersList }; + const { browser, device, os } = userAgent(userAgentStructure); + + passkeyName = `${device.vendor ?? ""} ${device.model ?? ""}${ + device.vendor || device.model ? ", " : "" + }${os.name}${os.name ? ", " : ""}${browser.name}`; + } + + const sessionCookie = await getSessionCookieById({ sessionId }); + const session = await getSession(sessionCookie.id, sessionCookie.token); + const userId = session?.session?.factors?.user?.id; + + if (!userId) { + throw new Error("Could not get session"); + } + + return verifyPasskeyRegistration( + create(VerifyPasskeyRegistrationRequestSchema, { + passkeyId, + passkeyName, + publicKeyCredential, + userId, + }), + ); +} diff --git a/apps/login/src/ui/RegisterPasskey.tsx b/apps/login/src/ui/RegisterPasskey.tsx index 6b2bdb1fb66..68f77490514 100644 --- a/apps/login/src/ui/RegisterPasskey.tsx +++ b/apps/login/src/ui/RegisterPasskey.tsx @@ -9,6 +9,7 @@ import Alert from "./Alert"; import { coerceToArrayBuffer, coerceToBase64Url } from "@/utils/base64"; import BackButton from "./BackButton"; import { RegisterPasskeyResponse } from "@zitadel/proto/zitadel/user/v2/user_service_pb"; +import { registerPasskeyLink, verifyPasskey } from "@/lib/server/passkeys"; type Inputs = {}; @@ -35,29 +36,6 @@ export default function RegisterPasskey({ const router = useRouter(); - async function submitRegister() { - setError(""); - setLoading(true); - const res = await fetch("/api/passkeys", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - sessionId, - }), - }); - - const response = await res.json(); - - setLoading(false); - if (!res.ok) { - setError(response.details); - return Promise.reject(response.details); - } - return response; - } - async function submitVerify( passkeyId: string, passkeyName: string, @@ -65,118 +43,118 @@ export default function RegisterPasskey({ sessionId: string, ) { setLoading(true); - const res = await fetch("/api/passkeys/verify", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - passkeyId, - passkeyName, - publicKeyCredential, - sessionId, - }), + const response = await verifyPasskey({ + passkeyId, + passkeyName, + publicKeyCredential, + sessionId, + }).catch((error: Error) => { + setError(error.message); }); - const response = await res.json(); - setLoading(false); - if (!res.ok) { - setError(response.details); - return Promise.reject(response.details); - } return response; } - function submitRegisterAndContinue(value: Inputs): Promise { - return submitRegister().then((resp: RegisterPasskeyResponse) => { - const passkeyId = resp.passkeyId; - const options: CredentialCreationOptions = - (resp.publicKeyCredentialCreationOptions as CredentialCreationOptions) ?? - {}; - - if (options?.publicKey) { - options.publicKey.challenge = coerceToArrayBuffer( - options.publicKey.challenge, - "challenge", - ); - options.publicKey.user.id = coerceToArrayBuffer( - options.publicKey.user.id, - "userid", - ); - if (options.publicKey.excludeCredentials) { - options.publicKey.excludeCredentials.map((cred: any) => { - cred.id = coerceToArrayBuffer( - cred.id as string, - "excludeCredentials.id", - ); - return cred; - }); - } - - navigator.credentials - .create(options) - .then((resp) => { - if ( - resp && - (resp as any).response.attestationObject && - (resp as any).response.clientDataJSON && - (resp as any).rawId - ) { - const attestationObject = (resp as any).response - .attestationObject; - const clientDataJSON = (resp as any).response.clientDataJSON; - const rawId = (resp as any).rawId; - - const data = { - id: resp.id, - rawId: coerceToBase64Url(rawId, "rawId"), - type: resp.type, - response: { - attestationObject: coerceToBase64Url( - attestationObject, - "attestationObject", - ), - clientDataJSON: coerceToBase64Url( - clientDataJSON, - "clientDataJSON", - ), - }, - }; - - return submitVerify(passkeyId, "", data, sessionId).then(() => { - const params = new URLSearchParams(); - - if (organization) { - params.set("organization", organization); - } - - if (authRequestId) { - params.set("authRequestId", authRequestId); - params.set("sessionId", sessionId); - // params.set("altPassword", ${false}); // without setting altPassword this does not allow password - // params.set("loginName", resp.loginName); - - router.push("/passkey/login?" + params); - } else { - router.push("/accounts?" + params); - } - }); - } else { - setLoading(false); - setError("An error on registering passkey"); - return null; - } - }) - .catch((error) => { - console.error(error); - setLoading(false); - setError(error); - - return null; - }); - } + async function submitRegisterAndContinue(): Promise { + setLoading(true); + const resp = await registerPasskeyLink({ + sessionId, + }).catch((error: Error) => { + setError(error.message); + setLoading(false); }); + setLoading(false); + + if (!resp) { + setError("An error on registering passkey"); + return; + } + + const passkeyId = resp.passkeyId; + const options: CredentialCreationOptions = + (resp.publicKeyCredentialCreationOptions as CredentialCreationOptions) ?? + {}; + + if (!options.publicKey) { + setError("An error on registering passkey"); + return; + } + options.publicKey.challenge = coerceToArrayBuffer( + options.publicKey.challenge, + "challenge", + ); + options.publicKey.user.id = coerceToArrayBuffer( + options.publicKey.user.id, + "userid", + ); + if (options.publicKey.excludeCredentials) { + options.publicKey.excludeCredentials.map((cred: any) => { + cred.id = coerceToArrayBuffer( + cred.id as string, + "excludeCredentials.id", + ); + return cred; + }); + } + + const credentials = await navigator.credentials.create(options); + + if ( + !credentials || + !(credentials as any).response?.attestationObject || + !(credentials as any).response?.clientDataJSON || + !(credentials as any).rawId + ) { + setError("An error on registering passkey"); + return; + } + + const attestationObject = (credentials as any).response.attestationObject; + const clientDataJSON = (credentials as any).response.clientDataJSON; + const rawId = (credentials as any).rawId; + + const data = { + id: credentials.id, + rawId: coerceToBase64Url(rawId, "rawId"), + type: credentials.type, + response: { + attestationObject: coerceToBase64Url( + attestationObject, + "attestationObject", + ), + clientDataJSON: coerceToBase64Url(clientDataJSON, "clientDataJSON"), + }, + }; + + const verificationResponse = await submitVerify( + passkeyId, + "", + data, + sessionId, + ); + + if (!verificationResponse) { + setError("Could not verify Passkey!"); + return; + } + + const params = new URLSearchParams(); + + if (organization) { + params.set("organization", organization); + } + + if (authRequestId) { + params.set("authRequestId", authRequestId); + params.set("sessionId", sessionId); + // params.set("altPassword", ${false}); // without setting altPassword this does not allow password + // params.set("loginName", resp.loginName); + + router.push("/passkey/login?" + params); + } else { + router.push("/accounts?" + params); + } } const { errors } = formState;