diff --git a/apps/login/app/(login)/accounts/page.tsx b/apps/login/app/(login)/accounts/page.tsx index b017dbc8..6c41d2f3 100644 --- a/apps/login/app/(login)/accounts/page.tsx +++ b/apps/login/app/(login)/accounts/page.tsx @@ -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 { - const ids = await getAllSessionIds(); + const ids = await getAllSessionCookieIds(); if (ids && ids.length) { const response = await listSessions( @@ -20,7 +20,13 @@ async function loadSessions(): Promise { } } -export default async function Page() { +export default async function Page({ + searchParams, +}: { + searchParams: Record; +}) { + const authRequestId = searchParams?.authRequestId; + let sessions = await loadSessions(); return ( @@ -29,7 +35,7 @@ export default async function Page() {

Use your ZITADEL Account

- +
diff --git a/apps/login/app/(login)/login/route.ts b/apps/login/app/(login)/login/route.ts new file mode 100644 index 00000000..c229c738 --- /dev/null +++ b/apps/login/app/(login)/login/route.ts @@ -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 { + 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(); + } +} diff --git a/apps/login/app/(login)/loginname/page.tsx b/apps/login/app/(login)/loginname/page.tsx index 735ce185..ecf4ac6d 100644 --- a/apps/login/app/(login)/loginname/page.tsx +++ b/apps/login/app/(login)/loginname/page.tsx @@ -7,6 +7,7 @@ export default async function Page({ searchParams: Record; }) { 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({
diff --git a/apps/login/app/(login)/passkey/login/page.tsx b/apps/login/app/(login)/passkey/login/page.tsx index d8eac1ef..e523ff30 100644 --- a/apps/login/app/(login)/passkey/login/page.tsx +++ b/apps/login/app/(login)/passkey/login/page.tsx @@ -13,7 +13,7 @@ export default async function Page({ }: { searchParams: Record; }) { - const { loginName, altPassword } = searchParams; + const { loginName, altPassword, authRequestId } = searchParams; const sessionFactors = await loadSession(loginName); @@ -48,6 +48,7 @@ export default async function Page({ {loginName && ( )} diff --git a/apps/login/app/(login)/password/page.tsx b/apps/login/app/(login)/password/page.tsx index 0ea96aa6..5458909b 100644 --- a/apps/login/app/(login)/password/page.tsx +++ b/apps/login/app/(login)/password/page.tsx @@ -9,7 +9,7 @@ export default async function Page({ }: { searchParams: Record; }) { - 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({ diff --git a/apps/login/app/(login)/register/idp/[provider]/success/page.tsx b/apps/login/app/(login)/register/idp/[provider]/success/page.tsx index 1dc4792b..f7b55fcf 100644 --- a/apps/login/app/(login)/register/idp/[provider]/success/page.tsx +++ b/apps/login/app/(login)/register/idp/[provider]/success/page.tsx @@ -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 { 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; }); } diff --git a/apps/login/app/(login)/signedin/page.tsx b/apps/login/app/(login)/signedin/page.tsx index 2231bcb1..740840df 100644 --- a/apps/login/app/(login)/signedin/page.tsx +++ b/apps/login/app/(login)/signedin/page.tsx @@ -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 (
diff --git a/apps/login/app/api/idp/start/route.ts b/apps/login/app/api/idp/start/route.ts index 84ef0547..18a7f61e 100644 --- a/apps/login/app/api/idp/start/route.ts +++ b/apps/login/app/api/idp/start/route.ts @@ -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); }) diff --git a/apps/login/app/api/loginname/route.ts b/apps/login/app/api/loginname/route.ts index 1fa251e7..257e56e5 100644 --- a/apps/login/app/api/loginname/route.ts +++ b/apps/login/app/api/loginname/route.ts @@ -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 domain: string = request.nextUrl.hostname; + const { loginName, authRequestId } = body; - 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(); diff --git a/apps/login/app/api/session/route.ts b/apps/login/app/api/session/route.ts index a970ff35..3f41dc96 100644 --- a/apps/login/app/api/session/route.ts +++ b/apps/login/app/api/session/route.ts @@ -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 = 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, diff --git a/apps/login/cypress/integration/login.cy.ts b/apps/login/cypress/integration/login.cy.ts index d35d006e..8b38ecd9 100644 --- a/apps/login/cypress/integration/login.cy.ts +++ b/apps/login/cypress/integration/login.cy.ts @@ -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", - { - data: { - authMethodTypes: [1], // 1 for password authentication - }, - } - ); + 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", - { - data: { - authMethodTypes: [2], // 2 for passwordless authentication - }, - } - ); + 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" diff --git a/apps/login/cypress/integration/register-idp.cy.ts b/apps/login/cypress/integration/register-idp.cy.ts index a0ff93d8..bd0c5e09 100644 --- a/apps/login/cypress/integration/register-idp.cy.ts +++ b/apps/login/cypress/integration/register-idp.cy.ts @@ -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, }, diff --git a/apps/login/cypress/integration/register.cy.ts b/apps/login/cypress/integration/register.cy.ts index 4ef537d2..cd7770aa 100644 --- a/apps/login/cypress/integration/register.cy.ts +++ b/apps/login/cypress/integration/register.cy.ts @@ -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", }, diff --git a/apps/login/cypress/integration/verify.cy.ts b/apps/login/cypress/integration/verify.cy.ts index e584b1af..e8bca4dd 100644 --- a/apps/login/cypress/integration/verify.cy.ts +++ b/apps/login/cypress/integration/verify.cy.ts @@ -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", }); diff --git a/apps/login/lib/zitadel.ts b/apps/login/lib/zitadel.ts index 97beffe4..9148fb9e 100644 --- a/apps/login/lib/zitadel.ts +++ b/apps/login/lib/zitadel.ts @@ -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 { 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 { 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 { const userService = user.getUser(server); - const payload = { + const payload: Partial = { 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 { + { idpId, urls }: StartIdentityProviderIntentRequest +): Promise { 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 { + { idpIntentId, idpIntentToken }: RetrieveIdentityProviderIntentRequest +): Promise { const userService = user.getUser(server); - return userService.retrieveIdentityProviderInformation({ - intentId, - token, + return userService.retrieveIdentityProviderIntent({ + idpIntentId, + idpIntentToken, }); } +export async function getAuthRequest( + server: ZitadelServer, + { authRequestId }: GetAuthRequestRequest +): Promise { + const oidcService = oidc.getOidc(server); + + return oidcService.getAuthRequest({ + authRequestId, + }); +} + +export async function createCallback( + server: ZitadelServer, + req: CreateCallbackRequest +): Promise { + const oidcService = oidc.getOidc(server); + + return oidcService.createCallback(req); +} + export async function verifyEmail( server: ZitadelServer, userId: string, diff --git a/apps/login/middleware.ts b/apps/login/middleware.ts new file mode 100644 index 00000000..88badee8 --- /dev/null +++ b/apps/login/middleware.ts @@ -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, + }); +} diff --git a/apps/login/mock/initial-stubs/zitadel.settings.v2alpha.SettingsService.json b/apps/login/mock/initial-stubs/zitadel.settings.v2beta.SettingsService.json similarity index 79% rename from apps/login/mock/initial-stubs/zitadel.settings.v2alpha.SettingsService.json rename to apps/login/mock/initial-stubs/zitadel.settings.v2beta.SettingsService.json index 74a5e2d0..8cb2a2c0 100644 --- a/apps/login/mock/initial-stubs/zitadel.settings.v2alpha.SettingsService.json +++ b/apps/login/mock/initial-stubs/zitadel.settings.v2beta.SettingsService.json @@ -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": { diff --git a/apps/login/mock/mocked-services.cfg b/apps/login/mock/mocked-services.cfg index 33b1ae05..58938f40 100644 --- a/apps/login/mock/mocked-services.cfg +++ b/apps/login/mock/mocked-services.cfg @@ -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 \ No newline at end of file diff --git a/apps/login/package.json b/apps/login/package.json index 19e7bf3f..8f492580 100644 --- a/apps/login/package.json +++ b/apps/login/package.json @@ -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", diff --git a/apps/login/ui/LoginPasskey.tsx b/apps/login/ui/LoginPasskey.tsx index ce3425af..e36b9bda 100644 --- a/apps/login/ui/LoginPasskey.tsx +++ b/apps/login/ui/LoginPasskey.tsx @@ -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(""); const [loading, setLoading] = useState(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) { diff --git a/apps/login/ui/PasswordForm.tsx b/apps/login/ui/PasswordForm.tsx index e547e801..52a1169d 100644 --- a/apps/login/ui/PasswordForm.tsx +++ b/apps/login/ui/PasswordForm.tsx @@ -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, + } + ) + ); } }); } diff --git a/apps/login/ui/RegisterFormWithoutPassword.tsx b/apps/login/ui/RegisterFormWithoutPassword.tsx index b75e9951..49a89df5 100644 --- a/apps/login/ui/RegisterFormWithoutPassword.tsx +++ b/apps/login/ui/RegisterFormWithoutPassword.tsx @@ -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 }), }); diff --git a/apps/login/ui/SessionItem.tsx b/apps/login/ui/SessionItem.tsx index 3214ea99..e181343d 100644 --- a/apps/login/ui/SessionItem.tsx +++ b/apps/login/ui/SessionItem.tsx @@ -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(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({ - loginName: session.factors?.user?.loginName as string, - }) + new URLSearchParams( + authRequestId + ? { + loginName: session.factors?.user?.loginName as string, + authRequestId, + } + : { + loginName: session.factors?.user?.loginName as string, + } + ) : `/loginname?` + - new URLSearchParams({ - loginName: session.factors?.user?.loginName as string, - submit: "true", - }) + 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" > diff --git a/apps/login/ui/SessionsList.tsx b/apps/login/ui/SessionsList.tsx index 0e54b8da..a7e0c4cf 100644 --- a/apps/login/ui/SessionsList.tsx +++ b/apps/login/ui/SessionsList.tsx @@ -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(sessions); return sessions ? (
@@ -19,6 +20,7 @@ export default function SessionsList({ sessions }: Props) { return ( { setList(list.filter((s) => s.id !== session.id)); }} diff --git a/apps/login/ui/SetPasswordForm.tsx b/apps/login/ui/SetPasswordForm.tsx index 2efe33b8..6b065a37 100644 --- a/apps/login/ui/SetPasswordForm.tsx +++ b/apps/login/ui/SetPasswordForm.tsx @@ -79,6 +79,7 @@ export default function SetPasswordForm({ body: JSON.stringify({ loginName: loginName, password: password, + // authRequestId, register does not need an oidc callback }), }); diff --git a/apps/login/ui/SignInWithIDP.tsx b/apps/login/ui/SignInWithIDP.tsx index 7f45f004..a7788415 100644 --- a/apps/login/ui/SignInWithIDP.tsx +++ b/apps/login/ui/SignInWithIDP.tsx @@ -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, diff --git a/apps/login/ui/UsernameForm.tsx b/apps/login/ui/UsernameForm.tsx index 729b418e..c6d45be7 100644 --- a/apps/login/ui/UsernameForm.tsx +++ b/apps/login/ui/UsernameForm.tsx @@ -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({ @@ -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: + const paramsPassword: any = { loginName: values.loginName }; + + if (loginSettings?.passkeysType === 1) { + paramsPassword.promptPasswordless = `true`; // PasskeysType.PASSKEYS_TYPE_ALLOWED, + } + + if (authRequestId) { + paramsPassword.authRequestId = authRequestId; + } + return router.push( - "/password?" + - new URLSearchParams( - loginSettings?.passkeysType === 1 - ? { - loginName: values.loginName, - promptPasswordless: `true`, // PasskeysType.PASSKEYS_TYPE_ALLOWED, - } - : { loginName: values.loginName } - ) + "/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: + const paramsPasskeyDefault: any = { loginName: values.loginName }; + + if (loginSettings?.passkeysType === 1) { + paramsPasskeyDefault.promptPasswordless = `true`; // PasskeysType.PASSKEYS_TYPE_ALLOWED, + } + + if (authRequestId) { + paramsPasskeyDefault.authRequestId = authRequestId; + } return router.push( - "/password?" + - new URLSearchParams( - loginSettings?.passkeysType === 1 - ? { - loginName: values.loginName, - promptPasswordless: `true`, // PasskeysType.PASSKEYS_TYPE_ALLOWED, - } - : { loginName: values.loginName } - ) + "/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)) { + 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({ - loginName: values.loginName, - altPassword: `${response.authMethodTypes.includes(1)}`, // show alternative password option - }) + "/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} />
+ {error && ( +
+ {error} +
+ )} +
- {/* */}