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)
.then((resp) => {
const { idpInformation, userId } = resp;
if (idpInformation) {
// handle login
if (userId) {
@@ -166,10 +167,14 @@ export default async function Page({
});
} else {
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<div className="flex flex-col items-center space-y-4">
<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() {
return <div className="flex flex-col items-center space-y-4">mfa</div>;
import {
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 { Button, ButtonVariants } from "#/ui/Button";
import { TextInput } from "#/ui/Input";
import {
getBrandingSettings,
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 { useRouter } from "next/navigation";
import {
getMostRecentCookieWithLoginname,
getSessionCookieById,
} from "#/utils/cookies";
import { user } from "@zitadel/server";
export default function Page() {
const router = useRouter();
export default async function Page({
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 (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>Password</h1>
<p className="ztdl-p mb-6 block">Enter your password.</p>
<h1>Set up 2-Factor</h1>
<p className="ztdl-p">Choose one of the following second factors.</p>
{sessionWithData && (
<UserAvatar
loginName={loginName ?? sessionWithData.factors?.user?.loginName}
displayName={sessionWithData.factors?.user?.displayName}
showDropdown
displayName="Max Peintner"
loginName="max@zitadel.com"
searchParams={searchParams}
></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>
)}
{!(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>
</DynamicTheme>
);
}

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}
displayName={sessionFactors.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
)}
<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}
displayName={sessionFactors.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
)}
<p className="ztdl-p mb-6 block">{description}</p>
{!sessionFactors && <div className="py-4"></div>}
{!(loginName || sessionId) && (
<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 DynamicTheme from "#/ui/DynamicTheme";
import PasswordForm from "#/ui/PasswordForm";
@@ -28,6 +33,7 @@ export default async function Page({
}
const branding = await getBrandingSettings(server, organization);
const loginSettings = await getLoginSettings(server, organization);
return (
<DynamicTheme branding={branding}>
@@ -49,6 +55,7 @@ export default async function Page({
loginName={loginName ?? sessionFactors.factors?.user?.loginName}
displayName={sessionFactors.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
)}
@@ -56,6 +63,7 @@ export default async function Page({
loginName={loginName}
authRequestId={authRequestId}
organization={organization}
loginSettings={loginSettings}
promptPasswordless={promptPasswordless === "true"}
isAlternative={alt === "true"}
/>

View File

@@ -43,6 +43,7 @@ export default async function Page({ searchParams }: { searchParams: any }) {
loginName={loginName ?? sessionFactors?.factors?.user?.loginName}
displayName={sessionFactors?.factors?.user?.displayName}
showDropdown
searchParams={searchParams}
></UserAvatar>
</div>
</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 {
SessionCookie,
getMostRecentSessionCookie,
@@ -11,7 +17,7 @@ import {
createSessionForIdpAndUpdateCookie,
setSessionAndUpdateCookie,
} from "#/utils/session";
import { RequestChallenges } from "@zitadel/server";
import { Challenges, Checks, RequestChallenges } from "@zitadel/server";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
@@ -67,11 +73,10 @@ export async function PUT(request: NextRequest) {
loginName,
sessionId,
organization,
password,
webAuthN,
checks,
authRequestId,
challenges,
} = body;
const challenges: RequestChallenges = body.challenges;
const recentPromise: Promise<SessionCookie> = sessionId
? getSessionCookieById(sessionId).catch((error) => {
@@ -92,22 +97,63 @@ export async function PUT(request: NextRequest) {
}
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(
recent,
password,
webAuthN,
checks,
challenges,
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({
sessionId: session.id,
factors: session.factors,
challenges: session.challenges,
authMethods,
});
});
})
.catch((error) => {
console.error(error);
return NextResponse.json({ details: error }, { status: 500 });
});
} 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);
}
/**
* 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) {
console.log(
`Login with session: ${sessionId} and authRequest: ${authRequestId}`
@@ -86,7 +93,8 @@ export async function GET(request: NextRequest) {
if (authRequestId) {
console.log(`Login with authRequest: ${authRequestId}`);
const { authRequest } = await getAuthRequest(server, { authRequestId });
let organization;
let organization = "";
if (authRequest?.scope) {
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)) {
const registerUrl = new URL("/register", request.url);
if (authRequest?.id) {
@@ -128,15 +148,7 @@ export async function GET(request: NextRequest) {
if (authRequest && sessions.length) {
// if some accounts are available for selection and select_account is set
if (authRequest.prompt.includes(Prompt.PROMPT_SELECT_ACCOUNT)) {
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);
return gotoAccounts();
} else if (authRequest.prompt.includes(Prompt.PROMPT_LOGIN)) {
// if prompt is login
const loginNameUrl = new URL("/loginname", request.url);
@@ -196,26 +208,25 @@ export async function GET(request: NextRequest) {
sessionId: cookie?.id,
sessionToken: cookie?.token,
};
try {
const { callbackUrl } = await createCallback(server, {
authRequestId,
session,
});
if (callbackUrl) {
return NextResponse.redirect(callbackUrl);
} else {
const accountsUrl = new URL("/accounts", request.url);
accountsUrl.searchParams.set("authRequestId", authRequestId);
if (organization) {
accountsUrl.searchParams.set("organization", organization);
return gotoAccounts();
}
return NextResponse.redirect(accountsUrl);
} catch (error) {
console.error(error);
return gotoAccounts();
}
} else {
const accountsUrl = new URL("/accounts", request.url);
accountsUrl.searchParams.set("authRequestId", authRequestId);
if (organization) {
accountsUrl.searchParams.set("organization", organization);
return gotoAccounts();
}
return NextResponse.redirect(accountsUrl);
} else {
return gotoAccounts();
}
}
} 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 {
GetUserByIDResponse,
RegisterTOTPResponse,
VerifyTOTPRegistrationResponse,
} from "@zitadel/server";
import {
LegalAndSupportSettings,
PasswordComplexitySettings,
ZitadelServer,
VerifyMyAuthFactorOTPResponse,
ZitadelServerOptions,
user,
oidc,
settings,
getServers,
auth,
initializeServer,
session,
GetGeneralSettingsResponse,
CreateSessionResponse,
GetBrandingSettingsResponse,
GetPasswordComplexitySettingsResponse,
RegisterU2FResponse,
GetLegalAndSupportSettingsResponse,
AddHumanUserResponse,
BrandingSettings,
ListSessionsResponse,
LegalAndSupportSettings,
PasswordComplexitySettings,
GetSessionResponse,
VerifyEmailResponse,
Checks,
SetSessionResponse,
SetSessionRequest,
ListUsersResponse,
@@ -39,9 +49,14 @@ import {
CreateCallbackResponse,
RequestChallenges,
TextQueryMethod,
ListHumanAuthFactorsResponse,
AddHumanUserRequest,
AddOTPEmailResponse,
AddOTPSMSResponse,
} from "@zitadel/server";
const SESSION_LIFETIME_S = 3000;
export const zitadelConfig: ZitadelServerOptions = {
name: "zitadel login",
apiUrl: process.env.ZITADEL_API_URL ?? "",
@@ -78,6 +93,65 @@ export async function getLoginSettings(
.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(
server: ZitadelServer
): Promise<string[] | undefined> {
@@ -118,63 +192,18 @@ export async function getPasswordComplexitySettings(
.then((resp: GetPasswordComplexitySettingsResponse) => resp.settings);
}
export async function createSessionForLoginname(
export async function createSessionFromChecks(
server: ZitadelServer,
loginName: string,
password: string | undefined,
checks: Checks,
challenges: RequestChallenges | undefined
): Promise<CreateSessionResponse | undefined> {
const sessionService = session.getSession(server);
return password
? sessionService.createSession(
return sessionService.createSession(
{
checks: { user: { loginName }, password: { password } },
checks: checks,
challenges,
lifetime: {
seconds: 300,
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,
seconds: SESSION_LIFETIME_S,
nanos: 0,
},
},
@@ -208,9 +237,8 @@ export async function setSession(
server: ZitadelServer,
sessionId: string,
sessionToken: string,
password: string | undefined,
webAuthN: { credentialAssertionData: any } | undefined,
challenges: RequestChallenges | undefined
challenges: RequestChallenges | undefined,
checks: Checks
): Promise<SetSessionResponse | undefined> {
const sessionService = session.getSession(server);
@@ -222,12 +250,8 @@ export async function setSession(
metadata: {},
};
if (password && payload.checks) {
payload.checks.password = { password };
}
if (webAuthN && payload.checks) {
payload.checks.webAuthN = webAuthN;
if (checks && payload.checks) {
payload.checks = checks;
}
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(
userName: string,
organizationId: string
@@ -423,16 +476,63 @@ export async function setEmail(
* @returns the newly set email
*/
export async function createPasskeyRegistrationLink(
userId: string
userId: string,
token?: string
): 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,
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

View File

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

View File

@@ -7,6 +7,7 @@ let colors = {
text: { light: { contrast: {} }, dark: { contrast: {} } },
link: { light: { contrast: {} }, dark: { contrast: {} } },
};
const shades = [
"50",
"100",
@@ -49,7 +50,51 @@ module.exports = {
},
theme: {
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: {
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 && (
<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>
);
}

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

View File

@@ -7,12 +7,19 @@ import { useForm } from "react-hook-form";
import { useRouter } from "next/navigation";
import { Spinner } from "./Spinner";
import Alert from "./Alert";
import {
LoginSettings,
AuthFactor,
Checks,
AuthenticationMethodType,
} from "@zitadel/server";
type Inputs = {
password: string;
};
type Props = {
loginSettings: LoginSettings | undefined;
loginName?: string;
organization?: string;
authRequestId?: string;
@@ -21,6 +28,7 @@ type Props = {
};
export default function PasswordForm({
loginSettings,
loginName,
organization,
authRequestId,
@@ -49,7 +57,9 @@ export default function PasswordForm({
body: JSON.stringify({
loginName,
organization,
password: values.password,
checks: {
password: { password: values.password },
} as Checks,
authRequestId,
}),
});
@@ -58,15 +68,76 @@ export default function PasswordForm({
setLoading(false);
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 response;
}
function submitPasswordAndContinue(value: Inputs): Promise<boolean | void> {
return submitPassword(value).then((resp: any) => {
if (
return submitPassword(value).then((resp) => {
// 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.passwordless && // if session was not verified with a passkey
promptPasswordless && // if explicitly prompted due policy
@@ -77,13 +148,16 @@ export default function PasswordForm({
promptPasswordless: "true",
});
if (authRequestId) {
params.append("authRequestId", authRequestId);
}
if (organization) {
params.append("organization", organization);
}
return router.push(`/passkey/add?` + params);
} else {
if (authRequestId && resp && resp.sessionId) {
} else if (authRequestId && resp && resp.sessionId) {
const params = new URLSearchParams({
sessionId: resp.sessionId,
authRequest: authRequestId,
@@ -95,6 +169,7 @@ export default function PasswordForm({
return router.push(`/login?` + params);
} else {
// without OIDC flow
const params = new URLSearchParams(
authRequestId
? {
@@ -112,12 +187,9 @@ export default function PasswordForm({
return router.push(`/signedin?` + params);
}
}
});
}
const { errors } = formState;
return (
<form className="w-full">
<div className={`${error && "transform-gpu animate-shake"}`}>

View File

@@ -200,14 +200,17 @@ export default function RegisterPasskey({
onClick={() => {
const params = new URLSearchParams();
if (authRequestId) {
params.set("authRequestId", authRequestId);
params.set("authRequest", authRequestId);
}
if (sessionId) {
params.set("sessionId", sessionId);
}
if (organization) {
params.set("organization", organization);
}
router.push("/accounts?" + params);
router.push("/login?" + params);
}}
>
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;
displayName?: string;
showDropdown: boolean;
searchParams?: Record<string | number | symbol, string | undefined>;
};
export default function UserAvatar({
loginName,
displayName,
showDropdown,
searchParams,
}: 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 (
<div className="flex h-full flex-row items-center rounded-full border p-[1px] dark:border-white/20">
<div>
@@ -26,7 +46,7 @@ export default function UserAvatar({
<span className="flex-grow"></span>
{showDropdown && (
<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"
>
<ChevronDownIcon className="h-4 w-4" />

View File

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

View File

@@ -5,25 +5,43 @@ import {
AuthServiceDefinition,
GetMyUserResponse,
} from "../proto/server/zitadel/auth";
import { ZitadelServer } from "../server";
import { ZitadelServer, getServers } from "../server";
import { authMiddleware } from "../middleware";
const createClient = <Client>(
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 ?? "");
return createClientFactory()
.use(authMiddleware(accessToken))
.use(authMiddleware(token))
.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>(
AuthServiceDefinition as CompatServiceDefinition,
""
config.apiUrl,
config.token
);
}
};
export async function getMyUser(): Promise<GetMyUserResponse> {
const auth = await getAuth();

View File

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

8331
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff