From e888b7674793d367cb89e14c2f7eb4eb9a5ed99d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 3 Nov 2025 11:10:17 -0800 Subject: [PATCH] complete web device auth flow --- messages/en-US.json | 23 +- server/db/sqlite/schema/schema.ts | 25 ++- server/middlewares/verifySession.ts | 20 +- server/private/lib/config.ts | 6 + server/private/lib/readConfigFile.ts | 1 + server/routers/auth/index.ts | 5 +- server/routers/auth/pollDeviceWebAuth.ts | 166 ++++++++++++++ server/routers/auth/setServerAdmin.ts | 2 +- server/routers/auth/startDeviceWebAuth.ts | 141 ++++++++++++ server/routers/auth/verifyDeviceWebAuth.ts | 126 +++++++++++ server/routers/external.ts | 50 ++++- src/app/[orgId]/page.tsx | 4 + src/app/auth/layout.tsx | 107 +++++++++ src/app/auth/login/device/page.tsx | 17 ++ src/app/auth/login/device/success/page.tsx | 47 ++++ src/app/auth/login/page.tsx | 2 +- src/app/page.tsx | 2 +- src/components/DashboardLoginForm.tsx | 25 +-- src/components/DeviceAuthConfirmation.tsx | 144 +++++++++++++ src/components/DeviceLoginForm.tsx | 240 +++++++++++++++++++++ src/components/LoginForm.tsx | 8 +- src/components/SignupForm.tsx | 2 +- src/components/ui/alert.tsx | 4 +- src/lib/api/cookies.ts | 36 ++-- src/lib/auth/verifySession.ts | 4 +- src/lib/cleanRedirect.ts | 3 +- src/lib/pullEnv.ts | 8 +- src/lib/types/env.ts | 1 + 28 files changed, 1151 insertions(+), 68 deletions(-) create mode 100644 server/routers/auth/pollDeviceWebAuth.ts create mode 100644 server/routers/auth/startDeviceWebAuth.ts create mode 100644 server/routers/auth/verifyDeviceWebAuth.ts create mode 100644 src/app/auth/login/device/page.tsx create mode 100644 src/app/auth/login/device/success/page.tsx create mode 100644 src/components/DeviceAuthConfirmation.tsx create mode 100644 src/components/DeviceLoginForm.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 97272c6f..88f48590 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2081,5 +2081,26 @@ "supportSend": "Send", "supportMessageSent": "Message Sent!", "supportWillContact": "We'll be in touch shortly!", - "selectLogRetention": "Select log retention" + "selectLogRetention": "Select log retention", + "terms": "Terms", + "privacy": "Privacy", + "security": "Security", + "docs": "Docs", + "deviceActivation": "Device activation", + "deviceCodeInvalidFormat": "Code must be 9 characters (e.g., A1AJ-N5JD)", + "deviceCodeInvalidOrExpired": "Invalid or expired code", + "deviceCodeVerifyFailed": "Failed to verify device code", + "signedInAs": "Signed in as", + "deviceCodeEnterPrompt": "Enter the code displayed on your device", + "continue": "Continue", + "deviceUnknownLocation": "Unknown location", + "deviceAuthorizationRequested": "This authorization was requested from {location} on {date}. Make sure you trust this device as it will get access to your account.", + "deviceLabel": "Device: {deviceName}", + "deviceWantsAccess": "wants to access your account", + "deviceExistingAccess": "Existing access:", + "deviceFullAccess": "Full access to your account", + "deviceOrganizationsAccess": "Access to all organizations your account has access to", + "deviceAuthorize": "Authorize {applicationName}", + "deviceConnected": "Device Connected!", + "deviceAuthorizedMessage": "Your device is authorized to access your account." } diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 13453d2e..7cc7ffd9 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1,7 +1,6 @@ import { randomUUID } from "crypto"; import { InferSelectModel } from "drizzle-orm"; import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; -import { boolean } from "yargs"; export const domains = sqliteTable("domains", { domainId: text("domainId").primaryKey(), @@ -25,11 +24,10 @@ export const dnsRecords = sqliteTable("dnsRecords", { recordType: text("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT" baseDomain: text("baseDomain"), - value: text("value").notNull(), - verified: integer("verified", { mode: "boolean" }).notNull().default(false), + value: text("value").notNull(), + verified: integer("verified", { mode: "boolean" }).notNull().default(false) }); - export const orgs = sqliteTable("orgs", { orgId: text("orgId").primaryKey(), name: text("name").notNull(), @@ -142,9 +140,10 @@ export const resources = sqliteTable("resources", { onDelete: "set null" }), headers: text("headers"), // comma-separated list of headers to add to the request - proxyProtocol: integer("proxyProtocol", { mode: "boolean" }).notNull().default(false), + proxyProtocol: integer("proxyProtocol", { mode: "boolean" }) + .notNull() + .default(false), proxyProtocolVersion: integer("proxyProtocolVersion").default(1) - }); export const targets = sqliteTable("targets", { @@ -802,6 +801,19 @@ export const requestAuditLog = sqliteTable( ] ); +export const deviceWebAuthCodes = sqliteTable("deviceWebAuthCodes", { + codeId: integer("codeId").primaryKey({ autoIncrement: true }), + code: text("code").notNull().unique(), + ip: text("ip"), + city: text("city"), + deviceName: text("deviceName"), + applicationName: text("applicationName").notNull(), + expiresAt: integer("expiresAt").notNull(), + createdAt: integer("createdAt").notNull(), + verified: integer("verified", { mode: "boolean" }).notNull().default(false), + userId: text("userId").references(() => users.userId, { onDelete: "cascade" }) +}); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -859,3 +871,4 @@ export type LicenseKey = InferSelectModel; export type SecurityKey = InferSelectModel; export type WebauthnChallenge = InferSelectModel; export type RequestAuditLog = InferSelectModel; +export type DeviceWebAuthCode = InferSelectModel; diff --git a/server/middlewares/verifySession.ts b/server/middlewares/verifySession.ts index 6af34e4c..3ef5978d 100644 --- a/server/middlewares/verifySession.ts +++ b/server/middlewares/verifySession.ts @@ -1,10 +1,5 @@ import { NextFunction, Response } from "express"; import ErrorResponse from "@server/types/ErrorResponse"; -import { db } from "@server/db"; -import { users } from "@server/db"; -import { eq } from "drizzle-orm"; -import createHttpError from "http-errors"; -import HttpCode from "@server/types/HttpCode"; import { verifySession } from "@server/auth/sessions/verifySession"; import { unauthorized } from "@server/auth/unauthorizedResponse"; @@ -18,19 +13,8 @@ export const verifySessionMiddleware = async ( return next(unauthorized()); } - const existingUser = await db - .select() - .from(users) - .where(eq(users.userId, user.userId)); - - if (!existingUser || !existingUser[0]) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "User does not exist") - ); - } - - req.user = existingUser[0]; + req.user = user; req.session = session; - next(); + return next(); }; diff --git a/server/private/lib/config.ts b/server/private/lib/config.ts index d310d271..e4a7a98b 100644 --- a/server/private/lib/config.ts +++ b/server/private/lib/config.ts @@ -45,6 +45,12 @@ export class PrivateConfig { this.rawPrivateConfig = parsedPrivateConfig; + if (this.rawPrivateConfig.branding?.hide_auth_layout_footer) { + process.env.HIDE_AUTH_LAYOUT_FOOTER = JSON.stringify( + this.rawPrivateConfig.branding?.hide_auth_layout_footer + ); + } + if (this.rawPrivateConfig.branding?.colors) { process.env.BRANDING_COLORS = JSON.stringify( this.rawPrivateConfig.branding?.colors diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index 20712704..c1981f14 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -124,6 +124,7 @@ export const privateConfigSchema = z.object({ }) ) .optional(), + hide_auth_layout_footer: z.boolean().optional().default(false), login_page: z .object({ subtitle_text: z.string().optional(), diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index 754478fc..4600a4cc 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -13,4 +13,7 @@ export * from "./initialSetupComplete"; export * from "./validateSetupToken"; export * from "./changePassword"; export * from "./checkResourceSession"; -export * from "./securityKey"; \ No newline at end of file +export * from "./securityKey"; +export * from "./startDeviceWebAuth"; +export * from "./verifyDeviceWebAuth"; +export * from "./pollDeviceWebAuth"; \ No newline at end of file diff --git a/server/routers/auth/pollDeviceWebAuth.ts b/server/routers/auth/pollDeviceWebAuth.ts new file mode 100644 index 00000000..21740344 --- /dev/null +++ b/server/routers/auth/pollDeviceWebAuth.ts @@ -0,0 +1,166 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; +import { response } from "@server/lib/response"; +import { db, deviceWebAuthCodes } from "@server/db"; +import { eq, and, gt } from "drizzle-orm"; +import { + createSession, + generateSessionToken +} from "@server/auth/sessions/app"; + +const paramsSchema = z.object({ + code: z.string().min(1, "Code is required") +}); + +export type PollDeviceWebAuthParams = z.infer; + +export type PollDeviceWebAuthResponse = { + verified: boolean; + token?: string; +}; + +// Helper function to extract IP from request (same as in startDeviceWebAuth) +function extractIpFromRequest(req: Request): string | undefined { + const ip = req.ip || req.socket.remoteAddress; + if (!ip) { + return undefined; + } + + // Handle IPv6 format [::1] or IPv4 format + if (ip.startsWith("[") && ip.includes("]")) { + const ipv6Match = ip.match(/\[(.*?)\]/); + if (ipv6Match) { + return ipv6Match[1]; + } + } + + // Handle IPv4 with port (split at last colon) + const lastColonIndex = ip.lastIndexOf(":"); + if (lastColonIndex !== -1) { + return ip.substring(0, lastColonIndex); + } + + return ip; +} + +export async function pollDeviceWebAuth( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedParams = paramsSchema.safeParse(req.params); + + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + try { + const { code } = parsedParams.data; + const now = Date.now(); + const requestIp = extractIpFromRequest(req); + + // Find the code in the database + const [deviceCode] = await db + .select() + .from(deviceWebAuthCodes) + .where(eq(deviceWebAuthCodes.code, code)) + .limit(1); + + if (!deviceCode) { + return response(res, { + data: { + verified: false + }, + success: true, + error: false, + message: "Code not found", + status: HttpCode.OK + }); + } + + // Check if code is expired + if (deviceCode.expiresAt <= now) { + return response(res, { + data: { + verified: false + }, + success: true, + error: false, + message: "Code expired", + status: HttpCode.OK + }); + } + + // Check if code is verified + if (!deviceCode.verified) { + return response(res, { + data: { + verified: false + }, + success: true, + error: false, + message: "Code not yet verified", + status: HttpCode.OK + }); + } + + // Check if IP matches + if (!requestIp || !deviceCode.ip || requestIp !== deviceCode.ip) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "IP address does not match" + ) + ); + } + + // Check if userId is set (should be set when verified) + if (!deviceCode.userId) { + logger.error("Device code is verified but userId is missing", { codeId: deviceCode.codeId }); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Invalid code state" + ) + ); + } + + // Generate session token + const token = generateSessionToken(); + await createSession(token, deviceCode.userId); + + // Delete the code after successful exchange for a token + await db + .delete(deviceWebAuthCodes) + .where(eq(deviceWebAuthCodes.codeId, deviceCode.codeId)); + + return response(res, { + data: { + verified: true, + token + }, + success: true, + error: false, + message: "Code verified and session created", + status: HttpCode.OK + }); + } catch (e) { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to poll device code" + ) + ); + } +} + diff --git a/server/routers/auth/setServerAdmin.ts b/server/routers/auth/setServerAdmin.ts index 307f5504..1f63e411 100644 --- a/server/routers/auth/setServerAdmin.ts +++ b/server/routers/auth/setServerAdmin.ts @@ -13,7 +13,7 @@ import { eq, and } from "drizzle-orm"; import { UserType } from "@server/types/UserTypes"; import moment from "moment"; -export const bodySchema = z.object({ +const bodySchema = z.object({ email: z.string().toLowerCase().email(), password: passwordSchema, setupToken: z.string().min(1, "Setup token is required") diff --git a/server/routers/auth/startDeviceWebAuth.ts b/server/routers/auth/startDeviceWebAuth.ts new file mode 100644 index 00000000..45106b82 --- /dev/null +++ b/server/routers/auth/startDeviceWebAuth.ts @@ -0,0 +1,141 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; +import { response } from "@server/lib/response"; +import { db, deviceWebAuthCodes } from "@server/db"; +import { alphabet, generateRandomString } from "oslo/crypto"; +import { createDate } from "oslo"; +import { TimeSpan } from "oslo"; +import { maxmindLookup } from "@server/db/maxmind"; + +const bodySchema = z.object({ + deviceName: z.string().optional(), + applicationName: z.string().min(1, "Application name is required") +}).strict(); + +export type StartDeviceWebAuthBody = z.infer; + +export type StartDeviceWebAuthResponse = { + code: string; + expiresAt: number; +}; + +// Helper function to generate device code in format A1AJ-N5JD +function generateDeviceCode(): string { + const part1 = generateRandomString(4, alphabet("A-Z", "0-9")); + const part2 = generateRandomString(4, alphabet("A-Z", "0-9")); + return `${part1}-${part2}`; +} + +// Helper function to extract IP from request +function extractIpFromRequest(req: Request): string | undefined { + const ip = req.ip || req.socket.remoteAddress; + if (!ip) { + return undefined; + } + + // Handle IPv6 format [::1] or IPv4 format + if (ip.startsWith("[") && ip.includes("]")) { + const ipv6Match = ip.match(/\[(.*?)\]/); + if (ipv6Match) { + return ipv6Match[1]; + } + } + + // Handle IPv4 with port (split at last colon) + const lastColonIndex = ip.lastIndexOf(":"); + if (lastColonIndex !== -1) { + return ip.substring(0, lastColonIndex); + } + + return ip; +} + +// Helper function to get city from IP (if available) +async function getCityFromIp(ip: string): Promise { + try { + if (!maxmindLookup) { + return undefined; + } + + const result = maxmindLookup.get(ip); + if (!result) { + return undefined; + } + + // MaxMind CountryResponse doesn't include city by default + // If city data is available, it would be in result.city?.names?.en + // But since we're using CountryResponse type, we'll just return undefined + // The user said "don't do this if not easy", so we'll skip city for now + return undefined; + } catch (error) { + logger.debug("Failed to get city from IP", error); + return undefined; + } +} + +export async function startDeviceWebAuth( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = bodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + try { + const { deviceName, applicationName } = parsedBody.data; + + // Generate device code + const code = generateDeviceCode(); + + // Extract IP from request + const ip = extractIpFromRequest(req); + + // Get city (optional, may return undefined) + const city = ip ? await getCityFromIp(ip) : undefined; + + // Set expiration to 5 minutes from now + const expiresAt = createDate(new TimeSpan(5, "m")).getTime(); + + // Insert into database + await db.insert(deviceWebAuthCodes).values({ + code, + ip: ip || null, + city: city || null, + deviceName: deviceName || null, + applicationName, + expiresAt, + createdAt: Date.now() + }); + + return response(res, { + data: { + code, + expiresAt + }, + success: true, + error: false, + message: "Device web auth code generated", + status: HttpCode.OK + }); + } catch (e) { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to start device web auth" + ) + ); + } +} diff --git a/server/routers/auth/verifyDeviceWebAuth.ts b/server/routers/auth/verifyDeviceWebAuth.ts new file mode 100644 index 00000000..d8837591 --- /dev/null +++ b/server/routers/auth/verifyDeviceWebAuth.ts @@ -0,0 +1,126 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; +import { response } from "@server/lib/response"; +import { db, deviceWebAuthCodes } from "@server/db"; +import { eq, and, gt } from "drizzle-orm"; + +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(); + +export type VerifyDeviceWebAuthBody = z.infer; + +export type VerifyDeviceWebAuthResponse = { + success: boolean; + message: string; + metadata?: { + ip: string | null; + city: string | null; + deviceName: string | null; + applicationName: string; + createdAt: number; + }; +}; + +export async function verifyDeviceWebAuth( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = bodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + try { + const { code, verify } = parsedBody.data; + const now = Date.now(); + + logger.debug("Verifying device web auth code:", { code }); + + // Find the code in the database that is not expired and not already verified + const [deviceCode] = await db + .select() + .from(deviceWebAuthCodes) + .where( + and( + eq(deviceWebAuthCodes.code, code), + gt(deviceWebAuthCodes.expiresAt, now), + eq(deviceWebAuthCodes.verified, false) + ) + ) + .limit(1); + + logger.debug("Device code lookup result:", deviceCode); + + if (!deviceCode) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid, expired, or already verified code" + ) + ); + } + + // If verify is false, just return metadata without verifying + if (!verify) { + return response(res, { + data: { + success: true, + message: "Code is valid", + metadata: { + ip: deviceCode.ip, + city: deviceCode.city, + deviceName: deviceCode.deviceName, + applicationName: deviceCode.applicationName, + createdAt: deviceCode.createdAt + } + }, + success: true, + error: false, + message: "Code validation successful", + status: HttpCode.OK + }); + } + + // Update the code to mark it as verified and store the user who verified it + await db + .update(deviceWebAuthCodes) + .set({ + verified: true, + userId: req.user!.userId + }) + .where(eq(deviceWebAuthCodes.codeId, deviceCode.codeId)); + + return response(res, { + data: { + success: true, + message: "Device code verified successfully" + }, + success: true, + error: false, + message: "Device code verified successfully", + status: HttpCode.OK + }); + } catch (e) { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to verify device code" + ) + ); + } +} + diff --git a/server/routers/external.ts b/server/routers/external.ts index 5c235902..8fcadfbb 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -1237,4 +1237,52 @@ authRouter.delete( store: createStore() }), auth.deleteSecurityKey -); \ No newline at end of file +); + +authRouter.post( + "/device-web-auth/start", + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 30, // Allow 30 device auth code requests per 15 minutes per IP + keyGenerator: (req) => + `deviceWebAuthStart:${ipKeyGenerator(req.ip || "")}`, + handler: (req, res, next) => { + const message = `You can only request a device auth code ${30} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + auth.startDeviceWebAuth +); + +authRouter.get( + "/device-web-auth/poll/:code", + rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: 60, // Allow 60 polling requests per minute per IP (poll every second) + keyGenerator: (req) => + `deviceWebAuthPoll:${ipKeyGenerator(req.ip || "")}:${req.params.code}`, + handler: (req, res, next) => { + const message = `You can only poll a device auth code ${60} times per minute. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + auth.pollDeviceWebAuth +); + +authenticated.post( + "/device-web-auth/verify", + rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 50, // Allow 50 verification attempts per 15 minutes per user + keyGenerator: (req) => + `deviceWebAuthVerify:${req.user?.userId || ipKeyGenerator(req.ip || "")}`, + handler: (req, res, next) => { + const message = `You can only verify a device auth code ${50} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + auth.verifyDeviceWebAuth +); diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index 25b3de1f..c883f038 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -22,6 +22,10 @@ export default async function OrgPage(props: OrgPageProps) { const orgId = params.orgId; const env = pullEnv(); + if (!orgId) { + redirect(`/`); + } + const getUser = cache(verifySession); const user = await getUser(); diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx index 88b0f07d..9b6eaaab 100644 --- a/src/app/auth/layout.tsx +++ b/src/app/auth/layout.tsx @@ -1,5 +1,13 @@ import ThemeSwitcher from "@app/components/ThemeSwitcher"; +import { Separator } from "@app/components/ui/separator"; +import { priv } from "@app/lib/api"; +import { verifySession } from "@app/lib/auth/verifySession"; +import { pullEnv } from "@app/lib/pullEnv"; +import { GetLicenseStatusResponse } from "@server/routers/license/types"; +import { AxiosResponse } from "axios"; import { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { cache } from "react"; export const metadata: Metadata = { title: `Auth - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, @@ -11,6 +19,20 @@ type AuthLayoutProps = { }; export default async function AuthLayout({ children }: AuthLayoutProps) { + const getUser = cache(verifySession); + const env = pullEnv(); + const user = await getUser(); + const t = await getTranslations(); + const hideFooter = env.branding.hideAuthLayoutFooter || false; + + const licenseStatusRes = await cache( + async () => + await priv.get>( + "/license/status" + ) + )(); + const licenseStatus = licenseStatusRes.data.data; + return (
@@ -20,6 +42,91 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
{children}
+ + {!( + hideFooter || + (licenseStatus.isHostLicensed && licenseStatus.isLicenseValid) + ) && ( + + )}
); } diff --git a/src/app/auth/login/device/page.tsx b/src/app/auth/login/device/page.tsx new file mode 100644 index 00000000..9b9cb335 --- /dev/null +++ b/src/app/auth/login/device/page.tsx @@ -0,0 +1,17 @@ +import { verifySession } from "@app/lib/auth/verifySession"; +import { redirect } from "next/navigation"; +import DeviceLoginForm from "@/components/DeviceLoginForm"; +import { cache } from "react"; + +export const dynamic = "force-dynamic"; + +export default async function DeviceLoginPage() { + const getUser = cache(verifySession); + const user = await getUser(); + + if (!user) { + redirect("/auth/login?redirect=/auth/login/device"); + } + + return ; +} diff --git a/src/app/auth/login/device/success/page.tsx b/src/app/auth/login/device/success/page.tsx new file mode 100644 index 00000000..18ad5277 --- /dev/null +++ b/src/app/auth/login/device/success/page.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import BrandingLogo from "@app/components/BrandingLogo"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { CheckCircle2 } from "lucide-react"; +import { useTranslations } from "next-intl"; + +export default function DeviceAuthSuccessPage() { + const { env } = useEnvContext(); + const { isUnlocked } = useLicenseStatusContext(); + const t = useTranslations(); + + const logoWidth = isUnlocked() + ? env.branding.logo?.authPage?.width || 175 + : 175; + const logoHeight = isUnlocked() + ? env.branding.logo?.authPage?.height || 58 + : 58; + + return ( + + +
+ +
+
+

{t("deviceActivation")}

+
+
+ +
+ +
+

+ {t("deviceConnected")} +

+

+ {t("deviceAuthorizedMessage")} +

+
+
+
+
+ ); +} diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 11543345..68e93214 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -53,7 +53,7 @@ export default async function Page(props: { if (loginPageDomain) { const redirectUrl = searchParams.redirect as string | undefined; - + let url = `https://${loginPageDomain}/auth/org`; if (redirectUrl) { url += `?redirect=${redirectUrl}`; diff --git a/src/app/page.tsx b/src/app/page.tsx index 2db1b6b1..f89aa866 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -80,7 +80,7 @@ export default async function Page(props: { const lastOrgCookie = allCookies.get("pangolin-last-org")?.value; const lastOrgExists = orgs.some((org) => org.orgId === lastOrgCookie); - if (lastOrgExists) { + if (lastOrgExists && lastOrgCookie) { redirect(`/${lastOrgCookie}`); } else { let ownedOrg = orgs.find((org) => org.isOwner); diff --git a/src/components/DashboardLoginForm.tsx b/src/components/DashboardLoginForm.tsx index 6d3fc332..e3a3bc44 100644 --- a/src/components/DashboardLoginForm.tsx +++ b/src/components/DashboardLoginForm.tsx @@ -36,17 +36,18 @@ export default function DashboardLoginForm({ return t("loginStart"); } - const logoWidth = isUnlocked() ? env.branding.logo?.authPage?.width || 175 : 175; - const logoHeight = isUnlocked() ? env.branding.logo?.authPage?.height || 58 : 58; + const logoWidth = isUnlocked() + ? env.branding.logo?.authPage?.width || 175 + : 175; + const logoHeight = isUnlocked() + ? env.branding.logo?.authPage?.height || 58 + : 58; return ( - +
- +

{getSubtitle()}

@@ -56,12 +57,12 @@ export default function DashboardLoginForm({ { - if (redirect) { - const safe = cleanRedirect(redirect); - router.push(safe); + onLogin={(redirectUrl) => { + if (redirectUrl) { + const safe = cleanRedirect(redirectUrl); + router.replace(safe); } else { - router.push("/"); + router.replace("/"); } }} /> diff --git a/src/components/DeviceAuthConfirmation.tsx b/src/components/DeviceAuthConfirmation.tsx new file mode 100644 index 00000000..93344b60 --- /dev/null +++ b/src/components/DeviceAuthConfirmation.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle +} from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertTriangle, CheckCircle2, Monitor } from "lucide-react"; +import BrandingLogo from "./BrandingLogo"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useTranslations } from "next-intl"; + +type DeviceAuthMetadata = { + ip: string | null; + city: string | null; + deviceName: string | null; + applicationName: string; + createdAt: number; +}; + +type DeviceAuthConfirmationProps = { + metadata: DeviceAuthMetadata; + onConfirm: () => void; + onCancel: () => void; + loading: boolean; +}; + +export function DeviceAuthConfirmation({ + metadata, + onConfirm, + onCancel, + loading +}: DeviceAuthConfirmationProps) { + const { env } = useEnvContext(); + const { isUnlocked } = useLicenseStatusContext(); + const t = useTranslations(); + + const formatDate = (timestamp: number) => { + const date = new Date(timestamp); + return date.toLocaleString(undefined, { + month: "long", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZoneName: "short" + }); + }; + + const locationText = + metadata.city && metadata.ip + ? `${metadata.city} ${metadata.ip}` + : metadata.ip || t("deviceUnknownLocation"); + + const logoWidth = isUnlocked() + ? env.branding.logo?.authPage?.width || 175 + : 175; + const logoHeight = isUnlocked() + ? env.branding.logo?.authPage?.height || 58 + : 58; + + return ( + + +
+ +
+
+

{t("deviceActivation")}

+
+
+ + + + {t("deviceAuthorizationRequested", { + location: locationText, + date: formatDate(metadata.createdAt) + })} + + + +
+
+ +
+

+ {metadata.applicationName} +

+ {metadata.deviceName && ( +

+ {t("deviceLabel", { deviceName: metadata.deviceName })} +

+ )} +

+ {t("deviceWantsAccess")} +

+
+
+ +
+

{t("deviceExistingAccess")}

+
+
+ + {t("deviceFullAccess")} +
+
+ + + {t("deviceOrganizationsAccess")} + +
+
+
+
+
+ + + + + + +
+ ); +} diff --git a/src/components/DeviceLoginForm.tsx b/src/components/DeviceLoginForm.tsx new file mode 100644 index 00000000..f09682be --- /dev/null +++ b/src/components/DeviceLoginForm.tsx @@ -0,0 +1,240 @@ +"use client"; + +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useRouter } from "next/navigation"; +import { + InputOTP, + InputOTPGroup, + InputOTPSeparator, + InputOTPSlot +} from "@/components/ui/input-otp"; +import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"; +import { AlertTriangle } from "lucide-react"; +import { DeviceAuthConfirmation } from "@/components/DeviceAuthConfirmation"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import BrandingLogo from "./BrandingLogo"; +import { useTranslations } from "next-intl"; + +const createFormSchema = (t: (key: string) => string) => z.object({ + code: z.string().length(8, t("deviceCodeInvalidFormat")) +}); + +type DeviceAuthMetadata = { + ip: string | null; + city: string | null; + deviceName: string | null; + applicationName: string; + createdAt: number; +}; + +type DeviceLoginFormProps = { + userEmail: string; +}; + +export default function DeviceLoginForm({ userEmail }: DeviceLoginFormProps) { + const router = useRouter(); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [metadata, setMetadata] = useState(null); + const [code, setCode] = useState(""); + const { isUnlocked } = useLicenseStatusContext(); + const t = useTranslations(); + + const formSchema = createFormSchema(t); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + code: "" + } + }); + + async function onSubmit(data: z.infer) { + setError(null); + setLoading(true); + + try { + // split code and add dash if missing + if (!data.code.includes("-") && data.code.length === 8) { + 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 + }); + + if (res.data.success && res.data.data.metadata) { + setMetadata(res.data.data.metadata); + setCode(data.code.toUpperCase()); + } else { + setError(t("deviceCodeInvalidOrExpired")); + } + } catch (e: any) { + const errorMessage = formatAxiosError(e); + setError(errorMessage || t("deviceCodeInvalidOrExpired")); + } finally { + setLoading(false); + } + } + + async function onConfirm() { + if (!code || !metadata) return; + + setError(null); + setLoading(true); + + try { + // Final verify + await api.post("/device-web-auth/verify", { + code: code, + verify: true + }); + + // Redirect to success page + router.push("/auth/login/device/success"); + } catch (e: any) { + const errorMessage = formatAxiosError(e); + setError(errorMessage || t("deviceCodeVerifyFailed")); + setMetadata(null); + setCode(""); + form.reset(); + } finally { + setLoading(false); + } + } + + const logoWidth = isUnlocked() + ? env.branding.logo?.authPage?.width || 175 + : 175; + const logoHeight = isUnlocked() + ? env.branding.logo?.authPage?.height || 58 + : 58; + + function onCancel() { + setMetadata(null); + setCode(""); + form.reset(); + setError(null); + } + + if (metadata) { + return ( + + ); + } + + return ( + + +
+ +
+
+

{t("deviceActivation")}

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

+ {t("deviceCodeEnterPrompt")} +

+
+ + ( + + +
+ { + // Strip hyphens and convert to uppercase + const cleanedValue = value + .replace(/-/g, "") + .toUpperCase(); + field.onChange( + cleanedValue + ); + }} + > + + + + + + + + + + + + + + +
+
+ +
+ )} + /> + + {error && ( + + {error} + + )} + + + + +
+
+ ); +} diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 8149989e..15b4d165 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -58,7 +58,7 @@ export type LoginFormIDP = { type LoginFormProps = { redirect?: string; - onLogin?: () => void | Promise; + onLogin?: (redirectUrl?: string) => void | Promise; idps?: LoginFormIDP[]; orgId?: string; }; @@ -175,7 +175,7 @@ export default function LoginForm({ if (verifyResponse.success) { if (onLogin) { - await onLogin(); + await onLogin(redirect); } } } catch (error: any) { @@ -263,7 +263,7 @@ export default function LoginForm({ // Handle case where data is null (e.g., already logged in) if (!data) { if (onLogin) { - await onLogin(); + await onLogin(redirect); } return; } @@ -312,7 +312,7 @@ export default function LoginForm({ } if (onLogin) { - await onLogin(); + await onLogin(redirect); } } catch (e: any) { console.error(e); diff --git a/src/components/SignupForm.tsx b/src/components/SignupForm.tsx index c9cb6a48..5c20d8ea 100644 --- a/src/components/SignupForm.tsx +++ b/src/components/SignupForm.tsx @@ -199,7 +199,7 @@ export default function SignupForm({ : 58; return ( - +
diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index dff7777c..fb291022 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -14,9 +14,9 @@ const alertVariants = cva( "border-destructive/50 border bg-destructive/10 text-destructive dark:border-destructive [&>svg]:text-destructive", success: "border-green-500/50 border bg-green-500/10 text-green-500 dark:border-success [&>svg]:text-green-500", - info: "border-blue-500/50 border bg-blue-500/10 text-blue-500 dark:border-blue-400 [&>svg]:text-blue-500", + info: "border-blue-500/50 border bg-blue-500/10 text-blue-800 dark:border-blue-400 [&>svg]:text-blue-500", warning: - "border-yellow-500/50 border bg-yellow-500/10 text-yellow-500 dark:border-yellow-400 [&>svg]:text-yellow-500" + "border-yellow-500/50 border bg-yellow-500/10 text-yellow-800 dark:border-yellow-400 [&>svg]:text-yellow-500" } }, defaultVariants: { diff --git a/src/lib/api/cookies.ts b/src/lib/api/cookies.ts index fac1810b..fe3c0090 100644 --- a/src/lib/api/cookies.ts +++ b/src/lib/api/cookies.ts @@ -1,22 +1,32 @@ -import { cookies, headers } from "next/headers"; -import { pullEnv } from "../pullEnv"; +import { headers } from "next/headers"; export async function authCookieHeader() { - const env = pullEnv(); - - const allCookies = await cookies(); - const cookieName = env.server.sessionCookieName; - const sessionId = allCookies.get(cookieName)?.value ?? null; - - // all other headers - // this is needed to pass through x-forwarded-for, x-forwarded-proto, etc. const otherHeaders = await headers(); const otherHeadersObject = Object.fromEntries(otherHeaders.entries()); return { headers: { - Cookie: `${cookieName}=${sessionId}`, - ...otherHeadersObject - }, + cookie: + otherHeadersObject["cookie"] || otherHeadersObject["Cookie"], + host: otherHeadersObject["host"] || otherHeadersObject["Host"], + "user-agent": + otherHeadersObject["user-agent"] || + otherHeadersObject["User-Agent"], + "x-forwarded-for": + otherHeadersObject["x-forwarded-for"] || + otherHeadersObject["X-Forwarded-For"], + "x-forwarded-host": + otherHeadersObject["fx-forwarded-host"] || + otherHeadersObject["Fx-Forwarded-Host"], + "x-forwarded-port": + otherHeadersObject["x-forwarded-port"] || + otherHeadersObject["X-Forwarded-Port"], + "x-forwarded-proto": + otherHeadersObject["x-forwarded-proto"] || + otherHeadersObject["X-Forwarded-Proto"], + "x-real-ip": + otherHeadersObject["x-real-ip"] || + otherHeadersObject["X-Real-IP"] + } }; } diff --git a/src/lib/auth/verifySession.ts b/src/lib/auth/verifySession.ts index e51c5096..ae29d2b4 100644 --- a/src/lib/auth/verifySession.ts +++ b/src/lib/auth/verifySession.ts @@ -5,7 +5,7 @@ import { AxiosResponse } from "axios"; import { pullEnv } from "../pullEnv"; export async function verifySession({ - skipCheckVerifyEmail, + skipCheckVerifyEmail }: { skipCheckVerifyEmail?: boolean; } = {}): Promise { @@ -14,7 +14,7 @@ export async function verifySession({ try { const res = await internal.get>( "/user", - await authCookieHeader(), + await authCookieHeader() ); const user = res.data.data; diff --git a/src/lib/cleanRedirect.ts b/src/lib/cleanRedirect.ts index b573ab66..afa902d3 100644 --- a/src/lib/cleanRedirect.ts +++ b/src/lib/cleanRedirect.ts @@ -6,7 +6,8 @@ type PatternConfig = { 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: "Resource Auth Portal", regex: /^\/auth\/resource\/\d+$/ }, + { name: "Device Login", regex: /^\/auth\/login\/device$/ } ]; export function cleanRedirect(input: string, fallback?: string): string { diff --git a/src/lib/pullEnv.ts b/src/lib/pullEnv.ts index c55f06fe..890a6e5f 100644 --- a/src/lib/pullEnv.ts +++ b/src/lib/pullEnv.ts @@ -50,14 +50,16 @@ export function pullEnv(): Env { hideSupporterKey: process.env.HIDE_SUPPORTER_KEY === "true" ? true : false, usePangolinDns: - process.env.USE_PANGOLIN_DNS === "true" - ? true - : false + process.env.USE_PANGOLIN_DNS === "true" ? true : false }, branding: { appName: process.env.BRANDING_APP_NAME as string, background_image_path: process.env.BACKGROUND_IMAGE_PATH as string, + hideAuthLayoutFooter: + process.env.BRANDING_HIDE_AUTH_LAYOUT_FOOTER === "true" + ? true + : false, logo: { lightPath: process.env.BRANDING_LOGO_LIGHT_PATH as string, darkPath: process.env.BRANDING_LOGO_DARK_PATH as string, diff --git a/src/lib/types/env.ts b/src/lib/types/env.ts index 9ded37a0..9e519671 100644 --- a/src/lib/types/env.ts +++ b/src/lib/types/env.ts @@ -33,6 +33,7 @@ export type Env = { branding: { appName?: string; background_image_path?: string; + hideAuthLayoutFooter?: boolean; logo?: { lightPath?: string; darkPath?: string;