Merge pull request #438 from zitadel/device-code

Device Authorization Flow
This commit is contained in:
Max Peintner
2025-05-07 10:14:02 +02:00
committed by GitHub
24 changed files with 778 additions and 94 deletions

View File

@@ -166,7 +166,11 @@
"signedin": { "signedin": {
"title": "Willkommen {user}!", "title": "Willkommen {user}!",
"description": "Sie sind angemeldet.", "description": "Sie sind angemeldet.",
"continue": "Weiter" "continue": "Weiter",
"error": {
"title": "Fehler",
"description": "Ein Fehler ist aufgetreten."
}
}, },
"verify": { "verify": {
"userIdMissing": "Keine Benutzer-ID angegeben!", "userIdMissing": "Keine Benutzer-ID angegeben!",
@@ -187,7 +191,29 @@
"allSetup": "Sie haben bereits einen Authentifikator eingerichtet!", "allSetup": "Sie haben bereits einen Authentifikator eingerichtet!",
"linkWithIDP": "oder verknüpfe mit einem Identitätsanbieter" "linkWithIDP": "oder verknüpfe mit einem Identitätsanbieter"
}, },
"device": {
"usercode": {
"title": "Gerätecode",
"description": "Geben Sie den Code ein.",
"submit": "Weiter"
},
"request": {
"title": "{appName} möchte eine Verbindung herstellen:",
"disclaimer": "{appName} hat Zugriff auf:",
"description": "Durch Klicken auf Zulassen erlauben Sie {appName} und Zitadel, Ihre Informationen gemäß ihren jeweiligen Nutzungsbedingungen und Datenschutzrichtlinien zu verwenden. Sie können diesen Zugriff jederzeit widerrufen.",
"submit": "Zulassen",
"deny": "Ablehnen"
},
"scope": {
"openid": "Überprüfen Ihrer Identität.",
"email": "Zugriff auf Ihre E-Mail-Adresse.",
"profile": "Zugriff auf Ihre vollständigen Profilinformationen.",
"offline_access": "Erlauben Sie den Offline-Zugriff auf Ihr Konto."
}
},
"error": { "error": {
"noUserCode": "Kein Benutzercode angegeben!",
"noDeviceRequest": " Es wurde keine Geräteanforderung gefunden. Bitte überprüfen Sie die URL.",
"unknownContext": "Der Kontext des Benutzers konnte nicht ermittelt werden. Stellen Sie sicher, dass Sie zuerst den Benutzernamen eingeben oder einen loginName als Suchparameter angeben.", "unknownContext": "Der Kontext des Benutzers konnte nicht ermittelt werden. Stellen Sie sicher, dass Sie zuerst den Benutzernamen eingeben oder einen loginName als Suchparameter angeben.",
"sessionExpired": "Ihre aktuelle Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", "sessionExpired": "Ihre aktuelle Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
"failedLoading": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut.", "failedLoading": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut.",

View File

@@ -166,7 +166,11 @@
"signedin": { "signedin": {
"title": "Welcome {user}!", "title": "Welcome {user}!",
"description": "You are signed in.", "description": "You are signed in.",
"continue": "Continue" "continue": "Continue",
"error": {
"title": "Error",
"description": "An error occurred while trying to sign in."
}
}, },
"verify": { "verify": {
"userIdMissing": "No userId provided!", "userIdMissing": "No userId provided!",
@@ -187,7 +191,29 @@
"allSetup": "You have already setup an authenticator!", "allSetup": "You have already setup an authenticator!",
"linkWithIDP": "or link with an Identity Provider" "linkWithIDP": "or link with an Identity Provider"
}, },
"device": {
"usercode": {
"title": "Device code",
"description": "Enter the code displayed on your app or device.",
"submit": "Continue"
},
"request": {
"title": "{appName} would like to connect",
"description": "{appName} will have access to:",
"disclaimer": "By clicking Allow, you allow {appName} and Zitadel to use your information in accordance with their respective terms of service and privacy policies. You can revoke this access at any time.",
"submit": "Allow",
"deny": "Deny"
},
"scope": {
"openid": "Verify your identity.",
"email": "View your email address.",
"profile": "View your full profile information.",
"offline_access": "Allow offline access to your account."
}
},
"error": { "error": {
"noUserCode": "No user code provided!",
"noDeviceRequest": "No device request found.",
"unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.", "unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.",
"sessionExpired": "Your current session has expired. Please login again.", "sessionExpired": "Your current session has expired. Please login again.",
"failedLoading": "Failed to load data. Please try again.", "failedLoading": "Failed to load data. Please try again.",

View File

@@ -166,7 +166,11 @@
"signedin": { "signedin": {
"title": "¡Bienvenido {user}!", "title": "¡Bienvenido {user}!",
"description": "Has iniciado sesión.", "description": "Has iniciado sesión.",
"continue": "Continuar" "continue": "Continuar",
"error": {
"title": "Error",
"description": "Ocurrió un error al iniciar sesión."
}
}, },
"verify": { "verify": {
"userIdMissing": "¡No se proporcionó userId!", "userIdMissing": "¡No se proporcionó userId!",
@@ -187,7 +191,29 @@
"allSetup": "¡Ya has configurado un autenticador!", "allSetup": "¡Ya has configurado un autenticador!",
"linkWithIDP": "o vincúlalo con un proveedor de identidad" "linkWithIDP": "o vincúlalo con un proveedor de identidad"
}, },
"device": {
"usercode": {
"title": "Código del dispositivo",
"description": "Introduce el código.",
"submit": "Continuar"
},
"request": {
"title": "{appName} desea conectarse:",
"description": "{appName} tendrá acceso a:",
"disclaimer": "Al hacer clic en Permitir, autorizas a {appName} y a Zitadel a usar tu información de acuerdo con sus respectivos términos de servicio y políticas de privacidad. Puedes revocar este acceso en cualquier momento.",
"submit": "Permitir",
"deny": "Denegar"
},
"scope": {
"openid": "Verifica tu identidad.",
"email": "Accede a tu dirección de correo electrónico.",
"profile": "Accede a la información completa de tu perfil.",
"offline_access": "Permitir acceso sin conexión a tu cuenta."
}
},
"error": { "error": {
"noUserCode": "¡No se proporcionó código de usuario!",
"noDeviceRequest": "No se encontró ninguna solicitud de dispositivo.",
"unknownContext": "No se pudo obtener el contexto del usuario. Asegúrate de ingresar primero el nombre de usuario o proporcionar un loginName como parámetro de búsqueda.", "unknownContext": "No se pudo obtener el contexto del usuario. Asegúrate de ingresar primero el nombre de usuario o proporcionar un loginName como parámetro de búsqueda.",
"sessionExpired": "Tu sesión actual ha expirado. Por favor, inicia sesión de nuevo.", "sessionExpired": "Tu sesión actual ha expirado. Por favor, inicia sesión de nuevo.",
"failedLoading": "No se pudieron cargar los datos. Por favor, inténtalo de nuevo.", "failedLoading": "No se pudieron cargar los datos. Por favor, inténtalo de nuevo.",

View File

@@ -166,7 +166,11 @@
"signedin": { "signedin": {
"title": "Benvenuto {user}!", "title": "Benvenuto {user}!",
"description": "Sei connesso.", "description": "Sei connesso.",
"continue": "Continua" "continue": "Continua",
"error": {
"title": "Errore",
"description": "Si è verificato un errore durante il tentativo di accesso."
}
}, },
"verify": { "verify": {
"userIdMissing": "Nessun userId fornito!", "userIdMissing": "Nessun userId fornito!",
@@ -187,7 +191,29 @@
"allSetup": "Hai già configurato un autenticatore!", "allSetup": "Hai già configurato un autenticatore!",
"linkWithIDP": "o collega con un Identity Provider" "linkWithIDP": "o collega con un Identity Provider"
}, },
"device": {
"usercode": {
"title": "Codice dispositivo",
"description": "Inserisci il codice.",
"submit": "Continua"
},
"request": {
"title": "{appName} desidera connettersi:",
"description": "{appName} avrà accesso a:",
"disclaimer": "Cliccando su Consenti, autorizzi {appName} e Zitadel a utilizzare le tue informazioni in conformità con i rispettivi termini di servizio e politiche sulla privacy. Puoi revocare questo accesso in qualsiasi momento.",
"submit": "Consenti",
"deny": "Nega"
},
"scope": {
"openid": "Verifica la tua identità.",
"email": "Accedi al tuo indirizzo email.",
"profile": "Accedi alle informazioni complete del tuo profilo.",
"offline_access": "Consenti l'accesso offline al tuo account."
}
},
"error": { "error": {
"noUserCode": "Nessun codice utente fornito!",
"noDeviceRequest": "Nessuna richiesta di dispositivo trovata.",
"unknownContext": "Impossibile ottenere il contesto dell'utente. Assicurati di inserire prima il nome utente o di fornire un loginName come parametro di ricerca.", "unknownContext": "Impossibile ottenere il contesto dell'utente. Assicurati di inserire prima il nome utente o di fornire un loginName come parametro di ricerca.",
"sessionExpired": "La tua sessione attuale è scaduta. Effettua nuovamente l'accesso.", "sessionExpired": "La tua sessione attuale è scaduta. Effettua nuovamente l'accesso.",
"failedLoading": "Impossibile caricare i dati. Riprova.", "failedLoading": "Impossibile caricare i dati. Riprova.",

View File

@@ -166,7 +166,11 @@
"signedin": { "signedin": {
"title": "Witaj {user}!", "title": "Witaj {user}!",
"description": "Jesteś zalogowany.", "description": "Jesteś zalogowany.",
"continue": "Kontynuuj" "continue": "Kontynuuj",
"error": {
"title": "Błąd",
"description": "Nie można załadować danych. Sprawdź połączenie z internetem lub spróbuj ponownie później."
}
}, },
"verify": { "verify": {
"userIdMissing": "Nie podano identyfikatora użytkownika!", "userIdMissing": "Nie podano identyfikatora użytkownika!",
@@ -187,7 +191,29 @@
"allSetup": "Już skonfigurowałeś metodę uwierzytelniania!", "allSetup": "Już skonfigurowałeś metodę uwierzytelniania!",
"linkWithIDP": "lub połącz z dostawcą tożsamości" "linkWithIDP": "lub połącz z dostawcą tożsamości"
}, },
"device": {
"usercode": {
"title": "Kod urządzenia",
"description": "Wprowadź kod.",
"submit": "Kontynuuj"
},
"request": {
"title": "{appName} chce się połączyć:",
"description": "{appName} będzie miało dostęp do:",
"disclaimer": "Klikając Zezwól, pozwalasz tej aplikacji i Zitadel na korzystanie z Twoich informacji zgodnie z ich odpowiednimi warunkami użytkowania i politykami prywatności. Możesz cofnąć ten dostęp w dowolnym momencie.",
"submit": "Zezwól",
"deny": "Odmów"
},
"scope": {
"openid": "Zweryfikuj swoją tożsamość.",
"email": "Uzyskaj dostęp do swojego adresu e-mail.",
"profile": "Uzyskaj dostęp do pełnych informacji o swoim profilu.",
"offline_access": "Zezwól na dostęp offline do swojego konta."
}
},
"error": { "error": {
"noUserCode": "Nie podano kodu użytkownika!",
"noDeviceRequest": "Nie znaleziono żądania urządzenia.",
"unknownContext": "Nie udało się pobrać kontekstu użytkownika. Upewnij się, że najpierw wprowadziłeś nazwę użytkownika lub podałeś login jako parametr wyszukiwania.", "unknownContext": "Nie udało się pobrać kontekstu użytkownika. Upewnij się, że najpierw wprowadziłeś nazwę użytkownika lub podałeś login jako parametr wyszukiwania.",
"sessionExpired": "Twoja sesja wygasła. Zaloguj się ponownie.", "sessionExpired": "Twoja sesja wygasła. Zaloguj się ponownie.",
"failedLoading": "Nie udało się załadować danych. Spróbuj ponownie.", "failedLoading": "Nie udało się załadować danych. Spróbuj ponownie.",

View File

@@ -166,7 +166,11 @@
"signedin": { "signedin": {
"title": "Добро пожаловать, {user}!", "title": "Добро пожаловать, {user}!",
"description": "Вы вошли в систему.", "description": "Вы вошли в систему.",
"continue": "Продолжить" "continue": "Продолжить",
"error": {
"title": "Ошибка",
"description": "Не удалось войти в систему. Проверьте свои данные и попробуйте снова."
}
}, },
"verify": { "verify": {
"userIdMissing": "Не указан userId!", "userIdMissing": "Не указан userId!",
@@ -187,7 +191,29 @@
"allSetup": "Аутентификатор уже настроен!", "allSetup": "Аутентификатор уже настроен!",
"linkWithIDP": "или привязать через Identity Provider" "linkWithIDP": "или привязать через Identity Provider"
}, },
"device": {
"usercode": {
"title": "Код устройства",
"description": "Введите код.",
"submit": "Продолжить"
},
"request": {
"title": "{appName} хочет подключиться:",
"description": "{appName} получит доступ к:",
"disclaimer": "Нажимая «Разрешить», вы разрешаете этому приложению и Zitadel использовать вашу информацию в соответствии с их условиями использования и политиками конфиденциальности. Вы можете отозвать этот доступ в любое время.",
"submit": "Разрешить",
"deny": "Запретить"
},
"scope": {
"openid": "Проверка вашей личности.",
"email": "Доступ к вашему адресу электронной почты.",
"profile": "Доступ к полной информации вашего профиля.",
"offline_access": "Разрешить офлайн-доступ к вашему аккаунту."
}
},
"error": { "error": {
"noUserCode": "Не указан код пользователя!",
"noDeviceRequest": "Не найдена ни одна заявка на устройство.",
"unknownContext": "Не удалось получить контекст пользователя. Укажите имя пользователя или loginName в параметрах поиска.", "unknownContext": "Не удалось получить контекст пользователя. Укажите имя пользователя или loginName в параметрах поиска.",
"sessionExpired": "Ваша сессия истекла. Войдите снова.", "sessionExpired": "Ваша сессия истекла. Войдите снова.",
"failedLoading": "Ошибка загрузки данных. Попробуйте ещё раз.", "failedLoading": "Ошибка загрузки данных. Попробуйте ещё раз.",

View File

@@ -166,7 +166,11 @@
"signedin": { "signedin": {
"title": "欢迎 {user}", "title": "欢迎 {user}",
"description": "您已登录。", "description": "您已登录。",
"continue": "继续" "continue": "继续",
"error": {
"title": "错误",
"description": "登录时发生错误。"
}
}, },
"verify": { "verify": {
"userIdMissing": "未提供用户 ID", "userIdMissing": "未提供用户 ID",
@@ -187,7 +191,29 @@
"allSetup": "您已经设置好了一个认证器!", "allSetup": "您已经设置好了一个认证器!",
"linkWithIDP": "或将其与身份提供者关联" "linkWithIDP": "或将其与身份提供者关联"
}, },
"device": {
"usercode": {
"title": "设备代码",
"description": "输入代码。",
"submit": "继续"
},
"request": {
"title": "{appName} 想要连接:",
"description": "{appName} 将访问:",
"disclaimer": "点击“允许”即表示您允许此应用程序和 Zitadel 根据其各自的服务条款和隐私政策使用您的信息。您可以随时撤销此访问权限。",
"submit": "允许",
"deny": "拒绝"
},
"scope": {
"openid": "验证您的身份。",
"email": "访问您的电子邮件地址。",
"profile": "访问您的完整个人资料信息。",
"offline_access": "允许离线访问您的账户。"
}
},
"error": { "error": {
"noUserCode": "未提供用户代码!",
"noDeviceRequest": "没有找到设备请求。",
"unknownContext": "无法获取用户的上下文。请先输入用户名或提供 loginName 作为搜索参数。", "unknownContext": "无法获取用户的上下文。请先输入用户名或提供 loginName 作为搜索参数。",
"sessionExpired": "当前会话已过期,请重新登录。", "sessionExpired": "当前会话已过期,请重新登录。",
"failedLoading": "加载数据失败,请再试一次。", "failedLoading": "加载数据失败,请再试一次。",

View File

@@ -373,7 +373,7 @@ On all pages, where the current user is shown, you can jump to this page. This w
### /signedin ### /signedin
This is a success page which shows a completed login flow for a user, which did navigate to the login without a OIDC auth requrest. This is a success page which shows a completed login flow for a user, which did navigate to the login without a OIDC auth requrest. From here device authorization flows are completed. It checks if the requestId param of starts with `device_` and then executes the `authorizeOrDenyDeviceAuthorization` command.
<img src="./screenshots/signedin.png" alt="/signedin" width="400px" /> <img src="./screenshots/signedin.png" alt="/signedin" width="400px" />

View File

@@ -0,0 +1,83 @@
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.noUserCode")}</div>;
}
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
const { deviceAuthorizationRequest } = await getDeviceAuthorizationRequest({
serviceUrl,
userCode,
});
if (!deviceAuthorizationRequest) {
return <div>{t("error.noDeviceRequest")}</div>;
}
let defaultOrganization;
if (!organization) {
const org: Organization | null = await getDefaultOrg({
serviceUrl,
});
if (org) {
defaultOrganization = org.id;
}
}
const branding = await getBrandingSettings({
serviceUrl,
organization: organization ?? defaultOrganization,
});
const params = new URLSearchParams();
if (requestId) {
params.append("requestId", requestId);
}
if (organization) {
params.append("organization", organization);
}
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>
{t("request.title", { appName: deviceAuthorizationRequest?.appName })}
</h1>
<p className="ztdl-p">{t("request.description")}</p>
<ConsentScreen
deviceAuthorizationRequestId={deviceAuthorizationRequest?.id}
scope={deviceAuthorizationRequest.scope}
appName={deviceAuthorizationRequest?.appName}
nextUrl={`/loginname?` + params}
/>
</div>
</DynamicTheme>
);
}

View File

@@ -0,0 +1,50 @@
import { DeviceCodeForm } from "@/components/device-code-form";
import { DynamicTheme } from "@/components/dynamic-theme";
import { getServiceUrlFromHeaders } from "@/lib/service";
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";
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 organization = searchParams?.organization;
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
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}>
<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 userCode={userCode}></DeviceCodeForm>
</>
)}
</div>
</DynamicTheme>
);
}

View File

@@ -1,69 +1,29 @@
import { Alert, AlertType } from "@/components/alert";
import { Button, ButtonVariants } from "@/components/button"; import { Button, ButtonVariants } from "@/components/button";
import { DynamicTheme } from "@/components/dynamic-theme"; import { DynamicTheme } from "@/components/dynamic-theme";
import { SelfServiceMenu } from "@/components/self-service-menu";
import { UserAvatar } from "@/components/user-avatar"; import { UserAvatar } from "@/components/user-avatar";
import { getMostRecentCookieWithLoginname } from "@/lib/cookies";
import { getServiceUrlFromHeaders } from "@/lib/service";
import { import {
createCallback, getMostRecentCookieWithLoginname,
createResponse, getSessionCookieById,
} from "@/lib/cookies";
import { completeDeviceAuthorization } from "@/lib/server/device";
import { getServiceUrlFromHeaders } from "@/lib/service";
import { loadMostRecentSession } from "@/lib/session";
import {
getBrandingSettings, getBrandingSettings,
getLoginSettings, getLoginSettings,
getSession, getSession,
} from "@/lib/zitadel"; } from "@/lib/zitadel";
import { create } from "@zitadel/client";
import {
CreateCallbackRequestSchema,
SessionSchema,
} from "@zitadel/proto/zitadel/oidc/v2/oidc_service_pb";
import { CreateResponseRequestSchema } from "@zitadel/proto/zitadel/saml/v2/saml_service_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";
import Link from "next/link"; import Link from "next/link";
import { redirect } from "next/navigation";
async function loadSession( async function loadSessionById(
serviceUrl: string, serviceUrl: string,
sessionId: string,
loginName: string, organization?: string,
requestId?: string,
) { ) {
const recent = await getMostRecentCookieWithLoginname({ loginName }); const recent = await getSessionCookieById({ sessionId, organization });
if (requestId && requestId.startsWith("oidc_")) {
return createCallback({
serviceUrl,
req: create(CreateCallbackRequestSchema, {
authRequestId: requestId,
callbackKind: {
case: "session",
value: create(SessionSchema, {
sessionId: recent.id,
sessionToken: recent.token,
}),
},
}),
}).then(({ callbackUrl }) => {
return redirect(callbackUrl);
});
} else if (requestId && requestId.startsWith("saml_")) {
return createResponse({
serviceUrl,
req: create(CreateResponseRequestSchema, {
samlRequestId: requestId.replace("saml_", ""),
responseKind: {
case: "session",
value: {
sessionId: recent.id,
sessionToken: recent.token,
},
},
}),
}).then(({ url }) => {
return redirect(url);
});
}
return getSession({ return getSession({
serviceUrl, serviceUrl,
sessionId: recent.id, sessionId: recent.id,
@@ -83,14 +43,45 @@ export default async function Page(props: { searchParams: Promise<any> }) {
const _headers = await headers(); const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers); const { serviceUrl } = getServiceUrlFromHeaders(_headers);
const { loginName, requestId, organization } = searchParams; const { loginName, requestId, organization, sessionId } = searchParams;
const sessionFactors = await loadSession(serviceUrl, loginName, requestId);
const branding = await getBrandingSettings({ const branding = await getBrandingSettings({
serviceUrl, serviceUrl,
organization, organization,
}); });
// complete device authorization flow if device requestId is present
if (requestId && requestId.startsWith("device_")) {
const cookie = sessionId
? await getSessionCookieById({ sessionId, organization })
: await getMostRecentCookieWithLoginname({
loginName: loginName,
organization: organization,
});
await completeDeviceAuthorization(requestId.replace("device_", ""), {
sessionId: cookie.id,
sessionToken: cookie.token,
}).catch((err) => {
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col items-center space-y-4">
<h1>{t("error.title")}</h1>
<p className="ztdl-p mb-6 block">{t("error.description")}</p>
<Alert>{err.message}</Alert>
</div>
</DynamicTheme>
);
});
}
const sessionFactors = sessionId
? await loadSessionById(serviceUrl, sessionId, organization)
: await loadMostRecentSession({
serviceUrl,
sessionParams: { loginName, organization },
});
let loginSettings; let loginSettings;
if (!requestId) { if (!requestId) {
loginSettings = await getLoginSettings({ loginSettings = await getLoginSettings({
@@ -110,12 +101,15 @@ export default async function Page(props: { searchParams: Promise<any> }) {
<UserAvatar <UserAvatar
loginName={loginName ?? sessionFactors?.factors?.user?.loginName} loginName={loginName ?? sessionFactors?.factors?.user?.loginName}
displayName={sessionFactors?.factors?.user?.displayName} displayName={sessionFactors?.factors?.user?.displayName}
showDropdown showDropdown={!(requestId && requestId.startsWith("device_"))}
searchParams={searchParams} searchParams={searchParams}
/> />
{sessionFactors?.id && ( {requestId && requestId.startsWith("device_") && (
<SelfServiceMenu sessionId={sessionFactors?.id} /> <Alert type={AlertType.INFO}>
You can now close this window and return to the device where you
started the authorization process to continue.
</Alert>
)} )}
{loginSettings?.defaultRedirectUri && ( {loginSettings?.defaultRedirectUri && (

View File

@@ -1,7 +1,7 @@
import { getAllSessions } from "@/lib/cookies"; import { getAllSessions } from "@/lib/cookies";
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 +107,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 +117,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,
@@ -499,7 +499,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

@@ -0,0 +1,48 @@
import { ColorShade, getColorHash } from "@/helpers/colors";
import { useTheme } from "next-themes";
import Image from "next/image";
import { getInitials } from "./avatar";
interface AvatarProps {
appName: string;
imageUrl?: string;
shadow?: boolean;
}
export function AppAvatar({ appName, imageUrl, shadow }: AvatarProps) {
const { resolvedTheme } = useTheme();
const credentials = getInitials(appName, appName);
const color: ColorShade = getColorHash(appName);
const avatarStyleDark = {
backgroundColor: color[900],
color: color[200],
};
const avatarStyleLight = {
backgroundColor: color[200],
color: color[900],
};
return (
<div
className={`w-[100px] h-[100px] flex justify-center items-center cursor-default pointer-events-none group-focus:outline-none group-focus:ring-2 transition-colors duration-200 dark:group-focus:ring-offset-blue bg-primary-light-500 text-primary-light-contrast-500 hover:bg-primary-light-400 hover:dark:bg-primary-dark-500 group-focus:ring-primary-light-200 dark:group-focus:ring-primary-dark-400 dark:bg-primary-dark-300 dark:text-primary-dark-contrast-300 dark:text-blue rounded-full ${
shadow ? "shadow" : ""
}`}
style={resolvedTheme === "light" ? avatarStyleLight : avatarStyleDark}
>
{imageUrl ? (
<Image
height={48}
width={48}
alt="avatar"
className="w-full h-full border border-divider-light dark:border-divider-dark rounded-full"
src={imageUrl}
/>
) : (
<span className={`uppercase text-3xl`}>{credentials}</span>
)}
</div>
);
}

View File

@@ -12,7 +12,7 @@ interface AvatarProps {
shadow?: boolean; shadow?: boolean;
} }
function getInitials(name: string, loginName: string) { export function getInitials(name: string, loginName: string) {
let credentials = ""; let credentials = "";
if (name) { if (name) {
const split = name.split(" "); const split = name.split(" ");

View File

@@ -0,0 +1,106 @@
"use client";
import { completeDeviceAuthorization } from "@/lib/server/device";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Alert } from "./alert";
import { Button, ButtonVariants } from "./button";
import { Spinner } from "./spinner";
export function ConsentScreen({
scope,
nextUrl,
deviceAuthorizationRequestId,
appName,
}: {
scope?: string[];
nextUrl: string;
deviceAuthorizationRequestId: string;
appName?: string;
}) {
const t = useTranslations();
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string>("");
const router = useRouter();
async function denyDeviceAuth() {
setLoading(true);
const response = await completeDeviceAuthorization(
deviceAuthorizationRequestId,
)
.catch(() => {
setError("Could not register user");
return;
})
.finally(() => {
setLoading(false);
});
if (response) {
return router.push("/device");
}
}
return (
<div className="pt-4 w-full flex flex-col items-center space-y-4">
<ul className="list-disc space-y-2 w-full">
{scope
?.filter((s) => !!s)
.map((s) => {
const translationKey = `device.scope.${s}`;
const description = t(translationKey, null);
// Check if the key itself is returned and provide a fallback
const resolvedDescription =
description === translationKey ? "" : description;
return (
<li
key={s}
className="w-full text-sm flex flex-row items-center bg-background-light-400 dark:bg-background-dark-400 border border-divider-light py-2 px-4 rounded-md transition-all"
>
<span>{resolvedDescription}</span>
</li>
);
})}
</ul>
<p className="ztdl-p text-xs text-left">
{t("device.request.disclaimer", { appName: appName })}
</p>
{error && (
<div className="py-4">
<Alert>{error}</Alert>
</div>
)}
<div className="mt-4 flex w-full flex-row items-center">
<Button
onClick={() => {
denyDeviceAuth();
}}
variant={ButtonVariants.Secondary}
data-testid="deny-button"
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
{t("device.request.deny")}
</Button>
<span className="flex-grow"></span>
<Link href={nextUrl}>
<Button
data-testid="submit-button"
type="submit"
className="self-end"
variant={ButtonVariants.Primary}
>
{t("device.request.submit")}
</Button>
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { Alert } from "@/components/alert";
import { getDeviceAuthorizationRequest } from "@/lib/server/oidc";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { BackButton } from "./back-button";
import { Button, ButtonVariants } from "./button";
import { TextInput } from "./input";
import { Spinner } from "./spinner";
type Inputs = {
userCode: string;
};
export function DeviceCodeForm({ userCode }: { userCode?: string }) {
const t = useTranslations("verify");
const router = useRouter();
const { register, handleSubmit, formState } = useForm<Inputs>({
mode: "onBlur",
defaultValues: {
userCode: userCode || "",
},
});
const [error, setError] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
async function submitCodeAndContinue(value: Inputs): Promise<boolean | void> {
setLoading(true);
const response = await getDeviceAuthorizationRequest(value.userCode)
.catch(() => {
setError("Could not continue the request");
return;
})
.finally(() => {
setLoading(false);
});
if (!response || !response.deviceAuthorizationRequest?.id) {
setError("Could not continue the request");
return;
}
return router.push(
`/device/consent?` +
new URLSearchParams({
requestId: `device_${response.deviceAuthorizationRequest.id}`,
user_code: value.userCode,
}).toString(),
);
}
return (
<>
<form className="w-full">
<div className="mt-4">
<TextInput
type="text"
autoComplete="one-time-code"
{...register("userCode", { required: "This field is required" })}
label="Code"
data-testid="code-text-input"
/>
</div>
{error && (
<div className="py-4" data-testid="error">
<Alert>{error}</Alert>
</div>
)}
<div className="mt-8 flex w-full flex-row items-center">
<BackButton />
<span className="flex-grow"></span>
<Button
type="submit"
className="self-end"
variant={ButtonVariants.Primary}
disabled={loading || !formState.isValid}
onClick={handleSubmit(submitCodeAndContinue)}
data-testid="submit-button"
>
{loading && <Spinner className="h-5 w-5 mr-2" />}
{t("verify.submit")}
</Button>
</div>
</form>
</>
);
}

View File

@@ -3,27 +3,34 @@
import { Logo } from "@/components/logo"; import { Logo } from "@/components/logo";
import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { AppAvatar } from "./app-avatar";
import { ThemeWrapper } from "./theme-wrapper"; import { ThemeWrapper } from "./theme-wrapper";
export function DynamicTheme({ export function DynamicTheme({
branding, branding,
children, children,
appName,
}: { }: {
children: ReactNode; children: ReactNode;
branding?: BrandingSettings; branding?: BrandingSettings;
appName?: string;
}) { }) {
return ( return (
<ThemeWrapper branding={branding}> <ThemeWrapper branding={branding}>
<div className="rounded-lg bg-background-light-400 dark:bg-background-dark-500 px-8 py-12"> <div className="rounded-lg bg-background-light-400 dark:bg-background-dark-500 px-8 py-12">
<div className="mx-auto flex flex-col items-center space-y-4"> <div className="mx-auto flex flex-col items-center space-y-4">
<div className="relative"> <div className="relative flex flex-row items-center justify-center gap-8">
{branding && ( {branding && (
<Logo <>
lightSrc={branding.lightTheme?.logoUrl} <Logo
darkSrc={branding.darkTheme?.logoUrl} lightSrc={branding.lightTheme?.logoUrl}
height={150} darkSrc={branding.darkTheme?.logoUrl}
width={150} height={appName ? 100 : 150}
/> width={appName ? 100 : 150}
/>
{appName && <AppAvatar appName={appName} />}
</>
)} )}
</div> </div>

View File

@@ -5,6 +5,33 @@ type FinishFlowCommand =
} }
| { loginName: string }; | { loginName: string };
function goToSignedInPage(
props:
| { sessionId: string; organization?: string; requestId?: string }
| { organization?: string; loginName: string; requestId?: 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);
}
// required to show conditional UI for device flow
if (props.requestId) {
params.append("requestId", props.requestId);
}
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 +41,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 +76,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;
} }

View File

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

View File

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

View File

@@ -0,0 +1,20 @@
"use server";
import { authorizeOrDenyDeviceAuthorization } from "@/lib/zitadel";
import { headers } from "next/headers";
import { getServiceUrlFromHeaders } from "../service";
export async function completeDeviceAuthorization(
deviceAuthorizationId: string,
session?: { sessionId: string; sessionToken: string },
) {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
// without the session, device auth request is denied
return authorizeOrDenyDeviceAuthorization({
serviceUrl,
deviceAuthorizationId,
session,
});
}

View File

@@ -0,0 +1,15 @@
"use server";
import { getDeviceAuthorizationRequest as zitadelGetDeviceAuthorizationRequest } from "@/lib/zitadel";
import { headers } from "next/headers";
import { getServiceUrlFromHeaders } from "../service";
export async function getDeviceAuthorizationRequest(userCode: string) {
const _headers = await headers();
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
return zitadelGetDeviceAuthorizationRequest({
serviceUrl,
userCode,
});
}

View File

@@ -929,6 +929,45 @@ export async function getAuthRequest({
}); });
} }
export async function getDeviceAuthorizationRequest({
serviceUrl,
userCode,
}: {
serviceUrl: string;
userCode: string;
}) {
const oidcService = await createServiceForHost(OIDCService, serviceUrl);
return oidcService.getDeviceAuthorizationRequest({
userCode,
});
}
export async function authorizeOrDenyDeviceAuthorization({
serviceUrl,
deviceAuthorizationId,
session,
}: {
serviceUrl: string;
deviceAuthorizationId: string;
session?: { sessionId: string; sessionToken: string };
}) {
const oidcService = await createServiceForHost(OIDCService, serviceUrl);
return oidcService.authorizeOrDenyDeviceAuthorization({
deviceAuthorizationId,
decision: session
? {
case: "session",
value: session,
}
: {
case: "deny",
value: {},
},
});
}
export async function createCallback({ export async function createCallback({
serviceUrl, serviceUrl,
req, req,

View File

@@ -5,6 +5,10 @@
"outputs": ["dist/**", ".next/**", "!.next/cache/**"], "outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"dependsOn": ["^build"] "dependsOn": ["^build"]
}, },
"build:standalone": {
"outputs": ["dist/**", ".next/**", "!.next/cache/**"],
"dependsOn": ["^build"]
},
"test": { "test": {
"dependsOn": ["@zitadel/client#build"] "dependsOn": ["@zitadel/client#build"]
}, },