From ac68dbd545c0fc5b5ce01d60ec175998778f9ad8 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 25 Nov 2025 10:51:36 -0500 Subject: [PATCH 1/2] add my-device and force login --- messages/en-US.json | 4 +- server/auth/sessions/verifySession.ts | 34 +++++- server/middlewares/verifySession.ts | 4 +- server/middlewares/verifyUser.ts | 4 +- server/routers/auth/login.ts | 46 ++++++--- server/routers/auth/verifyDeviceWebAuth.ts | 34 ++++-- server/routers/external.ts | 8 ++ server/routers/olm/createUserOlm.ts | 34 +++--- server/routers/olm/deleteUserOlm.ts | 20 ++-- server/routers/olm/getUserOlm.ts | 70 +++++++++++++ server/routers/olm/index.ts | 1 + server/routers/olm/listUserOlms.ts | 22 ++-- server/routers/user/index.ts | 1 + server/routers/user/myDevice.ts | 114 +++++++++++++++++++++ src/actions/server.ts | 28 +++-- src/app/auth/login/device/page.tsx | 29 +++++- src/app/auth/login/page.tsx | 6 +- src/components/DashboardLoginForm.tsx | 5 +- src/components/DeviceLoginForm.tsx | 88 +++++++++++++--- src/components/LoginForm.tsx | 19 ++-- src/lib/auth/verifySession.ts | 6 +- src/lib/cleanRedirect.ts | 2 +- 22 files changed, 472 insertions(+), 107 deletions(-) create mode 100644 server/routers/olm/getUserOlm.ts create mode 100644 server/routers/user/myDevice.ts diff --git a/messages/en-US.json b/messages/en-US.json index 6cf0d1f9..f3ae34a7 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2200,5 +2200,7 @@ "niceIdUpdateErrorDescription": "An error occurred while updating the Nice ID.", "niceIdCannotBeEmpty": "Nice ID cannot be empty", "enterIdentifier": "Enter identifier", - "identifier": "Identifier" + "identifier": "Identifier", + "deviceLoginUseDifferentAccount": "Not you? Use a different account.", + "deviceLoginDeviceRequestingAccessToAccount": "Your device is requesting access to this account." } diff --git a/server/auth/sessions/verifySession.ts b/server/auth/sessions/verifySession.ts index ca8f18ff..68a1f17e 100644 --- a/server/auth/sessions/verifySession.ts +++ b/server/auth/sessions/verifySession.ts @@ -1,9 +1,37 @@ import { Request } from "express"; -import { validateSessionToken, SESSION_COOKIE_NAME } from "@server/auth/sessions/app"; +import { + validateSessionToken, + SESSION_COOKIE_NAME +} from "@server/auth/sessions/app"; -export async function verifySession(req: Request) { +export async function verifySession(req: Request, forceLogin?: boolean) { const res = await validateSessionToken( - req.cookies[SESSION_COOKIE_NAME] ?? "", + req.cookies[SESSION_COOKIE_NAME] ?? "" ); + + if (!forceLogin) { + return res; + } + if (!res.session || !res.user) { + return { + session: null, + user: null + }; + } + if (!res.session.issuedAt) { + return { + session: null, + user: null + }; + } + const mins = 3 * 60 * 1000; + const now = new Date().getTime(); + if (now - res.session.issuedAt > mins) { + return { + session: null, + user: null + }; + } + return res; } diff --git a/server/middlewares/verifySession.ts b/server/middlewares/verifySession.ts index 3ef5978d..69254cc9 100644 --- a/server/middlewares/verifySession.ts +++ b/server/middlewares/verifySession.ts @@ -8,7 +8,9 @@ export const verifySessionMiddleware = async ( res: Response, next: NextFunction ) => { - const { session, user } = await verifySession(req); + const { forceLogin } = req.query; + + const { session, user } = await verifySession(req, forceLogin === "true"); if (!session || !user) { return next(unauthorized()); } diff --git a/server/middlewares/verifyUser.ts b/server/middlewares/verifyUser.ts index 8fd38b24..6b491ef9 100644 --- a/server/middlewares/verifyUser.ts +++ b/server/middlewares/verifyUser.ts @@ -15,7 +15,9 @@ export const verifySessionUserMiddleware = async ( res: Response, next: NextFunction ) => { - const { session, user } = await verifySession(req); + const { forceLogin } = req.query; + + const { session, user } = await verifySession(req, forceLogin === "true"); if (!session || !user) { if (config.getRawConfig().app.log_failed_attempts) { logger.info(`User session not found. IP: ${req.ip}.`); diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index 9c913054..dca8bb87 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -1,7 +1,9 @@ import { createSession, generateSessionToken, - serializeSessionCookie + invalidateSession, + serializeSessionCookie, + SESSION_COOKIE_NAME } from "@server/auth/sessions/app"; import { db, resources } from "@server/db"; import { users, securityKeys } from "@server/db"; @@ -21,11 +23,11 @@ import { UserType } from "@server/types/UserTypes"; import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; export const loginBodySchema = z.strictObject({ - email: z.email().toLowerCase(), - password: z.string(), - code: z.string().optional(), - resourceGuid: z.string().optional() - }); + email: z.email().toLowerCase(), + password: z.string(), + code: z.string().optional(), + resourceGuid: z.string().optional() +}); export type LoginBody = z.infer; @@ -41,6 +43,21 @@ export async function login( res: Response, next: NextFunction ): Promise { + const { forceLogin } = req.query; + const { session: existingSession } = await verifySession( + req, + forceLogin === "true" + ); + if (existingSession) { + return response(res, { + data: null, + success: true, + error: false, + message: "Already logged in", + status: HttpCode.OK + }); + } + const parsedBody = loginBodySchema.safeParse(req.body); if (!parsedBody.success) { @@ -55,17 +72,6 @@ export async function login( const { email, password, code, resourceGuid } = parsedBody.data; try { - const { session: existingSession } = await verifySession(req); - if (existingSession) { - return response(res, { - data: null, - success: true, - error: false, - message: "Already logged in", - status: HttpCode.OK - }); - } - let resourceId: number | null = null; let orgId: string | null = null; if (resourceGuid) { @@ -225,6 +231,12 @@ export async function login( } } + // check for previous cookie value and expire it + const previousCookie = req.cookies[SESSION_COOKIE_NAME]; + if (previousCookie) { + await invalidateSession(previousCookie); + } + const token = generateSessionToken(); const sess = await createSession(token, existingUser.userId); const isSecure = req.protocol === "https"; diff --git a/server/routers/auth/verifyDeviceWebAuth.ts b/server/routers/auth/verifyDeviceWebAuth.ts index c9284925..715b299a 100644 --- a/server/routers/auth/verifyDeviceWebAuth.ts +++ b/server/routers/auth/verifyDeviceWebAuth.ts @@ -9,17 +9,18 @@ import { db, deviceWebAuthCodes } from "@server/db"; import { eq, and, gt } from "drizzle-orm"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; +import { unauthorized } from "@server/auth/unauthorizedResponse"; -const bodySchema = z.object({ - code: z.string().min(1, "Code is required"), - verify: z.boolean().optional().default(false) // If false, just check and return metadata -}).strict(); +const bodySchema = z + .object({ + code: z.string().min(1, "Code is required"), + verify: z.boolean().optional().default(false) // If false, just check and return metadata + }) + .strict(); // Helper function to hash device code before querying database function hashDeviceCode(code: string): string { - return encodeHexLowerCase( - sha256(new TextEncoder().encode(code)) - ); + return encodeHexLowerCase(sha256(new TextEncoder().encode(code))); } export type VerifyDeviceWebAuthBody = z.infer; @@ -41,6 +42,24 @@ export async function verifyDeviceWebAuth( res: Response, next: NextFunction ): Promise { + const { user, session } = req; + if (!user || !session) { + logger.debug("Unauthorized attempt to verify device web auth code"); + return next(unauthorized()); + } + + if (!session.issuedAt) { + logger.debug("Session missing issuedAt timestamp"); + return next(unauthorized()); + } + + // make sure sessions is not older than 5 minutes + const now = Date.now(); + if (now - session.issuedAt > 3 * 60 * 1000) { + logger.debug("Session is too old to verify device web auth code"); + return next(unauthorized()); + } + const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { @@ -135,4 +154,3 @@ export async function verifyDeviceWebAuth( ); } } - diff --git a/server/routers/external.ts b/server/routers/external.ts index 7c614ce2..8418a605 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -712,6 +712,7 @@ unauthenticated.get( // ); unauthenticated.get("/user", verifySessionMiddleware, user.getUser); +unauthenticated.get("/my-device", verifySessionMiddleware, user.myDevice); authenticated.get("/users", verifyUserIsServerAdmin, user.adminListUsers); authenticated.get("/user/:userId", verifyUserIsServerAdmin, user.adminGetUser); @@ -816,6 +817,13 @@ authenticated.delete( olm.deleteUserOlm ); +authenticated.get( + "/user/:userId/olm/:olmId", + verifyIsLoggedInUser, + verifyOlmAccess, + olm.getUserOlm +); + authenticated.put( "/idp/oidc", verifyUserIsServerAdmin, diff --git a/server/routers/olm/createUserOlm.ts b/server/routers/olm/createUserOlm.ts index e9bcfa8a..0fc1e452 100644 --- a/server/routers/olm/createUserOlm.ts +++ b/server/routers/olm/createUserOlm.ts @@ -28,23 +28,23 @@ export type CreateOlmResponse = { secret: string; }; -registry.registerPath({ - method: "put", - path: "/user/{userId}/olm", - description: "Create a new olm for a user.", - tags: [OpenAPITags.User, OpenAPITags.Client], - request: { - body: { - content: { - "application/json": { - schema: bodySchema - } - } - }, - params: paramsSchema - }, - responses: {} -}); +// registry.registerPath({ +// method: "put", +// path: "/user/{userId}/olm", +// description: "Create a new olm for a user.", +// tags: [OpenAPITags.User, OpenAPITags.Client], +// request: { +// body: { +// content: { +// "application/json": { +// schema: bodySchema +// } +// } +// }, +// params: paramsSchema +// }, +// responses: {} +// }); export async function createUserOlm( req: Request, diff --git a/server/routers/olm/deleteUserOlm.ts b/server/routers/olm/deleteUserOlm.ts index e1a23a43..88e791db 100644 --- a/server/routers/olm/deleteUserOlm.ts +++ b/server/routers/olm/deleteUserOlm.ts @@ -17,16 +17,16 @@ const paramsSchema = z }) .strict(); -registry.registerPath({ - method: "delete", - path: "/user/{userId}/olm/{olmId}", - description: "Delete an olm for a user.", - tags: [OpenAPITags.User, OpenAPITags.Client], - request: { - params: paramsSchema - }, - responses: {} -}); +// registry.registerPath({ +// method: "delete", +// path: "/user/{userId}/olm/{olmId}", +// description: "Delete an olm for a user.", +// tags: [OpenAPITags.User, OpenAPITags.Client], +// request: { +// params: paramsSchema +// }, +// responses: {} +// }); export async function deleteUserOlm( req: Request, diff --git a/server/routers/olm/getUserOlm.ts b/server/routers/olm/getUserOlm.ts new file mode 100644 index 00000000..50b32fd8 --- /dev/null +++ b/server/routers/olm/getUserOlm.ts @@ -0,0 +1,70 @@ +import { NextFunction, Request, Response } from "express"; +import { db } from "@server/db"; +import { olms, clients, clientSites } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z + .object({ + userId: z.string(), + olmId: z.string() + }) + .strict(); + +// registry.registerPath({ +// method: "get", +// path: "/user/{userId}/olm/{olmId}", +// description: "Get an olm for a user.", +// tags: [OpenAPITags.User, OpenAPITags.Client], +// request: { +// params: paramsSchema +// }, +// responses: {} +// }); + +export async function getUserOlm( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { olmId, userId } = parsedParams.data; + + const [olm] = await db + .select() + .from(olms) + .where(and(eq(olms.userId, userId), eq(olms.olmId, olmId))); + + return response(res, { + data: olm, + success: true, + error: false, + message: "Successfully retrieved olm", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to retrieve olm" + ) + ); + } +} diff --git a/server/routers/olm/index.ts b/server/routers/olm/index.ts index 6882d019..7adbf859 100644 --- a/server/routers/olm/index.ts +++ b/server/routers/olm/index.ts @@ -6,3 +6,4 @@ export * from "./handleOlmPingMessage"; export * from "./deleteUserOlm"; export * from "./listUserOlms"; export * from "./deleteUserOlm"; +export * from "./getUserOlm"; diff --git a/server/routers/olm/listUserOlms.ts b/server/routers/olm/listUserOlms.ts index 1cc0a802..2756c917 100644 --- a/server/routers/olm/listUserOlms.ts +++ b/server/routers/olm/listUserOlms.ts @@ -31,17 +31,17 @@ const paramsSchema = z }) .strict(); -registry.registerPath({ - method: "delete", - path: "/user/{userId}/olms", - description: "List all olms for a user.", - tags: [OpenAPITags.User, OpenAPITags.Client], - request: { - query: querySchema, - params: paramsSchema - }, - responses: {} -}); +// registry.registerPath({ +// method: "delete", +// path: "/user/{userId}/olms", +// description: "List all olms for a user.", +// tags: [OpenAPITags.User, OpenAPITags.Client], +// request: { +// query: querySchema, +// params: paramsSchema +// }, +// responses: {} +// }); export type ListUserOlmsResponse = { olms: Array<{ diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 7148eb87..78587f3d 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -14,3 +14,4 @@ export * from "./createOrgUser"; export * from "./adminUpdateUser2FA"; export * from "./adminGetUser"; export * from "./updateOrgUser"; +export * from "./myDevice"; diff --git a/server/routers/user/myDevice.ts b/server/routers/user/myDevice.ts new file mode 100644 index 00000000..144108e1 --- /dev/null +++ b/server/routers/user/myDevice.ts @@ -0,0 +1,114 @@ +import { Request, Response, NextFunction } from "express"; +import { db, Olm, olms, orgs, userOrgs } from "@server/db"; +import { idp, users } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { GetUserResponse } from "./getUser"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const querySchema = z.object({ + olmId: z.string() +}); + +type ResponseOrg = { + orgId: string; + orgName: string; + roleId: number; +}; + +export type MyDeviceResponse = { + user: GetUserResponse; + orgs: ResponseOrg[]; + olm: Olm | null; +}; + +export async function myDevice( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const { olmId } = parsedQuery.data; + + const userId = req.user?.userId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not found") + ); + } + + const [user] = await db + .select({ + userId: users.userId, + email: users.email, + username: users.username, + name: users.name, + type: users.type, + twoFactorEnabled: users.twoFactorEnabled, + emailVerified: users.emailVerified, + serverAdmin: users.serverAdmin, + idpName: idp.name, + idpId: users.idpId + }) + .from(users) + .leftJoin(idp, eq(users.idpId, idp.idpId)) + .where(eq(users.userId, userId)) + .limit(1); + + if (!user) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `User with ID ${userId} not found` + ) + ); + } + + const [olm] = await db + .select() + .from(olms) + .where(and(eq(olms.userId, userId), eq(olms.olmId, olmId))); + + const userOrganizations = await db + .select({ + orgId: userOrgs.orgId, + orgName: orgs.name, + roleId: userOrgs.roleId + }) + .from(userOrgs) + .where(eq(userOrgs.userId, userId)) + .innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId)); + + return response(res, { + data: { + user, + orgs: userOrganizations, + olm + }, + success: true, + error: false, + message: "My device retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/src/actions/server.ts b/src/actions/server.ts index b75c3ed7..2759e621 100644 --- a/src/actions/server.ts +++ b/src/actions/server.ts @@ -237,10 +237,11 @@ export type SecurityKeyVerifyResponse = { }; export async function loginProxy( - request: LoginRequest + request: LoginRequest, + forceLogin?: boolean ): Promise> { const serverPort = process.env.SERVER_EXTERNAL_PORT; - const url = `http://localhost:${serverPort}/api/v1/auth/login`; + const url = `http://localhost:${serverPort}/api/v1/auth/login${forceLogin ? "?forceLogin=true" : ""}`; console.log("Making login request to:", url); @@ -248,10 +249,11 @@ export async function loginProxy( } export async function securityKeyStartProxy( - request: SecurityKeyStartRequest + request: SecurityKeyStartRequest, + forceLogin?: boolean ): Promise> { const serverPort = process.env.SERVER_EXTERNAL_PORT; - const url = `http://localhost:${serverPort}/api/v1/auth/security-key/authenticate/start`; + const url = `http://localhost:${serverPort}/api/v1/auth/security-key/authenticate/start${forceLogin ? "?forceLogin=true" : ""}`; console.log("Making security key start request to:", url); @@ -260,10 +262,11 @@ export async function securityKeyStartProxy( export async function securityKeyVerifyProxy( request: SecurityKeyVerifyRequest, - tempSessionId: string + tempSessionId: string, + forceLogin?: boolean ): Promise> { const serverPort = process.env.SERVER_EXTERNAL_PORT; - const url = `http://localhost:${serverPort}/api/v1/auth/security-key/authenticate/verify`; + const url = `http://localhost:${serverPort}/api/v1/auth/security-key/authenticate/verify${forceLogin ? "?forceLogin=true" : ""}`; console.log("Making security key verify request to:", url); @@ -407,10 +410,19 @@ export async function validateOidcUrlCallbackProxy( export async function generateOidcUrlProxy( idpId: number, redirect: string, - orgId?: string + orgId?: string, + forceLogin?: boolean ): Promise> { const serverPort = process.env.SERVER_EXTERNAL_PORT; - const url = `http://localhost:${serverPort}/api/v1/auth/idp/${idpId}/oidc/generate-url${orgId ? `?orgId=${orgId}` : ""}`; + const queryParams = new URLSearchParams(); + if (orgId) { + queryParams.append("orgId", orgId); + } + if (forceLogin) { + queryParams.append("forceLogin", "true"); + } + const queryString = queryParams.toString(); + const url = `http://localhost:${serverPort}/api/v1/auth/idp/${idpId}/oidc/generate-url${queryString ? `?${queryString}` : ""}`; console.log("Making OIDC URL generation request to:", url); diff --git a/src/app/auth/login/device/page.tsx b/src/app/auth/login/device/page.tsx index 9b9cb335..a19174d0 100644 --- a/src/app/auth/login/device/page.tsx +++ b/src/app/auth/login/device/page.tsx @@ -5,13 +5,32 @@ import { cache } from "react"; export const dynamic = "force-dynamic"; -export default async function DeviceLoginPage() { - const getUser = cache(verifySession); - const user = await getUser(); +type Props = { + searchParams: Promise<{ code?: string }>; +}; + +export default async function DeviceLoginPage({ searchParams }: Props) { + const user = await verifySession({ forceLogin: true }); + + const params = await searchParams; + const code = params.code || ""; + + console.log("user", user); if (!user) { - redirect("/auth/login?redirect=/auth/login/device"); + const redirectDestination = code + ? `/auth/login/device?code=${encodeURIComponent(code)}` + : "/auth/login/device"; + redirect(`/auth/login?forceLogin=true&redirect=${encodeURIComponent(redirectDestination)}`); } - return ; + const userName = user?.name || user?.username || ""; + + return ( + + ); } diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 68e93214..615f33cf 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -25,12 +25,14 @@ export default async function Page(props: { const user = await getUser({ skipCheckVerifyEmail: true }); const isInvite = searchParams?.redirect?.includes("/invite"); + const forceLoginParam = searchParams?.forceLogin; + const forceLogin = forceLoginParam === "true"; const env = pullEnv(); const signUpDisabled = env.flags.disableSignupWithoutInvite; - if (user) { + if (user && !forceLogin) { redirect("/"); } @@ -96,7 +98,7 @@ export default async function Page(props: { )} - + {(!signUpDisabled || isInvite) && (

diff --git a/src/components/DashboardLoginForm.tsx b/src/components/DashboardLoginForm.tsx index e3a3bc44..3274adcc 100644 --- a/src/components/DashboardLoginForm.tsx +++ b/src/components/DashboardLoginForm.tsx @@ -21,11 +21,13 @@ import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; type DashboardLoginFormProps = { redirect?: string; idps?: LoginFormIDP[]; + forceLogin?: boolean; }; export default function DashboardLoginForm({ redirect, - idps + idps, + forceLogin }: DashboardLoginFormProps) { const router = useRouter(); const { env } = useEnvContext(); @@ -57,6 +59,7 @@ export default function DashboardLoginForm({ { if (redirectUrl) { const safe = cleanRedirect(redirectUrl); diff --git a/src/components/DeviceLoginForm.tsx b/src/components/DeviceLoginForm.tsx index f09682be..8b6d460c 100644 --- a/src/components/DeviceLoginForm.tsx +++ b/src/components/DeviceLoginForm.tsx @@ -30,10 +30,12 @@ import { DeviceAuthConfirmation } from "@/components/DeviceAuthConfirmation"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import BrandingLogo from "./BrandingLogo"; import { useTranslations } from "next-intl"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -const createFormSchema = (t: (key: string) => string) => z.object({ - code: z.string().length(8, t("deviceCodeInvalidFormat")) -}); +const createFormSchema = (t: (key: string) => string) => + z.object({ + code: z.string().length(8, t("deviceCodeInvalidFormat")) + }); type DeviceAuthMetadata = { ip: string | null; @@ -45,9 +47,15 @@ type DeviceAuthMetadata = { type DeviceLoginFormProps = { userEmail: string; + userName?: string; + initialCode?: string; }; -export default function DeviceLoginForm({ userEmail }: DeviceLoginFormProps) { +export default function DeviceLoginForm({ + userEmail, + userName, + initialCode = "" +}: DeviceLoginFormProps) { const router = useRouter(); const { env } = useEnvContext(); const api = createApiClient({ env }); @@ -63,7 +71,7 @@ export default function DeviceLoginForm({ userEmail }: DeviceLoginFormProps) { const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { - code: "" + code: initialCode.replace(/-/g, "").toUpperCase() } }); @@ -77,10 +85,15 @@ export default function DeviceLoginForm({ userEmail }: DeviceLoginFormProps) { data.code = data.code.slice(0, 4) + "-" + data.code.slice(4); } // First check - get metadata - const res = await api.post("/device-web-auth/verify", { - code: data.code.toUpperCase(), - verify: false - }); + const res = await api.post( + "/device-web-auth/verify?forceLogin=true", + { + code: data.code.toUpperCase(), + verify: false + } + ); + + await new Promise((resolve) => setTimeout(resolve, 500)); // artificial delay for better UX if (res.data.success && res.data.data.metadata) { setMetadata(res.data.data.metadata); @@ -109,6 +122,8 @@ export default function DeviceLoginForm({ userEmail }: DeviceLoginFormProps) { verify: true }); + await new Promise((resolve) => setTimeout(resolve, 500)); // artificial delay for better UX + // Redirect to success page router.push("/auth/login/device/success"); } catch (e: any) { @@ -136,6 +151,30 @@ export default function DeviceLoginForm({ userEmail }: DeviceLoginFormProps) { setError(null); } + const profileLabel = (userName || userEmail || "").trim(); + const profileInitial = profileLabel + ? profileLabel.charAt(0).toUpperCase() + : "?"; + + async function handleUseDifferentAccount() { + try { + await api.post("/auth/logout"); + } catch (logoutError) { + console.error( + "Failed to logout before switching account", + logoutError + ); + } finally { + const currentSearch = + typeof window !== "undefined" ? window.location.search : ""; + const redirectTarget = `/auth/login/device${currentSearch || ""}`; + router.push( + `/auth/login?forceLogin=true&redirect=${encodeURIComponent(redirectTarget)}` + ); + router.refresh(); + } + } + if (metadata) { return (

-

{t("deviceActivation")}

+

+ {t("deviceActivation")} +

-
- {t("signedInAs")} - {userEmail} +
+ + {profileInitial} + +
+
+

+ {profileLabel || userEmail} +

+

+ {t( + "deviceLoginDeviceRequestingAccessToAccount" + )} +

+
+ +
diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 15b4d165..7b8235a1 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -61,13 +61,15 @@ type LoginFormProps = { onLogin?: (redirectUrl?: string) => void | Promise; idps?: LoginFormIDP[]; orgId?: string; + forceLogin?: boolean; }; export default function LoginForm({ redirect, onLogin, idps, - orgId + orgId, + forceLogin }: LoginFormProps) { const router = useRouter(); @@ -141,7 +143,7 @@ export default function LoginForm({ try { // Start WebAuthn authentication without email - const startResponse = await securityKeyStartProxy({}); + const startResponse = await securityKeyStartProxy({}, forceLogin); if (startResponse.error) { setError(startResponse.message); @@ -165,7 +167,8 @@ export default function LoginForm({ // Verify authentication const verifyResponse = await securityKeyVerifyProxy( { credential }, - tempSessionId + tempSessionId, + forceLogin ); if (verifyResponse.error) { @@ -234,12 +237,15 @@ export default function LoginForm({ setShowSecurityKeyPrompt(false); try { - const response = await loginProxy({ + const response = await loginProxy( + { email, password, code, resourceGuid: resourceGuid as string - }); + }, + forceLogin + ); try { const identity = { @@ -333,7 +339,8 @@ export default function LoginForm({ const data = await generateOidcUrlProxy( idpId, redirect || "/", - orgId + orgId, + forceLogin ); const url = data.data?.redirectUrl; if (data.error) { diff --git a/src/lib/auth/verifySession.ts b/src/lib/auth/verifySession.ts index ae29d2b4..0c87b1ae 100644 --- a/src/lib/auth/verifySession.ts +++ b/src/lib/auth/verifySession.ts @@ -5,15 +5,17 @@ import { AxiosResponse } from "axios"; import { pullEnv } from "../pullEnv"; export async function verifySession({ - skipCheckVerifyEmail + skipCheckVerifyEmail, + forceLogin }: { skipCheckVerifyEmail?: boolean; + forceLogin?: boolean; } = {}): Promise { const env = pullEnv(); try { const res = await internal.get>( - "/user", + `/user${forceLogin ? "?forceLogin=true" : ""}`, await authCookieHeader() ); diff --git a/src/lib/cleanRedirect.ts b/src/lib/cleanRedirect.ts index afa902d3..89edf8bc 100644 --- a/src/lib/cleanRedirect.ts +++ b/src/lib/cleanRedirect.ts @@ -7,7 +7,7 @@ const patterns: PatternConfig[] = [ { name: "Invite Token", regex: /^\/invite\?token=[a-zA-Z0-9-]+$/ }, { name: "Setup", regex: /^\/setup$/ }, { name: "Resource Auth Portal", regex: /^\/auth\/resource\/\d+$/ }, - { name: "Device Login", regex: /^\/auth\/login\/device$/ } + { name: "Device Login", regex: /^\/auth\/login\/device(\?code=[a-zA-Z0-9-]+)?$/ } ]; export function cleanRedirect(input: string, fallback?: string): string { From d977d57b2a10432dd0af106245747a7dcc72c7a3 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 25 Nov 2025 15:45:32 -0500 Subject: [PATCH 2/2] use border instead of bg --- src/components/DeviceAuthConfirmation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/DeviceAuthConfirmation.tsx b/src/components/DeviceAuthConfirmation.tsx index a7987ccb..65c68c6c 100644 --- a/src/components/DeviceAuthConfirmation.tsx +++ b/src/components/DeviceAuthConfirmation.tsx @@ -85,7 +85,7 @@ export function DeviceAuthConfirmation({
-
+