mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-12 11:17:32 +00:00
device code request
This commit is contained in:
67
apps/login/src/app/(login)/device/consent/page.tsx
Normal file
67
apps/login/src/app/(login)/device/consent/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
|
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
12
apps/login/src/components/consent.tsx
Normal file
12
apps/login/src/components/consent.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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 (
|
||||
|
123
apps/login/src/lib/device.ts
Normal file
123
apps/login/src/lib/device.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -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}`,
|
||||
);
|
||||
|
@@ -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}`,
|
||||
);
|
||||
|
Reference in New Issue
Block a user