From 6270cf9522add45c6d90c1cd37a5e3e8ff2a8039 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 2 May 2025 15:08:41 +0200 Subject: [PATCH] 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); }