device code request

This commit is contained in:
Max Peintner
2025-05-02 13:52:58 +02:00
parent ed37eaff80
commit 5274c2bd7d
8 changed files with 237 additions and 57 deletions

View File

@@ -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<Record<string | number | symbol, string | undefined>>;
}) {
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 <div>{t("error.no_user_code")}</div>;
}
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 (
<DynamicTheme
branding={branding}
appName={deviceAuthorizationRequest?.appName}
>
<div className="flex flex-col items-center space-y-4">
{!userCode && (
<>
<h1>{t("usercode.title")}</h1>
<p className="ztdl-p">{t("usercode.description")}</p>
<ConsentScreen scope={deviceAuthorizationRequest?.scope} />
</>
)}
</div>
</DynamicTheme>
);
}

View File

@@ -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 (
<DynamicTheme branding={branding} appName={deviceAuthRequest?.appName}>
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
{!userCode && (
<>
<h1>{t("usercode.title")}</h1>
<p className="ztdl-p">{t("usercode.description")}</p>
<DeviceCodeForm
// loginSettings={contextLoginSettings}
></DeviceCodeForm>
<DeviceCodeForm userCode={userCode}></DeviceCodeForm>
</>
)}
{deviceAuthRequest && (
<div>
<h1>
{deviceAuthRequest.appName}
<br />
{t("request.title")}
</h1>
<p className="ztdl-p text-left text-xs mt-4">
{t("request.description")}
</p>
{/* {JSON.stringify(deviceAuthRequest)} */}
</div>
)}
</div>
</DynamicTheme>
);

View File

@@ -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,
});
}
}

View File

@@ -0,0 +1,12 @@
export function ConsentScreen({ scope }: { scope?: string[] }) {
return (
<div className="flex flex-col items-center space-y-4">
<h1>Consent</h1>
<p className="ztdl-p">Please confirm your consent.</p>
<div className="flex flex-col items-center space-y-4">
<button className="btn btn-primary">Accept</button>
<button className="btn btn-secondary">Reject</button>
</div>
</div>
);
}

View File

@@ -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<Inputs>({
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 (

View File

@@ -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 });
}
}
}
}
}

View File

@@ -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}`,
);

View File

@@ -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}`,
);