diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index db46321b05..a2c137cf43 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -166,7 +166,11 @@ "signedin": { "title": "Willkommen {user}!", "description": "Sie sind angemeldet.", - "continue": "Weiter" + "continue": "Weiter", + "error": { + "title": "Fehler", + "description": "Ein Fehler ist aufgetreten." + } }, "verify": { "userIdMissing": "Keine Benutzer-ID angegeben!", @@ -187,7 +191,29 @@ "allSetup": "Sie haben bereits einen Authentifikator eingerichtet!", "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": { + "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.", "sessionExpired": "Ihre aktuelle Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.", "failedLoading": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut.", diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 36776ccbd9..63a45c7d15 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -166,7 +166,11 @@ "signedin": { "title": "Welcome {user}!", "description": "You are signed in.", - "continue": "Continue" + "continue": "Continue", + "error": { + "title": "Error", + "description": "An error occurred while trying to sign in." + } }, "verify": { "userIdMissing": "No userId provided!", @@ -187,7 +191,29 @@ "allSetup": "You have already setup an authenticator!", "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": { + "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.", "sessionExpired": "Your current session has expired. Please login again.", "failedLoading": "Failed to load data. Please try again.", diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index 4eba3a9696..60570eceb0 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -166,7 +166,11 @@ "signedin": { "title": "¡Bienvenido {user}!", "description": "Has iniciado sesión.", - "continue": "Continuar" + "continue": "Continuar", + "error": { + "title": "Error", + "description": "Ocurrió un error al iniciar sesión." + } }, "verify": { "userIdMissing": "¡No se proporcionó userId!", @@ -187,7 +191,29 @@ "allSetup": "¡Ya has configurado un autenticador!", "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": { + "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.", "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.", diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index d0969c86b3..53894fdf5d 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -166,7 +166,11 @@ "signedin": { "title": "Benvenuto {user}!", "description": "Sei connesso.", - "continue": "Continua" + "continue": "Continua", + "error": { + "title": "Errore", + "description": "Si è verificato un errore durante il tentativo di accesso." + } }, "verify": { "userIdMissing": "Nessun userId fornito!", @@ -187,7 +191,29 @@ "allSetup": "Hai già configurato un autenticatore!", "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": { + "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.", "sessionExpired": "La tua sessione attuale è scaduta. Effettua nuovamente l'accesso.", "failedLoading": "Impossibile caricare i dati. Riprova.", diff --git a/apps/login/locales/pl.json b/apps/login/locales/pl.json index 391196c49b..52b802eccb 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -166,7 +166,11 @@ "signedin": { "title": "Witaj {user}!", "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": { "userIdMissing": "Nie podano identyfikatora użytkownika!", @@ -187,7 +191,29 @@ "allSetup": "Już skonfigurowałeś metodę uwierzytelniania!", "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": { + "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.", "sessionExpired": "Twoja sesja wygasła. Zaloguj się ponownie.", "failedLoading": "Nie udało się załadować danych. Spróbuj ponownie.", diff --git a/apps/login/locales/ru.json b/apps/login/locales/ru.json index c52047897a..197b9663be 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -166,7 +166,11 @@ "signedin": { "title": "Добро пожаловать, {user}!", "description": "Вы вошли в систему.", - "continue": "Продолжить" + "continue": "Продолжить", + "error": { + "title": "Ошибка", + "description": "Не удалось войти в систему. Проверьте свои данные и попробуйте снова." + } }, "verify": { "userIdMissing": "Не указан userId!", @@ -187,7 +191,29 @@ "allSetup": "Аутентификатор уже настроен!", "linkWithIDP": "или привязать через Identity Provider" }, + "device": { + "usercode": { + "title": "Код устройства", + "description": "Введите код.", + "submit": "Продолжить" + }, + "request": { + "title": "{appName} хочет подключиться:", + "description": "{appName} получит доступ к:", + "disclaimer": "Нажимая «Разрешить», вы разрешаете этому приложению и Zitadel использовать вашу информацию в соответствии с их условиями использования и политиками конфиденциальности. Вы можете отозвать этот доступ в любое время.", + "submit": "Разрешить", + "deny": "Запретить" + }, + "scope": { + "openid": "Проверка вашей личности.", + "email": "Доступ к вашему адресу электронной почты.", + "profile": "Доступ к полной информации вашего профиля.", + "offline_access": "Разрешить офлайн-доступ к вашему аккаунту." + } + }, "error": { + "noUserCode": "Не указан код пользователя!", + "noDeviceRequest": "Не найдена ни одна заявка на устройство.", "unknownContext": "Не удалось получить контекст пользователя. Укажите имя пользователя или loginName в параметрах поиска.", "sessionExpired": "Ваша сессия истекла. Войдите снова.", "failedLoading": "Ошибка загрузки данных. Попробуйте ещё раз.", diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index 9c87a53a65..d4319dc051 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -166,7 +166,11 @@ "signedin": { "title": "欢迎 {user}!", "description": "您已登录。", - "continue": "继续" + "continue": "继续", + "error": { + "title": "错误", + "description": "登录时发生错误。" + } }, "verify": { "userIdMissing": "未提供用户 ID!", @@ -187,7 +191,29 @@ "allSetup": "您已经设置好了一个认证器!", "linkWithIDP": "或将其与身份提供者关联" }, + "device": { + "usercode": { + "title": "设备代码", + "description": "输入代码。", + "submit": "继续" + }, + "request": { + "title": "{appName} 想要连接:", + "description": "{appName} 将访问:", + "disclaimer": "点击“允许”即表示您允许此应用程序和 Zitadel 根据其各自的服务条款和隐私政策使用您的信息。您可以随时撤销此访问权限。", + "submit": "允许", + "deny": "拒绝" + }, + "scope": { + "openid": "验证您的身份。", + "email": "访问您的电子邮件地址。", + "profile": "访问您的完整个人资料信息。", + "offline_access": "允许离线访问您的账户。" + } + }, "error": { + "noUserCode": "未提供用户代码!", + "noDeviceRequest": "没有找到设备请求。", "unknownContext": "无法获取用户的上下文。请先输入用户名或提供 loginName 作为搜索参数。", "sessionExpired": "当前会话已过期,请重新登录。", "failedLoading": "加载数据失败,请再试一次。", diff --git a/apps/login/readme.md b/apps/login/readme.md index 120fad3cd7..ca7070a901 100644 --- a/apps/login/readme.md +++ b/apps/login/readme.md @@ -373,7 +373,7 @@ On all pages, where the current user is shown, you can jump to this page. This w ### /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. /signedin diff --git a/apps/login/src/app/(login)/device/consent/page.tsx b/apps/login/src/app/(login)/device/consent/page.tsx new file mode 100644 index 0000000000..379dad2720 --- /dev/null +++ b/apps/login/src/app/(login)/device/consent/page.tsx @@ -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>; +}) { + 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
{t("error.noUserCode")}
; + } + + const _headers = await headers(); + const { serviceUrl } = getServiceUrlFromHeaders(_headers); + + const { deviceAuthorizationRequest } = await getDeviceAuthorizationRequest({ + serviceUrl, + userCode, + }); + + if (!deviceAuthorizationRequest) { + return
{t("error.noDeviceRequest")}
; + } + + 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 ( + +
+

+ {t("request.title", { appName: deviceAuthorizationRequest?.appName })} +

+ +

{t("request.description")}

+ + +
+
+ ); +} diff --git a/apps/login/src/app/(login)/device/page.tsx b/apps/login/src/app/(login)/device/page.tsx new file mode 100644 index 0000000000..bde104b631 --- /dev/null +++ b/apps/login/src/app/(login)/device/page.tsx @@ -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>; +}) { + 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 ( + +
+ {!userCode && ( + <> +

{t("usercode.title")}

+

{t("usercode.description")}

+ + + )} +
+
+ ); +} diff --git a/apps/login/src/app/(login)/signedin/page.tsx b/apps/login/src/app/(login)/signedin/page.tsx index 8c5c5486ac..48595a3559 100644 --- a/apps/login/src/app/(login)/signedin/page.tsx +++ b/apps/login/src/app/(login)/signedin/page.tsx @@ -1,69 +1,29 @@ +import { Alert, AlertType } from "@/components/alert"; import { Button, ButtonVariants } from "@/components/button"; import { DynamicTheme } from "@/components/dynamic-theme"; -import { SelfServiceMenu } from "@/components/self-service-menu"; import { UserAvatar } from "@/components/user-avatar"; -import { getMostRecentCookieWithLoginname } from "@/lib/cookies"; -import { getServiceUrlFromHeaders } from "@/lib/service"; import { - createCallback, - createResponse, + getMostRecentCookieWithLoginname, + getSessionCookieById, +} from "@/lib/cookies"; +import { completeDeviceAuthorization } from "@/lib/server/device"; +import { getServiceUrlFromHeaders } from "@/lib/service"; +import { loadMostRecentSession } from "@/lib/session"; +import { getBrandingSettings, getLoginSettings, getSession, } 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 { headers } from "next/headers"; import Link from "next/link"; -import { redirect } from "next/navigation"; -async function loadSession( +async function loadSessionById( serviceUrl: string, - - loginName: string, - requestId?: string, + sessionId: string, + organization?: string, ) { - const recent = await getMostRecentCookieWithLoginname({ loginName }); - - 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); - }); - } - + const recent = await getSessionCookieById({ sessionId, organization }); return getSession({ serviceUrl, sessionId: recent.id, @@ -83,14 +43,45 @@ export default async function Page(props: { searchParams: Promise }) { const _headers = await headers(); const { serviceUrl } = getServiceUrlFromHeaders(_headers); - const { loginName, requestId, organization } = searchParams; - const sessionFactors = await loadSession(serviceUrl, loginName, requestId); + const { loginName, requestId, organization, sessionId } = searchParams; const branding = await getBrandingSettings({ serviceUrl, 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 ( + +
+

{t("error.title")}

+

{t("error.description")}

+ {err.message} +
+
+ ); + }); + } + + const sessionFactors = sessionId + ? await loadSessionById(serviceUrl, sessionId, organization) + : await loadMostRecentSession({ + serviceUrl, + sessionParams: { loginName, organization }, + }); + let loginSettings; if (!requestId) { loginSettings = await getLoginSettings({ @@ -110,12 +101,15 @@ export default async function Page(props: { searchParams: Promise }) { - {sessionFactors?.id && ( - + {requestId && requestId.startsWith("device_") && ( + + You can now close this window and return to the device where you + started the authorization process to continue. + )} {loginSettings?.defaultRedirectUri && ( diff --git a/apps/login/src/app/login/route.ts b/apps/login/src/app/login/route.ts index e3834e5a27..3072f45229 100644 --- a/apps/login/src/app/login/route.ts +++ b/apps/login/src/app/login/route.ts @@ -1,7 +1,7 @@ import { getAllSessions } from "@/lib/cookies"; 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 +107,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 +117,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, @@ -499,7 +499,9 @@ export async function GET(request: NextRequest) { 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( { error: "No authRequest nor samlRequest provided" }, { status: 500 }, diff --git a/apps/login/src/components/app-avatar.tsx b/apps/login/src/components/app-avatar.tsx new file mode 100644 index 0000000000..defe388438 --- /dev/null +++ b/apps/login/src/components/app-avatar.tsx @@ -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 ( +
+ {imageUrl ? ( + avatar + ) : ( + {credentials} + )} +
+ ); +} diff --git a/apps/login/src/components/avatar.tsx b/apps/login/src/components/avatar.tsx index 3f340e09b7..2300659875 100644 --- a/apps/login/src/components/avatar.tsx +++ b/apps/login/src/components/avatar.tsx @@ -12,7 +12,7 @@ interface AvatarProps { shadow?: boolean; } -function getInitials(name: string, loginName: string) { +export function getInitials(name: string, loginName: string) { let credentials = ""; if (name) { const split = name.split(" "); diff --git a/apps/login/src/components/consent.tsx b/apps/login/src/components/consent.tsx new file mode 100644 index 0000000000..30d5b4e407 --- /dev/null +++ b/apps/login/src/components/consent.tsx @@ -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(false); + const [error, setError] = useState(""); + 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 ( +
+
    + {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 ( +
  • + {resolvedDescription} +
  • + ); + })} +
+ +

+ {t("device.request.disclaimer", { appName: appName })} +

+ + {error && ( +
+ {error} +
+ )} + +
+ + + + + + +
+
+ ); +} diff --git a/apps/login/src/components/device-code-form.tsx b/apps/login/src/components/device-code-form.tsx new file mode 100644 index 0000000000..e09adb1147 --- /dev/null +++ b/apps/login/src/components/device-code-form.tsx @@ -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({ + mode: "onBlur", + defaultValues: { + userCode: userCode || "", + }, + }); + + const [error, setError] = useState(""); + + const [loading, setLoading] = useState(false); + + async function submitCodeAndContinue(value: Inputs): Promise { + 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 ( + <> +
+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + +
+
+ + ); +} diff --git a/apps/login/src/components/dynamic-theme.tsx b/apps/login/src/components/dynamic-theme.tsx index 7d0fecb558..d50bc082ea 100644 --- a/apps/login/src/components/dynamic-theme.tsx +++ b/apps/login/src/components/dynamic-theme.tsx @@ -3,27 +3,34 @@ import { Logo } from "@/components/logo"; import { BrandingSettings } from "@zitadel/proto/zitadel/settings/v2/branding_settings_pb"; import { ReactNode } from "react"; +import { AppAvatar } from "./app-avatar"; import { ThemeWrapper } from "./theme-wrapper"; export function DynamicTheme({ branding, children, + appName, }: { children: ReactNode; branding?: BrandingSettings; + appName?: string; }) { return (
-
+
{branding && ( - + <> + + + {appName && } + )}
diff --git a/apps/login/src/lib/client.ts b/apps/login/src/lib/client.ts index 953d66e7ee..a59af90b77 100644 --- a/apps/login/src/lib/client.ts +++ b/apps/login/src/lib/client.ts @@ -5,6 +5,33 @@ type FinishFlowCommand = } | { 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 * @param command @@ -14,7 +41,25 @@ export async function getNextUrl( command: FinishFlowCommand & { organization?: string }, defaultRedirectUri?: string, ): Promise { - 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({ sessionId: command.sessionId, requestId: command.requestId, @@ -31,13 +76,5 @@ export async function getNextUrl( return defaultRedirectUri; } - const params = new URLSearchParams({ - loginName: command.loginName, - }); - - if (command.organization) { - params.append("organization", command.organization); - } - - return `/signedin?` + params; + return goToSignedInPage(command); } diff --git a/apps/login/src/lib/oidc.ts b/apps/login/src/lib/oidc.ts index c1038d90c4..09f6e0354e 100644 --- a/apps/login/src/lib/oidc.ts +++ b/apps/login/src/lib/oidc.ts @@ -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}`, ); diff --git a/apps/login/src/lib/saml.ts b/apps/login/src/lib/saml.ts index 9b12e48d25..7d294c908a 100644 --- a/apps/login/src/lib/saml.ts +++ b/apps/login/src/lib/saml.ts @@ -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}`, ); diff --git a/apps/login/src/lib/server/device.ts b/apps/login/src/lib/server/device.ts new file mode 100644 index 0000000000..d96059f6a6 --- /dev/null +++ b/apps/login/src/lib/server/device.ts @@ -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, + }); +} diff --git a/apps/login/src/lib/server/oidc.ts b/apps/login/src/lib/server/oidc.ts new file mode 100644 index 0000000000..4ae01b4a47 --- /dev/null +++ b/apps/login/src/lib/server/oidc.ts @@ -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, + }); +} diff --git a/apps/login/src/lib/zitadel.ts b/apps/login/src/lib/zitadel.ts index 0511eaaf0d..da690c10e2 100644 --- a/apps/login/src/lib/zitadel.ts +++ b/apps/login/src/lib/zitadel.ts @@ -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({ serviceUrl, req, diff --git a/apps/login/turbo.json b/apps/login/turbo.json index e8a243feaf..80224125a2 100644 --- a/apps/login/turbo.json +++ b/apps/login/turbo.json @@ -5,6 +5,10 @@ "outputs": ["dist/**", ".next/**", "!.next/cache/**"], "dependsOn": ["^build"] }, + "build:standalone": { + "outputs": ["dist/**", ".next/**", "!.next/cache/**"], + "dependsOn": ["^build"] + }, "test": { "dependsOn": ["@zitadel/client#build"] },