Merge pull request #65 from zitadel/qa

Multifactor Authentication
This commit is contained in:
Max Peintner
2024-05-08 08:28:52 +02:00
committed by GitHub
39 changed files with 4891 additions and 6131 deletions

View File

@@ -97,6 +97,7 @@ export default async function Page({
return retrieveIDPIntent(id, token) return retrieveIDPIntent(id, token)
.then((resp) => { .then((resp) => {
const { idpInformation, userId } = resp; const { idpInformation, userId } = resp;
if (idpInformation) { if (idpInformation) {
// handle login // handle login
if (userId) { if (userId) {
@@ -166,10 +167,14 @@ export default async function Page({
}); });
} else { } else {
return ( return (
<div className="flex flex-col items-center space-y-4"> <DynamicTheme branding={branding}>
<h1>Register</h1> <div className="flex flex-col items-center space-y-4">
<p className="ztdl-p">No id and token received!</p> <div className="flex flex-col items-center space-y-4">
</div> <h1>Register</h1>
<p className="ztdl-p">No id and token received!</p>
</div>
</div>
</DynamicTheme>
); );
} }
} }

View File

@@ -1,35 +0,0 @@
"use client";
import { Button, ButtonVariants } from "#/ui/Button";
import { TextInput } from "#/ui/Input";
import UserAvatar from "#/ui/UserAvatar";
import { useRouter } from "next/navigation";
export default function Page() {
const router = useRouter();
return (
<div className="flex flex-col items-center space-y-4">
<h1>Password</h1>
<p className="ztdl-p mb-6 block">Enter your password.</p>
<UserAvatar
showDropdown
displayName="Max Peintner"
loginName="max@zitadel.com"
></UserAvatar>
<div className="w-full">
<TextInput type="password" label="Password" />
</div>
<div className="flex w-full flex-row items-center justify-between">
<Button
onClick={() => router.back()}
variant={ButtonVariants.Secondary}
>
back
</Button>
<Button variant={ButtonVariants.Primary}>continue</Button>
</div>
</div>
);
}

View File

@@ -1,3 +1,102 @@
export default function Page() { import {
return <div className="flex flex-col items-center space-y-4">mfa</div>; getBrandingSettings,
getSession,
listAuthenticationMethodTypes,
server,
} from "#/lib/zitadel";
import Alert from "#/ui/Alert";
import ChooseSecondFactor from "#/ui/ChooseSecondFactor";
import DynamicTheme from "#/ui/DynamicTheme";
import UserAvatar from "#/ui/UserAvatar";
import {
getMostRecentCookieWithLoginname,
getSessionCookieById,
} from "#/utils/cookies";
export default async function Page({
searchParams,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const { loginName, checkAfter, authRequestId, organization, sessionId } =
searchParams;
const sessionFactors = sessionId
? await loadSessionById(sessionId, organization)
: await loadSessionByLoginname(loginName, organization);
async function loadSessionByLoginname(
loginName?: string,
organization?: string
) {
const recent = await getMostRecentCookieWithLoginname(
loginName,
organization
);
return getSession(server, recent.id, recent.token).then((response) => {
if (response?.session && response.session.factors?.user?.id) {
return listAuthenticationMethodTypes(
response.session.factors.user.id
).then((methods) => {
return {
factors: response.session?.factors,
authMethods: methods.authMethodTypes ?? [],
};
});
}
});
}
async function loadSessionById(sessionId: string, organization?: string) {
const recent = await getSessionCookieById(sessionId, organization);
return getSession(server, recent.id, recent.token).then((response) => {
if (response?.session && response.session.factors?.user?.id) {
return listAuthenticationMethodTypes(
response.session.factors.user.id
).then((methods) => {
return {
factors: response.session?.factors,
authMethods: methods.authMethodTypes ?? [],
};
});
}
});
}
const branding = await getBrandingSettings(server, organization);
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>Verify 2-Factor</h1>
<p className="ztdl-p">Choose one of the following second factors.</p>
{sessionFactors && (
<UserAvatar
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
)}
{!(loginName || sessionId) && (
<Alert>Provide your active session as loginName param</Alert>
)}
{sessionFactors ? (
<ChooseSecondFactor
loginName={loginName}
sessionId={sessionId}
authRequestId={authRequestId}
organization={organization}
userMethods={sessionFactors.authMethods ?? []}
></ChooseSecondFactor>
) : (
<Alert>No second factors available to setup.</Alert>
)}
</div>
</DynamicTheme>
);
} }

View File

@@ -1,34 +1,116 @@
"use client"; import {
import { Button, ButtonVariants } from "#/ui/Button"; getBrandingSettings,
import { TextInput } from "#/ui/Input"; getLoginSettings,
getSession,
getUserByID,
listAuthenticationMethodTypes,
server,
} from "#/lib/zitadel";
import Alert from "#/ui/Alert";
import ChooseSecondFactorToSetup from "#/ui/ChooseSecondFactorToSetup";
import DynamicTheme from "#/ui/DynamicTheme";
import UserAvatar from "#/ui/UserAvatar"; import UserAvatar from "#/ui/UserAvatar";
import { useRouter } from "next/navigation"; import {
getMostRecentCookieWithLoginname,
getSessionCookieById,
} from "#/utils/cookies";
import { user } from "@zitadel/server";
export default function Page() { export default async function Page({
const router = useRouter(); searchParams,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const { loginName, checkAfter, authRequestId, organization, sessionId } =
searchParams;
const sessionWithData = sessionId
? await loadSessionById(sessionId, organization)
: await loadSessionByLoginname(loginName, organization);
async function loadSessionByLoginname(
loginName?: string,
organization?: string
) {
const recent = await getMostRecentCookieWithLoginname(
loginName,
organization
);
return getSession(server, recent.id, recent.token).then((response) => {
if (response?.session && response.session.factors?.user?.id) {
const userId = response.session.factors.user.id;
return listAuthenticationMethodTypes(userId).then((methods) => {
return getUserByID(userId).then((user) => {
return {
factors: response.session?.factors,
authMethods: methods.authMethodTypes ?? [],
phoneVerified: user.user?.human?.phone?.isVerified ?? false,
emailVerified: user.user?.human?.email?.isVerified ?? false,
};
});
});
}
});
}
async function loadSessionById(sessionId: string, organization?: string) {
const recent = await getSessionCookieById(sessionId, organization);
return getSession(server, recent.id, recent.token).then((response) => {
if (response?.session && response.session.factors?.user?.id) {
const userId = response.session.factors.user.id;
return listAuthenticationMethodTypes(userId).then((methods) => {
return getUserByID(userId).then((user) => {
return {
factors: response.session?.factors,
authMethods: methods.authMethodTypes ?? [],
phoneVerified: user.user?.human?.phone?.isVerified ?? false,
emailVerified: user.user?.human?.email?.isVerified ?? false,
};
});
});
}
});
}
const branding = await getBrandingSettings(server, organization);
const loginSettings = await getLoginSettings(server, organization);
return ( return (
<div className="flex flex-col items-center space-y-4"> <DynamicTheme branding={branding}>
<h1>Password</h1> <div className="flex flex-col items-center space-y-4">
<p className="ztdl-p mb-6 block">Enter your password.</p> <h1>Set up 2-Factor</h1>
<UserAvatar <p className="ztdl-p">Choose one of the following second factors.</p>
showDropdown
displayName="Max Peintner" {sessionWithData && (
loginName="max@zitadel.com" <UserAvatar
></UserAvatar> loginName={loginName ?? sessionWithData.factors?.user?.loginName}
<div className="w-full"> displayName={sessionWithData.factors?.user?.displayName}
<TextInput type="password" label="Password" /> showDropdown
searchParams={searchParams}
></UserAvatar>
)}
{!(loginName || sessionId) && (
<Alert>Provide your active session as loginName param</Alert>
)}
{loginSettings && sessionWithData ? (
<ChooseSecondFactorToSetup
loginName={loginName}
sessionId={sessionId}
authRequestId={authRequestId}
organization={organization}
loginSettings={loginSettings}
userMethods={sessionWithData.authMethods ?? []}
phoneVerified={sessionWithData.phoneVerified ?? false}
emailVerified={sessionWithData.emailVerified ?? false}
checkAfter={checkAfter === "true"}
></ChooseSecondFactorToSetup>
) : (
<Alert>No second factors available to setup.</Alert>
)}
</div> </div>
<div className="flex w-full flex-row items-center justify-between"> </DynamicTheme>
<Button
onClick={() => router.back()}
variant={ButtonVariants.Secondary}
>
back
</Button>
<Button variant={ButtonVariants.Primary}>continue</Button>
</div>
</div>
); );
} }

View File

@@ -0,0 +1,84 @@
import {
getBrandingSettings,
getLoginSettings,
getSession,
server,
} from "#/lib/zitadel";
import Alert from "#/ui/Alert";
import DynamicTheme from "#/ui/DynamicTheme";
import LoginOTP from "#/ui/LoginOTP";
import UserAvatar from "#/ui/UserAvatar";
import { getMostRecentCookieWithLoginname } from "#/utils/cookies";
export default async function Page({
searchParams,
params,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
params: Record<string | number | symbol, string | undefined>;
}) {
const { loginName, authRequestId, sessionId, organization, code, submit } =
searchParams;
const { method } = params;
const { session, token } = await loadSession(loginName, organization);
const branding = await getBrandingSettings(server, organization);
async function loadSession(loginName?: string, organization?: string) {
const recent = await getMostRecentCookieWithLoginname(
loginName,
organization
);
return getSession(server, recent.id, recent.token).then((response) => {
return { session: response?.session, token: recent.token };
});
}
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>Verify 2-Factor</h1>
{method === "time-based" && (
<p className="ztdl-p">Enter the code from your authenticator app.</p>
)}
{method === "sms" && (
<p className="ztdl-p">Enter the code you got on your phone.</p>
)}
{method === "email" && (
<p className="ztdl-p">Enter the code you got via your email.</p>
)}
{!session && (
<div className="py-4">
<Alert>
Could not get the context of the user. Make sure to enter the
username first or provide a loginName as searchParam.
</Alert>
</div>
)}
{session && (
<UserAvatar
loginName={loginName ?? session.factors?.user?.loginName}
displayName={session.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
)}
{method && (
<LoginOTP
loginName={loginName}
sessionId={sessionId}
authRequestId={authRequestId}
organization={organization}
method={method}
></LoginOTP>
)}
</div>
</DynamicTheme>
);
}

View File

@@ -0,0 +1,179 @@
import {
addOTPEmail,
addOTPSMS,
getBrandingSettings,
getSession,
registerTOTP,
server,
} from "#/lib/zitadel";
import Alert from "#/ui/Alert";
import { Button, ButtonVariants } from "#/ui/Button";
import DynamicTheme from "#/ui/DynamicTheme";
import { Spinner } from "#/ui/Spinner";
import TOTPRegister from "#/ui/TOTPRegister";
import UserAvatar from "#/ui/UserAvatar";
import { getMostRecentCookieWithLoginname } from "#/utils/cookies";
import { RegisterTOTPResponse } from "@zitadel/server";
import Link from "next/link";
import { ClientError } from "nice-grpc";
export default async function Page({
searchParams,
params,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
params: Record<string | number | symbol, string | undefined>;
}) {
const { loginName, organization, sessionId, authRequestId, checkAfter } =
searchParams;
const { method } = params;
const branding = await getBrandingSettings(server, organization);
const { session, token } = await loadSession(loginName, organization);
let totpResponse: RegisterTOTPResponse | undefined,
totpError: ClientError | undefined;
if (session && session.factors?.user?.id) {
if (method === "time-based") {
await registerTOTP(session.factors.user.id)
.then((resp) => {
if (resp) {
totpResponse = resp;
}
})
.catch((error) => {
totpError = error;
});
} else if (method === "sms") {
// does not work
await addOTPSMS(session.factors.user.id);
} else if (method === "email") {
// works
await addOTPEmail(session.factors.user.id);
} else {
throw new Error("Invalid method");
}
} else {
throw new Error("No session found");
}
async function loadSession(loginName?: string, organization?: string) {
const recent = await getMostRecentCookieWithLoginname(
loginName,
organization
);
return getSession(server, recent.id, recent.token).then((response) => {
return { session: response?.session, token: recent.token };
});
}
const paramsToContinue = new URLSearchParams({});
let urlToContinue = "/accounts";
if (authRequestId && sessionId) {
if (sessionId) {
paramsToContinue.append("sessionId", sessionId);
}
if (authRequestId) {
paramsToContinue.append("authRequestId", authRequestId);
}
if (organization) {
paramsToContinue.append("organization", organization);
}
urlToContinue = `/login?` + paramsToContinue;
} else if (loginName) {
if (loginName) {
paramsToContinue.append("loginName", loginName);
}
if (authRequestId) {
paramsToContinue.append("authRequestId", authRequestId);
}
if (organization) {
paramsToContinue.append("organization", organization);
}
urlToContinue = `/signedin?` + paramsToContinue;
}
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>Register 2-factor</h1>
{!session && (
<div className="py-4">
<Alert>
Could not get the context of the user. Make sure to enter the
username first or provide a loginName as searchParam.
</Alert>
</div>
)}
{totpError && (
<div className="py-4">
<Alert>{totpError?.details}</Alert>
</div>
)}
{session && (
<UserAvatar
loginName={loginName ?? session.factors?.user?.loginName}
displayName={session.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
)}
{totpResponse && "uri" in totpResponse && "secret" in totpResponse ? (
<>
<p className="ztdl-p">
Scan the QR Code or navigate to the URL manually.
</p>
<div>
{/* {auth && <div>{auth.to}</div>} */}
<TOTPRegister
uri={totpResponse.uri as string}
secret={totpResponse.secret as string}
loginName={loginName}
sessionId={sessionId}
authRequestId={authRequestId}
organization={organization}
checkAfter={checkAfter === "true"}
></TOTPRegister>
</div>{" "}
</>
) : (
<>
<p className="ztdl-p">
{method === "email"
? "Code via email was successfully added."
: method === "sms"
? "Code via SMS was successfully added."
: ""}
</p>
<div className="mt-8 flex w-full flex-row items-center">
<span className="flex-grow"></span>
<Link
href={
checkAfter
? `/otp/${method}?` + new URLSearchParams()
: urlToContinue
}
>
<Button
type="submit"
className="self-end"
variant={ButtonVariants.Primary}
>
continue
</Button>
</Link>
</div>
</>
)}
</div>
</DynamicTheme>
);
}

View File

@@ -45,6 +45,7 @@ export default async function Page({
loginName={loginName ?? sessionFactors.factors?.user?.loginName} loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName} displayName={sessionFactors.factors?.user?.displayName}
showDropdown showDropdown
searchParams={searchParams}
></UserAvatar> ></UserAvatar>
)} )}
<p className="ztdl-p mb-6 block">{description}</p> <p className="ztdl-p mb-6 block">{description}</p>

View File

@@ -60,12 +60,11 @@ export default async function Page({
loginName={loginName ?? sessionFactors.factors?.user?.loginName} loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName} displayName={sessionFactors.factors?.user?.displayName}
showDropdown showDropdown
searchParams={searchParams}
></UserAvatar> ></UserAvatar>
)} )}
<p className="ztdl-p mb-6 block">{description}</p> <p className="ztdl-p mb-6 block">{description}</p>
{!sessionFactors && <div className="py-4"></div>}
{!(loginName || sessionId) && ( {!(loginName || sessionId) && (
<Alert>Provide your active session as loginName param</Alert> <Alert>Provide your active session as loginName param</Alert>
)} )}

