From e2718483cc8ccd52dd5316acdf554a51d4922d8f Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Mon, 28 Apr 2025 14:57:08 +0200 Subject: [PATCH 01/23] device page, code form --- apps/login/locales/en.json | 12 +++ apps/login/src/app/(login)/device/page.tsx | 86 +++++++++++++++++ apps/login/src/components/app-avatar.tsx | 48 ++++++++++ apps/login/src/components/avatar.tsx | 2 +- .../login/src/components/device-code-form.tsx | 93 +++++++++++++++++++ apps/login/src/components/dynamic-theme.tsx | 21 +++-- apps/login/src/lib/server/oidc.ts | 15 +++ apps/login/src/lib/zitadel.ts | 14 +++ 8 files changed, 283 insertions(+), 8 deletions(-) create mode 100644 apps/login/src/app/(login)/device/page.tsx create mode 100644 apps/login/src/components/app-avatar.tsx create mode 100644 apps/login/src/components/device-code-form.tsx create mode 100644 apps/login/src/lib/server/oidc.ts diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 36776ccbd9..cb5011f59a 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -187,6 +187,18 @@ "allSetup": "You have already setup an authenticator!", "linkWithIDP": "or link with an Identity Provider" }, + "device": { + "usercode": { + "title": "Device code", + "description": "Enter the code provided in the verification email.", + "submit": "Continue" + }, + "request": { + "title": "would like to connect:", + "description": "By clicking Allow, you allow this app and Zitadel to use your information in accordance with their respective terms of service and privacy policies. You can revoke this access at any time.", + "submit": "Allow" + } + }, "error": { "unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.", "sessionExpired": "Your current session has expired. Please login again.", diff --git a/apps/login/src/app/(login)/device/page.tsx b/apps/login/src/app/(login)/device/page.tsx new file mode 100644 index 0000000000..3c533271c0 --- /dev/null +++ b/apps/login/src/app/(login)/device/page.tsx @@ -0,0 +1,86 @@ +import { DeviceCodeForm } from "@/components/device-code-form"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { getServiceUrlFromHeaders } from "@/lib/service"; +import { + getBrandingSettings, + getDefaultOrg, + getDeviceAuthorizationRequest, +} from "@/lib/zitadel"; +import { DeviceAuthorizationRequest } from "@zitadel/proto/zitadel/oidc/v2/authorization_pb"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "device" }); + + const loginName = searchParams?.loginName; + const userCode = searchParams?.user_code; + const organization = searchParams?.organization; + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + defaultOrganization = org.id; + } + } + + let deviceAuthRequest: DeviceAuthorizationRequest | null = null; + if (userCode) { + const deviceAuthorizationRequestResponse = + await getDeviceAuthorizationRequest({ + serviceUrl, + userCode, + }); + + if (deviceAuthorizationRequestResponse.deviceAuthorizationRequest) { + deviceAuthRequest = + deviceAuthorizationRequestResponse.deviceAuthorizationRequest; + } + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + return ( + +
+ {!userCode && ( + <> +

{t("usercode.title")}

+

{t("usercode.description")}

+ + + )} + + {deviceAuthRequest && ( +
+

+ {deviceAuthRequest.appName} +
+ {t("request.title")} +

+

+ {t("request.description")} +

+ {/* {JSON.stringify(deviceAuthRequest)} */} +
+ )} +
+
+ ); +} diff --git a/apps/login/src/components/app-avatar.tsx b/apps/login/src/components/app-avatar.tsx new file mode 100644 index 0000000000..defe388438 --- /dev/null +++ b/apps/login/src/components/app-avatar.tsx @@ -0,0 +1,48 @@ +import { ColorShade, getColorHash } from "@/helpers/colors"; +import { useTheme } from "next-themes"; +import Image from "next/image"; +import { getInitials } from "./avatar"; + +interface AvatarProps { + appName: string; + imageUrl?: string; + shadow?: boolean; +} + +export function AppAvatar({ appName, imageUrl, shadow }: AvatarProps) { + const { resolvedTheme } = useTheme(); + const credentials = getInitials(appName, appName); + + const color: ColorShade = getColorHash(appName); + + const avatarStyleDark = { + backgroundColor: color[900], + color: color[200], + }; + + const avatarStyleLight = { + backgroundColor: color[200], + color: color[900], + }; + + return ( +
+ {imageUrl ? ( + avatar + ) : ( + {credentials} + )} +
+ ); +} diff --git a/apps/login/src/components/avatar.tsx b/apps/login/src/components/avatar.tsx index 3f340e09b7..2300659875 100644 --- a/apps/login/src/components/avatar.tsx +++ b/apps/login/src/components/avatar.tsx @@ -12,7 +12,7 @@ interface AvatarProps { shadow?: boolean; } -function getInitials(name: string, loginName: string) { +export function getInitials(name: string, loginName: string) { let credentials = ""; if (name) { const split = name.split(" "); diff --git a/apps/login/src/components/device-code-form.tsx b/apps/login/src/components/device-code-form.tsx new file mode 100644 index 0000000000..5747e52f3c --- /dev/null +++ b/apps/login/src/components/device-code-form.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { Alert } from "@/components/alert"; +import { getDeviceAuthorizationRequest } from "@/lib/server/oidc"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { BackButton } from "./back-button"; +import { Button, ButtonVariants } from "./button"; +import { TextInput } from "./input"; +import { Spinner } from "./spinner"; + +type Inputs = { + userCode: string; +}; + +export function DeviceCodeForm() { + const t = useTranslations("verify"); + + const router = useRouter(); + + const { register, handleSubmit, formState } = useForm({ + mode: "onBlur", + defaultValues: { + userCode: "", + }, + }); + + const [error, setError] = useState(""); + + const [loading, setLoading] = useState(false); + + async function submitCodeAndContinue(value: Inputs): Promise { + setLoading(true); + + const response = await getDeviceAuthorizationRequest(value.userCode) + .catch(() => { + setError("Could not verify user"); + return; + }) + .finally(() => { + setLoading(false); + }); + + if (response && "error" in response && response?.error) { + setError(response.error); + return; + } + + if (response && "redirect" in response && response?.redirect) { + return router.push(response?.redirect); + } + } + + return ( + <> +
+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + +
+
+ + ); +} diff --git a/apps/login/src/components/dynamic-theme.tsx b/apps/login/src/components/dynamic-theme.tsx index 7d0fecb558..d50bc082ea 100644 --- a/apps/login/src/components/dynamic-theme.tsx +++ b/apps/login/src/components/dynamic-theme.tsx @@ -3,27 +3,34 @@ import { Logo } from "@/components/logo"; import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; import { ReactNode } from "react"; +import { AppAvatar } from "./app-avatar"; import { ThemeWrapper } from "./theme-wrapper"; export function DynamicTheme({ branding, children, + appName, }: { children: ReactNode; branding?: BrandingSettings; + appName?: string; }) { return (
-
+
{branding && ( - + <> + + + {appName && } + )}
diff --git a/apps/login/src/lib/server/oidc.ts b/apps/login/src/lib/server/oidc.ts new file mode 100644 index 0000000000..4ae01b4a47 --- /dev/null +++ b/apps/login/src/lib/server/oidc.ts @@ -0,0 +1,15 @@ +"use server"; + +import { getDeviceAuthorizationRequest as zitadelGetDeviceAuthorizationRequest } from "@/lib/zitadel"; +import { headers } from "next/headers"; +import { getServiceUrlFromHeaders } from "../service"; + +export async function getDeviceAuthorizationRequest(userCode: string) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + return zitadelGetDeviceAuthorizationRequest({ + serviceUrl, + userCode, + }); +} diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index 0511eaaf0d..aee182dc41 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -929,6 +929,20 @@ export async function getAuthRequest({ }); } +export async function getDeviceAuthorizationRequest({ + serviceUrl, + userCode, +}: { + serviceUrl: string; + userCode: string; +}) { + const oidcService = await createServiceForHost(OIDCService, serviceUrl); + + return oidcService.getDeviceAuthorizationRequest({ + userCode, + }); +} + export async function createCallback({ serviceUrl, req, From ed37eaff80afff4acd99a0a9eb28218d95dd903c Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 2 May 2025 09:01:42 +0200 Subject: [PATCH 02/23] authorizeOrDenyDeviceAuthorization --- apps/login/src/lib/zitadel.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index aee182dc41..da690c10e2 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -943,6 +943,31 @@ export async function getDeviceAuthorizationRequest({ }); } +export async function authorizeOrDenyDeviceAuthorization({ + serviceUrl, + deviceAuthorizationId, + session, +}: { + serviceUrl: string; + deviceAuthorizationId: string; + session?: { sessionId: string; sessionToken: string }; +}) { + const oidcService = await createServiceForHost(OIDCService, serviceUrl); + + return oidcService.authorizeOrDenyDeviceAuthorization({ + deviceAuthorizationId, + decision: session + ? { + case: "session", + value: session, + } + : { + case: "deny", + value: {}, + }, + }); +} + export async function createCallback({ serviceUrl, req, From 5274c2bd7d1f33965b0e45473ad8cdaa649880ef Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 2 May 2025 13:52:58 +0200 Subject: [PATCH 03/23] device code request --- .../src/app/(login)/device/consent/page.tsx | 67 ++++++++++ apps/login/src/app/(login)/device/page.tsx | 42 +----- apps/login/src/app/login/route.ts | 18 ++- apps/login/src/components/consent.tsx | 12 ++ .../login/src/components/device-code-form.tsx | 20 +-- apps/login/src/lib/device.ts | 123 ++++++++++++++++++ apps/login/src/lib/oidc.ts | 6 +- apps/login/src/lib/saml.ts | 6 +- 8 files changed, 237 insertions(+), 57 deletions(-) create mode 100644 apps/login/src/app/(login)/device/consent/page.tsx create mode 100644 apps/login/src/components/consent.tsx create mode 100644 apps/login/src/lib/device.ts diff --git a/apps/login/src/app/(login)/device/consent/page.tsx b/apps/login/src/app/(login)/device/consent/page.tsx new file mode 100644 index 0000000000..ee4312b955 --- /dev/null +++ b/apps/login/src/app/(login)/device/consent/page.tsx @@ -0,0 +1,67 @@ +import { ConsentScreen } from "@/components/consent"; +import { DynamicTheme } from "@/components/dynamic-theme"; +import { getServiceUrlFromHeaders } from "@/lib/service"; +import { + getBrandingSettings, + getDefaultOrg, + getDeviceAuthorizationRequest, +} from "@/lib/zitadel"; +import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; +import { getLocale, getTranslations } from "next-intl/server"; +import { headers } from "next/headers"; + +export default async function Page(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + const locale = getLocale(); + const t = await getTranslations({ locale, namespace: "device" }); + + const userCode = searchParams?.user_code; + const requestId = searchParams?.requestId; + const organization = searchParams?.organization; + + if (!userCode || !requestId) { + return
{t("error.no_user_code")}
; + } + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const { deviceAuthorizationRequest } = await getDeviceAuthorizationRequest({ + serviceUrl, + userCode, + }); + + let defaultOrganization; + if (!organization) { + const org: Organization | null = await getDefaultOrg({ + serviceUrl, + }); + if (org) { + defaultOrganization = org.id; + } + } + + const branding = await getBrandingSettings({ + serviceUrl, + organization: organization ?? defaultOrganization, + }); + + return ( + +
+ {!userCode && ( + <> +

{t("usercode.title")}

+

{t("usercode.description")}

+ + + )} +
+
+ ); +} diff --git a/apps/login/src/app/(login)/device/page.tsx b/apps/login/src/app/(login)/device/page.tsx index 3c533271c0..bde104b631 100644 --- a/apps/login/src/app/(login)/device/page.tsx +++ b/apps/login/src/app/(login)/device/page.tsx @@ -1,12 +1,7 @@ import { DeviceCodeForm } from "@/components/device-code-form"; import { DynamicTheme } from "@/components/dynamic-theme"; import { getServiceUrlFromHeaders } from "@/lib/service"; -import { - getBrandingSettings, - getDefaultOrg, - getDeviceAuthorizationRequest, -} from "@/lib/zitadel"; -import { DeviceAuthorizationRequest } from "@zitadel/proto/zitadel/oidc/v2/authorization_pb"; +import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel"; import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb"; import { getLocale, getTranslations } from "next-intl/server"; import { headers } from "next/headers"; @@ -18,7 +13,6 @@ export default async function Page(props: { const locale = getLocale(); const t = await getTranslations({ locale, namespace: "device" }); - const loginName = searchParams?.loginName; const userCode = searchParams?.user_code; const organization = searchParams?.organization; @@ -35,51 +29,21 @@ export default async function Page(props: { } } - let deviceAuthRequest: DeviceAuthorizationRequest | null = null; - if (userCode) { - const deviceAuthorizationRequestResponse = - await getDeviceAuthorizationRequest({ - serviceUrl, - userCode, - }); - - if (deviceAuthorizationRequestResponse.deviceAuthorizationRequest) { - deviceAuthRequest = - deviceAuthorizationRequestResponse.deviceAuthorizationRequest; - } - } - const branding = await getBrandingSettings({ serviceUrl, organization: organization ?? defaultOrganization, }); return ( - +
{!userCode && ( <>

{t("usercode.title")}

{t("usercode.description")}

- + )} - - {deviceAuthRequest && ( -
-

- {deviceAuthRequest.appName} -
- {t("request.title")} -

-

- {t("request.description")} -

- {/* {JSON.stringify(deviceAuthRequest)} */} -
- )}
); diff --git a/apps/login/src/app/login/route.ts b/apps/login/src/app/login/route.ts index e3834e5a27..4e57030470 100644 --- a/apps/login/src/app/login/route.ts +++ b/apps/login/src/app/login/route.ts @@ -1,7 +1,8 @@ import { getAllSessions } from "@/lib/cookies"; +import { loginWithDeviceAndSession } from "@/lib/device"; import { idpTypeToSlug } from "@/lib/idp"; -import { loginWithOIDCandSession } from "@/lib/oidc"; -import { loginWithSAMLandSession } from "@/lib/saml"; +import { loginWithOIDCAndSession } from "@/lib/oidc"; +import { loginWithSAMLAndSession } from "@/lib/saml"; import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; import { constructUrl, getServiceUrlFromHeaders } from "@/lib/service"; import { findValidSession } from "@/lib/session"; @@ -107,7 +108,7 @@ export async function GET(request: NextRequest) { if (requestId && sessionId) { if (requestId.startsWith("oidc_")) { // this finishes the login process for OIDC - return loginWithOIDCandSession({ + return loginWithOIDCAndSession({ serviceUrl, authRequest: requestId.replace("oidc_", ""), sessionId, @@ -117,7 +118,7 @@ export async function GET(request: NextRequest) { }); } else if (requestId.startsWith("saml_")) { // this finishes the login process for SAML - return loginWithSAMLandSession({ + return loginWithSAMLAndSession({ serviceUrl, samlRequest: requestId.replace("saml_", ""), sessionId, @@ -125,6 +126,15 @@ export async function GET(request: NextRequest) { sessionCookies, request, }); + } else if (requestId.startsWith("device_")) { + return loginWithDeviceAndSession({ + serviceUrl, + deviceRequest: requestId.replace("device_", ""), + sessionId, + sessions, + sessionCookies, + request, + }); } } diff --git a/apps/login/src/components/consent.tsx b/apps/login/src/components/consent.tsx new file mode 100644 index 0000000000..315aaded13 --- /dev/null +++ b/apps/login/src/components/consent.tsx @@ -0,0 +1,12 @@ +export function ConsentScreen({ scope }: { scope?: string[] }) { + return ( +
+

Consent

+

Please confirm your consent.

+
+ + +
+
+ ); +} diff --git a/apps/login/src/components/device-code-form.tsx b/apps/login/src/components/device-code-form.tsx index 5747e52f3c..faa77c3cdd 100644 --- a/apps/login/src/components/device-code-form.tsx +++ b/apps/login/src/components/device-code-form.tsx @@ -15,7 +15,7 @@ type Inputs = { userCode: string; }; -export function DeviceCodeForm() { +export function DeviceCodeForm({ userCode }: { userCode?: string }) { const t = useTranslations("verify"); const router = useRouter(); @@ -23,7 +23,7 @@ export function DeviceCodeForm() { const { register, handleSubmit, formState } = useForm({ mode: "onBlur", defaultValues: { - userCode: "", + userCode: userCode || "", }, }); @@ -36,21 +36,25 @@ export function DeviceCodeForm() { const response = await getDeviceAuthorizationRequest(value.userCode) .catch(() => { - setError("Could not verify user"); + setError("Could not complete the request"); return; }) .finally(() => { setLoading(false); }); - if (response && "error" in response && response?.error) { - setError(response.error); + if (!response || !response.deviceAuthorizationRequest?.id) { + setError("Could not complete the request"); return; } - if (response && "redirect" in response && response?.redirect) { - return router.push(response?.redirect); - } + return router.push( + `/device/consent?` + + new URLSearchParams({ + requestId: `device_${response.deviceAuthorizationRequest.id}`, + user_code: value.userCode, + }).toString(), + ); } return ( diff --git a/apps/login/src/lib/device.ts b/apps/login/src/lib/device.ts new file mode 100644 index 0000000000..cc42c6cafa --- /dev/null +++ b/apps/login/src/lib/device.ts @@ -0,0 +1,123 @@ +import { Cookie } from "@/lib/cookies"; +import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; +import { + authorizeOrDenyDeviceAuthorization, + getLoginSettings, +} from "@/lib/zitadel"; +import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; +import { NextRequest, NextResponse } from "next/server"; +import { constructUrl } from "./service"; +import { isSessionValid } from "./session"; + +type LoginWithOIDCandSession = { + serviceUrl: string; + deviceRequest: string; + sessionId: string; + sessions: Session[]; + sessionCookies: Cookie[]; + request: NextRequest; +}; +export async function loginWithDeviceAndSession({ + serviceUrl, + deviceRequest, + sessionId, + sessions, + sessionCookies, + request, +}: LoginWithOIDCandSession) { + console.log( + `Login with session: ${sessionId} and deviceRequest: ${deviceRequest}`, + ); + + const selectedSession = sessions.find((s) => s.id === sessionId); + + if (selectedSession && selectedSession.id) { + console.log(`Found session ${selectedSession.id}`); + + const isValid = await isSessionValid({ + serviceUrl, + session: selectedSession, + }); + + console.log("Session is valid:", isValid); + + if (!isValid && selectedSession.factors?.user) { + // if the session is not valid anymore, we need to redirect the user to re-authenticate / + // TODO: handle IDP intent direcly if available + const command: SendLoginnameCommand = { + loginName: selectedSession.factors.user?.loginName, + organization: selectedSession.factors?.user?.organizationId, + requestId: `device_${deviceRequest}`, + }; + + const res = await sendLoginname(command); + + if (res && "redirect" in res && res?.redirect) { + const absoluteUrl = constructUrl(request, res.redirect); + return NextResponse.redirect(absoluteUrl.toString()); + } + } + + const cookie = sessionCookies.find( + (cookie) => cookie.id === selectedSession?.id, + ); + + if (cookie && cookie.id && cookie.token) { + const session = { + sessionId: cookie?.id, + sessionToken: cookie?.token, + }; + + // works not with _rsc request + try { + const authResponse = await authorizeOrDenyDeviceAuthorization({ + serviceUrl, + deviceAuthorizationId: deviceRequest, + session, + }); + if (!authResponse) { + return NextResponse.json( + { error: "An error occurred!" }, + { status: 500 }, + ); + } + } catch (error: unknown) { + // handle already handled gracefully as these could come up if old emails with requestId are used (reset password, register emails etc.) + console.error(error); + if ( + error && + typeof error === "object" && + "code" in error && + error?.code === 9 + ) { + const loginSettings = await getLoginSettings({ + serviceUrl, + organization: selectedSession.factors?.user?.organizationId, + }); + + if (loginSettings?.defaultRedirectUri) { + return NextResponse.redirect(loginSettings.defaultRedirectUri); + } + + const signedinUrl = constructUrl(request, "/signedin"); + + if (selectedSession.factors?.user?.loginName) { + signedinUrl.searchParams.set( + "loginName", + selectedSession.factors?.user?.loginName, + ); + } + if (selectedSession.factors?.user?.organizationId) { + signedinUrl.searchParams.set( + "organization", + selectedSession.factors?.user?.organizationId, + ); + } + return NextResponse.redirect(signedinUrl); + } else { + return NextResponse.json({ error }, { status: 500 }); + } + } + } + } +} diff --git a/apps/login/src/lib/oidc.ts b/apps/login/src/lib/oidc.ts index c1038d90c4..09f6e0354e 100644 --- a/apps/login/src/lib/oidc.ts +++ b/apps/login/src/lib/oidc.ts @@ -11,7 +11,7 @@ import { NextRequest, NextResponse } from "next/server"; import { constructUrl } from "./service"; import { isSessionValid } from "./session"; -type LoginWithOIDCandSession = { +type LoginWithOIDCAndSession = { serviceUrl: string; authRequest: string; sessionId: string; @@ -19,14 +19,14 @@ type LoginWithOIDCandSession = { sessionCookies: Cookie[]; request: NextRequest; }; -export async function loginWithOIDCandSession({ +export async function loginWithOIDCAndSession({ serviceUrl, authRequest, sessionId, sessions, sessionCookies, request, -}: LoginWithOIDCandSession) { +}: LoginWithOIDCAndSession) { console.log( `Login with session: ${sessionId} and authRequest: ${authRequest}`, ); diff --git a/apps/login/src/lib/saml.ts b/apps/login/src/lib/saml.ts index 9b12e48d25..7d294c908a 100644 --- a/apps/login/src/lib/saml.ts +++ b/apps/login/src/lib/saml.ts @@ -8,7 +8,7 @@ import { NextRequest, NextResponse } from "next/server"; import { constructUrl } from "./service"; import { isSessionValid } from "./session"; -type LoginWithSAMLandSession = { +type LoginWithSAMLAndSession = { serviceUrl: string; samlRequest: string; sessionId: string; @@ -17,14 +17,14 @@ type LoginWithSAMLandSession = { request: NextRequest; }; -export async function loginWithSAMLandSession({ +export async function loginWithSAMLAndSession({ serviceUrl, samlRequest, sessionId, sessions, sessionCookies, request, -}: LoginWithSAMLandSession) { +}: LoginWithSAMLAndSession) { console.log( `Login with session: ${sessionId} and samlRequest: ${samlRequest}`, ); From 6270cf9522add45c6d90c1cd37a5e3e8ff2a8039 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 2 May 2025 15:08:41 +0200 Subject: [PATCH 04/23] device code flow --- apps/login/locales/en.json | 2 +- apps/login/src/app/(login)/signedin/page.tsx | 23 +++++++- apps/login/src/app/login/route.ts | 5 +- .../login/src/components/device-code-form.tsx | 2 +- apps/login/src/lib/client.ts | 52 +++++++++++++++---- 5 files changed, 70 insertions(+), 14 deletions(-) diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index cb5011f59a..806b91dbff 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -190,7 +190,7 @@ "device": { "usercode": { "title": "Device code", - "description": "Enter the code provided in the verification email.", + "description": "Enter the code.", "submit": "Continue" }, "request": { diff --git a/apps/login/src/app/(login)/signedin/page.tsx b/apps/login/src/app/(login)/signedin/page.tsx index 8c5c5486ac..271b8ea5ac 100644 --- a/apps/login/src/app/(login)/signedin/page.tsx +++ b/apps/login/src/app/(login)/signedin/page.tsx @@ -5,9 +5,11 @@ import { UserAvatar } from "@/components/user-avatar"; import { getMostRecentCookieWithLoginname } from "@/lib/cookies"; import { getServiceUrlFromHeaders } from "@/lib/service"; import { + authorizeOrDenyDeviceAuthorization, createCallback, createResponse, getBrandingSettings, + getDeviceAuthorizationRequest, getLoginSettings, getSession, } from "@/lib/zitadel"; @@ -24,7 +26,6 @@ import { redirect } from "next/navigation"; async function loadSession( serviceUrl: string, - loginName: string, requestId?: string, ) { @@ -62,6 +63,26 @@ async function loadSession( }).then(({ url }) => { return redirect(url); }); + } else if (requestId && requestId.startsWith("device_")) { + const userCode = requestId.replace("device_", ""); + + const { deviceAuthorizationRequest } = await getDeviceAuthorizationRequest({ + serviceUrl, + userCode, + }); + + if (!deviceAuthorizationRequest) { + throw new Error("Device authorization request not found"); + } + + return authorizeOrDenyDeviceAuthorization({ + serviceUrl, + deviceAuthorizationId: deviceAuthorizationRequest?.id, + session: { + sessionId: recent.id, + sessionToken: recent.token, + }, + }); } return getSession({ diff --git a/apps/login/src/app/login/route.ts b/apps/login/src/app/login/route.ts index 4e57030470..365f83a225 100644 --- a/apps/login/src/app/login/route.ts +++ b/apps/login/src/app/login/route.ts @@ -127,6 +127,7 @@ export async function GET(request: NextRequest) { request, }); } else if (requestId.startsWith("device_")) { + // this finishes the login process for Device Authorization return loginWithDeviceAndSession({ serviceUrl, deviceRequest: requestId.replace("device_", ""), @@ -509,7 +510,9 @@ export async function GET(request: NextRequest) { requestId: `saml_${samlRequest.id}`, }); } - } else { + } + // Device Authorization does not need to start here as it is handled on the /device endpoint + else { return NextResponse.json( { error: "No authRequest nor samlRequest provided" }, { status: 500 }, diff --git a/apps/login/src/components/device-code-form.tsx b/apps/login/src/components/device-code-form.tsx index faa77c3cdd..8adb8c3386 100644 --- a/apps/login/src/components/device-code-form.tsx +++ b/apps/login/src/components/device-code-form.tsx @@ -51,7 +51,7 @@ export function DeviceCodeForm({ userCode }: { userCode?: string }) { return router.push( `/device/consent?` + new URLSearchParams({ - requestId: `device_${response.deviceAuthorizationRequest.id}`, + requestId: `device_${userCode}`, user_code: value.userCode, }).toString(), ); diff --git a/apps/login/src/lib/client.ts b/apps/login/src/lib/client.ts index 953d66e7ee..df04986ccc 100644 --- a/apps/login/src/lib/client.ts +++ b/apps/login/src/lib/client.ts @@ -5,6 +5,28 @@ type FinishFlowCommand = } | { loginName: string }; +function goToSignedInPage( + props: + | { sessionId: string; organization?: string } + | { organization?: string; loginName: string }, +) { + const params = new URLSearchParams({}); + + if ("loginName" in props && props.loginName) { + params.append("loginName", props.loginName); + } + + if ("sessionId" in props && props.sessionId) { + params.append("sessionId", props.sessionId); + } + + if (props.organization) { + params.append("organization", props.organization); + } + + return `/signedin?` + params; +} + /** * for client: redirects user back to an OIDC or SAML application or to a success page when using requestId, check if a default redirect and redirect to it, or just redirect to a success page with the loginName * @param command @@ -14,7 +36,25 @@ export async function getNextUrl( command: FinishFlowCommand & { organization?: string }, defaultRedirectUri?: string, ): Promise { - if ("sessionId" in command && "requestId" in command) { + // finish Device Authorization Flow + if ( + "requestId" in command && + command.requestId.startsWith("device_") && + ("loginName" in command || "sessionId" in command) + ) { + return goToSignedInPage({ + ...command, + organization: command.organization, + }); + } + + // finish SAML or OIDC flow + if ( + "sessionId" in command && + "requestId" in command && + (command.requestId.startsWith("saml_") || + command.requestId.startsWith("oidc_")) + ) { const params = new URLSearchParams({ sessionId: command.sessionId, requestId: command.requestId, @@ -31,13 +71,5 @@ export async function getNextUrl( return defaultRedirectUri; } - const params = new URLSearchParams({ - loginName: command.loginName, - }); - - if (command.organization) { - params.append("organization", command.organization); - } - - return `/signedin?` + params; + return goToSignedInPage(command); } From 54fd748b12988206e4ed6e9b513bea174622a890 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 2 May 2025 17:20:28 +0200 Subject: [PATCH 05/23] dc --- apps/login/locales/en.json | 5 ++ .../src/app/(login)/device/consent/page.tsx | 25 +++++--- apps/login/src/app/(login)/signedin/page.tsx | 30 ++++------ apps/login/src/components/consent.tsx | 60 ++++++++++++++++--- .../login/src/components/device-code-form.tsx | 2 +- 5 files changed, 90 insertions(+), 32 deletions(-) diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 806b91dbff..1b52353c87 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -197,6 +197,11 @@ "title": "would like to connect:", "description": "By clicking Allow, you allow this app and Zitadel to use your information in accordance with their respective terms of service and privacy policies. You can revoke this access at any time.", "submit": "Allow" + }, + "scope": { + "email": "Access your email address.", + "profile": "Access your full profile information.", + "offline_access": "Allow offline access to your account." } }, "error": { diff --git a/apps/login/src/app/(login)/device/consent/page.tsx b/apps/login/src/app/(login)/device/consent/page.tsx index ee4312b955..1c33d6c831 100644 --- a/apps/login/src/app/(login)/device/consent/page.tsx +++ b/apps/login/src/app/(login)/device/consent/page.tsx @@ -33,6 +33,8 @@ export default async function Page(props: { userCode, }); + console.log(deviceAuthorizationRequest); + let defaultOrganization; if (!organization) { const org: Organization | null = await getDefaultOrg({ @@ -48,19 +50,28 @@ export default async function Page(props: { organization: organization ?? defaultOrganization, }); + const params = new URLSearchParams(); + + if (requestId) { + params.append("requestId", requestId); + } + + if (organization) { + params.append("organization", organization); + } + return (
- {!userCode && ( - <> -

{t("usercode.title")}

-

{t("usercode.description")}

- - - )} +

{t("usercode.title")}

+

{t("usercode.description")}

+
); diff --git a/apps/login/src/app/(login)/signedin/page.tsx b/apps/login/src/app/(login)/signedin/page.tsx index 271b8ea5ac..42a10dabd4 100644 --- a/apps/login/src/app/(login)/signedin/page.tsx +++ b/apps/login/src/app/(login)/signedin/page.tsx @@ -9,7 +9,6 @@ import { createCallback, createResponse, getBrandingSettings, - getDeviceAuthorizationRequest, getLoginSettings, getSession, } from "@/lib/zitadel"; @@ -64,24 +63,17 @@ async function loadSession( return redirect(url); }); } else if (requestId && requestId.startsWith("device_")) { - const userCode = requestId.replace("device_", ""); - - const { deviceAuthorizationRequest } = await getDeviceAuthorizationRequest({ - serviceUrl, - userCode, - }); - - if (!deviceAuthorizationRequest) { - throw new Error("Device authorization request not found"); - } + const session = { + sessionId: recent.id, + sessionToken: recent.token, + }; return authorizeOrDenyDeviceAuthorization({ serviceUrl, - deviceAuthorizationId: deviceAuthorizationRequest?.id, - session: { - sessionId: recent.id, - sessionToken: recent.token, - }, + deviceAuthorizationId: requestId.replace("device_", ""), + session, + }).then(() => { + return session; }); } @@ -105,7 +97,11 @@ export default async function Page(props: { searchParams: Promise }) { const { serviceUrl } = getServiceUrlFromHeaders(_headers); const { loginName, requestId, organization } = searchParams; - const sessionFactors = await loadSession(serviceUrl, loginName, requestId); + // const sessionFactors = await loadSession(serviceUrl, loginName, requestId); + + const sessionFactors = sessionId + ? await loadSessionById(serviceUrl, sessionId, organization) + : await loadSessionByLoginname(serviceUrl, loginName, organization); const branding = await getBrandingSettings({ serviceUrl, diff --git a/apps/login/src/components/consent.tsx b/apps/login/src/components/consent.tsx index 315aaded13..5bf3747f73 100644 --- a/apps/login/src/components/consent.tsx +++ b/apps/login/src/components/consent.tsx @@ -1,11 +1,57 @@ -export function ConsentScreen({ scope }: { scope?: string[] }) { +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { Button, ButtonVariants } from "./button"; + +export function ConsentScreen({ + scope, + nextUrl, +}: { + scope?: string[]; + nextUrl: string; +}) { + const t = useTranslations(); + return ( -
-

Consent

-

Please confirm your consent.

-
- - +
+
    + {scope?.map((s) => { + const translationKey = `device.scope.${s}`; + const description = t(translationKey, null); + + // Check if the key itself is returned and provide a fallback + const resolvedDescription = + description === translationKey + ? "No description available." + : description; + + return ( +
  • + {s} + {resolvedDescription} +
  • + ); + })} +
+ +
+ + + + + +
); diff --git a/apps/login/src/components/device-code-form.tsx b/apps/login/src/components/device-code-form.tsx index 8adb8c3386..faa77c3cdd 100644 --- a/apps/login/src/components/device-code-form.tsx +++ b/apps/login/src/components/device-code-form.tsx @@ -51,7 +51,7 @@ export function DeviceCodeForm({ userCode }: { userCode?: string }) { return router.push( `/device/consent?` + new URLSearchParams({ - requestId: `device_${userCode}`, + requestId: `device_${response.deviceAuthorizationRequest.id}`, user_code: value.userCode, }).toString(), ); From 781462d9f1bfe6ecdbd47bbb2a8883bcc38d3e3f Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Mon, 5 May 2025 09:39:50 +0200 Subject: [PATCH 06/23] fix signedin session --- apps/login/locales/en.json | 2 +- .../src/app/(login)/device/consent/page.tsx | 6 ++-- apps/login/src/app/(login)/signedin/page.tsx | 30 +++++++++++++++++-- apps/login/src/components/consent.tsx | 10 ++++--- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 1b52353c87..c604615152 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -194,7 +194,7 @@ "submit": "Continue" }, "request": { - "title": "would like to connect:", + "title": "{appName} would like to connect:", "description": "By clicking Allow, you allow this app and Zitadel to use your information in accordance with their respective terms of service and privacy policies. You can revoke this access at any time.", "submit": "Allow" }, diff --git a/apps/login/src/app/(login)/device/consent/page.tsx b/apps/login/src/app/(login)/device/consent/page.tsx index 1c33d6c831..283d46d155 100644 --- a/apps/login/src/app/(login)/device/consent/page.tsx +++ b/apps/login/src/app/(login)/device/consent/page.tsx @@ -66,8 +66,10 @@ export default async function Page(props: { appName={deviceAuthorizationRequest?.appName} >
-

{t("usercode.title")}

-

{t("usercode.description")}

+

+ {t("request.title", { appName: deviceAuthorizationRequest?.appName })} +

+ { + if (response?.session) { + return response.session; + } + }); +} + export default async function Page(props: { searchParams: Promise }) { const searchParams = await props.searchParams; const locale = getLocale(); @@ -96,12 +117,15 @@ export default async function Page(props: { searchParams: Promise }) { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); - const { loginName, requestId, organization } = searchParams; + const { loginName, requestId, organization, sessionId } = searchParams; // const sessionFactors = await loadSession(serviceUrl, loginName, requestId); const sessionFactors = sessionId ? await loadSessionById(serviceUrl, sessionId, organization) - : await loadSessionByLoginname(serviceUrl, loginName, organization); + : await loadMostRecentSession({ + serviceUrl, + sessionParams: { loginName, organization }, + }); const branding = await getBrandingSettings({ serviceUrl, diff --git a/apps/login/src/components/consent.tsx b/apps/login/src/components/consent.tsx index 5bf3747f73..7422b657d6 100644 --- a/apps/login/src/components/consent.tsx +++ b/apps/login/src/components/consent.tsx @@ -12,7 +12,7 @@ export function ConsentScreen({ const t = useTranslations(); return ( -
+
    {scope?.map((s) => { const translationKey = `device.scope.${s}`; @@ -20,9 +20,7 @@ export function ConsentScreen({ // Check if the key itself is returned and provide a fallback const resolvedDescription = - description === translationKey - ? "No description available." - : description; + description === translationKey ? "" : description; return (
  • +

    + {t("device.request.description")} +

    +
    @@ -51,7 +92,7 @@ export function ConsentScreen({ className="self-end" variant={ButtonVariants.Primary} > - continue + {t("device.request.submit")}
    diff --git a/apps/login/src/lib/server/oidc.ts b/apps/login/src/lib/server/oidc.ts index 4ae01b4a47..3bd0e53df4 100644 --- a/apps/login/src/lib/server/oidc.ts +++ b/apps/login/src/lib/server/oidc.ts @@ -1,6 +1,9 @@ "use server"; -import { getDeviceAuthorizationRequest as zitadelGetDeviceAuthorizationRequest } from "@/lib/zitadel"; +import { + authorizeOrDenyDeviceAuthorization, + getDeviceAuthorizationRequest as zitadelGetDeviceAuthorizationRequest, +} from "@/lib/zitadel"; import { headers } from "next/headers"; import { getServiceUrlFromHeaders } from "../service"; @@ -13,3 +16,14 @@ export async function getDeviceAuthorizationRequest(userCode: string) { userCode, }); } + +export async function denyDeviceAuthorization(deviceAuthorizationId: string) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + // without the session, device auth request is denied + return authorizeOrDenyDeviceAuthorization({ + serviceUrl, + deviceAuthorizationId, + }); +} From a4e08b5419e136774310e5f6405a7a1f1f352b31 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 6 May 2025 14:41:44 +0200 Subject: [PATCH 09/23] i18n --- apps/login/locales/de.json | 1 + apps/login/locales/en.json | 7 +- apps/login/locales/es.json | 1 + apps/login/locales/it.json | 1 + apps/login/locales/pl.json | 1 + apps/login/locales/ru.json | 1 + apps/login/locales/zh.json | 1 + .../src/app/(login)/device/consent/page.tsx | 5 +- apps/login/src/app/(login)/signedin/page.tsx | 80 +------------------ apps/login/src/components/consent.tsx | 8 +- apps/login/src/lib/client.ts | 9 ++- 11 files changed, 23 insertions(+), 92 deletions(-) diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index befd14e7be..452620c697 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -200,6 +200,7 @@ "deny": "Ablehnen" }, "scope": { + "openid": "Überprüfen Sie Ihre Identität.", "email": "Zugriff auf Ihre E-Mail-Adresse.", "profile": "Zugriff auf Ihre vollständigen Profilinformationen.", "offline_access": "Erlauben Sie den Offline-Zugriff auf Ihr Konto." diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 7aecf0294b..27b0880615 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -190,7 +190,7 @@ "device": { "usercode": { "title": "Device code", - "description": "Enter the code.", + "description": "Enter the code displayed on your app or device.", "submit": "Continue" }, "request": { @@ -200,8 +200,9 @@ "deny": "Deny" }, "scope": { - "email": "Access your email address.", - "profile": "Access your full profile information.", + "openid": "Verify your identity.", + "email": "Access to your email address.", + "profile": "Access to your full profile information.", "offline_access": "Allow offline access to your account." } }, diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index 450aadd867..e27ef1c25d 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -200,6 +200,7 @@ "deny": "Denegar" }, "scope": { + "openid": "Verifica tu identidad.", "email": "Accede a tu dirección de correo electrónico.", "profile": "Accede a la información completa de tu perfil.", "offline_access": "Permitir acceso sin conexión a tu cuenta." diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index 558ac51a23..1c7a4d495d 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -200,6 +200,7 @@ "deny": "Nega" }, "scope": { + "openid": "Verifica la tua identità.", "email": "Accedi al tuo indirizzo email.", "profile": "Accedi alle informazioni complete del tuo profilo.", "offline_access": "Consenti l'accesso offline al tuo account." diff --git a/apps/login/locales/pl.json b/apps/login/locales/pl.json index 49683303db..a82efd9807 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -200,6 +200,7 @@ "deny": "Odmów" }, "scope": { + "openid": "Zweryfikuj swoją tożsamość.", "email": "Uzyskaj dostęp do swojego adresu e-mail.", "profile": "Uzyskaj dostęp do pełnych informacji o swoim profilu.", "offline_access": "Zezwól na dostęp offline do swojego konta." diff --git a/apps/login/locales/ru.json b/apps/login/locales/ru.json index 149bea5a88..f3ac0dfa43 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -200,6 +200,7 @@ "deny": "Запретить" }, "scope": { + "openid": "Проверка вашей личности.", "email": "Доступ к вашему адресу электронной почты.", "profile": "Доступ к полной информации вашего профиля.", "offline_access": "Разрешить офлайн-доступ к вашему аккаунту." diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index 13a91ac77e..72c4bc4735 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -200,6 +200,7 @@ "deny": "拒绝" }, "scope": { + "openid": "验证您的身份。", "email": "访问您的电子邮件地址。", "profile": "访问您的完整个人资料信息。", "offline_access": "允许离线访问您的账户。" diff --git a/apps/login/src/app/(login)/device/consent/page.tsx b/apps/login/src/app/(login)/device/consent/page.tsx index c190451d48..64754d5ab8 100644 --- a/apps/login/src/app/(login)/device/consent/page.tsx +++ b/apps/login/src/app/(login)/device/consent/page.tsx @@ -63,10 +63,7 @@ export default async function Page(props: { } return ( - +

    {t("request.title", { appName: deviceAuthorizationRequest?.appName })} diff --git a/apps/login/src/app/(login)/signedin/page.tsx b/apps/login/src/app/(login)/signedin/page.tsx index 4fbaf9b937..09c96b56d4 100644 --- a/apps/login/src/app/(login)/signedin/page.tsx +++ b/apps/login/src/app/(login)/signedin/page.tsx @@ -2,95 +2,17 @@ import { Alert, AlertType } from "@/components/alert"; import { Button, ButtonVariants } from "@/components/button"; import { DynamicTheme } from "@/components/dynamic-theme"; import { UserAvatar } from "@/components/user-avatar"; -import { - getMostRecentCookieWithLoginname, - getSessionCookieById, -} from "@/lib/cookies"; +import { getSessionCookieById } from "@/lib/cookies"; import { getServiceUrlFromHeaders } from "@/lib/service"; import { loadMostRecentSession } from "@/lib/session"; import { - authorizeOrDenyDeviceAuthorization, - createCallback, - createResponse, getBrandingSettings, getLoginSettings, getSession, } from "@/lib/zitadel"; -import { create } from "@zitadel/client"; -import { - CreateCallbackRequestSchema, - SessionSchema, -} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb"; -import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_pb"; import { getLocale, getTranslations } from "next-intl/server"; import { headers } from "next/headers"; import Link from "next/link"; -import { redirect } from "next/navigation"; - -async function loadSession( - serviceUrl: string, - loginName: string, - requestId?: string, -) { - const recent = await getMostRecentCookieWithLoginname({ loginName }); - - if (requestId && requestId.startsWith("oidc_")) { - return createCallback({ - serviceUrl, - req: create(CreateCallbackRequestSchema, { - authRequestId: requestId, - callbackKind: { - case: "session", - value: create(SessionSchema, { - sessionId: recent.id, - sessionToken: recent.token, - }), - }, - }), - }).then(({ callbackUrl }) => { - return redirect(callbackUrl); - }); - } else if (requestId && requestId.startsWith("saml_")) { - return createResponse({ - serviceUrl, - req: create(CreateResponseRequestSchema, { - samlRequestId: requestId.replace("saml_", ""), - responseKind: { - case: "session", - value: { - sessionId: recent.id, - sessionToken: recent.token, - }, - }, - }), - }).then(({ url }) => { - return redirect(url); - }); - } else if (requestId && requestId.startsWith("device_")) { - const session = { - sessionId: recent.id, - sessionToken: recent.token, - }; - - return authorizeOrDenyDeviceAuthorization({ - serviceUrl, - deviceAuthorizationId: requestId.replace("device_", ""), - session, - }).then(() => { - return session; - }); - } - - return getSession({ - serviceUrl, - sessionId: recent.id, - sessionToken: recent.token, - }).then((response) => { - if (response?.session) { - return response.session; - } - }); -} async function loadSessionById( serviceUrl: string, diff --git a/apps/login/src/components/consent.tsx b/apps/login/src/components/consent.tsx index 82266a71dc..15b106e62e 100644 --- a/apps/login/src/components/consent.tsx +++ b/apps/login/src/components/consent.tsx @@ -1,3 +1,5 @@ +"use client"; + import { denyDeviceAuthorization } from "@/lib/server/oidc"; import { useTranslations } from "next-intl"; import Link from "next/link"; @@ -32,11 +34,9 @@ export function ConsentScreen({ setLoading(false); }); - if (response && "redirect" in response && response.redirect) { + if (response) { return router.push("/device"); } - - return response; } return ( @@ -77,7 +77,7 @@ export function ConsentScreen({ onClick={() => { denyDeviceAuth(); }} - variant={ButtonVariants.Destructive} + variant={ButtonVariants.Secondary} data-testid="deny-button" > {loading && } diff --git a/apps/login/src/lib/client.ts b/apps/login/src/lib/client.ts index df04986ccc..a59af90b77 100644 --- a/apps/login/src/lib/client.ts +++ b/apps/login/src/lib/client.ts @@ -7,8 +7,8 @@ type FinishFlowCommand = function goToSignedInPage( props: - | { sessionId: string; organization?: string } - | { organization?: string; loginName: string }, + | { sessionId: string; organization?: string; requestId?: string } + | { organization?: string; loginName: string; requestId?: string }, ) { const params = new URLSearchParams({}); @@ -24,6 +24,11 @@ function goToSignedInPage( params.append("organization", props.organization); } + // required to show conditional UI for device flow + if (props.requestId) { + params.append("requestId", props.requestId); + } + return `/signedin?` + params; } From 4f8aca1434c1373a92dd3f5f76759521d8d87bac Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 6 May 2025 14:49:14 +0200 Subject: [PATCH 10/23] i18n improvement --- apps/login/locales/de.json | 2 +- apps/login/locales/en.json | 2 +- apps/login/locales/es.json | 2 +- apps/login/locales/it.json | 2 +- apps/login/src/app/(login)/device/consent/page.tsx | 1 + apps/login/src/components/consent.tsx | 4 +++- 6 files changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index 452620c697..25f2ad4b38 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -195,7 +195,7 @@ }, "request": { "title": "{appName} möchte eine Verbindung herstellen:", - "description": "Durch Klicken auf Zulassen erlauben Sie dieser App und Zitadel, Ihre Informationen gemäß ihren jeweiligen Nutzungsbedingungen und Datenschutzrichtlinien zu verwenden. Sie können diesen Zugriff jederzeit widerrufen.", + "description": "Durch Klicken auf Zulassen erlauben Sie {appName} und Zitadel, Ihre Informationen gemäß ihren jeweiligen Nutzungsbedingungen und Datenschutzrichtlinien zu verwenden. Sie können diesen Zugriff jederzeit widerrufen.", "submit": "Zulassen", "deny": "Ablehnen" }, diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 27b0880615..efcb5a8503 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -195,7 +195,7 @@ }, "request": { "title": "{appName} would like to connect:", - "description": "By clicking Allow, you allow this app and Zitadel to use your information in accordance with their respective terms of service and privacy policies. You can revoke this access at any time.", + "description": "By clicking Allow, you allow {appName} and Zitadel to use your information in accordance with their respective terms of service and privacy policies. You can revoke this access at any time.", "submit": "Allow", "deny": "Deny" }, diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index e27ef1c25d..9a9f63f5cd 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -195,7 +195,7 @@ }, "request": { "title": "{appName} desea conectarse:", - "description": "Al hacer clic en Permitir, autorizas a esta aplicación y a Zitadel a usar tu información de acuerdo con sus respectivos términos de servicio y políticas de privacidad. Puedes revocar este acceso en cualquier momento.", + "description": "Al hacer clic en Permitir, autorizas a {appName} y a Zitadel a usar tu información de acuerdo con sus respectivos términos de servicio y políticas de privacidad. Puedes revocar este acceso en cualquier momento.", "submit": "Permitir", "deny": "Denegar" }, diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index 1c7a4d495d..af1a60f3cd 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -195,7 +195,7 @@ }, "request": { "title": "{appName} desidera connettersi:", - "description": "Cliccando su Consenti, autorizzi questa app e Zitadel a utilizzare le tue informazioni in conformità con i rispettivi termini di servizio e politiche sulla privacy. Puoi revocare questo accesso in qualsiasi momento.", + "description": "Cliccando su Consenti, autorizzi {appName} e Zitadel a utilizzare le tue informazioni in conformità con i rispettivi termini di servizio e politiche sulla privacy. Puoi revocare questo accesso in qualsiasi momento.", "submit": "Consenti", "deny": "Nega" }, diff --git a/apps/login/src/app/(login)/device/consent/page.tsx b/apps/login/src/app/(login)/device/consent/page.tsx index 64754d5ab8..150c9b4043 100644 --- a/apps/login/src/app/(login)/device/consent/page.tsx +++ b/apps/login/src/app/(login)/device/consent/page.tsx @@ -72,6 +72,7 @@ export default async function Page(props: {

    diff --git a/apps/login/src/components/consent.tsx b/apps/login/src/components/consent.tsx index 15b106e62e..755087c9e9 100644 --- a/apps/login/src/components/consent.tsx +++ b/apps/login/src/components/consent.tsx @@ -13,10 +13,12 @@ export function ConsentScreen({ scope, nextUrl, deviceAuthorizationRequestId, + appName, }: { scope?: string[]; nextUrl: string; deviceAuthorizationRequestId: string; + appName?: string; }) { const t = useTranslations(); const [loading, setLoading] = useState(false); @@ -63,7 +65,7 @@ export function ConsentScreen({

- {t("device.request.description")} + {t("device.request.description", { appName: appName })}

{error && ( From 2f7c628dcdfa0afbb569f9d2f4ffbbf3b1341d37 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 6 May 2025 14:55:20 +0200 Subject: [PATCH 11/23] cleanup --- apps/login/locales/de.json | 1 + apps/login/locales/en.json | 1 + apps/login/locales/es.json | 1 + apps/login/locales/it.json | 1 + apps/login/locales/pl.json | 1 + apps/login/locales/ru.json | 1 + apps/login/locales/zh.json | 1 + .../src/app/(login)/device/consent/page.tsx | 2 +- apps/login/src/app/login/route.ts | 11 -- apps/login/src/lib/device.ts | 125 ------------------ 10 files changed, 8 insertions(+), 137 deletions(-) delete mode 100644 apps/login/src/lib/device.ts diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index 25f2ad4b38..f01d3d8f10 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -207,6 +207,7 @@ } }, "error": { + "noUserCode": "Kein Benutzercode angegeben!", "noDeviceRequest": " Es wurde keine Geräteanforderung gefunden. Bitte überprüfen Sie die URL.", "unknownContext": "Der Kontext des Benutzers konnte nicht ermittelt werden. Stellen Sie sicher, dass Sie zuerst den Benutzernamen eingeben oder einen loginName als Suchparameter angeben.", "sessionExpired": "Ihre aktuelle Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index efcb5a8503..49f1fb99da 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -207,6 +207,7 @@ } }, "error": { + "noUserCode": "No user code provided!", "noDeviceRequest": "No device request found.", "unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.", "sessionExpired": "Your current session has expired. Please login again.", diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index 9a9f63f5cd..8ec58e2b55 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -207,6 +207,7 @@ } }, "error": { + "noUserCode": "¡No se proporcionó código de usuario!", "noDeviceRequest": "No se encontró ninguna solicitud de dispositivo.", "unknownContext": "No se pudo obtener el contexto del usuario. Asegúrate de ingresar primero el nombre de usuario o proporcionar un loginName como parámetro de búsqueda.", "sessionExpired": "Tu sesión actual ha expirado. Por favor, inicia sesión de nuevo.", diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index af1a60f3cd..c59aeda2ab 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -207,6 +207,7 @@ } }, "error": { + "noUserCode": "Nessun codice utente fornito!", "noDeviceRequest": "Nessuna richiesta di dispositivo trovata.", "unknownContext": "Impossibile ottenere il contesto dell'utente. Assicurati di inserire prima il nome utente o di fornire un loginName come parametro di ricerca.", "sessionExpired": "La tua sessione attuale è scaduta. Effettua nuovamente l'accesso.", diff --git a/apps/login/locales/pl.json b/apps/login/locales/pl.json index a82efd9807..132c06f10f 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -207,6 +207,7 @@ } }, "error": { + "noUserCode": "Nie podano kodu użytkownika!", "noDeviceRequest": "Nie znaleziono żądania urządzenia.", "unknownContext": "Nie udało się pobrać kontekstu użytkownika. Upewnij się, że najpierw wprowadziłeś nazwę użytkownika lub podałeś login jako parametr wyszukiwania.", "sessionExpired": "Twoja sesja wygasła. Zaloguj się ponownie.", diff --git a/apps/login/locales/ru.json b/apps/login/locales/ru.json index f3ac0dfa43..9a3ecd7cdd 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -207,6 +207,7 @@ } }, "error": { + "noUserCode": "Не указан код пользователя!", "noDeviceRequest": "Не найдена ни одна заявка на устройство.", "unknownContext": "Не удалось получить контекст пользователя. Укажите имя пользователя или loginName в параметрах поиска.", "sessionExpired": "Ваша сессия истекла. Войдите снова.", diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index 72c4bc4735..d0fdd44d16 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -207,6 +207,7 @@ } }, "error": { + "noUserCode": "未提供用户代码!", "noDeviceRequest": "没有找到设备请求。", "unknownContext": "无法获取用户的上下文。请先输入用户名或提供 loginName 作为搜索参数。", "sessionExpired": "当前会话已过期,请重新登录。", diff --git a/apps/login/src/app/(login)/device/consent/page.tsx b/apps/login/src/app/(login)/device/consent/page.tsx index 150c9b4043..9d55f4f6b8 100644 --- a/apps/login/src/app/(login)/device/consent/page.tsx +++ b/apps/login/src/app/(login)/device/consent/page.tsx @@ -22,7 +22,7 @@ export default async function Page(props: { const organization = searchParams?.organization; if (!userCode || !requestId) { - return
{t("error.no_user_code")}
; + return
{t("error.noUserCode")}
; } const _headers = await headers(); diff --git a/apps/login/src/app/login/route.ts b/apps/login/src/app/login/route.ts index 365f83a225..3072f45229 100644 --- a/apps/login/src/app/login/route.ts +++ b/apps/login/src/app/login/route.ts @@ -1,5 +1,4 @@ import { getAllSessions } from "@/lib/cookies"; -import { loginWithDeviceAndSession } from "@/lib/device"; import { idpTypeToSlug } from "@/lib/idp"; import { loginWithOIDCAndSession } from "@/lib/oidc"; import { loginWithSAMLAndSession } from "@/lib/saml"; @@ -126,16 +125,6 @@ export async function GET(request: NextRequest) { sessionCookies, request, }); - } else if (requestId.startsWith("device_")) { - // this finishes the login process for Device Authorization - return loginWithDeviceAndSession({ - serviceUrl, - deviceRequest: requestId.replace("device_", ""), - sessionId, - sessions, - sessionCookies, - request, - }); } } diff --git a/apps/login/src/lib/device.ts b/apps/login/src/lib/device.ts deleted file mode 100644 index 36074803b9..0000000000 --- a/apps/login/src/lib/device.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { Cookie } from "@/lib/cookies"; -import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; -import { - authorizeOrDenyDeviceAuthorization, - getLoginSettings, -} from "@/lib/zitadel"; -import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb"; -import { NextRequest, NextResponse } from "next/server"; -import { constructUrl } from "./service"; -import { isSessionValid } from "./session"; - -type LoginWithOIDCandSession = { - serviceUrl: string; - deviceRequest: string; - sessionId: string; - sessions: Session[]; - sessionCookies: Cookie[]; - request: NextRequest; -}; -export async function loginWithDeviceAndSession({ - serviceUrl, - deviceRequest, - sessionId, - sessions, - sessionCookies, - request, -}: LoginWithOIDCandSession) { - console.log( - `Login with session: ${sessionId} and deviceRequest: ${deviceRequest}`, - ); - - const selectedSession = sessions.find((s) => s.id === sessionId); - - if (selectedSession && selectedSession.id) { - console.log(`Found session ${selectedSession.id}`); - - const isValid = await isSessionValid({ - serviceUrl, - session: selectedSession, - }); - - console.log("Session is valid:", isValid); - - if (!isValid && selectedSession.factors?.user) { - // if the session is not valid anymore, we need to redirect the user to re-authenticate / - // TODO: handle IDP intent direcly if available - const command: SendLoginnameCommand = { - loginName: selectedSession.factors.user?.loginName, - organization: selectedSession.factors?.user?.organizationId, - requestId: `device_${deviceRequest}`, - }; - - const res = await sendLoginname(command); - - if (res && "redirect" in res && res?.redirect) { - const absoluteUrl = constructUrl(request, res.redirect); - return NextResponse.redirect(absoluteUrl.toString()); - } - } - - const cookie = sessionCookies.find( - (cookie) => cookie.id === selectedSession?.id, - ); - - if (cookie && cookie.id && cookie.token) { - const session = { - sessionId: cookie?.id, - sessionToken: cookie?.token, - }; - - // works not with _rsc request - try { - const authResponse = await authorizeOrDenyDeviceAuthorization({ - serviceUrl, - deviceAuthorizationId: deviceRequest, - session, - }); - if (!authResponse) { - return NextResponse.json( - { error: "An error occurred!" }, - { status: 500 }, - ); - } - } catch (error: unknown) { - // handle already handled gracefully as these could come up if old emails with requestId are used (reset password, register emails etc.) - console.error(error); - if ( - error && - typeof error === "object" && - "code" in error && - error?.code === 9 - ) { - const loginSettings = await getLoginSettings({ - serviceUrl, - organization: selectedSession.factors?.user?.organizationId, - }); - - if (loginSettings?.defaultRedirectUri) { - return NextResponse.redirect(loginSettings.defaultRedirectUri); - } - - const signedinUrl = constructUrl(request, "/signedin"); - - signedinUrl.searchParams.set("requestId", `device_${deviceRequest}`); - - if (selectedSession.factors?.user?.loginName) { - signedinUrl.searchParams.set( - "loginName", - selectedSession.factors?.user?.loginName, - ); - } - if (selectedSession.factors?.user?.organizationId) { - signedinUrl.searchParams.set( - "organization", - selectedSession.factors?.user?.organizationId, - ); - } - return NextResponse.redirect(signedinUrl); - } else { - return NextResponse.json({ error }, { status: 500 }); - } - } - } - } -} From 4588c48fda3301881bb0c464ee105983fe4eb667 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 6 May 2025 15:24:25 +0200 Subject: [PATCH 12/23] move flow completion to signedin page --- apps/login/locales/de.json | 6 +- apps/login/locales/en.json | 6 +- apps/login/locales/es.json | 6 +- apps/login/locales/it.json | 6 +- apps/login/locales/pl.json | 6 +- apps/login/locales/ru.json | 6 +- apps/login/locales/zh.json | 6 +- apps/login/src/app/(login)/signedin/page.tsx | 55 ++++++++++++++----- apps/login/src/components/consent.tsx | 6 +- .../login/src/components/device-code-form.tsx | 4 +- apps/login/src/lib/server/device.ts | 20 +++++++ apps/login/src/lib/server/oidc.ts | 16 +----- apps/login/src/lib/zitadel.ts | 4 ++ 13 files changed, 108 insertions(+), 39 deletions(-) create mode 100644 apps/login/src/lib/server/device.ts diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index f01d3d8f10..87518d74a5 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -166,7 +166,11 @@ "signedin": { "title": "Willkommen {user}!", "description": "Sie sind angemeldet.", - "continue": "Weiter" + "continue": "Weiter", + "error": { + "title": "Fehler", + "description": "Ein Fehler ist aufgetreten." + } }, "verify": { "userIdMissing": "Keine Benutzer-ID angegeben!", diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 49f1fb99da..e32d736e70 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -166,7 +166,11 @@ "signedin": { "title": "Welcome {user}!", "description": "You are signed in.", - "continue": "Continue" + "continue": "Continue", + "error": { + "title": "Error", + "description": "An error occurred while trying to sign in." + } }, "verify": { "userIdMissing": "No userId provided!", diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index 8ec58e2b55..ff2fd9a4cb 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -166,7 +166,11 @@ "signedin": { "title": "¡Bienvenido {user}!", "description": "Has iniciado sesión.", - "continue": "Continuar" + "continue": "Continuar", + "error": { + "title": "Error", + "description": "Ocurrió un error al iniciar sesión." + } }, "verify": { "userIdMissing": "¡No se proporcionó userId!", diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index c59aeda2ab..792568872d 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -166,7 +166,11 @@ "signedin": { "title": "Benvenuto {user}!", "description": "Sei connesso.", - "continue": "Continua" + "continue": "Continua", + "error": { + "title": "Errore", + "description": "Si è verificato un errore durante il tentativo di accesso." + } }, "verify": { "userIdMissing": "Nessun userId fornito!", diff --git a/apps/login/locales/pl.json b/apps/login/locales/pl.json index 132c06f10f..18326df262 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -166,7 +166,11 @@ "signedin": { "title": "Witaj {user}!", "description": "Jesteś zalogowany.", - "continue": "Kontynuuj" + "continue": "Kontynuuj", + "error": { + "title": "Błąd", + "description": "Nie można załadować danych. Sprawdź połączenie z internetem lub spróbuj ponownie później." + } }, "verify": { "userIdMissing": "Nie podano identyfikatora użytkownika!", diff --git a/apps/login/locales/ru.json b/apps/login/locales/ru.json index 9a3ecd7cdd..9eda1730e6 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -166,7 +166,11 @@ "signedin": { "title": "Добро пожаловать, {user}!", "description": "Вы вошли в систему.", - "continue": "Продолжить" + "continue": "Продолжить", + "error": { + "title": "Ошибка", + "description": "Не удалось войти в систему. Проверьте свои данные и попробуйте снова." + } }, "verify": { "userIdMissing": "Не указан userId!", diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index d0fdd44d16..8a6200bf33 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -166,7 +166,11 @@ "signedin": { "title": "欢迎 {user}!", "description": "您已登录。", - "continue": "继续" + "continue": "继续", + "error": { + "title": "错误", + "description": "登录时发生错误。" + } }, "verify": { "userIdMissing": "未提供用户 ID!", diff --git a/apps/login/src/app/(login)/signedin/page.tsx b/apps/login/src/app/(login)/signedin/page.tsx index 09c96b56d4..0150a57f84 100644 --- a/apps/login/src/app/(login)/signedin/page.tsx +++ b/apps/login/src/app/(login)/signedin/page.tsx @@ -2,7 +2,11 @@ import { Alert, AlertType } from "@/components/alert"; import { Button, ButtonVariants } from "@/components/button"; import { DynamicTheme } from "@/components/dynamic-theme"; import { UserAvatar } from "@/components/user-avatar"; -import { getSessionCookieById } from "@/lib/cookies"; +import { + getMostRecentCookieWithLoginname, + getSessionCookieById, +} from "@/lib/cookies"; +import { completeDeviceAuthorization } from "@/lib/server/device"; import { getServiceUrlFromHeaders } from "@/lib/service"; import { loadMostRecentSession } from "@/lib/session"; import { @@ -41,6 +45,36 @@ export default async function Page(props: { searchParams: Promise }) { const { loginName, requestId, organization, sessionId } = searchParams; + const branding = await getBrandingSettings({ + serviceUrl, + organization, + }); + + // complete device authorization flow if device requestId is present + if (requestId && requestId.startsWith("device_")) { + const cookie = sessionId + ? await getSessionCookieById({ sessionId, organization }) + : await getMostRecentCookieWithLoginname({ + loginName: loginName, + organization: organization, + }); + + await completeDeviceAuthorization(requestId.replace("device_", ""), { + sessionId: cookie.id, + sessionToken: cookie.token, + }).catch((err) => { + return ( + +
+

{t("error.title")}

+

{t("error.description")}

+ {err.message} +
+
+ ); + }); + } + const sessionFactors = sessionId ? await loadSessionById(serviceUrl, sessionId, organization) : await loadMostRecentSession({ @@ -48,11 +82,6 @@ export default async function Page(props: { searchParams: Promise }) { sessionParams: { loginName, organization }, }); - const branding = await getBrandingSettings({ - serviceUrl, - organization, - }); - let loginSettings; if (!requestId) { loginSettings = await getLoginSettings({ @@ -69,6 +98,13 @@ export default async function Page(props: { searchParams: Promise }) {

{t("description")}

+ + {requestId && requestId.startsWith("device_") && ( You can now close this window and return to the device where you @@ -76,13 +112,6 @@ export default async function Page(props: { searchParams: Promise }) { )} - - {/* {sessionFactors?.id && ( )} */} diff --git a/apps/login/src/components/consent.tsx b/apps/login/src/components/consent.tsx index 755087c9e9..6ae3cade45 100644 --- a/apps/login/src/components/consent.tsx +++ b/apps/login/src/components/consent.tsx @@ -1,6 +1,6 @@ "use client"; -import { denyDeviceAuthorization } from "@/lib/server/oidc"; +import { completeDeviceAuthorization } from "@/lib/server/device"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -27,7 +27,9 @@ export function ConsentScreen({ async function denyDeviceAuth() { setLoading(true); - const response = await denyDeviceAuthorization(deviceAuthorizationRequestId) + const response = await completeDeviceAuthorization( + deviceAuthorizationRequestId, + ) .catch(() => { setError("Could not register user"); return; diff --git a/apps/login/src/components/device-code-form.tsx b/apps/login/src/components/device-code-form.tsx index faa77c3cdd..e09adb1147 100644 --- a/apps/login/src/components/device-code-form.tsx +++ b/apps/login/src/components/device-code-form.tsx @@ -36,7 +36,7 @@ export function DeviceCodeForm({ userCode }: { userCode?: string }) { const response = await getDeviceAuthorizationRequest(value.userCode) .catch(() => { - setError("Could not complete the request"); + setError("Could not continue the request"); return; }) .finally(() => { @@ -44,7 +44,7 @@ export function DeviceCodeForm({ userCode }: { userCode?: string }) { }); if (!response || !response.deviceAuthorizationRequest?.id) { - setError("Could not complete the request"); + setError("Could not continue the request"); return; } diff --git a/apps/login/src/lib/server/device.ts b/apps/login/src/lib/server/device.ts new file mode 100644 index 0000000000..d96059f6a6 --- /dev/null +++ b/apps/login/src/lib/server/device.ts @@ -0,0 +1,20 @@ +"use server"; + +import { authorizeOrDenyDeviceAuthorization } from "@/lib/zitadel"; +import { headers } from "next/headers"; +import { getServiceUrlFromHeaders } from "../service"; + +export async function completeDeviceAuthorization( + deviceAuthorizationId: string, + session?: { sessionId: string; sessionToken: string }, +) { + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + // without the session, device auth request is denied + return authorizeOrDenyDeviceAuthorization({ + serviceUrl, + deviceAuthorizationId, + session, + }); +} diff --git a/apps/login/src/lib/server/oidc.ts b/apps/login/src/lib/server/oidc.ts index 3bd0e53df4..4ae01b4a47 100644 --- a/apps/login/src/lib/server/oidc.ts +++ b/apps/login/src/lib/server/oidc.ts @@ -1,9 +1,6 @@ "use server"; -import { - authorizeOrDenyDeviceAuthorization, - getDeviceAuthorizationRequest as zitadelGetDeviceAuthorizationRequest, -} from "@/lib/zitadel"; +import { getDeviceAuthorizationRequest as zitadelGetDeviceAuthorizationRequest } from "@/lib/zitadel"; import { headers } from "next/headers"; import { getServiceUrlFromHeaders } from "../service"; @@ -16,14 +13,3 @@ export async function getDeviceAuthorizationRequest(userCode: string) { userCode, }); } - -export async function denyDeviceAuthorization(deviceAuthorizationId: string) { - const _headers = await headers(); - const { serviceUrl } = getServiceUrlFromHeaders(_headers); - - // without the session, device auth request is denied - return authorizeOrDenyDeviceAuthorization({ - serviceUrl, - deviceAuthorizationId, - }); -} diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index da690c10e2..cc79754735 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -952,6 +952,10 @@ export async function authorizeOrDenyDeviceAuthorization({ deviceAuthorizationId: string; session?: { sessionId: string; sessionToken: string }; }) { + console.log("authorizeOrDenyDeviceAuthorization"); + + console.log("session", session); + const oidcService = await createServiceForHost(OIDCService, serviceUrl); return oidcService.authorizeOrDenyDeviceAuthorization({ From c8a6cd66a57b8ed79c828c288bbbd270c2fe7a8f Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 6 May 2025 15:25:17 +0200 Subject: [PATCH 13/23] cleanup --- apps/login/src/app/(login)/signedin/page.tsx | 4 ---- apps/login/src/lib/zitadel.ts | 4 ---- 2 files changed, 8 deletions(-) diff --git a/apps/login/src/app/(login)/signedin/page.tsx b/apps/login/src/app/(login)/signedin/page.tsx index 0150a57f84..48595a3559 100644 --- a/apps/login/src/app/(login)/signedin/page.tsx +++ b/apps/login/src/app/(login)/signedin/page.tsx @@ -112,10 +112,6 @@ export default async function Page(props: { searchParams: Promise }) { )} - {/* {sessionFactors?.id && ( - - )} */} - {loginSettings?.defaultRedirectUri && (
diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index cc79754735..da690c10e2 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -952,10 +952,6 @@ export async function authorizeOrDenyDeviceAuthorization({ deviceAuthorizationId: string; session?: { sessionId: string; sessionToken: string }; }) { - console.log("authorizeOrDenyDeviceAuthorization"); - - console.log("session", session); - const oidcService = await createServiceForHost(OIDCService, serviceUrl); return oidcService.authorizeOrDenyDeviceAuthorization({ From 1f94e40af7338e65fd62a4a8b4b65ebc4fa23967 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 6 May 2025 15:38:24 +0200 Subject: [PATCH 14/23] readme doc --- apps/login/readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/login/readme.md b/apps/login/readme.md index 120fad3cd7..ca7070a901 100644 --- a/apps/login/readme.md +++ b/apps/login/readme.md @@ -373,7 +373,7 @@ On all pages, where the current user is shown, you can jump to this page. This w ### /signedin -This is a success page which shows a completed login flow for a user, which did navigate to the login without a OIDC auth requrest. +This is a success page which shows a completed login flow for a user, which did navigate to the login without a OIDC auth requrest. From here device authorization flows are completed. It checks if the requestId param of starts with `device_` and then executes the `authorizeOrDenyDeviceAuthorization` command. /signedin From 3c28e71c0ed04a964f7180c98d9d1316921d94f1 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 6 May 2025 15:48:37 +0200 Subject: [PATCH 15/23] filter for undefined scope values --- apps/login/src/components/consent.tsx | 35 +++++++++++++++------------ 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/login/src/components/consent.tsx b/apps/login/src/components/consent.tsx index 6ae3cade45..7897ed7d7d 100644 --- a/apps/login/src/components/consent.tsx +++ b/apps/login/src/components/consent.tsx @@ -43,27 +43,30 @@ export function ConsentScreen({ } } + console.log("scope", scope); return (
    - {scope?.map((s) => { - const translationKey = `device.scope.${s}`; - const description = t(translationKey, null); + {scope + ?.filter((s) => !!s) + .map((s) => { + const translationKey = `device.scope.${s}`; + const description = t(translationKey, null); - // Check if the key itself is returned and provide a fallback - const resolvedDescription = - description === translationKey ? "" : description; + // Check if the key itself is returned and provide a fallback + const resolvedDescription = + description === translationKey ? "" : description; - return ( -
  • - {s} - {resolvedDescription} -
  • - ); - })} + return ( +
  • + {s} + {resolvedDescription} +
  • + ); + })}

From 606248521b74bef1501e4eb4482fead14ccb7b67 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 6 May 2025 16:04:57 +0200 Subject: [PATCH 16/23] Update apps/login/src/components/consent.tsx Co-authored-by: David Skewis --- apps/login/src/components/consent.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/login/src/components/consent.tsx b/apps/login/src/components/consent.tsx index 7897ed7d7d..56026b1e27 100644 --- a/apps/login/src/components/consent.tsx +++ b/apps/login/src/components/consent.tsx @@ -43,7 +43,6 @@ export function ConsentScreen({ } } - console.log("scope", scope); return (

    From e1e9459495b1378028e627310240808f75adcb94 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 6 May 2025 16:05:12 +0200 Subject: [PATCH 17/23] Update apps/login/locales/en.json Co-authored-by: David Skewis --- apps/login/locales/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index e32d736e70..b850a2a99b 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -198,7 +198,7 @@ "submit": "Continue" }, "request": { - "title": "{appName} would like to connect:", + "title": "{appName} would like to connect", "description": "By clicking Allow, you allow {appName} and Zitadel to use your information in accordance with their respective terms of service and privacy policies. You can revoke this access at any time.", "submit": "Allow", "deny": "Deny" From 6da4e7e25d9534fe908cc3b08bc26a09818cccec Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 6 May 2025 16:06:45 +0200 Subject: [PATCH 18/23] Update apps/login/locales/en.json Co-authored-by: David Skewis --- apps/login/locales/en.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index b850a2a99b..a42679561b 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -205,8 +205,8 @@ }, "scope": { "openid": "Verify your identity.", - "email": "Access to your email address.", - "profile": "Access to your full profile information.", + "email": "View your email address.", + "profile": "View your full profile information.", "offline_access": "Allow offline access to your account." } }, From f6d560ded2ef36fb0391acf0c612547e5d9d4b94 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 6 May 2025 16:06:54 +0200 Subject: [PATCH 19/23] Update apps/login/src/components/consent.tsx Co-authored-by: David Skewis --- apps/login/src/components/consent.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/login/src/components/consent.tsx b/apps/login/src/components/consent.tsx index 56026b1e27..ef78169e36 100644 --- a/apps/login/src/components/consent.tsx +++ b/apps/login/src/components/consent.tsx @@ -61,7 +61,6 @@ export function ConsentScreen({ key={s} className="grid grid-cols-4 w-full text-sm flex flex-row items-center bg-background-light-400 dark:bg-background-dark-400 border border-divider-light py-2 px-4 rounded-md transition-all" > - {s} {resolvedDescription} ); From 5a97af410fadcf99a101ce0e06053018f89c5d8b Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 6 May 2025 16:08:45 +0200 Subject: [PATCH 20/23] cleanup consent screen --- apps/login/src/components/consent.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/login/src/components/consent.tsx b/apps/login/src/components/consent.tsx index ef78169e36..3b89e334c1 100644 --- a/apps/login/src/components/consent.tsx +++ b/apps/login/src/components/consent.tsx @@ -59,9 +59,9 @@ export function ConsentScreen({ return (
  • - {resolvedDescription} + {resolvedDescription}
  • ); })} From 018e478cd6382e7c8b5d2fa7b8a6388b58d39ac8 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 6 May 2025 16:14:19 +0200 Subject: [PATCH 21/23] de --- apps/login/locales/de.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index 87518d74a5..d250bb65ad 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -204,7 +204,7 @@ "deny": "Ablehnen" }, "scope": { - "openid": "Überprüfen Sie Ihre Identität.", + "openid": "Überprüfen Ihrer Identität.", "email": "Zugriff auf Ihre E-Mail-Adresse.", "profile": "Zugriff auf Ihre vollständigen Profilinformationen.", "offline_access": "Erlauben Sie den Offline-Zugriff auf Ihr Konto." From 3f3b3d95bbac3d7888b969b49aa423cf869facd9 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Tue, 6 May 2025 20:06:37 +0200 Subject: [PATCH 22/23] disclaimer, description --- apps/login/locales/de.json | 1 + apps/login/locales/en.json | 3 ++- apps/login/locales/es.json | 3 ++- apps/login/locales/it.json | 3 ++- apps/login/locales/pl.json | 3 ++- apps/login/locales/ru.json | 3 ++- apps/login/locales/zh.json | 3 ++- apps/login/src/app/(login)/device/consent/page.tsx | 2 ++ apps/login/src/components/consent.tsx | 2 +- 9 files changed, 16 insertions(+), 7 deletions(-) diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index d250bb65ad..a2c137cf43 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -199,6 +199,7 @@ }, "request": { "title": "{appName} möchte eine Verbindung herstellen:", + "disclaimer": "{appName} hat Zugriff auf:", "description": "Durch Klicken auf Zulassen erlauben Sie {appName} und Zitadel, Ihre Informationen gemäß ihren jeweiligen Nutzungsbedingungen und Datenschutzrichtlinien zu verwenden. Sie können diesen Zugriff jederzeit widerrufen.", "submit": "Zulassen", "deny": "Ablehnen" diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index a42679561b..63a45c7d15 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -199,7 +199,8 @@ }, "request": { "title": "{appName} would like to connect", - "description": "By clicking Allow, you allow {appName} and Zitadel to use your information in accordance with their respective terms of service and privacy policies. You can revoke this access at any time.", + "description": "{appName} will have access to:", + "disclaimer": "By clicking Allow, you allow {appName} and Zitadel to use your information in accordance with their respective terms of service and privacy policies. You can revoke this access at any time.", "submit": "Allow", "deny": "Deny" }, diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index ff2fd9a4cb..60570eceb0 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -199,7 +199,8 @@ }, "request": { "title": "{appName} desea conectarse:", - "description": "Al hacer clic en Permitir, autorizas a {appName} y a Zitadel a usar tu información de acuerdo con sus respectivos términos de servicio y políticas de privacidad. Puedes revocar este acceso en cualquier momento.", + "description": "{appName} tendrá acceso a:", + "disclaimer": "Al hacer clic en Permitir, autorizas a {appName} y a Zitadel a usar tu información de acuerdo con sus respectivos términos de servicio y políticas de privacidad. Puedes revocar este acceso en cualquier momento.", "submit": "Permitir", "deny": "Denegar" }, diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index 792568872d..53894fdf5d 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -199,7 +199,8 @@ }, "request": { "title": "{appName} desidera connettersi:", - "description": "Cliccando su Consenti, autorizzi {appName} e Zitadel a utilizzare le tue informazioni in conformità con i rispettivi termini di servizio e politiche sulla privacy. Puoi revocare questo accesso in qualsiasi momento.", + "description": "{appName} avrà accesso a:", + "disclaimer": "Cliccando su Consenti, autorizzi {appName} e Zitadel a utilizzare le tue informazioni in conformità con i rispettivi termini di servizio e politiche sulla privacy. Puoi revocare questo accesso in qualsiasi momento.", "submit": "Consenti", "deny": "Nega" }, diff --git a/apps/login/locales/pl.json b/apps/login/locales/pl.json index 18326df262..52b802eccb 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -199,7 +199,8 @@ }, "request": { "title": "{appName} chce się połączyć:", - "description": "Klikając Zezwól, pozwalasz tej aplikacji i Zitadel na korzystanie z Twoich informacji zgodnie z ich odpowiednimi warunkami użytkowania i politykami prywatności. Możesz cofnąć ten dostęp w dowolnym momencie.", + "description": "{appName} będzie miało dostęp do:", + "disclaimer": "Klikając Zezwól, pozwalasz tej aplikacji i Zitadel na korzystanie z Twoich informacji zgodnie z ich odpowiednimi warunkami użytkowania i politykami prywatności. Możesz cofnąć ten dostęp w dowolnym momencie.", "submit": "Zezwól", "deny": "Odmów" }, diff --git a/apps/login/locales/ru.json b/apps/login/locales/ru.json index 9eda1730e6..197b9663be 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -199,7 +199,8 @@ }, "request": { "title": "{appName} хочет подключиться:", - "description": "Нажимая «Разрешить», вы разрешаете этому приложению и Zitadel использовать вашу информацию в соответствии с их условиями использования и политиками конфиденциальности. Вы можете отозвать этот доступ в любое время.", + "description": "{appName} получит доступ к:", + "disclaimer": "Нажимая «Разрешить», вы разрешаете этому приложению и Zitadel использовать вашу информацию в соответствии с их условиями использования и политиками конфиденциальности. Вы можете отозвать этот доступ в любое время.", "submit": "Разрешить", "deny": "Запретить" }, diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index 8a6200bf33..d4319dc051 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -199,7 +199,8 @@ }, "request": { "title": "{appName} 想要连接:", - "description": "点击“允许”即表示您允许此应用程序和 Zitadel 根据其各自的服务条款和隐私政策使用您的信息。您可以随时撤销此访问权限。", + "description": "{appName} 将访问:", + "disclaimer": "点击“允许”即表示您允许此应用程序和 Zitadel 根据其各自的服务条款和隐私政策使用您的信息。您可以随时撤销此访问权限。", "submit": "允许", "deny": "拒绝" }, diff --git a/apps/login/src/app/(login)/device/consent/page.tsx b/apps/login/src/app/(login)/device/consent/page.tsx index 9d55f4f6b8..379dad2720 100644 --- a/apps/login/src/app/(login)/device/consent/page.tsx +++ b/apps/login/src/app/(login)/device/consent/page.tsx @@ -69,6 +69,8 @@ export default async function Page(props: { {t("request.title", { appName: deviceAuthorizationRequest?.appName })} +

    {t("request.description")}

    +

    - {t("device.request.description", { appName: appName })} + {t("device.request.disclaimer", { appName: appName })}

    {error && ( From 2a01f0f2e75da85b5a4f4cbe5de7941358595057 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Wed, 7 May 2025 09:12:58 +0200 Subject: [PATCH 23/23] turbo --- apps/login/turbo.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/login/turbo.json b/apps/login/turbo.json index e8a243feaf..80224125a2 100644 --- a/apps/login/turbo.json +++ b/apps/login/turbo.json @@ -5,6 +5,10 @@ "outputs": ["dist/**", ".next/**", "!.next/cache/**"], "dependsOn": ["^build"] }, + "build:standalone": { + "outputs": ["dist/**", ".next/**", "!.next/cache/**"], + "dependsOn": ["^build"] + }, "test": { "dependsOn": ["@zitadel/client#build"] },