From 5274c2bd7d1f33965b0e45473ad8cdaa649880ef Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 2 May 2025 13:52:58 +0200 Subject: [PATCH] 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}`, );