device code flow

This commit is contained in:
Max Peintner
2025-05-02 15:08:41 +02:00
parent 5274c2bd7d
commit 6270cf9522
5 changed files with 70 additions and 14 deletions

View File

@@ -190,7 +190,7 @@
"device": { "device": {
"usercode": { "usercode": {
"title": "Device code", "title": "Device code",
"description": "Enter the code provided in the verification email.", "description": "Enter the code.",
"submit": "Continue" "submit": "Continue"
}, },
"request": { "request": {

View File

@@ -5,9 +5,11 @@ import { UserAvatar } from "@/components/user-avatar";
import { getMostRecentCookieWithLoginname } from "@/lib/cookies"; import { getMostRecentCookieWithLoginname } from "@/lib/cookies";
import { getServiceUrlFromHeaders } from "@/lib/service"; import { getServiceUrlFromHeaders } from "@/lib/service";
import { import {
authorizeOrDenyDeviceAuthorization,
createCallback, createCallback,
createResponse, createResponse,
getBrandingSettings, getBrandingSettings,
getDeviceAuthorizationRequest,
getLoginSettings, getLoginSettings,
getSession, getSession,
} from "@/lib/zitadel"; } from "@/lib/zitadel";
@@ -24,7 +26,6 @@ import { redirect } from "next/navigation";
async function loadSession( async function loadSession(
serviceUrl: string, serviceUrl: string,
loginName: string, loginName: string,
requestId?: string, requestId?: string,
) { ) {
@@ -62,6 +63,26 @@ async function loadSession(
}).then(({ url }) => { }).then(({ url }) => {
return redirect(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({ return getSession({

View File

@@ -127,6 +127,7 @@ export async function GET(request: NextRequest) {
request, request,
}); });
} else if (requestId.startsWith("device_")) { } else if (requestId.startsWith("device_")) {
// this finishes the login process for Device Authorization
return loginWithDeviceAndSession({ return loginWithDeviceAndSession({
serviceUrl, serviceUrl,
deviceRequest: requestId.replace("device_", ""), deviceRequest: requestId.replace("device_", ""),
@@ -509,7 +510,9 @@ export async function GET(request: NextRequest) {
requestId: `saml_${samlRequest.id}`, 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( return NextResponse.json(
{ error: "No authRequest nor samlRequest provided" }, { error: "No authRequest nor samlRequest provided" },
{ status: 500 }, { status: 500 },

View File

@@ -51,7 +51,7 @@ export function DeviceCodeForm({ userCode }: { userCode?: string }) {
return router.push( return router.push(
`/device/consent?` + `/device/consent?` +
new URLSearchParams({ new URLSearchParams({
requestId: `device_${response.deviceAuthorizationRequest.id}`, requestId: `device_${userCode}`,
user_code: value.userCode, user_code: value.userCode,
}).toString(), }).toString(),
); );

View File

@@ -5,6 +5,28 @@ type FinishFlowCommand =
} }
| { loginName: string }; | { 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 * 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 * @param command
@@ -14,7 +36,25 @@ export async function getNextUrl(
command: FinishFlowCommand & { organization?: string }, command: FinishFlowCommand & { organization?: string },
defaultRedirectUri?: string, defaultRedirectUri?: string,
): Promise<string> { ): Promise<string> {
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({ const params = new URLSearchParams({
sessionId: command.sessionId, sessionId: command.sessionId,
requestId: command.requestId, requestId: command.requestId,
@@ -31,13 +71,5 @@ export async function getNextUrl(
return defaultRedirectUri; return defaultRedirectUri;
} }
const params = new URLSearchParams({ return goToSignedInPage(command);
loginName: command.loginName,
});
if (command.organization) {
params.append("organization", command.organization);
}
return `/signedin?` + params;
} }