mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-13 10:57: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 { DeviceCodeForm } from "@/components/device-code-form";
|
||||||
import { DynamicTheme } from "@/components/dynamic-theme";
|
import { DynamicTheme } from "@/components/dynamic-theme";
|
||||||
import { getServiceUrlFromHeaders } from "@/lib/service";
|
import { getServiceUrlFromHeaders } from "@/lib/service";
|
||||||
import {
|
import { getBrandingSettings, getDefaultOrg } from "@/lib/zitadel";
|
||||||
getBrandingSettings,
|
|
||||||
getDefaultOrg,
|
|
||||||
getDeviceAuthorizationRequest,
|
|
||||||
} from "@/lib/zitadel";
|
|
||||||
import { DeviceAuthorizationRequest } from "@zitadel/proto/zitadel/oidc/v2/authorization_pb";
|
|
||||||
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
|
import { Organization } from "@zitadel/proto/zitadel/org/v2/org_pb";
|
||||||
import { getLocale, getTranslations } from "next-intl/server";
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
@@ -18,7 +13,6 @@ export default async function Page(props: {
|
|||||||
const locale = getLocale();
|
const locale = getLocale();
|
||||||
const t = await getTranslations({ locale, namespace: "device" });
|
const t = await getTranslations({ locale, namespace: "device" });
|
||||||
|
|
||||||
const loginName = searchParams?.loginName;
|
|
||||||
const userCode = searchParams?.user_code;
|
const userCode = searchParams?.user_code;
|
||||||
const organization = searchParams?.organization;
|
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({
|
const branding = await getBrandingSettings({
|
||||||
serviceUrl,
|
serviceUrl,
|
||||||
organization: organization ?? defaultOrganization,
|
organization: organization ?? defaultOrganization,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicTheme branding={branding} appName={deviceAuthRequest?.appName}>
|
<DynamicTheme branding={branding}>
|
||||||
<div className="flex flex-col items-center space-y-4">
|
<div className="flex flex-col items-center space-y-4">
|
||||||
{!userCode && (
|
{!userCode && (
|
||||||
<>
|
<>
|
||||||
<h1>{t("usercode.title")}</h1>
|
<h1>{t("usercode.title")}</h1>
|
||||||
<p className="ztdl-p">{t("usercode.description")}</p>
|
<p className="ztdl-p">{t("usercode.description")}</p>
|
||||||
<DeviceCodeForm
|
<DeviceCodeForm userCode={userCode}></DeviceCodeForm>
|
||||||
// loginSettings={contextLoginSettings}
|
|
||||||
></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>
|
</div>
|
||||||
</DynamicTheme>
|
</DynamicTheme>
|
||||||
);
|
);
|
||||||
|
@@ -1,7 +1,8 @@
|
|||||||
import { getAllSessions } from "@/lib/cookies";
|
import { getAllSessions } from "@/lib/cookies";
|
||||||
|
import { loginWithDeviceAndSession } from "@/lib/device";
|
||||||
import { idpTypeToSlug } from "@/lib/idp";
|
import { idpTypeToSlug } from "@/lib/idp";
|
||||||
import { loginWithOIDCandSession } from "@/lib/oidc";
|
import { loginWithOIDCAndSession } from "@/lib/oidc";
|
||||||
import { loginWithSAMLandSession } from "@/lib/saml";
|
import { loginWithSAMLAndSession } from "@/lib/saml";
|
||||||
import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname";
|
import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname";
|
||||||
import { constructUrl, getServiceUrlFromHeaders } from "@/lib/service";
|
import { constructUrl, getServiceUrlFromHeaders } from "@/lib/service";
|
||||||
import { findValidSession } from "@/lib/session";
|
import { findValidSession } from "@/lib/session";
|
||||||
@@ -107,7 +108,7 @@ export async function GET(request: NextRequest) {
|
|||||||
if (requestId && sessionId) {
|
if (requestId && sessionId) {
|
||||||
if (requestId.startsWith("oidc_")) {
|
if (requestId.startsWith("oidc_")) {
|
||||||
// this finishes the login process for OIDC
|
// this finishes the login process for OIDC
|
||||||
return loginWithOIDCandSession({
|
return loginWithOIDCAndSession({
|
||||||
serviceUrl,
|
serviceUrl,
|
||||||
authRequest: requestId.replace("oidc_", ""),
|
authRequest: requestId.replace("oidc_", ""),
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -117,7 +118,7 @@ export async function GET(request: NextRequest) {
|
|||||||
});
|
});
|
||||||
} else if (requestId.startsWith("saml_")) {
|
} else if (requestId.startsWith("saml_")) {
|
||||||
// this finishes the login process for SAML
|
// this finishes the login process for SAML
|
||||||
return loginWithSAMLandSession({
|
return loginWithSAMLAndSession({
|
||||||
serviceUrl,
|
serviceUrl,
|
||||||
samlRequest: requestId.replace("saml_", ""),
|
samlRequest: requestId.replace("saml_", ""),
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -125,6 +126,15 @@ export async function GET(request: NextRequest) {
|
|||||||
sessionCookies,
|
sessionCookies,
|
||||||
request,
|
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;
|
userCode: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DeviceCodeForm() {
|
export function DeviceCodeForm({ userCode }: { userCode?: string }) {
|
||||||
const t = useTranslations("verify");
|
const t = useTranslations("verify");
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -23,7 +23,7 @@ export function DeviceCodeForm() {
|
|||||||
const { register, handleSubmit, formState } = useForm<Inputs>({
|
const { register, handleSubmit, formState } = useForm<Inputs>({
|
||||||
mode: "onBlur",
|
mode: "onBlur",
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
userCode: "",
|
userCode: userCode || "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -36,21 +36,25 @@ export function DeviceCodeForm() {
|
|||||||
|
|
||||||
const response = await getDeviceAuthorizationRequest(value.userCode)
|
const response = await getDeviceAuthorizationRequest(value.userCode)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setError("Could not verify user");
|
setError("Could not complete the request");
|
||||||
return;
|
return;
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response && "error" in response && response?.error) {
|
if (!response || !response.deviceAuthorizationRequest?.id) {
|
||||||
setError(response.error);
|
setError("Could not complete the request");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response && "redirect" in response && response?.redirect) {
|
return router.push(
|
||||||
return router.push(response?.redirect);
|
`/device/consent?` +
|
||||||
}
|
new URLSearchParams({
|
||||||
|
requestId: `device_${response.deviceAuthorizationRequest.id}`,
|
||||||
|
user_code: value.userCode,
|
||||||
|
}).toString(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 { constructUrl } from "./service";
|
||||||
import { isSessionValid } from "./session";
|
import { isSessionValid } from "./session";
|
||||||
|
|
||||||
type LoginWithOIDCandSession = {
|
type LoginWithOIDCAndSession = {
|
||||||
serviceUrl: string;
|
serviceUrl: string;
|
||||||
authRequest: string;
|
authRequest: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -19,14 +19,14 @@ type LoginWithOIDCandSession = {
|
|||||||
sessionCookies: Cookie[];
|
sessionCookies: Cookie[];
|
||||||
request: NextRequest;
|
request: NextRequest;
|
||||||
};
|
};
|
||||||
export async function loginWithOIDCandSession({
|
export async function loginWithOIDCAndSession({
|
||||||
serviceUrl,
|
serviceUrl,
|
||||||
authRequest,
|
authRequest,
|
||||||
sessionId,
|
sessionId,
|
||||||
sessions,
|
sessions,
|
||||||
sessionCookies,
|
sessionCookies,
|
||||||
request,
|
request,
|
||||||
}: LoginWithOIDCandSession) {
|
}: LoginWithOIDCAndSession) {
|
||||||
console.log(
|
console.log(
|
||||||
`Login with session: ${sessionId} and authRequest: ${authRequest}`,
|
`Login with session: ${sessionId} and authRequest: ${authRequest}`,
|
||||||
);
|
);
|
||||||
|
@@ -8,7 +8,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { constructUrl } from "./service";
|
import { constructUrl } from "./service";
|
||||||
import { isSessionValid } from "./session";
|
import { isSessionValid } from "./session";
|
||||||
|
|
||||||
type LoginWithSAMLandSession = {
|
type LoginWithSAMLAndSession = {
|
||||||
serviceUrl: string;
|
serviceUrl: string;
|
||||||
samlRequest: string;
|
samlRequest: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -17,14 +17,14 @@ type LoginWithSAMLandSession = {
|
|||||||
request: NextRequest;
|
request: NextRequest;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loginWithSAMLandSession({
|
export async function loginWithSAMLAndSession({
|
||||||
serviceUrl,
|
serviceUrl,
|
||||||
samlRequest,
|
samlRequest,
|
||||||
sessionId,
|
sessionId,
|
||||||
sessions,
|
sessions,
|
||||||
sessionCookies,
|
sessionCookies,
|
||||||
request,
|
request,
|
||||||
}: LoginWithSAMLandSession) {
|
}: LoginWithSAMLAndSession) {
|
||||||
console.log(
|
console.log(
|
||||||
`Login with session: ${sessionId} and samlRequest: ${samlRequest}`,
|
`Login with session: ${sessionId} and samlRequest: ${samlRequest}`,
|
||||||
);
|
);
|
||||||
|
Reference in New Issue
Block a user