View File

@@ -1,4 +1,9 @@
import { getBrandingSettings, getSession, server } from "#/lib/zitadel"; import {
getBrandingSettings,
getLoginSettings,
getSession,
server,
} from "#/lib/zitadel";
import Alert from "#/ui/Alert"; import Alert from "#/ui/Alert";
import DynamicTheme from "#/ui/DynamicTheme"; import DynamicTheme from "#/ui/DynamicTheme";
import PasswordForm from "#/ui/PasswordForm"; import PasswordForm from "#/ui/PasswordForm";
@@ -28,6 +33,7 @@ export default async function Page({
} }
const branding = await getBrandingSettings(server, organization); const branding = await getBrandingSettings(server, organization);
const loginSettings = await getLoginSettings(server, organization);
return ( return (
<DynamicTheme branding={branding}> <DynamicTheme branding={branding}>
@@ -49,6 +55,7 @@ export default async function Page({
loginName={loginName ?? sessionFactors.factors?.user?.loginName} loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName} displayName={sessionFactors.factors?.user?.displayName}
showDropdown showDropdown
searchParams={searchParams}
></UserAvatar> ></UserAvatar>
)} )}
@@ -56,6 +63,7 @@ export default async function Page({
loginName={loginName} loginName={loginName}
authRequestId={authRequestId} authRequestId={authRequestId}
organization={organization} organization={organization}
loginSettings={loginSettings}
promptPasswordless={promptPasswordless === "true"} promptPasswordless={promptPasswordless === "true"}
isAlternative={alt === "true"} isAlternative={alt === "true"}
/> />

View File

@@ -43,6 +43,7 @@ export default async function Page({ searchParams }: { searchParams: any }) {
loginName={loginName ?? sessionFactors?.factors?.user?.loginName} loginName={loginName ?? sessionFactors?.factors?.user?.loginName}
displayName={sessionFactors?.factors?.user?.displayName} displayName={sessionFactors?.factors?.user?.displayName}
showDropdown showDropdown
searchParams={searchParams}
></UserAvatar> ></UserAvatar>
</div> </div>
</DynamicTheme> </DynamicTheme>

View File

@@ -0,0 +1,89 @@
import {
getBrandingSettings,
getLoginSettings,
getSession,
server,
} from "#/lib/zitadel";
import Alert from "#/ui/Alert";
import DynamicTheme from "#/ui/DynamicTheme";
import LoginPasskey from "#/ui/LoginPasskey";
import UserAvatar from "#/ui/UserAvatar";
import {
getMostRecentCookieWithLoginname,
getSessionCookieById,
} from "#/utils/cookies";
export default async function Page({
searchParams,
params,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
params: Record<string | number | symbol, string | undefined>;
}) {
const { loginName, authRequestId, sessionId, organization } = searchParams;
const branding = await getBrandingSettings(server, organization);
const sessionFactors = sessionId
? await loadSessionById(sessionId, organization)
: await loadSessionByLoginname(loginName, organization);
async function loadSessionByLoginname(
loginName?: string,
organization?: string
) {
const recent = await getMostRecentCookieWithLoginname(
loginName,
organization
);
return getSession(server, recent.id, recent.token).then((response) => {
if (response?.session) {
return response.session;
}
});
}
async function loadSessionById(sessionId: string, organization?: string) {
const recent = await getSessionCookieById(sessionId, organization);
return getSession(server, recent.id, recent.token).then((response) => {
if (response?.session) {
return response.session;
}
});
}
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>Verify 2-Factor</h1>
{sessionFactors && (
<UserAvatar
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
)}
<p className="ztdl-p mb-6 block">
Verify your account with your device.
</p>
{!(loginName || sessionId) && (
<Alert>Provide your active session as loginName param</Alert>
)}
{(loginName || sessionId) && (
<LoginPasskey
loginName={loginName}
sessionId={sessionId}
authRequestId={authRequestId}
altPassword={false}
organization={organization}
login={false} // this sets the userVerificationRequirement to discouraged as its used as second factor
/>
)}
</div>
</DynamicTheme>
);
}

View File

@@ -0,0 +1,69 @@
import { getBrandingSettings, getSession, server } from "#/lib/zitadel";
import Alert, { AlertType } from "#/ui/Alert";
import DynamicTheme from "#/ui/DynamicTheme";
import RegisterPasskey from "#/ui/RegisterPasskey";
import RegisterU2F from "#/ui/RegisterU2F";
import UserAvatar from "#/ui/UserAvatar";
import { getMostRecentCookieWithLoginname } from "#/utils/cookies";
export default async function Page({
searchParams,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const { loginName, organization, authRequestId } = searchParams;
const sessionFactors = await loadSession(loginName);
async function loadSession(loginName?: string) {
const recent = await getMostRecentCookieWithLoginname(
loginName,
organization
);
return getSession(server, recent.id, recent.token).then((response) => {
if (response?.session) {
return response.session;
}
});
}
const title = "Use your passkey to confirm it's really you";
const description =
"Your device will ask for your fingerprint, face, or screen lock";
const branding = await getBrandingSettings(server, organization);
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>{title}</h1>
{sessionFactors && (
<UserAvatar
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
)}
<p className="ztdl-p mb-6 block">{description}</p>
{!sessionFactors && (
<div className="py-4">
<Alert>
Could not get the context of the user. Make sure to enter the
username first or provide a loginName as searchParam.
</Alert>
</div>
)}
{sessionFactors?.id && (
<RegisterU2F
sessionId={sessionFactors.id}
organization={organization}
authRequestId={authRequestId}
/>
)}
</div>
</DynamicTheme>
);
}

View File

@@ -0,0 +1,70 @@
import {
SessionCookie,
getMostRecentSessionCookie,
getSessionCookieById,
getSessionCookieByLoginName,
} from "#/utils/cookies";
import { setSessionAndUpdateCookie } from "#/utils/session";
import { Checks } from "@zitadel/server";
import { NextRequest, NextResponse, userAgent } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
if (body) {
const { loginName, sessionId, organization, authRequestId, code, method } =
body;
const recentPromise: Promise<SessionCookie> = 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: Checks = {};
if (method === "time-based") {
checks.totp = {
code,
};
} else if (method === "sms") {
checks.otpSms = {
code,
};
} else if (method === "email") {
checks.otpEmail = {
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 }
);
}
}

View File

@@ -1,4 +1,10 @@
import { server, deleteSession } from "#/lib/zitadel"; import {
server,
deleteSession,
getSession,
getUserByID,
listAuthenticationMethodTypes,
} from "#/lib/zitadel";
import { import {
SessionCookie, SessionCookie,
getMostRecentSessionCookie, getMostRecentSessionCookie,
@@ -11,7 +17,7 @@ import {
createSessionForIdpAndUpdateCookie, createSessionForIdpAndUpdateCookie,
setSessionAndUpdateCookie, setSessionAndUpdateCookie,
} from "#/utils/session"; } from "#/utils/session";
import { RequestChallenges } from "@zitadel/server"; import { Challenges, Checks, RequestChallenges } from "@zitadel/server";
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -67,11 +73,10 @@ export async function PUT(request: NextRequest) {
loginName, loginName,
sessionId, sessionId,
organization, organization,
password, checks,
webAuthN,
authRequestId, authRequestId,
challenges,
} = body; } = body;
const challenges: RequestChallenges = body.challenges;
const recentPromise: Promise<SessionCookie> = sessionId const recentPromise: Promise<SessionCookie> = sessionId
? getSessionCookieById(sessionId).catch((error) => { ? getSessionCookieById(sessionId).catch((error) => {
@@ -92,22 +97,63 @@ export async function PUT(request: NextRequest) {
} }
return recentPromise return recentPromise
.then((recent) => { .then(async (recent) => {
if (
challenges &&
(challenges.otpEmail === "" || challenges.otpSms === "")
) {
const sessionResponse = await getSession(
server,
recent.id,
recent.token
);
if (sessionResponse && sessionResponse.session?.factors?.user?.id) {
const userResponse = await getUserByID(
sessionResponse.session.factors.user.id
);
if (
challenges.otpEmail === "" &&
userResponse.user?.human?.email?.email
) {
challenges.otpEmail = userResponse.user?.human?.email?.email;
}
if (
challenges.otpSms === "" &&
userResponse.user?.human?.phone?.phone
) {
challenges.otpSms = userResponse.user?.human?.phone?.phone;
}
}
}
return setSessionAndUpdateCookie( return setSessionAndUpdateCookie(
recent, recent,
password, checks,
webAuthN,
challenges, challenges,
authRequestId authRequestId
).then((session) => { ).then(async (session) => {
// if password, check if user has MFA methods
let authMethods;
if (checks && checks.password && session.factors?.user?.id) {
const response = await listAuthenticationMethodTypes(
session.factors?.user?.id
);
if (response.authMethodTypes && response.authMethodTypes.length) {
authMethods = response.authMethodTypes;
}
}
return NextResponse.json({ return NextResponse.json({
sessionId: session.id, sessionId: session.id,
factors: session.factors, factors: session.factors,
challenges: session.challenges, challenges: session.challenges,
authMethods,
}); });
}); });
}) })
.catch((error) => { .catch((error) => {
console.error(error);
return NextResponse.json({ details: error }, { status: 500 }); return NextResponse.json({ details: error }, { status: 500 });
}); });
} else { } else {

View File

@@ -0,0 +1,46 @@
import {
createPasskeyRegistrationLink,
getSession,
registerPasskey,
registerU2F,
server,
} from "#/lib/zitadel";
import { getSessionCookieById } from "#/utils/cookies";
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(
server,
sessionCookie.id,
sessionCookie.token
);
const domain: string = request.nextUrl.hostname;
const userId = session?.session?.factors?.user?.id;
if (userId) {
return registerU2F(userId, 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 });
}
}

View File

@@ -0,0 +1,50 @@
import { getSession, server, verifyU2FRegistration } from "#/lib/zitadel";
import { getSessionCookieById } from "#/utils/cookies";
import { VerifyU2FRegistrationRequest } from "@zitadel/server";
import { NextRequest, NextResponse, userAgent } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
if (body) {
let { u2fId, 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(
server,
sessionCookie.id,
sessionCookie.token
);
const userId = session?.session?.factors?.user?.id;
if (userId) {
const req: VerifyU2FRegistrationRequest = {
publicKeyCredential,
u2fId,
userId,
tokenName: passkeyName,
};
return verifyU2FRegistration(req)
.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 });
}
}

View File

@@ -53,6 +53,13 @@ export async function GET(request: NextRequest) {
sessions = await loadSessions(ids); sessions = await loadSessions(ids);
} }
/**
* TODO: before automatically redirecting to the callbackUrl, check if the session is still valid
* possible scenaio:
* mfa is required, session is not valid anymore (e.g. session expired, user logged out, etc.)
* to check for mfa for automatically selected session -> const response = await listAuthenticationMethodTypes(userId);
**/
if (authRequestId && sessionId) { if (authRequestId && sessionId) {
console.log( console.log(
`Login with session: ${sessionId} and authRequest: ${authRequestId}` `Login with session: ${sessionId} and authRequest: ${authRequestId}`
@@ -86,7 +93,8 @@ export async function GET(request: NextRequest) {
if (authRequestId) { if (authRequestId) {
console.log(`Login with authRequest: ${authRequestId}`); console.log(`Login with authRequest: ${authRequestId}`);
const { authRequest } = await getAuthRequest(server, { authRequestId }); const { authRequest } = await getAuthRequest(server, { authRequestId });
let organization;
let organization = "";
if (authRequest?.scope) { if (authRequest?.scope) {
const orgScope = authRequest.scope.find((s: string) => const orgScope = authRequest.scope.find((s: string) =>
@@ -112,6 +120,18 @@ export async function GET(request: NextRequest) {
} }
} }
const gotoAccounts = (): NextResponse<unknown> => {
const accountsUrl = new URL("/accounts", request.url);
if (authRequest?.id) {
accountsUrl.searchParams.set("authRequestId", authRequest?.id);
}
if (organization) {
accountsUrl.searchParams.set("organization", organization);
}
return NextResponse.redirect(accountsUrl);
};
if (authRequest && authRequest.prompt.includes(Prompt.PROMPT_CREATE)) { if (authRequest && authRequest.prompt.includes(Prompt.PROMPT_CREATE)) {
const registerUrl = new URL("/register", request.url); const registerUrl = new URL("/register", request.url);
if (authRequest?.id) { if (authRequest?.id) {
@@ -128,15 +148,7 @@ export async function GET(request: NextRequest) {
if (authRequest && sessions.length) { if (authRequest && sessions.length) {
// if some accounts are available for selection and select_account is set // if some accounts are available for selection and select_account is set
if (authRequest.prompt.includes(Prompt.PROMPT_SELECT_ACCOUNT)) { if (authRequest.prompt.includes(Prompt.PROMPT_SELECT_ACCOUNT)) {
const accountsUrl = new URL("/accounts", request.url); return gotoAccounts();
if (authRequest?.id) {
accountsUrl.searchParams.set("authRequestId", authRequest?.id);
}
if (organization) {
accountsUrl.searchParams.set("organization", organization);
}
return NextResponse.redirect(accountsUrl);
} else if (authRequest.prompt.includes(Prompt.PROMPT_LOGIN)) { } else if (authRequest.prompt.includes(Prompt.PROMPT_LOGIN)) {
// if prompt is login // if prompt is login
const loginNameUrl = new URL("/loginname", request.url); const loginNameUrl = new URL("/loginname", request.url);
@@ -196,26 +208,25 @@ export async function GET(request: NextRequest) {
sessionId: cookie?.id, sessionId: cookie?.id,
sessionToken: cookie?.token, sessionToken: cookie?.token,
}; };
const { callbackUrl } = await createCallback(server, { try {
authRequestId, const { callbackUrl } = await createCallback(server, {
session, authRequestId,
}); session,
return NextResponse.redirect(callbackUrl); });
} else { if (callbackUrl) {
const accountsUrl = new URL("/accounts", request.url); return NextResponse.redirect(callbackUrl);
accountsUrl.searchParams.set("authRequestId", authRequestId); } else {
if (organization) { return gotoAccounts();
accountsUrl.searchParams.set("organization", organization); }
} catch (error) {
console.error(error);
return gotoAccounts();
} }
return NextResponse.redirect(accountsUrl); } else {
return gotoAccounts();
} }
} else { } else {
const accountsUrl = new URL("/accounts", request.url); return gotoAccounts();
accountsUrl.searchParams.set("authRequestId", authRequestId);
if (organization) {
accountsUrl.searchParams.set("organization", organization);
}
return NextResponse.redirect(accountsUrl);
} }
} }
} else { } else {

View File

@@ -0,0 +1,24 @@
"use server";
import { getMostRecentCookieWithLoginname } from "#/utils/cookies";
import { getSession, server, verifyTOTPRegistration } from "./zitadel";
export async function verifyTOTP(
code: string,
loginName?: string,
organization?: string
) {
return getMostRecentCookieWithLoginname(loginName, organization)
.then((recent) => {
return getSession(server, recent.id, recent.token).then((response) => {
return { session: response?.session, token: recent.token };
});
})
.then(({ session, token }) => {
if (session?.factors?.user?.id) {
return verifyTOTPRegistration(code, session.factors.user.id, token);
} else {
throw Error("No user id found in session.");
}
});
}

View File

@@ -1,24 +1,34 @@
import { VerifyU2FRegistrationRequest } from "@zitadel/server";
import { import {
GetUserByIDResponse,
RegisterTOTPResponse,
VerifyTOTPRegistrationResponse,
} from "@zitadel/server";
import {
LegalAndSupportSettings,
PasswordComplexitySettings,
ZitadelServer, ZitadelServer,
VerifyMyAuthFactorOTPResponse,
ZitadelServerOptions, ZitadelServerOptions,
user, user,
oidc, oidc,
settings, settings,
getServers, getServers,
auth,
initializeServer, initializeServer,
session, session,
GetGeneralSettingsResponse, GetGeneralSettingsResponse,
CreateSessionResponse, CreateSessionResponse,
GetBrandingSettingsResponse, GetBrandingSettingsResponse,
GetPasswordComplexitySettingsResponse, GetPasswordComplexitySettingsResponse,
RegisterU2FResponse,
GetLegalAndSupportSettingsResponse, GetLegalAndSupportSettingsResponse,
AddHumanUserResponse, AddHumanUserResponse,
BrandingSettings, BrandingSettings,
ListSessionsResponse, ListSessionsResponse,
LegalAndSupportSettings,
PasswordComplexitySettings,
GetSessionResponse, GetSessionResponse,
VerifyEmailResponse, VerifyEmailResponse,
Checks,
SetSessionResponse, SetSessionResponse,
SetSessionRequest, SetSessionRequest,
ListUsersResponse, ListUsersResponse,
@@ -39,9 +49,14 @@ import {
CreateCallbackResponse, CreateCallbackResponse,
RequestChallenges, RequestChallenges,
TextQueryMethod, TextQueryMethod,
ListHumanAuthFactorsResponse,
AddHumanUserRequest, AddHumanUserRequest,
AddOTPEmailResponse,
AddOTPSMSResponse,
} from "@zitadel/server"; } from "@zitadel/server";
const SESSION_LIFETIME_S = 3000;
export const zitadelConfig: ZitadelServerOptions = { export const zitadelConfig: ZitadelServerOptions = {
name: "zitadel login", name: "zitadel login",
apiUrl: process.env.ZITADEL_API_URL ?? "", apiUrl: process.env.ZITADEL_API_URL ?? "",
@@ -78,6 +93,65 @@ export async function getLoginSettings(
.then((resp: GetLoginSettingsResponse) => resp.settings); .then((resp: GetLoginSettingsResponse) => resp.settings);
} }
export async function verifyMyAuthFactorOTP(
code: string
): Promise<VerifyMyAuthFactorOTPResponse> {
const authService = auth.getAuth(server);
return authService.verifyMyAuthFactorOTP({ code }, {});
}
export async function addOTPEmail(
userId: string
): Promise<AddOTPEmailResponse | undefined> {
const userService = user.getUser(server);
return userService.addOTPEmail(
{
userId,
},
{}
);
}
export async function addOTPSMS(
userId: string,
token?: string
): Promise<AddOTPSMSResponse | undefined> {
let userService;
if (token) {
const authConfig: ZitadelServerOptions = {
name: "zitadel login",
apiUrl: process.env.ZITADEL_API_URL ?? "",
token: token,
};
const sessionUser = initializeServer(authConfig);
userService = user.getUser(sessionUser);
} else {
userService = user.getUser(server);
}
return userService.addOTPSMS({ userId }, {});
}
export async function registerTOTP(
userId: string,
token?: string
): Promise<RegisterTOTPResponse | undefined> {
let userService;
if (token) {
const authConfig: ZitadelServerOptions = {
name: "zitadel login",
apiUrl: process.env.ZITADEL_API_URL ?? "",
token: token,
};
const sessionUser = initializeServer(authConfig);
userService = user.getUser(sessionUser);
} else {
userService = user.getUser(server);
}
return userService.registerTOTP({ userId }, {});
}
export async function getGeneralSettings( export async function getGeneralSettings(
server: ZitadelServer server: ZitadelServer
): Promise<string[] | undefined> { ): Promise<string[] | undefined> {
@@ -118,68 +192,23 @@ export async function getPasswordComplexitySettings(
.then((resp: GetPasswordComplexitySettingsResponse) => resp.settings); .then((resp: GetPasswordComplexitySettingsResponse) => resp.settings);
} }
export async function createSessionForLoginname( export async function createSessionFromChecks(
server: ZitadelServer, server: ZitadelServer,
loginName: string, checks: Checks,
password: string | undefined,
challenges: RequestChallenges | undefined challenges: RequestChallenges | undefined
): Promise<CreateSessionResponse | undefined> { ): Promise<CreateSessionResponse | undefined> {
const sessionService = session.getSession(server); const sessionService = session.getSession(server);
return password return sessionService.createSession(
? sessionService.createSession( {
{ checks: checks,
checks: { user: { loginName }, password: { password } }, challenges,
challenges, lifetime: {
lifetime: { seconds: SESSION_LIFETIME_S,
seconds: 300, nanos: 0,
nanos: 0, },
}, },
}, {}
{} );
)
: sessionService.createSession(
{
checks: { user: { loginName } },
challenges,
lifetime: {
seconds: 300,
nanos: 0,
},
},
{}
);
}
export async function createSessionForUserId(
server: ZitadelServer,
userId: string,
password: string | undefined,
challenges: RequestChallenges | undefined
): Promise<CreateSessionResponse | undefined> {
const sessionService = session.getSession(server);
return password
? sessionService.createSession(
{
checks: { user: { userId }, password: { password } },
challenges,
lifetime: {
seconds: 300,
nanos: 0,
},
},
{}
)
: sessionService.createSession(
{
checks: { user: { userId } },
challenges,
lifetime: {
seconds: 300,
nanos: 0,
},
},
{}
);
} }
export async function createSessionForUserIdAndIdpIntent( export async function createSessionForUserIdAndIdpIntent(
@@ -208,9 +237,8 @@ export async function setSession(
server: ZitadelServer, server: ZitadelServer,
sessionId: string, sessionId: string,
sessionToken: string, sessionToken: string,
password: string | undefined, challenges: RequestChallenges | undefined,
webAuthN: { credentialAssertionData: any } | undefined, checks: Checks
challenges: RequestChallenges | undefined
): Promise<SetSessionResponse | undefined> { ): Promise<SetSessionResponse | undefined> {
const sessionService = session.getSession(server); const sessionService = session.getSession(server);
@@ -222,12 +250,8 @@ export async function setSession(
metadata: {}, metadata: {},
}; };
if (password && payload.checks) { if (checks && payload.checks) {
payload.checks.password = { password }; payload.checks = checks;
}
if (webAuthN && payload.checks) {
payload.checks.webAuthN = webAuthN;
} }
return sessionService.setSession(payload, {}); return sessionService.setSession(payload, {});
@@ -296,6 +320,35 @@ export async function addHumanUser(
); );
} }
export async function verifyTOTPRegistration(
code: string,
userId: string,
token?: string
): Promise<VerifyTOTPRegistrationResponse> {
let userService;
if (token) {
const authConfig: ZitadelServerOptions = {
name: "zitadel login",
apiUrl: process.env.ZITADEL_API_URL ?? "",
token: token,
};
const sessionUser = initializeServer(authConfig);
userService = user.getUser(sessionUser);
} else {
userService = user.getUser(server);
}
return userService.verifyTOTPRegistration({ code, userId }, {});
}
export async function getUserByID(
userId: string
): Promise<GetUserByIDResponse> {
const userService = user.getUser(server);
return userService.getUserByID({ userId }, {});
}
export async function listUsers( export async function listUsers(
userName: string, userName: string,
organizationId: string organizationId: string
@@ -423,16 +476,63 @@ export async function setEmail(
* @returns the newly set email * @returns the newly set email
*/ */
export async function createPasskeyRegistrationLink( export async function createPasskeyRegistrationLink(
userId: string userId: string,
token?: string
): Promise<any> { ): Promise<any> {
const userservice = user.getUser(server); let userService;
if (token) {
const authConfig: ZitadelServerOptions = {
name: "zitadel login",
apiUrl: process.env.ZITADEL_API_URL ?? "",
token: token,
};
return userservice.createPasskeyRegistrationLink({ const sessionUser = initializeServer(authConfig);
userService = user.getUser(sessionUser);
} else {
userService = user.getUser(server);
}
return userService.createPasskeyRegistrationLink({
userId, userId,
returnCode: {}, returnCode: {},
}); });
} }
/**
*
* @param server
* @param userId the id of the user where the email should be set
* @param domain the domain on which the factor is registered
* @returns the newly set email
*/
export async function registerU2F(
userId: string,
domain: string
): Promise<RegisterU2FResponse> {
const userservice = user.getUser(server);
return userservice.registerU2F({
userId,
domain,
});
}
/**
*
* @param server
* @param userId the id of the user where the email should be set
* @param domain the domain on which the factor is registered
* @returns the newly set email
*/
export async function verifyU2FRegistration(
request: VerifyU2FRegistrationRequest
): Promise<any> {
const userservice = user.getUser(server);
return userservice.verifyU2FRegistration(request, {});
}
/** /**
* *
* @param server * @param server

View File

@@ -40,10 +40,12 @@
"@zitadel/react": "workspace:*", "@zitadel/react": "workspace:*",
"@zitadel/server": "workspace:*", "@zitadel/server": "workspace:*",
"clsx": "1.2.1", "clsx": "1.2.1",
"copy-to-clipboard": "^3.3.3",
"moment": "^2.29.4", "moment": "^2.29.4",
"next": "13.4.12", "next": "13.4.12",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"nice-grpc": "2.0.1", "nice-grpc": "2.0.1",
"qrcode.react": "^3.1.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-hook-form": "7.39.5", "react-hook-form": "7.39.5",

View File

@@ -7,6 +7,7 @@ let colors = {
text: { light: { contrast: {} }, dark: { contrast: {} } }, text: { light: { contrast: {} }, dark: { contrast: {} } },
link: { light: { contrast: {} }, dark: { contrast: {} } }, link: { light: { contrast: {} }, dark: { contrast: {} } },
}; };
const shades = [ const shades = [
"50", "50",
"100", "100",
@@ -49,7 +50,51 @@ module.exports = {
}, },
theme: { theme: {
extend: { extend: {
colors, colors: {
...colors,
state: {
success: {
light: {
background: "#cbf4c9",
color: "#0e6245",
},
dark: {
background: "#68cf8340",
color: "#cbf4c9",
},
},
error: {
light: {
background: "#ffc1c1",
color: "#620e0e",
},
dark: {
background: "#af455359",
color: "#ffc1c1",
},
},
neutral: {
light: {
background: "#e4e7e4",
color: "#000000",
},
dark: {
background: "#1a253c",
color: "#ffffff",
},
},
alert: {
light: {
background: "#fbbf24",
color: "#92400e",
},
dark: {
background: "#92400e50",
color: "#fbbf24",
},
},
},
},
animation: { animation: {
shake: "shake .8s cubic-bezier(.36,.07,.19,.97) both;", shake: "shake .8s cubic-bezier(.36,.07,.19,.97) both;",
}, },

View File

@@ -38,7 +38,7 @@ export default function Alert({ children, type = AlertType.ALERT }: Props) {
{type === AlertType.INFO && ( {type === AlertType.INFO && (
<InformationCircleIcon className="flex-shrink-0 h-5 w-5 mr-2 ml-2" /> <InformationCircleIcon className="flex-shrink-0 h-5 w-5 mr-2 ml-2" />
)} )}
<span className="text-sm">{children}</span> <span className="text-sm w-full ">{children}</span>
</div> </div>
); );
} }

View File

@@ -0,0 +1,205 @@
import clsx from "clsx";
import Link from "next/link";
import { BadgeState, StateBadge } from "./StateBadge";
import { CheckIcon } from "@heroicons/react/24/solid";
import { ReactNode } from "react";
const cardClasses = (alreadyAdded: boolean) =>
clsx(
"relative bg-background-light-400 dark:bg-background-dark-400 group block space-y-1.5 rounded-md px-5 py-3 border border-divider-light dark:border-divider-dark transition-all ",
alreadyAdded
? "opacity-50 cursor-default"
: "hover:shadow-lg hover:dark:bg-white/10"
);
const LinkWrapper = ({
alreadyAdded,
children,
link,
}: {
alreadyAdded: boolean;
children: ReactNode;
link: string;
}) => {
return !alreadyAdded ? (
<Link href={link} className={cardClasses(alreadyAdded)}>
{children}
</Link>
) : (
<div className={cardClasses(alreadyAdded)}>{children}</div>
);
};
export const TOTP = (alreadyAdded: boolean, link: string) => {
return (
<LinkWrapper alreadyAdded={alreadyAdded} link={link}>
<div
className={clsx(
"font-medium flex items-center",
alreadyAdded ? "opacity-50" : ""
)}
>
<svg
className="h-9 w-9 transform -translate-x-[2px] mr-4"
version="1.1"
baseProfile="basic"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 512 512"
xmlSpace="preserve"
>
<path
fill="#1A73E8"
d="M440,255.99997v0.00006C440,273.12085,426.12085,287,409.00003,287H302l-46-93.01001l49.6507-85.9951
c8.56021-14.82629,27.51834-19.9065,42.34518-11.34724l0.00586,0.0034c14.82776,8.55979,19.90875,27.51928,11.34857,42.34682
L309.70001,225h99.30002C426.12085,225,440,238.87917,440,255.99997z"
/>
<path
fill="#EA4335"
d="M348.00174,415.34897l-0.00586,0.00339c-14.82684,8.55927-33.78497,3.47903-42.34518-11.34723L256,318.01001
l-49.65065,85.99509c-8.5602,14.82629-27.51834,19.90652-42.34517,11.34729l-0.00591-0.00342
c-14.82777-8.55978-19.90875-27.51929-11.34859-42.34683L202.29999,287L256,285l53.70001,2l49.6503,86.00214
C367.91049,387.82968,362.8295,406.78918,348.00174,415.34897z"
/>
<path
fill="#FBBC04"
d="M256,193.98999L242,232l-39.70001-7l-49.6503-86.00212
c-8.56017-14.82755-3.47919-33.78705,11.34859-42.34684l0.00591-0.00341c14.82683-8.55925,33.78497-3.47903,42.34517,11.34726
L256,193.98999z"
/>
<path
fill="#34A853"
d="M248,225l-36,62H102.99997C85.87916,287,72,273.12085,72,256.00003v-0.00006
C72,238.87917,85.87916,225,102.99997,225H248z"
/>
<polygon
fill="#185DB7"
points="309.70001,287 202.29999,287 256,193.98999 "
/>
</svg>{" "}
<span>Authenticator App</span>
</div>
{alreadyAdded && (
<>
<Setup />
</>
)}
</LinkWrapper>
);
};
export const U2F = (alreadyAdded: boolean, link: string) => {
return (
<LinkWrapper alreadyAdded={alreadyAdded} link={link}>
<div
className={clsx(
"font-medium flex items-center",
alreadyAdded ? "" : ""
)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
className="w-8 h-8 mr-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M7.864 4.243A7.5 7.5 0 0119.5 10.5c0 2.92-.556 5.709-1.568 8.268M5.742 6.364A7.465 7.465 0 004.5 10.5a7.464 7.464 0 01-1.15 3.993m1.989 3.559A11.209 11.209 0 008.25 10.5a3.75 3.75 0 117.5 0c0 .527-.021 1.049-.064 1.565M12 10.5a14.94 14.94 0 01-3.6 9.75m6.633-4.596a18.666 18.666 0 01-2.485 5.33"
/>
</svg>
<span>Universal Second Factor</span>
</div>
{alreadyAdded && (
<>
<Setup />
</>
)}
</LinkWrapper>
);
};
export const EMAIL = (alreadyAdded: boolean, link: string) => {
return (
<LinkWrapper alreadyAdded={alreadyAdded} link={link}>
<div
className={clsx(
"font-medium flex items-center",
alreadyAdded ? "" : ""
)}
>
<svg
className="w-8 h-8 mr-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width={1.5}
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
<span>Code via Email</span>
</div>
{alreadyAdded && (
<>
<Setup />
</>
)}
</LinkWrapper>
);
};
export const SMS = (alreadyAdded: boolean, link: string) => {
return (
<LinkWrapper alreadyAdded={alreadyAdded} link={link}>
<div
className={clsx(
"font-medium flex items-center",
alreadyAdded ? "" : ""
)}
>
<svg
className="w-8 h-8 mr-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
/>
</svg>
<span>Code via SMS</span>
</div>
{alreadyAdded && (
<>
<Setup />
</>
)}
</LinkWrapper>
);
};
function Setup() {
return (
<div className="transform absolute right-2 top-0">
<StateBadge evenPadding={true} state={BadgeState.Success}>
<CheckIcon className="w-4 h-4" />
</StateBadge>
</div>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import {
AuthenticationMethodType,
LoginSettings,
login,
} from "@zitadel/server";
import Link from "next/link";
import { BadgeState, StateBadge } from "./StateBadge";
import clsx from "clsx";
import { CheckIcon } from "@heroicons/react/24/outline";
import { EMAIL, SMS, TOTP, U2F } from "./AuthMethods";
type Props = {
loginName?: string;
sessionId?: string;
authRequestId?: string;
organization?: string;
userMethods: AuthenticationMethodType[];
};
export default function ChooseSecondFactor({
loginName,
sessionId,
authRequestId,
organization,
userMethods,
}: Props) {
const params = new URLSearchParams({});
if (loginName) {
params.append("loginName", loginName);
}
if (sessionId) {
params.append("sessionId", sessionId);
}
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization) {
params.append("organization", organization);
}
return (
<div className="grid grid-cols-1 gap-5 w-full pt-4">
{userMethods.map((method, i) => {
return (
<div key={"method-" + i}>
{method === 4 && TOTP(false, "/otp/time-based?" + params)}
{method === 5 && U2F(false, "/u2f?" + params)}
{method === 7 && EMAIL(false, "/otp/email?" + params)}
{method === 6 && SMS(false, "/otp/sms?" + params)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,62 @@
"use client";
import { AuthenticationMethodType, LoginSettings } from "@zitadel/server";
import { EMAIL, SMS, TOTP, U2F } from "./AuthMethods";
type Props = {
loginName?: string;
sessionId?: string;
authRequestId?: string;
organization?: string;
loginSettings: LoginSettings;
userMethods: AuthenticationMethodType[];
checkAfter: boolean;
phoneVerified: boolean;
emailVerified: boolean;
};
export default function ChooseSecondFactorToSetup({
loginName,
sessionId,
authRequestId,
organization,
loginSettings,
userMethods,
checkAfter,
phoneVerified,
emailVerified,
}: Props) {
const params = new URLSearchParams({});
if (loginName) {
params.append("loginName", loginName);
}
if (sessionId) {
params.append("sessionId", sessionId);
}
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization) {
params.append("organization", organization);
}
if (checkAfter) {
params.append("checkAfter", "true");
}
return (
<div className="grid grid-cols-1 gap-5 w-full pt-4">
{loginSettings.secondFactors.map((factor, i) => {
return factor === 1
? TOTP(userMethods.includes(4), "/otp/time-based/set?" + params)
: factor === 2
? U2F(userMethods.includes(5), "/u2f/set?" + params)
: factor === 3 && emailVerified
? EMAIL(userMethods.includes(7), "/otp/email/set?" + params)
: factor === 4 && phoneVerified
? SMS(userMethods.includes(6), "/otp/sms/set?" + params)
: null;
})}
</div>
);
}

View File

@@ -0,0 +1,41 @@
"use client";
import {
ClipboardDocumentCheckIcon,
ClipboardIcon,
} from "@heroicons/react/20/solid";
import copy from "copy-to-clipboard";
import { useEffect, useState } from "react";
type Props = {
value: string;
};
export default function CopyToClipboard({ value }: Props) {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (copied) {
copy(value);
const to = setTimeout(setCopied, 1000, false);
return () => clearTimeout(to);
}
}, [copied]);
return (
<div className="flex flex-row items-center px-2">
<button
id="tooltip-ctc"
type="button"
className=" text-primary-light-500 dark:text-primary-dark-500"
onClick={() => setCopied(true)}
>
{!copied ? (
<ClipboardIcon className="h-5 w-5" />
) : (
<ClipboardDocumentCheckIcon className="h-5 w-5" />
)}
</button>
</div>
);
}

245
apps/login/ui/LoginOTP.tsx Normal file
View File

@@ -0,0 +1,245 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { coerceToArrayBuffer, coerceToBase64Url } from "#/utils/base64";
import { Button, ButtonVariants } from "./Button";
import Alert, { AlertType } from "./Alert";
import { Spinner } from "./Spinner";
import { Checks } from "@zitadel/server";
import { useForm } from "react-hook-form";
import { TextInput } from "./Input";
import { Challenges } from "@zitadel/server";
// either loginName or sessionId must be provided
type Props = {
loginName?: string;
sessionId?: string;
authRequestId?: string;
organization?: string;
method: string;
code?: string;
};
type Inputs = {
code: string;
};
export default function LoginOTP({
loginName,
sessionId,
authRequestId,
organization,
method,
code,
}: Props) {
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const router = useRouter();
const initialized = useRef(false);
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur",
defaultValues: {
code: code ? code : "",
},
});
useEffect(() => {
if (!initialized.current && ["email", "sms"].includes(method)) {
initialized.current = true;
setLoading(true);
updateSessionForOTPChallenge()
.then((response) => {
setLoading(false);
})
.catch((error) => {
setError(error);
setLoading(false);
});
}
}, []);
async function updateSessionForOTPChallenge() {
const challenges: Challenges = {};
if (method === "email") {
challenges.otpEmail = "";
}
if (method === "sms") {
challenges.otpSms = "";
}
setLoading(true);
const res = await fetch("/api/session", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
loginName,
sessionId,
organization,
challenges,
authRequestId,
}),
});
setLoading(false);
if (!res.ok) {
const error = await res.json();
throw error.details.details;
}
return res.json();
}
async function submitCode(values: Inputs, organization?: string) {
setLoading(true);
let body: any = {
code: values.code,
method,
};
if (organization) {
body.organization = organization;
}
if (authRequestId) {
body.authRequestId = authRequestId;
}
const checks: Checks = {};
if (method === "sms") {
checks.otpSms = { code: values.code };
}
if (method === "email") {
checks.otpEmail = { code: values.code };
}
if (method === "time-based") {
checks.totp = { code: values.code };
}
const res = await fetch("/api/session", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
loginName,
sessionId,
organization,
checks,
authRequestId,
}),
});
setLoading(false);
if (!res.ok) {
const response = await res.json();
setError(response.details.details ?? "An internal error occurred");
return Promise.reject(
response.details.details ?? "An internal error occurred"
);
}
return res.json();
}
function setCodeAndContinue(values: Inputs, organization?: string) {
return submitCode(values, organization).then((response) => {
if (authRequestId && response && response.sessionId) {
const params = new URLSearchParams({
sessionId: response.sessionId,
authRequest: authRequestId,
});
if (organization) {
params.append("organization", organization);
}
return router.push(`/login?` + params);
} else {
const params = new URLSearchParams(
authRequestId
? {
loginName: response.factors.user.loginName,
authRequestId,
}
: {
loginName: response.factors.user.loginName,
}
);
if (organization) {
params.append("organization", organization);
}
return router.push(`/signedin?` + params);
}
});
}
const { errors } = formState;
return (
<form className="w-full">
{["email", "sms"].includes(method) && (
<Alert type={AlertType.INFO}>
<div className="flex flex-row">
<span className="flex-1 mr-auto text-left">
Did not get the Code?
</span>
<button
aria-label="Resend OTP Code"
disabled={loading}
className="ml-4 text-primary-light-500 dark:text-primary-dark-500 hover:dark:text-primary-dark-400 hover:text-primary-light-400 cursor-pointer disabled:cursor-default disabled:text-gray-400 dark:disabled:text-gray-700"
onClick={() => {
setLoading(true);
updateSessionForOTPChallenge()
.then((response) => {
setLoading(false);
})
.catch((error) => {
setError(error);
setLoading(false);
});
}}
>
Resend
</button>
</div>
</Alert>
)}
<div className="mt-4">
<TextInput
type="text"
{...register("code", { required: "This field is required" })}
label="Code"
/>
</div>
{error && (
<div className="py-4">
<Alert>{error}</Alert>
</div>
)}
<div className="mt-8 flex w-full flex-row items-center">
<span className="flex-grow"></span>
<Button
type="submit"
className="self-end"
variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid}
onClick={handleSubmit((e) => setCodeAndContinue(e, organization))}
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
continue
</Button>
</div>
</form>
);
}

View File

@@ -6,6 +6,7 @@ import { coerceToArrayBuffer, coerceToBase64Url } from "#/utils/base64";
import { Button, ButtonVariants } from "./Button"; import { Button, ButtonVariants } from "./Button";
import Alert from "./Alert"; import Alert from "./Alert";
import { Spinner } from "./Spinner"; import { Spinner } from "./Spinner";
import { Checks } from "@zitadel/server";
// either loginName or sessionId must be provided // either loginName or sessionId must be provided
type Props = { type Props = {
@@ -13,6 +14,7 @@ type Props = {
sessionId?: string; sessionId?: string;
authRequestId?: string; authRequestId?: string;
altPassword: boolean; altPassword: boolean;
login?: boolean;
organization?: string; organization?: string;
}; };
@@ -22,6 +24,7 @@ export default function LoginPasskey({
authRequestId, authRequestId,
altPassword, altPassword,
organization, organization,
login = true,
}: Props) { }: Props) {
const [error, setError] = useState<string>(""); const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false); const [loading, setLoading] = useState<boolean>(false);
@@ -30,6 +33,7 @@ export default function LoginPasskey({
const initialized = useRef(false); const initialized = useRef(false);
// TODO: move this to server side
useEffect(() => { useEffect(() => {
if (!initialized.current) { if (!initialized.current) {
initialized.current = true; initialized.current = true;
@@ -60,7 +64,9 @@ export default function LoginPasskey({
} }
}, []); }, []);
async function updateSessionForChallenge() { async function updateSessionForChallenge(
userVerificationRequirement: number = login ? 1 : 3
) {
setLoading(true); setLoading(true);
const res = await fetch("/api/session", { const res = await fetch("/api/session", {
method: "PUT", method: "PUT",
@@ -74,7 +80,11 @@ export default function LoginPasskey({
challenges: { challenges: {
webAuthN: { webAuthN: {
domain: "", domain: "",
userVerificationRequirement: 1, // USER_VERIFICATION_REQUIREMENT_UNSPECIFIED = 0;
// USER_VERIFICATION_REQUIREMENT_REQUIRED = 1; - passkey login
// USER_VERIFICATION_REQUIREMENT_PREFERRED = 2;
// USER_VERIFICATION_REQUIREMENT_DISCOURAGED = 3; - mfa
userVerificationRequirement: userVerificationRequirement,
}, },
}, },
authRequestId, authRequestId,
@@ -100,7 +110,9 @@ export default function LoginPasskey({
loginName, loginName,
sessionId, sessionId,
organization, organization,
webAuthN: { credentialAssertionData: data }, checks: {
webAuthN: { credentialAssertionData: data },
} as Checks,
authRequestId, authRequestId,
}), }),
}); });

View File

@@ -7,12 +7,19 @@ import { useForm } from "react-hook-form";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Spinner } from "./Spinner"; import { Spinner } from "./Spinner";
import Alert from "./Alert"; import Alert from "./Alert";
import {
LoginSettings,
AuthFactor,
Checks,
AuthenticationMethodType,
} from "@zitadel/server";
type Inputs = { type Inputs = {
password: string; password: string;
}; };
type Props = { type Props = {
loginSettings: LoginSettings | undefined;
loginName?: string; loginName?: string;
organization?: string; organization?: string;
authRequestId?: string; authRequestId?: string;
@@ -21,6 +28,7 @@ type Props = {
}; };
export default function PasswordForm({ export default function PasswordForm({
loginSettings,
loginName, loginName,
organization, organization,
authRequestId, authRequestId,
@@ -49,7 +57,9 @@ export default function PasswordForm({
body: JSON.stringify({ body: JSON.stringify({
loginName, loginName,
organization, organization,
password: values.password, checks: {
password: { password: values.password },
} as Checks,
authRequestId, authRequestId,
}), }),
}); });
@@ -58,15 +68,76 @@ export default function PasswordForm({
setLoading(false); setLoading(false);
if (!res.ok) { if (!res.ok) {
setError(response.details); console.log(response.details.details);
setError(response.details?.details ?? "Could not verify password");
return Promise.reject(response.details); return Promise.reject(response.details);
} }
return response; return response;
} }
function submitPasswordAndContinue(value: Inputs): Promise<boolean | void> { function submitPasswordAndContinue(value: Inputs): Promise<boolean | void> {
return submitPassword(value).then((resp: any) => { return submitPassword(value).then((resp) => {
if ( // if user has mfa -> /otp/[method] or /u2f
// if mfa is forced and user has no mfa -> /mfa/set
// if no passwordless -> /passkey/add
// exclude password
const availableSecondFactors = resp.authMethods?.filter(
(m: AuthenticationMethodType) => m !== 1
);
if (availableSecondFactors.length == 1) {
const params = new URLSearchParams({
loginName: resp.factors.user.loginName,
});
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization) {
params.append("organization", organization);
}
const factor = availableSecondFactors[0];
if (factor === 4) {
return router.push(`/otp/time-based?` + params);
} else if (factor === 6) {
return router.push(`/otp/sms?` + params);
} else if (factor === 7) {
return router.push(`/otp/email?` + params);
} else if (factor === 5) {
return router.push(`/u2f?` + params);
}
} else if (availableSecondFactors.length >= 1) {
const params = new URLSearchParams({
loginName: resp.factors.user.loginName,
});
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization) {
params.append("organization", organization);
}
return router.push(`/mfa?` + params);
} else if (loginSettings?.forceMfa && !availableSecondFactors.length) {
const params = new URLSearchParams({
loginName: resp.factors.user.loginName,
checkAfter: "true", // this defines if the check is directly made after the setup
});
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization) {
params.append("organization", organization);
}
return router.push(`/mfa/set?` + params);
} else if (
resp.factors && resp.factors &&
!resp.factors.passwordless && // if session was not verified with a passkey !resp.factors.passwordless && // if session was not verified with a passkey
promptPasswordless && // if explicitly prompted due policy promptPasswordless && // if explicitly prompted due policy
@@ -77,47 +148,48 @@ export default function PasswordForm({
promptPasswordless: "true", promptPasswordless: "true",
}); });
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization) { if (organization) {
params.append("organization", organization); params.append("organization", organization);
} }
return router.push(`/passkey/add?` + params); return router.push(`/passkey/add?` + params);
} else { } else if (authRequestId && resp && resp.sessionId) {
if (authRequestId && resp && resp.sessionId) { const params = new URLSearchParams({
const params = new URLSearchParams({ sessionId: resp.sessionId,
sessionId: resp.sessionId, authRequest: authRequestId,
authRequest: authRequestId, });
});
if (organization) { if (organization) {
params.append("organization", organization); params.append("organization", organization);
}
return router.push(`/login?` + params);
} else {
const params = new URLSearchParams(
authRequestId
? {
loginName: resp.factors.user.loginName,
authRequestId,
}
: {
loginName: resp.factors.user.loginName,
}
);
if (organization) {
params.append("organization", organization);
}
return router.push(`/signedin?` + params);
} }
return router.push(`/login?` + params);
} else {
// without OIDC flow
const params = new URLSearchParams(
authRequestId
? {
loginName: resp.factors.user.loginName,
authRequestId,
}
: {
loginName: resp.factors.user.loginName,
}
);
if (organization) {
params.append("organization", organization);
}
return router.push(`/signedin?` + params);
} }
}); });
} }
const { errors } = formState;
return ( return (
<form className="w-full"> <form className="w-full">
<div className={`${error && "transform-gpu animate-shake"}`}> <div className={`${error && "transform-gpu animate-shake"}`}>

View File

@@ -200,14 +200,17 @@ export default function RegisterPasskey({
onClick={() => { onClick={() => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (authRequestId) { if (authRequestId) {
params.set("authRequestId", authRequestId); params.set("authRequest", authRequestId);
}
if (sessionId) {
params.set("sessionId", sessionId);
} }
if (organization) { if (organization) {
params.set("organization", organization); params.set("organization", organization);
} }
router.push("/accounts?" + params); router.push("/login?" + params);
}} }}
> >
skip skip

View File

@@ -0,0 +1,216 @@
"use client";
import { useState } from "react";
import { Button, ButtonVariants } from "./Button";
import { useForm } from "react-hook-form";
import { useRouter } from "next/navigation";
import { Spinner } from "./Spinner";
import Alert from "./Alert";
import { RegisterU2FResponse } from "@zitadel/server";
import { coerceToArrayBuffer, coerceToBase64Url } from "#/utils/base64";
type Inputs = {};
type Props = {
sessionId: string;
authRequestId?: string;
organization?: string;
};
export default function RegisterU2F({
sessionId,
organization,
authRequestId,
}: Props) {
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur",
});
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const router = useRouter();
async function submitRegister() {
setError("");
setLoading(true);
const res = await fetch("/api/u2f", {
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(
u2fId: string,
passkeyName: string,
publicKeyCredential: any,
sessionId: string
) {
setLoading(true);
const res = await fetch("/api/u2f/verify", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
u2fId,
passkeyName,
publicKeyCredential,
sessionId,
}),
});
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<boolean | void> {
return submitRegister().then((resp: RegisterU2FResponse) => {
const u2fId = resp.u2fId;
if (
resp.publicKeyCredentialCreationOptions &&
resp.publicKeyCredentialCreationOptions.publicKey
) {
resp.publicKeyCredentialCreationOptions.publicKey.challenge =
coerceToArrayBuffer(
resp.publicKeyCredentialCreationOptions.publicKey.challenge,
"challenge"
);
resp.publicKeyCredentialCreationOptions.publicKey.user.id =
coerceToArrayBuffer(
resp.publicKeyCredentialCreationOptions.publicKey.user.id,
"userid"
);
if (
resp.publicKeyCredentialCreationOptions.publicKey.excludeCredentials
) {
resp.publicKeyCredentialCreationOptions.publicKey.excludeCredentials.map(
(cred: any) => {
cred.id = coerceToArrayBuffer(
cred.id as string,
"excludeCredentials.id"
);
return cred;
}
);
}
navigator.credentials
.create(resp.publicKeyCredentialCreationOptions)
.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(u2fId, "", 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("/u2f?" + 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;
});
}
});
}
const { errors } = formState;
return (
<form className="w-full">
{error && (
<div className="py-4">
<Alert>{error}</Alert>
</div>
)}
<div className="mt-8 flex w-full flex-row items-center">
<Button
type="button"
variant={ButtonVariants.Secondary}
onClick={() => router.back()}
>
back
</Button>
<span className="flex-grow"></span>
<Button
type="submit"
className="self-end"
variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid}
onClick={handleSubmit(submitRegisterAndContinue)}
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
continue
</Button>
</div>
</form>
);
}

View File

@@ -0,0 +1,40 @@
import clsx from "clsx";
import { ReactNode } from "react";
export enum BadgeState {
Info = "info",
Error = "error",
Success = "success",
Alert = "alert",
}
export type StateBadgeProps = {
state: BadgeState;
children: ReactNode;
evenPadding?: boolean;
};
const getBadgeClasses = (state: BadgeState, evenPadding: boolean) =>
clsx({
"w-fit border-box h-18.5px flex flex-row items-center whitespace-nowrap tracking-wider leading-4 items-center justify-center px-2 py-2px text-12px rounded-full shadow-sm":
true,
"bg-state-success-light-background text-state-success-light-color dark:bg-state-success-dark-background dark:text-state-success-dark-color ":
state === BadgeState.Success,
"bg-state-neutral-light-background text-state-neutral-light-color dark:bg-state-neutral-dark-background dark:text-state-neutral-dark-color":
state === BadgeState.Info,
"bg-state-error-light-background text-state-error-light-color dark:bg-state-error-dark-background dark:text-state-error-dark-color":
state === BadgeState.Error,
"bg-state-alert-light-background text-state-alert-light-color dark:bg-state-alert-dark-background dark:text-state-alert-dark-color":
state === BadgeState.Alert,
"p-[2px]": evenPadding,
});
export function StateBadge({
state = BadgeState.Success,
evenPadding = false,
children,
}: StateBadgeProps) {
return (
<span className={`${getBadgeClasses(state, evenPadding)}`}>{children}</span>
);
}

View File

@@ -0,0 +1,150 @@
"use client";
import { QRCodeSVG } from "qrcode.react";
import Alert, { AlertType } from "./Alert";
import Link from "next/link";
import CopyToClipboard from "./CopyToClipboard";
import { TextInput } from "./Input";
import { Button, ButtonVariants } from "./Button";
import { Spinner } from "./Spinner";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useRouter } from "next/navigation";
import { verifyTOTP } from "#/lib/server-actions";
import { login } from "@zitadel/server";
type Inputs = {
code: string;
};
type Props = {
uri: string;
secret: string;
loginName?: string;
sessionId?: string;
authRequestId?: string;
organization?: string;
checkAfter?: boolean;
};
export default function TOTPRegister({
uri,
secret,
loginName,
sessionId,
authRequestId,
organization,
checkAfter,
}: Props) {
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const router = useRouter();
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur",
defaultValues: {
code: "",
},
});
async function continueWithCode(values: Inputs) {
setLoading(true);
return verifyTOTP(values.code, loginName, organization)
.then((response) => {
setLoading(false);
// if attribute is set, validate MFA after it is setup, otherwise proceed as usual (when mfa is enforced to login)
if (checkAfter) {
const params = new URLSearchParams({});
if (loginName) {
params.append("loginName", loginName);
}
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization) {
params.append("organization", organization);
}
return router.push(`/otp/time-based?` + params);
} else {
if (authRequestId && sessionId) {
const params = new URLSearchParams({
sessionId: sessionId,
authRequest: authRequestId,
});
if (organization) {
params.append("organization", organization);
}
return router.push(`/login?` + params);
} else if (loginName) {
const params = new URLSearchParams({
loginName,
});
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization) {
params.append("organization", organization);
}
return router.push(`/signedin?` + params);
}
}
})
.catch((e) => {
setLoading(false);
setError(e.message);
});
}
return (
<div className="flex flex-col items-center ">
{uri && (
<>
<QRCodeSVG
className="rounded-md w-40 h-40 p-2 bg-white my-4"
value={uri}
/>
<div className="mb-4 w-96 flex text-sm my-2 border rounded-lg px-4 py-2 pr-2 border-divider-light dark:border-divider-dark">
<Link href={uri} target="_blank" className="flex-1 overflow-x-auto">
{uri}
</Link>
<CopyToClipboard value={uri}></CopyToClipboard>
</div>
<form className="w-full">
<div className="">
<TextInput
type="text"
{...register("code", { required: "This field is required" })}
label="Code"
/>
</div>
{error && (
<div className="py-4">
<Alert>{error}</Alert>
</div>
)}
<div className="mt-8 flex w-full flex-row items-center">
<span className="flex-grow"></span>
<Button
type="submit"
className="self-end"
variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid}
onClick={handleSubmit(continueWithCode)}
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
continue
</Button>
</div>
</form>
</>
)}
</div>
);
}

View File

@@ -6,13 +6,33 @@ type Props = {
loginName?: string; loginName?: string;
displayName?: string; displayName?: string;
showDropdown: boolean; showDropdown: boolean;
searchParams?: Record<string | number | symbol, string | undefined>;
}; };
export default function UserAvatar({ export default function UserAvatar({
loginName, loginName,
displayName, displayName,
showDropdown, showDropdown,
searchParams,
}: Props) { }: Props) {
const params = new URLSearchParams({});
if (searchParams?.sessionId) {
params.set("sessionId", searchParams.sessionId);
}
if (searchParams?.organization) {
params.set("organization", searchParams.organization);
}
if (searchParams?.authRequestId) {
params.set("authRequestId", searchParams.authRequestId);
}
if (searchParams?.loginName) {
params.set("loginName", searchParams.loginName);
}
return ( return (
<div className="flex h-full flex-row items-center rounded-full border p-[1px] dark:border-white/20"> <div className="flex h-full flex-row items-center rounded-full border p-[1px] dark:border-white/20">
<div> <div>
@@ -26,7 +46,7 @@ export default function UserAvatar({
<span className="flex-grow"></span> <span className="flex-grow"></span>
{showDropdown && ( {showDropdown && (
<Link <Link
href="/accounts" href={"/accounts?" + params}
className="ml-4 flex items-center justify-center p-1 hover:bg-black/10 dark:hover:bg-white/10 rounded-full mr-1 transition-all" className="ml-4 flex items-center justify-center p-1 hover:bg-black/10 dark:hover:bg-white/10 rounded-full mr-1 transition-all"
> >
<ChevronDownIcon className="h-4 w-4" /> <ChevronDownIcon className="h-4 w-4" />

View File

@@ -1,8 +1,7 @@
"use server"; "use server";
import { import {
createSessionForLoginname, createSessionFromChecks,
createSessionForUserId,
createSessionForUserIdAndIdpIntent, createSessionForUserIdAndIdpIntent,
getSession, getSession,
server, server,
@@ -13,7 +12,12 @@ import {
addSessionToCookie, addSessionToCookie,
updateSessionCookie, updateSessionCookie,
} from "./cookies"; } from "./cookies";
import { Session, Challenges, RequestChallenges } from "@zitadel/server"; import {
Session,
Challenges,
RequestChallenges,
Checks,
} from "@zitadel/server";
export async function createSessionAndUpdateCookie( export async function createSessionAndUpdateCookie(
loginName: string, loginName: string,
@@ -22,10 +26,15 @@ export async function createSessionAndUpdateCookie(
organization?: string, organization?: string,
authRequestId?: string authRequestId?: string
): Promise<Session> { ): Promise<Session> {
const createdSession = await createSessionForLoginname( const createdSession = await createSessionFromChecks(
server, server,
loginName, password
password, ? {
user: { loginName },
password: { password },
// totp: { code: totpCode },
}
: { user: { loginName } },
challenges challenges
); );
@@ -72,10 +81,15 @@ export async function createSessionForUserIdAndUpdateCookie(
challenges: RequestChallenges | undefined, challenges: RequestChallenges | undefined,
authRequestId: string | undefined authRequestId: string | undefined
): Promise<Session> { ): Promise<Session> {
const createdSession = await createSessionForUserId( const createdSession = await createSessionFromChecks(
server, server,
userId, password
password, ? {
user: { userId },
password: { password },
// totp: { code: totpCode },
}
: { user: { userId } },
challenges challenges
); );
@@ -174,8 +188,7 @@ export type SessionWithChallenges = Session & {
export async function setSessionAndUpdateCookie( export async function setSessionAndUpdateCookie(
recentCookie: SessionCookie, recentCookie: SessionCookie,
password: string | undefined, checks: Checks,
webAuthN: { credentialAssertionData: any } | undefined,
challenges: RequestChallenges | undefined, challenges: RequestChallenges | undefined,
authRequestId: string | undefined authRequestId: string | undefined
): Promise<SessionWithChallenges> { ): Promise<SessionWithChallenges> {
@@ -183,9 +196,8 @@ export async function setSessionAndUpdateCookie(
server, server,
recentCookie.id, recentCookie.id,
recentCookie.token, recentCookie.token,
password, challenges,
webAuthN, checks
challenges
).then((updatedSession) => { ).then((updatedSession) => {
if (updatedSession) { if (updatedSession) {
const sessionCookie: SessionCookie = { const sessionCookie: SessionCookie = {
@@ -202,39 +214,31 @@ export async function setSessionAndUpdateCookie(
sessionCookie.authRequestId = authRequestId; sessionCookie.authRequestId = authRequestId;
} }
return new Promise((resolve) => setTimeout(resolve, 1000)).then(() => return getSession(server, sessionCookie.id, sessionCookie.token).then(
// TODO: remove (response) => {
getSession(server, sessionCookie.id, sessionCookie.token).then( if (response?.session && response.session.factors?.user?.loginName) {
(response) => { const { session } = response;
if ( const newCookie: SessionCookie = {
response?.session && id: sessionCookie.id,
response.session.factors?.user?.loginName token: updatedSession.sessionToken,
) { creationDate: sessionCookie.creationDate,
const { session } = response; expirationDate: sessionCookie.expirationDate,
const newCookie: SessionCookie = { changeDate: `${session.changeDate?.getTime() ?? ""}`,
id: sessionCookie.id, loginName: session.factors?.user?.loginName ?? "",
token: updatedSession.sessionToken, organization: session.factors?.user?.organizationId ?? "",
creationDate: sessionCookie.creationDate, };
expirationDate: sessionCookie.expirationDate,
changeDate: `${session.changeDate?.getTime() ?? ""}`,
loginName: session.factors?.user?.loginName ?? "",
organization: session.factors?.user?.organizationId ?? "",
};
if (sessionCookie.authRequestId) { if (sessionCookie.authRequestId) {
newCookie.authRequestId = sessionCookie.authRequestId; newCookie.authRequestId = sessionCookie.authRequestId;
}
return updateSessionCookie(sessionCookie.id, newCookie).then(
() => {
return { challenges: updatedSession.challenges, ...session };
}
);
} else {
throw "could not get session or session does not have loginName";
} }
return updateSessionCookie(sessionCookie.id, newCookie).then(() => {
return { challenges: updatedSession.challenges, ...session };
});
} else {
throw "could not get session or session does not have loginName";
} }
) }
); );
} else { } else {
throw "Session not be set"; throw "Session not be set";

View File

@@ -5,25 +5,43 @@ import {
AuthServiceDefinition, AuthServiceDefinition,
GetMyUserResponse, GetMyUserResponse,
} from "../proto/server/zitadel/auth"; } from "../proto/server/zitadel/auth";
import { ZitadelServer } from "../server"; import { ZitadelServer, getServers } from "../server";
import { authMiddleware } from "../middleware"; import { authMiddleware } from "../middleware";
const createClient = <Client>( const createClient = <Client>(
definition: CompatServiceDefinition, definition: CompatServiceDefinition,
accessToken: string apiUrl: string,
token: string
) => { ) => {
if (!apiUrl) {
throw Error("ZITADEL_API_URL not set");
}
const channel = createChannel(process.env.ZITADEL_API_URL ?? ""); const channel = createChannel(process.env.ZITADEL_API_URL ?? "");
return createClientFactory() return createClientFactory()
.use(authMiddleware(accessToken)) .use(authMiddleware(token))
.create(definition, channel) as Client; .create(definition, channel) as Client;
}; };
export async function getAuth(app?: ZitadelServer): Promise<AuthServiceClient> { export const getAuth = (app?: string | ZitadelServer) => {
let config;
if (app && typeof app === "string") {
const apps = getServers();
config = apps.find((a) => a.name === app)?.config;
} else if (app && typeof app === "object") {
config = app.config;
}
if (!config) {
throw Error("No ZITADEL app found");
}
return createClient<AuthServiceClient>( return createClient<AuthServiceClient>(
AuthServiceDefinition as CompatServiceDefinition, AuthServiceDefinition as CompatServiceDefinition,
"" config.apiUrl,
config.token
); );
} };
export async function getMyUser(): Promise<GetMyUserResponse> { export async function getMyUser(): Promise<GetMyUserResponse> {
const auth = await getAuth(); const auth = await getAuth();

View File

@@ -1,2 +1,2 @@
export * from "../proto/server/zitadel/auth"; export * from "./auth";
export { getAuth } from "./auth"; export * as auth from "../proto/server/zitadel/auth";

View File

@@ -3,6 +3,7 @@ import * as session from "./v2/session";
import * as user from "./v2/user"; import * as user from "./v2/user";
import * as oidc from "./v2/oidc"; import * as oidc from "./v2/oidc";
import * as management from "./management"; import * as management from "./management";
import * as auth from "./auth";
import * as login from "./proto/server/zitadel/settings/v2beta/login_settings"; import * as login from "./proto/server/zitadel/settings/v2beta/login_settings";
import * as password from "./proto/server/zitadel/settings/v2beta/password_settings"; import * as password from "./proto/server/zitadel/settings/v2beta/password_settings";
@@ -51,6 +52,7 @@ export {
CreateSessionResponse, CreateSessionResponse,
SetSessionResponse, SetSessionResponse,
SetSessionRequest, SetSessionRequest,
Checks,
DeleteSessionResponse, DeleteSessionResponse,
} from "./proto/server/zitadel/session/v2beta/session_service"; } from "./proto/server/zitadel/session/v2beta/session_service";
export { export {
@@ -67,6 +69,7 @@ export { TextQueryMethod } from "./proto/server/zitadel/object/v2beta/object";
export { export {
AddHumanUserResponse, AddHumanUserResponse,
AddHumanUserRequest, AddHumanUserRequest,
GetUserByIDResponse,
VerifyEmailResponse, VerifyEmailResponse,
VerifyPasskeyRegistrationRequest, VerifyPasskeyRegistrationRequest,
VerifyPasskeyRegistrationResponse, VerifyPasskeyRegistrationResponse,
@@ -83,17 +86,37 @@ export {
RetrieveIdentityProviderIntentResponse, RetrieveIdentityProviderIntentResponse,
ListUsersRequest, ListUsersRequest,
ListUsersResponse, ListUsersResponse,
AddOTPEmailResponse,
AddOTPEmailRequest,
AddOTPSMSResponse,
AddOTPSMSRequest,
RegisterTOTPRequest,
RegisterTOTPResponse,
VerifyTOTPRegistrationRequest,
VerifyTOTPRegistrationResponse,
VerifyU2FRegistrationRequest,
VerifyU2FRegistrationResponse,
RegisterU2FResponse,
RegisterU2FRequest,
} from "./proto/server/zitadel/user/v2beta/user_service"; } from "./proto/server/zitadel/user/v2beta/user_service";
export { AuthFactor } from "./proto/server/zitadel/user";
export { export {
SetHumanPasswordResponse, SetHumanPasswordResponse,
SetHumanPasswordRequest, SetHumanPasswordRequest,
GetOrgByDomainGlobalResponse, GetOrgByDomainGlobalResponse,
ListHumanAuthFactorsResponse,
} from "./proto/server/zitadel/management"; } from "./proto/server/zitadel/management";
export * from "./proto/server/zitadel/idp"; export * from "./proto/server/zitadel/idp";
export { type LegalAndSupportSettings } from "./proto/server/zitadel/settings/v2beta/legal_settings"; export { type LegalAndSupportSettings } from "./proto/server/zitadel/settings/v2beta/legal_settings";
export { type PasswordComplexitySettings } from "./proto/server/zitadel/settings/v2beta/password_settings"; export { type PasswordComplexitySettings } from "./proto/server/zitadel/settings/v2beta/password_settings";
export { type ResourceOwnerType } from "./proto/server/zitadel/settings/v2beta/settings"; export { type ResourceOwnerType } from "./proto/server/zitadel/settings/v2beta/settings";
export {
type VerifyMyAuthFactorOTPResponse,
AddMyAuthFactorOTPResponse,
} from "./proto/server/zitadel/auth";
import { import {
getServers, getServers,
initializeServer, initializeServer,
@@ -115,4 +138,5 @@ export {
password, password,
legal, legal,
oidc, oidc,
auth,
}; };

8331
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff