complete web device auth flow

This commit is contained in:
miloschwartz
2025-11-03 11:10:17 -08:00
parent da0196a308
commit e888b76747
28 changed files with 1151 additions and 68 deletions

View File

@@ -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<typeof orgs>;
export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;
@@ -859,3 +871,4 @@ export type LicenseKey = InferSelectModel<typeof licenseKey>;
export type SecurityKey = InferSelectModel<typeof securityKeys>;
export type WebauthnChallenge = InferSelectModel<typeof webauthnChallenge>;
export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
export type DeviceWebAuthCode = InferSelectModel<typeof deviceWebAuthCodes>;

View File

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

View File

@@ -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

View File

@@ -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(),

View File

@@ -13,4 +13,7 @@ export * from "./initialSetupComplete";
export * from "./validateSetupToken";
export * from "./changePassword";
export * from "./checkResourceSession";
export * from "./securityKey";
export * from "./securityKey";
export * from "./startDeviceWebAuth";
export * from "./verifyDeviceWebAuth";
export * from "./pollDeviceWebAuth";

View File

@@ -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<typeof paramsSchema>;
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<any> {
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<PollDeviceWebAuthResponse>(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<PollDeviceWebAuthResponse>(res, {
data: {
verified: false
},
success: true,
error: false,
message: "Code expired",
status: HttpCode.OK
});
}
// Check if code is verified
if (!deviceCode.verified) {
return response<PollDeviceWebAuthResponse>(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<PollDeviceWebAuthResponse>(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"
)
);
}
}

View File

@@ -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")

View File

@@ -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<typeof bodySchema>;
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<string | undefined> {
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<any> {
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<StartDeviceWebAuthResponse>(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"
)
);
}
}

View File

@@ -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<typeof bodySchema>;
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<any> {
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<VerifyDeviceWebAuthResponse>(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<VerifyDeviceWebAuthResponse>(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"
)
);
}
}

View File

@@ -1237,4 +1237,52 @@ authRouter.delete(
store: createStore()
}),
auth.deleteSecurityKey
);
);
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
);