Merge pull request #40 from zitadel/oidc

feat: oidc proxy, handle authRequest, callback
This commit is contained in:
Max Peintner
2023-09-29 08:30:04 +02:00
committed by GitHub
41 changed files with 962 additions and 460 deletions

View File

@@ -1,12 +1,12 @@
import { Session } from "@zitadel/server";
import { listSessions, server } from "#/lib/zitadel";
import { getAllSessionIds } from "#/utils/cookies";
import { getAllSessionCookieIds } from "#/utils/cookies";
import { UserPlusIcon } from "@heroicons/react/24/outline";
import Link from "next/link";
import SessionsList from "#/ui/SessionsList";
async function loadSessions(): Promise<Session[]> {
const ids = await getAllSessionIds();
const ids = await getAllSessionCookieIds();
if (ids && ids.length) {
const response = await listSessions(
@@ -20,7 +20,13 @@ async function loadSessions(): Promise<Session[]> {
}
}
export default async function Page() {
export default async function Page({
searchParams,
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const authRequestId = searchParams?.authRequestId;
let sessions = await loadSessions();
return (
@@ -29,7 +35,7 @@ export default async function Page() {
<p className="ztdl-p mb-6 block">Use your ZITADEL Account</p>
<div className="flex flex-col w-full space-y-2">
<SessionsList sessions={sessions} />
<SessionsList sessions={sessions} authRequestId={authRequestId} />
<Link href="/loginname">
<div className="flex flex-row items-center py-3 px-4 hover:bg-black/10 dark:hover:bg-white/10 rounded-md transition-all">
<div className="w-8 h-8 mr-4 flex flex-row justify-center items-center rounded-full bg-black/5 dark:bg-white/5">

View File

@@ -0,0 +1,122 @@
import {
createCallback,
getAuthRequest,
listSessions,
server,
} from "#/lib/zitadel";
import { SessionCookie, getAllSessions } from "#/utils/cookies";
import { Session, AuthRequest, Prompt } from "@zitadel/server";
import { NextRequest, NextResponse } from "next/server";
async function loadSessions(ids: string[]): Promise<Session[]> {
const response = await listSessions(
server,
ids.filter((id: string | undefined) => !!id)
);
return response?.sessions ?? [];
}
function findSession(
sessions: Session[],
authRequest: AuthRequest
): Session | undefined {
if (authRequest.hintUserId) {
console.log(`find session for hintUserId: ${authRequest.hintUserId}`);
return sessions.find((s) => s.factors?.user?.id === authRequest.hintUserId);
}
if (authRequest.loginHint) {
console.log(`find session for loginHint: ${authRequest.loginHint}`);
return sessions.find(
(s) => s.factors?.user?.loginName === authRequest.loginHint
);
}
return undefined;
}
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const authRequestId = searchParams.get("authRequest");
if (authRequestId) {
const { authRequest } = await getAuthRequest(server, { authRequestId });
const sessionCookies: SessionCookie[] = await getAllSessions();
const ids = sessionCookies.map((s) => s.id);
let sessions: Session[] = [];
if (ids && ids.length) {
sessions = await loadSessions(ids);
} else {
console.info("No session cookie found.");
sessions = [];
}
// use existing session and hydrate it for oidc
if (authRequest && sessions.length) {
// if some accounts are available for selection and select_account is set
if (
authRequest &&
authRequest.prompt.includes(Prompt.PROMPT_SELECT_ACCOUNT)
) {
const accountsUrl = new URL("/accounts", request.url);
if (authRequest?.id) {
accountsUrl.searchParams.set("authRequestId", authRequest?.id);
}
return NextResponse.redirect(accountsUrl);
} else {
// check for loginHint, userId hint sessions
let selectedSession = findSession(sessions, authRequest);
// if (!selectedSession) {
// selectedSession = sessions[0]; // TODO: remove
// }
if (selectedSession && selectedSession.id) {
const cookie = sessionCookies.find(
(cookie) => cookie.id === selectedSession?.id
);
if (cookie && cookie.id && cookie.token) {
const session = {
sessionId: cookie?.id,
sessionToken: cookie?.token,
};
const { callbackUrl } = await createCallback(server, {
authRequestId,
session,
});
return NextResponse.redirect(callbackUrl);
} else {
const accountsUrl = new URL("/accounts", request.url);
if (authRequest?.id) {
accountsUrl.searchParams.set("authRequestId", authRequest?.id);
}
return NextResponse.redirect(accountsUrl);
}
} else {
const accountsUrl = new URL("/accounts", request.url);
if (authRequest?.id) {
accountsUrl.searchParams.set("authRequestId", authRequest?.id);
}
return NextResponse.redirect(accountsUrl);
// return NextResponse.error();
}
}
} else {
const loginNameUrl = new URL("/loginname", request.url);
if (authRequest?.id) {
loginNameUrl.searchParams.set("authRequestId", authRequest?.id);
if (authRequest.loginHint) {
loginNameUrl.searchParams.set("loginName", authRequest.loginHint);
loginNameUrl.searchParams.set("submit", "true"); // autosubmit
}
}
return NextResponse.redirect(loginNameUrl);
}
} else {
return NextResponse.error();
}
}

View File

@@ -7,6 +7,7 @@ export default async function Page({
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const loginName = searchParams?.loginName;
const authRequestId = searchParams?.authRequestId;
const submit: boolean = searchParams?.submit === "true";
const loginSettings = await getLoginSettings(server);
@@ -19,6 +20,7 @@ export default async function Page({
<UsernameForm
loginSettings={loginSettings}
loginName={loginName}
authRequestId={authRequestId}
submit={submit}
/>
</div>

View File

@@ -13,7 +13,7 @@ export default async function Page({
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const { loginName, altPassword } = searchParams;
const { loginName, altPassword, authRequestId } = searchParams;
const sessionFactors = await loadSession(loginName);
@@ -48,6 +48,7 @@ export default async function Page({
{loginName && (
<LoginPasskey
loginName={loginName}
authRequestId={authRequestId}
altPassword={altPassword === "true"}
/>
)}

View File

@@ -9,7 +9,7 @@ export default async function Page({
}: {
searchParams: Record<string | number | symbol, string | undefined>;
}) {
const { loginName, promptPasswordless, alt } = searchParams;
const { loginName, promptPasswordless, authRequestId, alt } = searchParams;
const sessionFactors = await loadSession(loginName);
async function loadSession(loginName?: string) {
@@ -46,6 +46,7 @@ export default async function Page({
<PasswordForm
loginName={loginName}
authRequestId={authRequestId}
promptPasswordless={promptPasswordless === "true"}
isAlternative={alt === "true"}
/>

View File

@@ -1,10 +1,10 @@
import { ProviderSlug } from "#/lib/demos";
import { addHumanUser, server } from "#/lib/zitadel";
import { server } from "#/lib/zitadel";
import Alert, { AlertType } from "#/ui/Alert";
import {
AddHumanUserRequest,
IDPInformation,
RetrieveIdentityProviderInformationResponse,
RetrieveIdentityProviderIntentResponse,
user,
IDPLink,
} from "@zitadel/server";
@@ -27,8 +27,8 @@ const PROVIDER_MAPPING: {
// organisation: Organisation | undefined;
profile: {
displayName: idp.rawInformation?.User?.name ?? "",
firstName: idp.rawInformation?.User?.given_name ?? "",
lastName: idp.rawInformation?.User?.family_name ?? "",
givenName: idp.rawInformation?.User?.given_name ?? "",
familyName: idp.rawInformation?.User?.family_name ?? "",
},
idpLinks: [idpLink],
};
@@ -49,8 +49,8 @@ const PROVIDER_MAPPING: {
// organisation: Organisation | undefined;
profile: {
displayName: idp.rawInformation?.name ?? "",
firstName: idp.rawInformation?.name ?? "",
lastName: idp.rawInformation?.name ?? "",
givenName: idp.rawInformation?.name ?? "",
familyName: idp.rawInformation?.name ?? "",
},
idpLinks: [idpLink],
};
@@ -64,8 +64,11 @@ function retrieveIDP(
): Promise<IDPInformation | undefined> {
const userService = user.getUser(server);
return userService
.retrieveIdentityProviderInformation({ intentId: id, token: token }, {})
.then((resp: RetrieveIdentityProviderInformationResponse) => {
.retrieveIdentityProviderIntent(
{ idpIntentId: id, idpIntentToken: token },
{}
)
.then((resp: RetrieveIdentityProviderIntentResponse) => {
return resp.idpInformation;
});
}

View File

@@ -1,10 +1,19 @@
import { getSession, server } from "#/lib/zitadel";
import { createCallback, getSession, server } from "#/lib/zitadel";
import UserAvatar from "#/ui/UserAvatar";
import { getMostRecentCookieWithLoginname } from "#/utils/cookies";
import { redirect } from "next/navigation";
async function loadSession(loginName: string) {
async function loadSession(loginName: string, authRequestId?: string) {
const recent = await getMostRecentCookieWithLoginname(`${loginName}`);
if (authRequestId) {
return createCallback(server, {
authRequestId,
session: { sessionId: recent.id, sessionToken: recent.token },
}).then(({ callbackUrl }) => {
return redirect(callbackUrl);
});
}
return getSession(server, recent.id, recent.token).then((response) => {
if (response?.session) {
return response.session;
@@ -13,8 +22,8 @@ async function loadSession(loginName: string) {
}
export default async function Page({ searchParams }: { searchParams: any }) {
const { loginName } = searchParams;
const sessionFactors = await loadSession(loginName);
const { loginName, authRequestId } = searchParams;
const sessionFactors = await loadSession(loginName, authRequestId);
return (
<div className="flex flex-col items-center space-y-4">

View File

@@ -6,7 +6,13 @@ export async function POST(request: NextRequest) {
if (body) {
let { idpId, successUrl, failureUrl } = body;
return startIdentityProviderFlow(server, { idpId, successUrl, failureUrl })
return startIdentityProviderFlow(server, {
idpId,
urls: {
successUrl,
failureUrl,
},
})
.then((resp) => {
return NextResponse.json(resp);
})

View File

@@ -1,53 +1,18 @@
import {
getSession,
listAuthenticationMethodTypes,
server,
} from "#/lib/zitadel";
import { getSessionCookieById } from "#/utils/cookies";
import { listAuthenticationMethodTypes } from "#/lib/zitadel";
import { createSessionAndUpdateCookie } from "#/utils/session";
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const sessionId = searchParams.get("sessionId");
if (sessionId) {
const sessionCookie = await getSessionCookieById(sessionId);
const session = await getSession(
server,
sessionCookie.id,
sessionCookie.token
);
const userId = session?.session?.factors?.user?.id;
if (userId) {
return listAuthenticationMethodTypes(userId)
.then((methods) => {
return NextResponse.json(methods);
})
.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 });
}
}
export async function POST(request: NextRequest) {
const body = await request.json();
if (body) {
const { loginName } = body;
const { loginName, authRequestId } = body;
const domain: string = request.nextUrl.hostname;
return createSessionAndUpdateCookie(loginName, undefined, domain, undefined)
return createSessionAndUpdateCookie(
loginName,
undefined,
undefined,
authRequestId
)
.then((session) => {
if (session.factors?.user?.id) {
return listAuthenticationMethodTypes(session.factors?.user?.id)
@@ -62,16 +27,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json(error, { status: 500 });
});
} else {
throw "No user id found in session";
throw { details: "No user id found in session" };
}
})
.catch((error) => {
return NextResponse.json(
{
details: "could not add session to cookie",
},
{ status: 500 }
);
return NextResponse.json(error, { status: 500 });
});
} else {
return NextResponse.error();

View File

@@ -10,6 +10,7 @@ import {
createSessionAndUpdateCookie,
setSessionAndUpdateCookie,
} from "#/utils/session";
import { RequestChallenges } from "@zitadel/server";
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
@@ -17,12 +18,10 @@ export async function POST(request: NextRequest) {
if (body) {
const { loginName, password } = body;
const domain: string = request.nextUrl.hostname;
return createSessionAndUpdateCookie(
loginName,
password,
domain,
undefined,
undefined
).then((session) => {
return NextResponse.json(session);
@@ -44,7 +43,8 @@ export async function PUT(request: NextRequest) {
const body = await request.json();
if (body) {
const { loginName, password, challenges, passkey } = body;
const { loginName, password, webAuthN, authRequestId } = body;
const challenges: RequestChallenges = body.challenges;
const recentPromise: Promise<SessionCookie> = loginName
? getSessionCookieByLoginName(loginName).catch((error) => {
@@ -56,16 +56,21 @@ export async function PUT(request: NextRequest) {
const domain: string = request.nextUrl.hostname;
if (challenges && challenges.webAuthN && !challenges.webAuthN.domain) {
challenges.webAuthN.domain = domain;
}
return recentPromise
.then((recent) => {
console.log("setsession", webAuthN);
return setSessionAndUpdateCookie(
recent.id,
recent.token,
recent.loginName,
password,
passkey,
domain,
challenges
webAuthN,
challenges,
authRequestId
).then((session) => {
return NextResponse.json({
sessionId: session.id,

View File

@@ -2,7 +2,7 @@ import { stub } from "../support/mock";
describe("login", () => {
beforeEach(() => {
stub("zitadel.session.v2alpha.SessionService", "CreateSession", {
stub("zitadel.session.v2beta.SessionService", "CreateSession", {
data: {
details: {
sequence: 859,
@@ -16,7 +16,7 @@ describe("login", () => {
},
});
stub("zitadel.session.v2alpha.SessionService", "GetSession", {
stub("zitadel.session.v2beta.SessionService", "GetSession", {
data: {
session: {
id: "221394658884845598",
@@ -29,16 +29,15 @@ describe("login", () => {
loginName: "john@zitadel.com",
},
password: undefined,
passkey: undefined,
webAuthN: undefined,
intent: undefined,
},
metadata: {},
domain: "localhost",
},
},
});
stub("zitadel.settings.v2alpha.SettingsService", "GetLoginSettings", {
stub("zitadel.settings.v2beta.SettingsService", "GetLoginSettings", {
data: {
settings: {
passkeysType: 1,
@@ -48,23 +47,19 @@ describe("login", () => {
});
describe("password login", () => {
beforeEach(() => {
stub(
"zitadel.user.v2alpha.UserService",
"ListAuthenticationMethodTypes",
{
stub("zitadel.user.v2beta.UserService", "ListAuthenticationMethodTypes", {
data: {
authMethodTypes: [1], // 1 for password authentication
},
}
);
});
});
it("should redirect a user with password authentication to /password", () => {
cy.visit("/loginname?loginName=johndoe%40zitadel.com&submit=true");
cy.visit("/loginname?loginName=john%40zitadel.com&submit=true");
cy.location("pathname", { timeout: 10_000 }).should("eq", "/password");
});
describe("with passkey prompt", () => {
beforeEach(() => {
stub("zitadel.session.v2alpha.SessionService", "SetSession", {
stub("zitadel.session.v2beta.SessionService", "SetSession", {
data: {
details: {
sequence: 859,
@@ -91,18 +86,14 @@ describe("login", () => {
});
describe("passkey login", () => {
beforeEach(() => {
stub(
"zitadel.user.v2alpha.UserService",
"ListAuthenticationMethodTypes",
{
stub("zitadel.user.v2beta.UserService", "ListAuthenticationMethodTypes", {
data: {
authMethodTypes: [2], // 2 for passwordless authentication
},
}
);
});
});
it("should redirect a user with passwordless authentication to /passkey/login", () => {
cy.visit("/loginname?loginName=johndoe%40zitadel.com&submit=true");
cy.visit("/loginname?loginName=john%40zitadel.com&submit=true");
cy.location("pathname", { timeout: 10_000 }).should(
"eq",
"/passkey/login"

View File

@@ -4,7 +4,7 @@ const IDP_URL = "https://example.com/idp/url";
describe("register idps", () => {
beforeEach(() => {
stub("zitadel.user.v2alpha.UserService", "StartIdentityProviderFlow", {
stub("zitadel.user.v2beta.UserService", "StartIdentityProviderIntent", {
data: {
authUrl: IDP_URL,
},

View File

@@ -2,7 +2,7 @@ import { stub } from "../support/mock";
describe("register", () => {
beforeEach(() => {
stub("zitadel.user.v2alpha.UserService", "AddHumanUser", {
stub("zitadel.user.v2beta.UserService", "AddHumanUser", {
data: {
userId: "123",
},

View File

@@ -2,12 +2,12 @@ import { stub } from "../support/mock";
describe("/verify", () => {
it("redirects after successful email verification", () => {
stub("zitadel.user.v2alpha.UserService", "VerifyEmail");
stub("zitadel.user.v2beta.UserService", "VerifyEmail");
cy.visit("/verify?userID=123&code=abc&submit=true");
cy.location("pathname", { timeout: 10_000 }).should("eq", "/loginname");
});
it("shows an error if validation failed", () => {
stub("zitadel.user.v2alpha.UserService", "VerifyEmail", {
stub("zitadel.user.v2beta.UserService", "VerifyEmail", {
code: 3,
error: "error validating code",
});

View File

@@ -2,6 +2,7 @@ import {
ZitadelServer,
ZitadelServerOptions,
user,
oidc,
settings,
getServers,
initializeServer,
@@ -19,16 +20,22 @@ import {
GetSessionResponse,
VerifyEmailResponse,
SetSessionResponse,
SetSessionRequest,
DeleteSessionResponse,
VerifyPasskeyRegistrationResponse,
ChallengeKind,
LoginSettings,
GetLoginSettingsResponse,
ListAuthenticationMethodTypesResponse,
StartIdentityProviderFlowRequest,
StartIdentityProviderFlowResponse,
RetrieveIdentityProviderInformationRequest,
RetrieveIdentityProviderInformationResponse,
StartIdentityProviderIntentRequest,
StartIdentityProviderIntentResponse,
RetrieveIdentityProviderIntentRequest,
RetrieveIdentityProviderIntentResponse,
GetAuthRequestResponse,
GetAuthRequestRequest,
CreateCallbackRequest,
CreateCallbackResponse,
RequestChallenges,
AddHumanUserRequest,
} from "@zitadel/server";
export const zitadelConfig: ZitadelServerOptions = {
@@ -95,9 +102,8 @@ export async function getPasswordComplexitySettings(
export async function createSession(
server: ZitadelServer,
loginName: string,
domain: string,
password: string | undefined,
challenges: ChallengeKind[] | undefined
challenges: RequestChallenges | undefined
): Promise<CreateSessionResponse | undefined> {
const sessionService = session.getSession(server);
return password
@@ -105,12 +111,12 @@ export async function createSession(
{
checks: { user: { loginName }, password: { password } },
challenges,
domain,
},
{}
)
: sessionService.createSession(
{ checks: { user: { loginName } }, domain },
{ checks: { user: { loginName } }, challenges },
{}
);
}
@@ -119,23 +125,29 @@ export async function setSession(
server: ZitadelServer,
sessionId: string,
sessionToken: string,
domain: string | undefined,
password: string | undefined,
passkey: { credentialAssertionData: any } | undefined,
challenges: ChallengeKind[] | undefined
webAuthN: { credentialAssertionData: any } | undefined,
challenges: RequestChallenges | undefined
): Promise<SetSessionResponse | undefined> {
const sessionService = session.getSession(server);
const payload = { sessionId, sessionToken, challenges, domain };
return password
? sessionService.setSession(
{
...payload,
checks: { password: { password }, passkey },
},
{}
)
: sessionService.setSession(payload, {});
const payload: SetSessionRequest = {
sessionId,
sessionToken,
challenges,
checks: {},
metadata: {},
};
if (password && payload.checks) {
payload.checks.password = { password };
}
if (webAuthN && payload.checks) {
payload.checks.webAuthN = webAuthN;
}
return sessionService.setSession(payload, {});
}
export async function getSession(
@@ -179,10 +191,10 @@ export async function addHumanUser(
): Promise<string> {
const userService = user.getUser(server);
const payload = {
const payload: Partial<AddHumanUserRequest> = {
email: { email },
username: email,
profile: { firstName, lastName },
profile: { givenName: firstName, familyName: lastName },
};
return userService
.addHumanUser(
@@ -201,29 +213,48 @@ export async function addHumanUser(
export async function startIdentityProviderFlow(
server: ZitadelServer,
{ idpId, successUrl, failureUrl }: StartIdentityProviderFlowRequest
): Promise<StartIdentityProviderFlowResponse> {
{ idpId, urls }: StartIdentityProviderIntentRequest
): Promise<StartIdentityProviderIntentResponse> {
const userService = user.getUser(server);
return userService.startIdentityProviderFlow({
return userService.startIdentityProviderIntent({
idpId,
successUrl,
failureUrl,
urls,
});
}
export async function retrieveIdentityProviderInformation(
server: ZitadelServer,
{ intentId, token }: RetrieveIdentityProviderInformationRequest
): Promise<RetrieveIdentityProviderInformationResponse> {
{ idpIntentId, idpIntentToken }: RetrieveIdentityProviderIntentRequest
): Promise<RetrieveIdentityProviderIntentResponse> {
const userService = user.getUser(server);
return userService.retrieveIdentityProviderInformation({
intentId,
token,
return userService.retrieveIdentityProviderIntent({
idpIntentId,
idpIntentToken,
});
}
export async function getAuthRequest(
server: ZitadelServer,
{ authRequestId }: GetAuthRequestRequest
): Promise<GetAuthRequestResponse> {
const oidcService = oidc.getOidc(server);
return oidcService.getAuthRequest({
authRequestId,
});
}
export async function createCallback(
server: ZitadelServer,
req: CreateCallbackRequest
): Promise<CreateCallbackResponse> {
const oidcService = oidc.getOidc(server);
return oidcService.createCallback(req);
}
export async function verifyEmail(
server: ZitadelServer,
userId: string,

28
apps/login/middleware.ts Normal file
View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export const config = {
matcher: ["/.well-known/:path*", "/oauth/:path*", "/oidc/:path*"],
};
const INSTANCE = process.env.ZITADEL_API_URL;
const SERVICE_USER_ID = process.env.ZITADEL_SERVICE_USER_ID as string;
export function middleware(request: NextRequest) {
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-zitadel-login-client", SERVICE_USER_ID);
requestHeaders.set("Forwarded", `host="${request.nextUrl.host}"`);
const responseHeaders = new Headers();
responseHeaders.set("Access-Control-Allow-Origin", "*");
responseHeaders.set("Access-Control-Allow-Headers", "*");
request.nextUrl.href = `${INSTANCE}${request.nextUrl.pathname}${request.nextUrl.search}`;
return NextResponse.rewrite(request.nextUrl, {
request: {
headers: requestHeaders,
},
headers: responseHeaders,
});
}

View File

@@ -1,11 +1,11 @@
[
{
"service": "zitadel.settings.v2alpha.SettingsService",
"service": "zitadel.settings.v2beta.SettingsService",
"method": "GetBrandingSettings",
"out": {}
},
{
"service": "zitadel.settings.v2alpha.SettingsService",
"service": "zitadel.settings.v2beta.SettingsService",
"method": "GetLegalAndSupportSettings",
"out": {
"data": {
@@ -18,7 +18,7 @@
}
},
{
"service": "zitadel.settings.v2alpha.SettingsService",
"service": "zitadel.settings.v2beta.SettingsService",
"method": "GetActiveIdentityProviders",
"out": {
"data": {
@@ -33,7 +33,7 @@
}
},
{
"service": "zitadel.settings.v2alpha.SettingsService",
"service": "zitadel.settings.v2beta.SettingsService",
"method": "GetPasswordComplexitySettings",
"out": {
"data": {

View File

@@ -1,6 +1,6 @@
zitadel/user/v2alpha/user_service.proto
zitadel/session/v2alpha/session_service.proto
zitadel/settings/v2alpha/settings_service.proto
zitadel/user/v2beta/user_service.proto
zitadel/session/v2beta/session_service.proto
zitadel/settings/v2beta/settings_service.proto
zitadel/management.proto
zitadel/auth.proto
zitadel/admin.proto

View File

@@ -40,7 +40,6 @@
"@zitadel/react": "workspace:*",
"@zitadel/server": "workspace:*",
"clsx": "1.2.1",
"date-fns": "2.29.3",
"moment": "^2.29.4",
"next": "13.4.12",
"next-themes": "^0.2.1",

View File

@@ -2,7 +2,6 @@
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Challenges_Passkey } from "@zitadel/server";
import { coerceToArrayBuffer, coerceToBase64Url } from "#/utils/base64";
import { Button, ButtonVariants } from "./Button";
import Alert from "./Alert";
@@ -10,10 +9,15 @@ import { Spinner } from "./Spinner";
type Props = {
loginName: string;
authRequestId?: string;
altPassword: boolean;
};
export default function LoginPasskey({ loginName, altPassword }: Props) {
export default function LoginPasskey({
loginName,
authRequestId,
altPassword,
}: Props) {
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
@@ -28,7 +32,7 @@ export default function LoginPasskey({ loginName, altPassword }: Props) {
updateSessionForChallenge()
.then((response) => {
const pK =
response.challenges.passkey.publicKeyCredentialRequestOptions
response.challenges.webAuthN.publicKeyCredentialRequestOptions
.publicKey;
if (pK) {
submitLoginAndContinue(pK)
@@ -60,7 +64,13 @@ export default function LoginPasskey({ loginName, altPassword }: Props) {
},
body: JSON.stringify({
loginName,
challenges: [1], // request passkey challenge
challenges: {
webAuthN: {
domain: "",
userVerificationRequirement: 1,
},
},
authRequestId,
}),
});
@@ -81,7 +91,8 @@ export default function LoginPasskey({ loginName, altPassword }: Props) {
},
body: JSON.stringify({
loginName,
passkey: data,
webAuthN: { credentialAssertionData: data },
authRequestId,
}),
});
@@ -115,18 +126,18 @@ export default function LoginPasskey({ loginName, altPassword }: Props) {
})
.then((assertedCredential: any) => {
if (assertedCredential) {
let authData = new Uint8Array(
const authData = new Uint8Array(
assertedCredential.response.authenticatorData
);
let clientDataJSON = new Uint8Array(
const clientDataJSON = new Uint8Array(
assertedCredential.response.clientDataJSON
);
let rawId = new Uint8Array(assertedCredential.rawId);
let sig = new Uint8Array(assertedCredential.response.signature);
let userHandle = new Uint8Array(
const rawId = new Uint8Array(assertedCredential.rawId);
const sig = new Uint8Array(assertedCredential.response.signature);
const userHandle = new Uint8Array(
assertedCredential.response.userHandle
);
let data = JSON.stringify({
const data = {
id: assertedCredential.id,
rawId: coerceToBase64Url(rawId, "rawId"),
type: assertedCredential.type,
@@ -139,9 +150,21 @@ export default function LoginPasskey({ loginName, altPassword }: Props) {
signature: coerceToBase64Url(sig, "sig"),
userHandle: coerceToBase64Url(userHandle, "userHandle"),
},
});
return submitLogin(data).then(() => {
return router.push(`/accounts`);
};
return submitLogin(data).then((resp) => {
return router.push(
`/signedin?` +
new URLSearchParams(
authRequestId
? {
loginName: resp.factors.user.loginName,
authRequestId,
}
: {
loginName: resp.factors.user.loginName,
}
)
);
});
} else {
setLoading(false);
@@ -169,11 +192,16 @@ export default function LoginPasskey({ loginName, altPassword }: Props) {
<Button
type="button"
variant={ButtonVariants.Secondary}
onClick={() =>
router.push(
"/password?" + new URLSearchParams({ loginName, alt: "true" }) // alt is set because password is requested as alternative auth method, so passwordless prompt can be escaped
)
}
onClick={() => {
const params = { loginName, alt: "true" };
return router.push(
"/password?" +
new URLSearchParams(
authRequestId ? { ...params, authRequestId } : params
) // alt is set because password is requested as alternative auth method, so passwordless prompt can be escaped
);
}}
>
use password
</Button>

View File

@@ -14,12 +14,14 @@ type Inputs = {
type Props = {
loginName?: string;
authRequestId?: string;
isAlternative?: boolean; // whether password was requested as alternative auth method
promptPasswordless?: boolean;
};
export default function PasswordForm({
loginName,
authRequestId,
promptPasswordless,
isAlternative,
}: Props) {
@@ -44,6 +46,7 @@ export default function PasswordForm({
body: JSON.stringify({
loginName,
password: values.password,
authRequestId,
}),
});
@@ -73,7 +76,19 @@ export default function PasswordForm({
})
);
} else {
return router.push(`/accounts`);
return router.push(
`/signedin?` +
new URLSearchParams(
authRequestId
? {
loginName: resp.factors.user.loginName,
authRequestId,
}
: {
loginName: resp.factors.user.loginName,
}
)
);
}
});
}

View File

@@ -66,6 +66,7 @@ export default function RegisterFormWithoutPassword({ legal }: Props) {
},
body: JSON.stringify({
loginName: loginName,
// authRequestId, register does not need an oidc callback at the end
}),
});

View File

@@ -9,9 +9,11 @@ import { XCircleIcon } from "@heroicons/react/24/outline";
export default function SessionItem({
session,
reload,
authRequestId,
}: {
session: Session;
reload: () => void;
authRequestId?: string;
}) {
const [loading, setLoading] = useState<boolean>(false);
@@ -39,7 +41,7 @@ export default function SessionItem({
}
const validPassword = session?.factors?.password?.verifiedAt;
const validPasskey = session?.factors?.passkey?.verifiedAt;
const validPasskey = session?.factors?.webAuthN?.verifiedAt;
const validUser = validPassword || validPasskey;
@@ -48,14 +50,29 @@ export default function SessionItem({
href={
validUser
? `/signedin?` +
new URLSearchParams({
new URLSearchParams(
authRequestId
? {
loginName: session.factors?.user?.loginName as string,
})
authRequestId,
}
: {
loginName: session.factors?.user?.loginName as string,
}
)
: `/loginname?` +
new URLSearchParams({
new URLSearchParams(
authRequestId
? {
loginName: session.factors?.user?.loginName as string,
submit: "true",
})
authRequestId,
}
: {
loginName: session.factors?.user?.loginName as string,
submit: "true",
}
)
}
className="group flex flex-row items-center bg-background-light-400 dark:bg-background-dark-400 border border-divider-light hover:shadow-lg dark:hover:bg-white/10 py-2 px-4 rounded-md transition-all"
>

View File

@@ -7,9 +7,10 @@ import { useEffect, useState } from "react";
type Props = {
sessions: Session[];
authRequestId?: string;
};
export default function SessionsList({ sessions }: Props) {
export default function SessionsList({ sessions, authRequestId }: Props) {
const [list, setList] = useState<Session[]>(sessions);
return sessions ? (
<div className="flex flex-col space-y-2">
@@ -19,6 +20,7 @@ export default function SessionsList({ sessions }: Props) {
return (
<SessionItem
session={session}
authRequestId={authRequestId}
reload={() => {
setList(list.filter((s) => s.id !== session.id));
}}

View File

@@ -79,6 +79,7 @@ export default function SetPasswordForm({
body: JSON.stringify({
loginName: loginName,
password: password,
// authRequestId, register does not need an oidc callback
}),
});

View File

@@ -19,7 +19,7 @@ export interface SignInWithIDPProps {
}
const START_IDP_FLOW_PATH = (idpId: string) =>
`/v2alpha/users/idps/${idpId}/start`;
`/v2beta/users/idps/${idpId}/start`;
export function SignInWithIDP({
host,

View File

@@ -7,6 +7,7 @@ import { useForm } from "react-hook-form";
import { useRouter } from "next/navigation";
import { Spinner } from "./Spinner";
import { LoginSettings } from "@zitadel/server";
import Alert from "./Alert";
type Inputs = {
loginName: string;
@@ -15,12 +16,14 @@ type Inputs = {
type Props = {
loginSettings: LoginSettings | undefined;
loginName: string | undefined;
authRequestId: string | undefined;
submit: boolean;
};
export default function UsernameForm({
loginSettings,
loginName,
authRequestId,
submit,
}: Props) {
const { register, handleSubmit, formState } = useForm<Inputs>({
@@ -37,19 +40,25 @@ export default function UsernameForm({
async function submitLoginName(values: Inputs) {
setLoading(true);
const body = {
loginName: values.loginName,
};
const res = await fetch("/api/loginname", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
loginName: values.loginName,
}),
body: JSON.stringify(authRequestId ? { ...body, authRequestId } : body),
});
setLoading(false);
if (!res.ok) {
throw new Error("Failed to load authentication methods");
const response = await res.json();
setError(response.details);
return Promise.reject(response.details);
}
return res.json();
}
@@ -60,33 +69,40 @@ export default function UsernameForm({
const method = response.authMethodTypes[0];
switch (method) {
case 1: //AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSWORD:
return router.push(
"/password?" +
new URLSearchParams(
loginSettings?.passkeysType === 1
? {
loginName: values.loginName,
promptPasswordless: `true`, // PasskeysType.PASSKEYS_TYPE_ALLOWED,
const paramsPassword: any = { loginName: values.loginName };
if (loginSettings?.passkeysType === 1) {
paramsPassword.promptPasswordless = `true`; // PasskeysType.PASSKEYS_TYPE_ALLOWED,
}
: { loginName: values.loginName }
)
if (authRequestId) {
paramsPassword.authRequestId = authRequestId;
}
return router.push(
"/password?" + new URLSearchParams(paramsPassword)
);
case 2: // AuthenticationMethodType.AUTHENTICATION_METHOD_TYPE_PASSKEY
const paramsPasskey: any = { loginName: values.loginName };
if (authRequestId) {
paramsPasskey.authRequestId = authRequestId;
}
return router.push(
"/passkey/login?" +
new URLSearchParams({ loginName: values.loginName })
"/passkey/login?" + new URLSearchParams(paramsPasskey)
);
default:
return router.push(
"/password?" +
new URLSearchParams(
loginSettings?.passkeysType === 1
? {
loginName: values.loginName,
promptPasswordless: `true`, // PasskeysType.PASSKEYS_TYPE_ALLOWED,
const paramsPasskeyDefault: any = { loginName: values.loginName };
if (loginSettings?.passkeysType === 1) {
paramsPasskeyDefault.promptPasswordless = `true`; // PasskeysType.PASSKEYS_TYPE_ALLOWED,
}
: { loginName: values.loginName }
)
if (authRequestId) {
paramsPasskeyDefault.authRequestId = authRequestId;
}
return router.push(
"/password?" + new URLSearchParams(paramsPasskeyDefault)
);
}
} else if (
@@ -99,12 +115,17 @@ export default function UsernameForm({
} else {
// prefer passkey in favor of other methods
if (response.authMethodTypes.includes(2)) {
return router.push(
"/passkey/login?" +
new URLSearchParams({
const passkeyParams: any = {
loginName: values.loginName,
altPassword: `${response.authMethodTypes.includes(1)}`, // show alternative password option
})
};
if (authRequestId) {
passkeyParams.authRequestId = authRequestId;
}
return router.push(
"/passkey/login?" + new URLSearchParams(passkeyParams)
);
}
}
@@ -128,14 +149,16 @@ export default function UsernameForm({
autoComplete="username"
{...register("loginName", { required: "This field is required" })}
label="Loginname"
// error={errors.username?.message as string}
/>
</div>
{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}>
back
</Button> */}
<span className="flex-grow"></span>
<Button
type="submit"

View File

@@ -7,6 +7,7 @@ export type SessionCookie = {
token: string;
loginName: string;
changeDate: string;
authRequestId?: string; // if its linked to an OIDC flow
};
function setSessionHttpOnlyCookie(sessions: SessionCookie[]) {
@@ -134,7 +135,7 @@ export async function getSessionCookieByLoginName(
}
}
export async function getAllSessionIds(): Promise<any> {
export async function getAllSessionCookieIds(): Promise<any> {
const cookiesList = cookies();
const stringifiedCookie = cookiesList.get("sessions");
@@ -146,6 +147,18 @@ export async function getAllSessionIds(): Promise<any> {
}
}
export async function getAllSessions(): Promise<SessionCookie[]> {
const cookiesList = cookies();
const stringifiedCookie = cookiesList.get("sessions");
if (stringifiedCookie?.value) {
const sessions: SessionCookie[] = JSON.parse(stringifiedCookie?.value);
return sessions;
} else {
return [];
}
}
/**
* Returns most recent session filtered by optinal loginName
* @param loginName

View File

@@ -4,18 +4,17 @@ import {
addSessionToCookie,
updateSessionCookie,
} from "./cookies";
import { ChallengeKind, Session, Challenges } from "@zitadel/server";
import { Session, Challenges, RequestChallenges } from "@zitadel/server";
export async function createSessionAndUpdateCookie(
loginName: string,
password: string | undefined,
domain: string,
challenges: ChallengeKind[] | undefined
challenges: RequestChallenges | undefined,
authRequestId: string | undefined
): Promise<Session> {
const createdSession = await createSession(
server,
loginName,
domain,
password,
challenges
);
@@ -34,6 +33,10 @@ export async function createSessionAndUpdateCookie(
loginName: response.session?.factors?.user?.loginName ?? "",
};
if (authRequestId) {
sessionCookie.authRequestId = authRequestId;
}
return addSessionToCookie(sessionCookie).then(() => {
return response.session as Session;
});
@@ -55,17 +58,16 @@ export async function setSessionAndUpdateCookie(
sessionToken: string,
loginName: string,
password: string | undefined,
passkey: { credentialAssertionData: any } | undefined,
domain: string | undefined,
challenges: ChallengeKind[] | undefined
webAuthN: { credentialAssertionData: any } | undefined,
challenges: RequestChallenges | undefined,
authRequestId: string | undefined
): Promise<SessionWithChallenges> {
return setSession(
server,
sessionId,
sessionToken,
domain,
password,
passkey,
webAuthN,
challenges
).then((updatedSession) => {
if (updatedSession) {
@@ -76,9 +78,18 @@ export async function setSessionAndUpdateCookie(
loginName: loginName,
};
return getSession(server, sessionCookie.id, sessionCookie.token).then(
if (authRequestId) {
sessionCookie.authRequestId = authRequestId;
}
return new Promise((resolve) => setTimeout(resolve, 1000)).then(() =>
// TODO: remove
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,
@@ -87,13 +98,20 @@ export async function setSessionAndUpdateCookie(
loginName: session.factors?.user?.loginName ?? "",
};
return updateSessionCookie(sessionCookie.id, newCookie).then(() => {
if (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";
}
}
)
);
} else {
throw "Session not be set";

View File

@@ -18,7 +18,7 @@
"test:unit:watch": "jest --watch",
"dev": "tsup --watch --dts",
"lint": "eslint \"src/**/*.ts*\"",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist && rm -rf src/proto"
},
"devDependencies": {
"@bufbuild/buf": "^1.14.0",

View File

@@ -19,7 +19,7 @@
"test:unit:watch": "jest --watch",
"dev": "tsup --dts --watch",
"lint": "eslint \"src/**/*.ts*\"",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist"
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist && rm -rf src/proto"
},
"devDependencies": {
"@bufbuild/buf": "^1.14.0",

View File

@@ -1,44 +1,58 @@
import * as settings from "./v2/settings";
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 login from "./proto/server/zitadel/settings/v2alpha/login_settings";
import * as password from "./proto/server/zitadel/settings/v2alpha/password_settings";
import * as legal from "./proto/server/zitadel/settings/v2alpha/legal_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 legal from "./proto/server/zitadel/settings/v2beta/legal_settings";
export {
BrandingSettings,
Theme,
} from "./proto/server/zitadel/settings/v2alpha/branding_settings";
} from "./proto/server/zitadel/settings/v2beta/branding_settings";
export {
LoginSettings,
IdentityProvider,
IdentityProviderType,
} from "./proto/server/zitadel/settings/v2alpha/login_settings";
} from "./proto/server/zitadel/settings/v2beta/login_settings";
export {
ChallengeKind,
RequestChallenges,
Challenges,
Challenges_Passkey,
} from "./proto/server/zitadel/session/v2alpha/challenge";
Challenges_WebAuthN,
} from "./proto/server/zitadel/session/v2beta/challenge";
export {
GetAuthRequestRequest,
GetAuthRequestResponse,
CreateCallbackRequest,
CreateCallbackResponse,
} from "./proto/server/zitadel/oidc/v2beta/oidc_service";
export {
AuthRequest,
Prompt,
} from "./proto/server/zitadel/oidc/v2beta/authorization";
export {
Session,
Factors,
} from "./proto/server/zitadel/session/v2alpha/session";
} from "./proto/server/zitadel/session/v2beta/session";
export {
IDPInformation,
IDPLink,
} from "./proto/server/zitadel/user/v2alpha/idp";
} from "./proto/server/zitadel/user/v2beta/idp";
export {
ListSessionsResponse,
GetSessionResponse,
CreateSessionResponse,
SetSessionResponse,
SetSessionRequest,
DeleteSessionResponse,
} from "./proto/server/zitadel/session/v2alpha/session_service";
} from "./proto/server/zitadel/session/v2beta/session_service";
export {
GetPasswordComplexitySettingsResponse,
GetBrandingSettingsResponse,
@@ -48,7 +62,7 @@ export {
GetLoginSettingsRequest,
GetActiveIdentityProvidersResponse,
GetActiveIdentityProvidersRequest,
} from "./proto/server/zitadel/settings/v2alpha/settings_service";
} from "./proto/server/zitadel/settings/v2beta/settings_service";
export {
AddHumanUserResponse,
AddHumanUserRequest,
@@ -62,19 +76,19 @@ export {
ListAuthenticationMethodTypesResponse,
ListAuthenticationMethodTypesRequest,
AuthenticationMethodType,
StartIdentityProviderFlowRequest,
StartIdentityProviderFlowResponse,
RetrieveIdentityProviderInformationRequest,
RetrieveIdentityProviderInformationResponse,
} from "./proto/server/zitadel/user/v2alpha/user_service";
StartIdentityProviderIntentRequest,
StartIdentityProviderIntentResponse,
RetrieveIdentityProviderIntentRequest,
RetrieveIdentityProviderIntentResponse,
} from "./proto/server/zitadel/user/v2beta/user_service";
export {
SetHumanPasswordResponse,
SetHumanPasswordRequest,
} from "./proto/server/zitadel/management";
export * from "./proto/server/zitadel/idp";
export { type LegalAndSupportSettings } from "./proto/server/zitadel/settings/v2alpha/legal_settings";
export { type PasswordComplexitySettings } from "./proto/server/zitadel/settings/v2alpha/password_settings";
export { type ResourceOwnerType } from "./proto/server/zitadel/settings/v2alpha/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 ResourceOwnerType } from "./proto/server/zitadel/settings/v2beta/settings";
import {
getServers,
@@ -96,4 +110,5 @@ export {
login,
password,
legal,
oidc,
};

View File

@@ -0,0 +1,2 @@
export * from "./oidc";
export * from "../../proto/server/zitadel/oidc/v2beta/oidc_service";

View File

@@ -0,0 +1,24 @@
import { CompatServiceDefinition } from "nice-grpc/lib/service-definitions";
import { ZitadelServer, createClient, getServers } from "../../server";
import { OIDCServiceClient, OIDCServiceDefinition } from ".";
export const getOidc = (server?: string | ZitadelServer) => {
let config;
if (server && typeof server === "string") {
const apps = getServers();
config = apps.find((a) => a.name === server)?.config;
} else if (server && typeof server === "object") {
config = server.config;
}
if (!config) {
throw Error("No ZITADEL server found");
}
return createClient<OIDCServiceClient>(
OIDCServiceDefinition as CompatServiceDefinition,
config.apiUrl,
config.token
);
};

View File

@@ -1,2 +1,2 @@
export * from "./session";
export * from "../../proto/server/zitadel/session/v2alpha/session";
export * from "../../proto/server/zitadel/session/v2beta/session";

View File

@@ -3,7 +3,7 @@ import { CompatServiceDefinition } from "nice-grpc/lib/service-definitions";
import {
SessionServiceClient,
SessionServiceDefinition,
} from "../../proto/server/zitadel/session/v2alpha/session_service";
} from "../../proto/server/zitadel/session/v2beta/session_service";
import { ZitadelServer, createClient, getServers } from "../../server";

View File

@@ -1,2 +1,2 @@
export * from "./settings";
export * from "../../proto/server/zitadel/settings/v2alpha/settings";
export * from "../../proto/server/zitadel/settings/v2beta/settings";

View File

@@ -3,7 +3,7 @@ import { CompatServiceDefinition } from "nice-grpc/lib/service-definitions";
import {
SettingsServiceClient,
SettingsServiceDefinition,
} from "../../proto/server/zitadel/settings/v2alpha/settings_service";
} from "../../proto/server/zitadel/settings/v2beta/settings_service";
import { ZitadelServer, createClient, getServers } from "../../server";

View File

@@ -1,2 +1,2 @@
export * from "./user";
export * from "../../proto/server/zitadel/user/v2alpha/user";
export * from "../../proto/server/zitadel/user/v2beta/user";

View File

@@ -3,7 +3,7 @@ import { CompatServiceDefinition } from "nice-grpc/lib/service-definitions";
import {
UserServiceClient,
UserServiceDefinition,
} from "../../proto/server/zitadel/user/v2alpha/user_service";
} from "../../proto/server/zitadel/user/v2beta/user_service";
import { ZitadelServer, createClient, getServers } from "../../server";

569
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff