Jelajahi Sumber

access token endpoints and other backend support

Milo Schwartz 7 bulan lalu
induk
melakukan
72dc02ff2e

+ 8 - 1
server/auth/actions.ts

@@ -49,7 +49,14 @@ export enum ActionsEnum {
     // addUserAction = "addUserAction",
     // removeUserAction = "removeUserAction",
     // removeUserSite = "removeUserSite",
-    getOrgUser = "getOrgUser"
+    getOrgUser = "getOrgUser",
+    "setResourcePassword" = "setResourcePassword",
+    "setResourcePincode" = "setResourcePincode",
+    "setResourceWhitelist" = "setResourceWhitelist",
+    "getResourceWhitelist" = "getResourceWhitelist",
+    "generateAccessToken" = "generateAccessToken",
+    "deleteAcessToken" = "deleteAcessToken",
+    "listAccessTokens" = "listAccessTokens"
 }
 
 export async function checkUserActionPermission(

+ 36 - 13
server/auth/resource.ts

@@ -1,6 +1,10 @@
 import { encodeHexLowerCase } from "@oslojs/encoding";
 import { sha256 } from "@oslojs/crypto/sha2";
-import { resourceSessions, ResourceSession } from "@server/db/schema";
+import {
+    resourceSessions,
+    ResourceSession,
+    resources
+} from "@server/db/schema";
 import db from "@server/db";
 import { eq, and } from "drizzle-orm";
 import config from "@server/config";
@@ -17,12 +21,19 @@ export async function createResourceSession(opts: {
     passwordId?: number;
     pincodeId?: number;
     whitelistId?: number;
+    accessTokenId?: string;
     usedOtp?: boolean;
+    doNotExtend?: boolean;
+    expiresAt?: number | null;
+    sessionLength: number;
 }): Promise<ResourceSession> {
-    if (!opts.passwordId && !opts.pincodeId && !opts.whitelistId) {
-        throw new Error(
-            "At least one of passwordId or pincodeId must be provided"
-        );
+    if (
+        !opts.passwordId &&
+        !opts.pincodeId &&
+        !opts.whitelistId &&
+        !opts.accessTokenId
+    ) {
+        throw new Error("Auth method must be provided");
     }
 
     const sessionId = encodeHexLowerCase(
@@ -31,11 +42,16 @@ export async function createResourceSession(opts: {
 
     const session: ResourceSession = {
         sessionId: sessionId,
-        expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(),
+        expiresAt:
+            opts.expiresAt ||
+            new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(),
+        sessionLength: opts.sessionLength || SESSION_COOKIE_EXPIRES,
         resourceId: opts.resourceId,
         passwordId: opts.passwordId || null,
         pincodeId: opts.pincodeId || null,
-        whitelistId: opts.whitelistId || null
+        whitelistId: opts.whitelistId || null,
+        doNotExtend: opts.doNotExtend || false,
+        accessTokenId: opts.accessTokenId || null
     };
 
     await db.insert(resourceSessions).values(session);
@@ -66,9 +82,18 @@ export async function validateResourceSessionToken(
 
     const resourceSession = result[0];
 
-    if (Date.now() >= resourceSession.expiresAt - SESSION_COOKIE_EXPIRES / 2) {
+    if (Date.now() >= resourceSession.expiresAt) {
+        await db
+            .delete(resourceSessions)
+            .where(eq(resourceSessions.sessionId, resourceSessions.sessionId));
+        return { resourceSession: null };
+    } else if (
+        !resourceSession.doNotExtend &&
+        Date.now() >=
+            resourceSession.expiresAt - resourceSession.sessionLength / 2
+    ) {
         resourceSession.expiresAt = new Date(
-            Date.now() + SESSION_COOKIE_EXPIRES
+            Date.now() + resourceSession.sessionLength
         ).getTime();
         await db
             .update(resourceSessions)
@@ -138,8 +163,7 @@ export async function invalidateAllSessions(
 
 export function serializeResourceSessionCookie(
     cookieName: string,
-    token: string,
-    fqdn: string
+    token: string
 ): string {
     if (SECURE_COOKIES) {
         return `${cookieName}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
@@ -149,8 +173,7 @@ export function serializeResourceSessionCookie(
 }
 
 export function createBlankResourceSessionTokenCookie(
-    cookieName: string,
-    fqdn: string
+    cookieName: string
 ): string {
     if (SECURE_COOKIES) {
         return `${cookieName}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;

+ 26 - 0
server/db/schema.ts

@@ -277,12 +277,32 @@ export const resourcePassword = sqliteTable("resourcePassword", {
     passwordHash: text("passwordHash").notNull()
 });
 
+export const resourceAccessToken = sqliteTable("resourceAccessToken", {
+    accessTokenId: text("accessTokenId").primaryKey(),
+    orgId: text("orgId")
+        .notNull()
+        .references(() => orgs.orgId, { onDelete: "cascade" }),
+    resourceId: integer("resourceId")
+        .notNull()
+        .references(() => resources.resourceId, { onDelete: "cascade" }),
+    tokenHash: text("tokenHash").notNull(),
+    sessionLength: integer("sessionLength").notNull(),
+    expiresAt: integer("expiresAt"),
+    title: text("title").notNull(),
+    description: text("description"),
+    createdAt: integer("createdAt").notNull()
+});
+
 export const resourceSessions = sqliteTable("resourceSessions", {
     sessionId: text("id").primaryKey(),
     resourceId: integer("resourceId")
         .notNull()
         .references(() => resources.resourceId, { onDelete: "cascade" }),
     expiresAt: integer("expiresAt").notNull(),
+    sessionLength: integer("sessionLength").notNull(),
+    doNotExtend: integer("doNotExtend", { mode: "boolean" })
+        .notNull()
+        .default(false),
     passwordId: integer("passwordId").references(
         () => resourcePassword.passwordId,
         {
@@ -300,6 +320,12 @@ export const resourceSessions = sqliteTable("resourceSessions", {
         {
             onDelete: "cascade"
         }
+    ),
+    accessTokenId: text("accessTokenId").references(
+        () => resourceAccessToken.accessTokenId,
+        {
+            onDelete: "cascade"
+        }
     )
 });
 

+ 21 - 16
server/internalServer.ts

@@ -4,29 +4,34 @@ import cors from "cors";
 import cookieParser from "cookie-parser";
 import config from "@server/config";
 import logger from "@server/logger";
-import { errorHandlerMiddleware, notFoundMiddleware } from "@server/middlewares";
+import {
+    errorHandlerMiddleware,
+    notFoundMiddleware
+} from "@server/middlewares";
 import internal from "@server/routers/internal";
 
 const internalPort = config.server.internal_port;
 
 export function createInternalServer() {
-  const internalServer = express();
+    const internalServer = express();
 
-  internalServer.use(helmet());
-  internalServer.use(cors());
-  internalServer.use(cookieParser());
-  internalServer.use(express.json());
+    internalServer.use(helmet());
+    internalServer.use(cors());
+    internalServer.use(cookieParser());
+    internalServer.use(express.json());
 
-  const prefix = `/api/v1`;
-  internalServer.use(prefix, internal);
+    const prefix = `/api/v1`;
+    internalServer.use(prefix, internal);
 
-  internalServer.use(notFoundMiddleware);
-  internalServer.use(errorHandlerMiddleware);
+    internalServer.use(notFoundMiddleware);
+    internalServer.use(errorHandlerMiddleware);
 
-  internalServer.listen(internalPort, (err?: any) => {
-    if (err) throw err;
-    logger.info(`Internal server is running on http://localhost:${internalPort}`);
-  });
+    internalServer.listen(internalPort, (err?: any) => {
+        if (err) throw err;
+        logger.info(
+            `Internal server is running on http://localhost:${internalPort}`
+        );
+    });
 
-  return internalServer;
-}
+    return internalServer;
+}

+ 1 - 0
server/logger.ts

@@ -16,6 +16,7 @@ const hformat = winston.format.printf(
 const transports: any = [
     new winston.transports.Console({
         format: winston.format.combine(
+            winston.format.errors({ stack: true }),
             winston.format.colorize(),
             winston.format.splat(),
             winston.format.timestamp(),

+ 45 - 0
server/middlewares/helpers/canUserAccessResource.ts

@@ -0,0 +1,45 @@
+import db from "@server/db";
+import { and, eq } from "drizzle-orm";
+import { roleResources, userResources } from "@server/db/schema";
+
+export async function canUserAccessResource({
+    userId,
+    resourceId,
+    roleId
+}: {
+    userId: string;
+    resourceId: number;
+    roleId: number;
+}): Promise<boolean> {
+    const roleResourceAccess = await db
+        .select()
+        .from(roleResources)
+        .where(
+            and(
+                eq(roleResources.resourceId, resourceId),
+                eq(roleResources.roleId, roleId)
+            )
+        )
+        .limit(1);
+
+    if (roleResourceAccess.length > 0) {
+        return true;
+    }
+
+    const userResourceAccess = await db
+        .select()
+        .from(userResources)
+        .where(
+            and(
+                eq(userResources.userId, userId),
+                eq(userResources.resourceId, resourceId)
+            )
+        )
+        .limit(1);
+
+    if (userResourceAccess.length > 0) {
+        return true;
+    }
+
+    return false;
+}

+ 1 - 0
server/middlewares/index.ts

@@ -13,3 +13,4 @@ export * from "./verifyUserAccess";
 export * from "./verifyAdmin";
 export * from "./verifySetResourceUsers";
 export * from "./verifyUserInRole";
+export * from "./verifyAccessTokenAccess";

+ 123 - 0
server/middlewares/verifyAccessTokenAccess.ts

@@ -0,0 +1,123 @@
+import { Request, Response, NextFunction } from "express";
+import { db } from "@server/db";
+import { resourceAccessToken, resources, userOrgs } from "@server/db/schema";
+import { and, eq } from "drizzle-orm";
+import createHttpError from "http-errors";
+import HttpCode from "@server/types/HttpCode";
+import { canUserAccessResource } from "./helpers/canUserAccessResource";
+
+export async function verifyAccessTokenAccess(
+    req: Request,
+    res: Response,
+    next: NextFunction
+) {
+    const userId = req.user!.userId;
+    const accessTokenId = req.params.accessTokenId;
+
+    if (!userId) {
+        return next(
+            createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
+        );
+    }
+
+    const [accessToken] = await db
+        .select()
+        .from(resourceAccessToken)
+        .where(eq(resourceAccessToken.accessTokenId, accessTokenId))
+        .limit(1);
+
+    if (!accessToken) {
+        return next(
+            createHttpError(
+                HttpCode.NOT_FOUND,
+                `Access token with ID ${accessTokenId} not found`
+            )
+        );
+    }
+
+    const resourceId = accessToken.resourceId;
+
+    if (!resourceId) {
+        return next(
+            createHttpError(
+                HttpCode.INTERNAL_SERVER_ERROR,
+                `Access token with ID ${accessTokenId} does not have a resource ID`
+            )
+        );
+    }
+
+    try {
+        const resource = await db
+            .select()
+            .from(resources)
+            .where(eq(resources.resourceId, resourceId!))
+            .limit(1);
+
+        if (resource.length === 0) {
+            return next(
+                createHttpError(
+                    HttpCode.NOT_FOUND,
+                    `Resource with ID ${resourceId} not found`
+                )
+            );
+        }
+
+        if (!resource[0].orgId) {
+            return next(
+                createHttpError(
+                    HttpCode.INTERNAL_SERVER_ERROR,
+                    `Resource with ID ${resourceId} does not have an organization ID`
+                )
+            );
+        }
+
+        if (!req.userOrg) {
+            const res = await db
+                .select()
+                .from(userOrgs)
+                .where(
+                    and(
+                        eq(userOrgs.userId, userId),
+                        eq(userOrgs.orgId, resource[0].orgId)
+                    )
+                );
+            req.userOrg = res[0];
+        }
+
+        if (!req.userOrg) {
+            next(
+                createHttpError(
+                    HttpCode.FORBIDDEN,
+                    "User does not have access to this organization"
+                )
+            );
+        } else {
+            req.userOrgRoleId = req.userOrg.roleId;
+            req.userOrgId = resource[0].orgId!;
+        }
+
+        const resourceAllowed = await canUserAccessResource({
+            userId,
+            resourceId,
+            roleId: req.userOrgRoleId!
+        });
+
+        if (!resourceAllowed) {
+            return next(
+                createHttpError(
+                    HttpCode.FORBIDDEN,
+                    "User does not have access to this resource"
+                )
+            );
+        }
+
+        next();
+    } catch (e) {
+        return next(
+            createHttpError(
+                HttpCode.INTERNAL_SERVER_ERROR,
+                "Error verifying organization access"
+            )
+        );
+    }
+}

+ 18 - 1
server/middlewares/verifyTargetAccess.ts

@@ -4,6 +4,7 @@ import { resources, targets, userOrgs } from "@server/db/schema";
 import { and, eq } from "drizzle-orm";
 import createHttpError from "http-errors";
 import HttpCode from "@server/types/HttpCode";
+import { canUserAccessResource } from "./helpers/canUserAccessResource";
 
 export async function verifyTargetAccess(
     req: Request,
@@ -99,8 +100,24 @@ export async function verifyTargetAccess(
         } else {
             req.userOrgRoleId = req.userOrg.roleId;
             req.userOrgId = resource[0].orgId!;
-            next();
         }
+
+        const resourceAllowed = await canUserAccessResource({
+            userId,
+            resourceId,
+            roleId: req.userOrgRoleId!
+        });
+
+        if (!resourceAllowed) {
+            return next(
+                createHttpError(
+                    HttpCode.FORBIDDEN,
+                    "User does not have access to this resource"
+                )
+            );
+        }
+
+        next();
     } catch (e) {
         return next(
             createHttpError(

+ 67 - 0
server/routers/accessToken/deleteAccessToken.ts

@@ -0,0 +1,67 @@
+import { Request, Response, NextFunction } from "express";
+import { z } from "zod";
+import response from "@server/utils/response";
+import HttpCode from "@server/types/HttpCode";
+import createHttpError from "http-errors";
+import logger from "@server/logger";
+import { fromError } from "zod-validation-error";
+import { resourceAccessToken } from "@server/db/schema";
+import { and, eq } from "drizzle-orm";
+import db from "@server/db";
+
+const deleteAccessTokenParamsSchema = z.object({
+    accessTokenId: z.string()
+});
+
+export async function deleteAccessToken(
+    req: Request,
+    res: Response,
+    next: NextFunction
+): Promise<any> {
+    try {
+        const parsedParams = deleteAccessTokenParamsSchema.safeParse(
+            req.params
+        );
+        if (!parsedParams.success) {
+            return next(
+                createHttpError(
+                    HttpCode.BAD_REQUEST,
+                    fromError(parsedParams.error).toString()
+                )
+            );
+        }
+
+        const { accessTokenId } = parsedParams.data;
+
+        const [accessToken] = await db
+            .select()
+            .from(resourceAccessToken)
+            .where(and(eq(resourceAccessToken.accessTokenId, accessTokenId)));
+
+        if (!accessToken) {
+            return next(
+                createHttpError(
+                    HttpCode.NOT_FOUND,
+                    "Resource access token not found"
+                )
+            );
+        }
+
+        await db
+            .delete(resourceAccessToken)
+            .where(and(eq(resourceAccessToken.accessTokenId, accessTokenId)));
+
+        return response(res, {
+            data: null,
+            success: true,
+            error: false,
+            message: "Resource access token deleted successfully",
+            status: HttpCode.OK
+        });
+    } catch (error) {
+        logger.error(error);
+        return next(
+            createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
+        );
+    }
+}

+ 120 - 0
server/routers/accessToken/generateAccessToken.ts

@@ -0,0 +1,120 @@
+import { hash } from "@node-rs/argon2";
+import {
+    generateId,
+    generateIdFromEntropySize,
+    SESSION_COOKIE_EXPIRES
+} from "@server/auth";
+import db from "@server/db";
+import { resourceAccessToken, resources } from "@server/db/schema";
+import HttpCode from "@server/types/HttpCode";
+import response from "@server/utils/response";
+import { eq } from "drizzle-orm";
+import { NextFunction, Request, Response } from "express";
+import createHttpError from "http-errors";
+import { z } from "zod";
+import { fromError } from "zod-validation-error";
+import logger from "@server/logger";
+import { createDate, TimeSpan } from "oslo";
+
+export const generateAccessTokenBodySchema = z.object({
+    validForSeconds: z.number().int().positive().optional(), // seconds
+    title: z.string().optional(),
+    description: z.string().optional()
+});
+
+export const generateAccssTokenParamsSchema = z.object({
+    resourceId: z.string().transform(Number).pipe(z.number().int().positive())
+});
+
+export type GenerateAccessTokenResponse = {
+    token: string;
+};
+
+export async function generateAccessToken(
+    req: Request,
+    res: Response,
+    next: NextFunction
+): Promise<any> {
+    const parsedBody = generateAccessTokenBodySchema.safeParse(req.body);
+
+    if (!parsedBody.success) {
+        return next(
+            createHttpError(
+                HttpCode.BAD_REQUEST,
+                fromError(parsedBody.error).toString()
+            )
+        );
+    }
+
+    const parsedParams = generateAccssTokenParamsSchema.safeParse(req.params);
+
+    if (!parsedParams.success) {
+        return next(
+            createHttpError(
+                HttpCode.BAD_REQUEST,
+                fromError(parsedParams.error).toString()
+            )
+        );
+    }
+
+    const { resourceId } = parsedParams.data;
+    const { validForSeconds, title, description } = parsedBody.data;
+
+    const [resource] = await db
+        .select()
+        .from(resources)
+        .where(eq(resources.resourceId, resourceId));
+
+    if (!resource) {
+        return next(createHttpError(HttpCode.NOT_FOUND, "Resource not found"));
+    }
+
+    try {
+        const sessionLength = validForSeconds
+            ? validForSeconds * 1000
+            : SESSION_COOKIE_EXPIRES;
+        const expiresAt = validForSeconds
+            ? createDate(new TimeSpan(validForSeconds, "s")).getTime()
+            : undefined;
+
+        const token = generateIdFromEntropySize(25);
+
+        const tokenHash = await hash(token, {
+            memoryCost: 19456,
+            timeCost: 2,
+            outputLen: 32,
+            parallelism: 1
+        });
+
+        const id = generateId(15);
+        await db.insert(resourceAccessToken).values({
+            accessTokenId: id,
+            orgId: resource.orgId,
+            resourceId,
+            tokenHash,
+            expiresAt: expiresAt || null,
+            sessionLength: sessionLength,
+            title: title || `${resource.name} Token ${new Date().getTime()}`,
+            description: description || null,
+            createdAt: new Date().getTime()
+        });
+
+        return response<GenerateAccessTokenResponse>(res, {
+            data: {
+                token: `${id}.${token}`
+            },
+            success: true,
+            error: false,
+            message: "Resource access token generated successfully",
+            status: HttpCode.OK
+        });
+    } catch (e) {
+        logger.error(e);
+        return next(
+            createHttpError(
+                HttpCode.INTERNAL_SERVER_ERROR,
+                "Failed to authenticate with resource"
+            )
+        );
+    }
+}

+ 3 - 0
server/routers/accessToken/index.ts

@@ -0,0 +1,3 @@
+export * from "./generateAccessToken";
+export * from "./listAccessTokens";
+export * from "./deleteAccessToken";

+ 183 - 0
server/routers/accessToken/listAccessTokens.ts

@@ -0,0 +1,183 @@
+import { Request, Response, NextFunction } from "express";
+import { z } from "zod";
+import { db } from "@server/db";
+import {
+    resources,
+    userResources,
+    roleResources,
+    resourceAccessToken
+} from "@server/db/schema";
+import response from "@server/utils/response";
+import HttpCode from "@server/types/HttpCode";
+import createHttpError from "http-errors";
+import { sql, eq, or, inArray, and, count } from "drizzle-orm";
+import logger from "@server/logger";
+import stoi from "@server/utils/stoi";
+
+const listAccessTokensParamsSchema = z
+    .object({
+        resourceId: z
+            .string()
+            .optional()
+            .transform(stoi)
+            .pipe(z.number().int().positive().optional()),
+        orgId: z.string().optional()
+    })
+    .refine((data) => !!data.resourceId !== !!data.orgId, {
+        message: "Either resourceId or orgId must be provided, but not both"
+    });
+
+const listAccessTokensSchema = z.object({
+    limit: z
+        .string()
+        .optional()
+        .default("1000")
+        .transform(Number)
+        .pipe(z.number().int().nonnegative()),
+
+    offset: z
+        .string()
+        .optional()
+        .default("0")
+        .transform(Number)
+        .pipe(z.number().int().nonnegative())
+});
+
+function queryAccessTokens(
+    accessibleResourceIds: number[],
+    orgId?: string,
+    resourceId?: number
+) {
+    const cols = {
+        accessTokenId: resourceAccessToken.accessTokenId,
+        orgId: resourceAccessToken.orgId,
+        resourceId: resourceAccessToken.resourceId,
+        sessionLength: resourceAccessToken.sessionLength,
+        expiresAt: resourceAccessToken.expiresAt,
+        title: resourceAccessToken.title,
+        description: resourceAccessToken.description,
+        createdAt: resourceAccessToken.createdAt
+    };
+
+    if (orgId) {
+        return db
+            .select(cols)
+            .from(resourceAccessToken)
+            .where(
+                and(
+                    inArray(resourceAccessToken.resourceId, accessibleResourceIds),
+                    eq(resourceAccessToken.orgId, orgId)
+                )
+            );
+    } else if (resourceId) {
+        return db
+            .select(cols)
+            .from(resourceAccessToken)
+            .where(
+                and(
+                    inArray(resources.resourceId, accessibleResourceIds),
+                    eq(resources.resourceId, resourceId)
+                )
+            );
+    }
+}
+
+export type ListAccessTokensResponse = {
+    accessTokens: NonNullable<Awaited<ReturnType<typeof queryAccessTokens>>>;
+    pagination: { total: number; limit: number; offset: number };
+};
+
+export async function listAccessTokens(
+    req: Request,
+    res: Response,
+    next: NextFunction
+): Promise<any> {
+    try {
+        const parsedQuery = listAccessTokensSchema.safeParse(req.query);
+        if (!parsedQuery.success) {
+            return next(
+                createHttpError(
+                    HttpCode.BAD_REQUEST,
+                    parsedQuery.error.errors.map((e) => e.message).join(", ")
+                )
+            );
+        }
+        const { limit, offset } = parsedQuery.data;
+
+        const parsedParams = listAccessTokensParamsSchema.safeParse(req.params);
+        if (!parsedParams.success) {
+            return next(
+                createHttpError(
+                    HttpCode.BAD_REQUEST,
+                    parsedParams.error.errors.map((e) => e.message).join(", ")
+                )
+            );
+        }
+        const { orgId, resourceId } = parsedParams.data;
+
+        if (orgId && orgId !== req.userOrgId) {
+            return next(
+                createHttpError(
+                    HttpCode.FORBIDDEN,
+                    "User does not have access to this organization"
+                )
+            );
+        }
+
+        const accessibleResources = await db
+            .select({
+                resourceId: sql<number>`COALESCE(${userResources.resourceId}, ${roleResources.resourceId})`
+            })
+            .from(userResources)
+            .fullJoin(
+                roleResources,
+                eq(userResources.resourceId, roleResources.resourceId)
+            )
+            .where(
+                or(
+                    eq(userResources.userId, req.user!.userId),
+                    eq(roleResources.roleId, req.userOrgRoleId!)
+                )
+            );
+
+        const accessibleResourceIds = accessibleResources.map(
+            (resource) => resource.resourceId
+        );
+
+        let countQuery: any = db
+            .select({ count: count() })
+            .from(resources)
+            .where(inArray(resources.resourceId, accessibleResourceIds));
+
+        const baseQuery = queryAccessTokens(
+            accessibleResourceIds,
+            orgId,
+            resourceId
+        );
+
+        const list = await baseQuery!.limit(limit).offset(offset);
+        const totalCountResult = await countQuery;
+        const totalCount = totalCountResult[0].count;
+
+        return response<ListAccessTokensResponse>(res, {
+            data: {
+                accessTokens: list,
+                pagination: {
+                    total: totalCount,
+                    limit,
+                    offset
+                }
+            },
+            success: true,
+            error: false,
+            message: "Access tokens retrieved successfully",
+            status: HttpCode.OK
+        });
+    } catch (error) {
+        throw error;
+        logger.error(error);
+        return next(
+            createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
+        );
+    }
+}

+ 0 - 1
server/routers/auth/checkResourceSession.ts

@@ -4,7 +4,6 @@ import { z } from "zod";
 import { fromError } from "zod-validation-error";
 import HttpCode from "@server/types/HttpCode";
 import { response } from "@server/utils";
-import { validateSessionToken } from "@server/auth";
 import { validateResourceSessionToken } from "@server/auth/resource";
 
 export const params = z.object({

+ 43 - 59
server/routers/badger/verifySession.ts

@@ -7,9 +7,11 @@ import { response } from "@server/utils/response";
 import { validateSessionToken } from "@server/auth";
 import db from "@server/db";
 import {
+    resourceAccessToken,
     resourcePassword,
     resourcePincode,
     resources,
+    resourceWhitelist,
     User,
     userOrgs
 } from "@server/db/schema";
@@ -89,7 +91,12 @@ export async function verifyResourceSession(
             return notAllowed(res);
         }
 
-        if (!resource.sso && !pincode && !password) {
+        if (
+            !resource.sso &&
+            !pincode &&
+            !password &&
+            !resource.emailWhitelistEnabled
+        ) {
             logger.debug("Resource allowed because no auth");
             return allowed(res);
         }
@@ -103,7 +110,7 @@ export async function verifyResourceSession(
         const sessionToken = sessions[config.server.session_cookie_name];
 
         // check for unified login
-        if (sso && sessionToken && !resource.otpEnabled) {
+        if (sso && sessionToken) {
             const { session, user } = await validateSessionToken(sessionToken);
             if (session && user) {
                 const isAllowed = await isUserAllowedToAccessResource(
@@ -125,69 +132,46 @@ export async function verifyResourceSession(
                 `${config.server.resource_session_cookie_name}_${resource.resourceId}`
             ];
 
-        if (
-            sso &&
-            sessionToken &&
-            resourceSessionToken &&
-            resource.otpEnabled
-        ) {
-            const { session, user } = await validateSessionToken(sessionToken);
-            const { resourceSession } = await validateResourceSessionToken(
-                resourceSessionToken,
-                resource.resourceId
-            );
-
-            if (session && user && resourceSession) {
-                if (!resourceSession.usedOtp) {
-                    logger.debug("Resource not allowed because OTP not used");
-                    return notAllowed(res, redirectUrl);
-                }
-
-                const isAllowed = await isUserAllowedToAccessResource(
-                    user,
-                    resource
-                );
-
-                if (isAllowed) {
-                    logger.debug(
-                        "Resource allowed because user and resource session is valid"
-                    );
-                    return allowed(res);
-                }
-            }
-        }
-
-        if ((pincode || password) && resourceSessionToken) {
+        if (resourceSessionToken) {
             const { resourceSession } = await validateResourceSessionToken(
                 resourceSessionToken,
                 resource.resourceId
             );
 
             if (resourceSession) {
-                if (resource.otpEnabled && !resourceSession.usedOtp) {
-                    logger.debug("Resource not allowed because OTP not used");
-                    return notAllowed(res, redirectUrl);
-                }
-
-                if (
-                    pincode &&
-                    resourceSession.pincodeId === pincode.pincodeId
-                ) {
-                    logger.debug(
-                        "Resource allowed because pincode session is valid"
-                    );
-                    return allowed(res);
-                }
-
-                if (
-                    password &&
-                    resourceSession.passwordId === password.passwordId
-                ) {
-                    logger.debug(
-                        "Resource allowed because password session is valid"
-                    );
-                    return allowed(res);
-                }
+                return allowed(res);
+
+                // Might not be needed
+                // if (pincode && resourceSession.pincodeId) {
+                //     logger.debug(
+                //         "Resource allowed because pincode session is valid"
+                //     );
+                //     return allowed(res);
+                // }
+                //
+                // if (password && resourceSession.passwordId) {
+                //     logger.debug(
+                //         "Resource allowed because password session is valid"
+                //     );
+                //     return allowed(res);
+                // }
+                //
+                // if (
+                //     resource.emailWhitelistEnabled &&
+                //     resourceSession.whitelistId
+                // ) {
+                //     logger.debug(
+                //         "Resource allowed because whitelist session is valid"
+                //     );
+                //     return allowed(res);
+                // }
+                //
+                // if (resourceSession.accessTokenId) {
+                //     logger.debug(
+                //         "Resource allowed because access token session is valid"
+                //     );
+                //     return allowed(res);
+                // }
             }
         }
 

+ 40 - 4
server/routers/external.ts

@@ -6,8 +6,10 @@ import * as target from "./target";
 import * as user from "./user";
 import * as auth from "./auth";
 import * as role from "./role";
+import * as accessToken from "./accessToken";
 import HttpCode from "@server/types/HttpCode";
 import {
+    verifyAccessTokenAccess,
     rateLimitMiddleware,
     verifySessionMiddleware,
     verifySessionUserMiddleware,
@@ -114,11 +116,13 @@ authenticated.put(
     verifyUserHasAction(ActionsEnum.createResource),
     resource.createResource
 );
+
 authenticated.get(
     "/site/:siteId/resources",
     verifyUserHasAction(ActionsEnum.listResources),
     resource.listResources
 );
+
 authenticated.get(
     "/org/:orgId/resources",
     verifyOrgAccess,
@@ -278,31 +282,59 @@ authenticated.post(
 authenticated.post(
     `/resource/:resourceId/password`,
     verifyResourceAccess,
-    verifyUserHasAction(ActionsEnum.updateResource), // REVIEW: group all resource related updates under update resource?
+    verifyUserHasAction(ActionsEnum.setResourcePassword),
     resource.setResourcePassword
 );
 
 authenticated.post(
     `/resource/:resourceId/pincode`,
     verifyResourceAccess,
-    verifyUserHasAction(ActionsEnum.updateResource),
+    verifyUserHasAction(ActionsEnum.setResourcePincode),
     resource.setResourcePincode
 );
 
 authenticated.post(
     `/resource/:resourceId/whitelist`,
     verifyResourceAccess,
-    verifyUserHasAction(ActionsEnum.updateResource),
+    verifyUserHasAction(ActionsEnum.setResourceWhitelist),
     resource.setResourceWhitelist
 );
 
 authenticated.get(
     `/resource/:resourceId/whitelist`,
     verifyResourceAccess,
-    verifyUserHasAction(ActionsEnum.getResource),
+    verifyUserHasAction(ActionsEnum.getResourceWhitelist),
     resource.getResourceWhitelist
 );
 
+authenticated.post(
+    `/resource/:resourceId/access-token`,
+    verifyResourceAccess,
+    verifyUserHasAction(ActionsEnum.generateAccessToken),
+    accessToken.generateAccessToken
+);
+
+authenticated.delete(
+    `/access-token/:accessTokenId`,
+    verifyAccessTokenAccess,
+    verifyUserHasAction(ActionsEnum.deleteAcessToken),
+    accessToken.deleteAccessToken
+);
+
+authenticated.get(
+    `/org/:orgId/access-tokens`,
+    verifyOrgAccess,
+    verifyUserHasAction(ActionsEnum.listAccessTokens),
+    accessToken.listAccessTokens
+);
+
+authenticated.get(
+    `/resource/:resourceId/access-tokens`,
+    verifyResourceAccess,
+    verifyUserHasAction(ActionsEnum.listAccessTokens),
+    accessToken.listAccessTokens
+);
+
 unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo);
 
 // authenticated.get(
@@ -422,3 +454,7 @@ authRouter.post("/reset-password/", auth.resetPassword);
 authRouter.post("/resource/:resourceId/password", resource.authWithPassword);
 authRouter.post("/resource/:resourceId/pincode", resource.authWithPincode);
 authRouter.post("/resource/:resourceId/whitelist", resource.authWithWhitelist);
+authRouter.post(
+    "/resource/:resourceId/access-token",
+    resource.authWithAccessToken
+);

+ 157 - 0
server/routers/resource/authWithAccessToken.ts

@@ -0,0 +1,157 @@
+import { generateSessionToken } from "@server/auth";
+import db from "@server/db";
+import { resourceAccessToken, resources } from "@server/db/schema";
+import HttpCode from "@server/types/HttpCode";
+import response from "@server/utils/response";
+import { eq, and } from "drizzle-orm";
+import { NextFunction, Request, Response } from "express";
+import createHttpError from "http-errors";
+import { z } from "zod";
+import { fromError } from "zod-validation-error";
+import {
+    createResourceSession,
+    serializeResourceSessionCookie
+} from "@server/auth/resource";
+import config from "@server/config";
+import logger from "@server/logger";
+import { verify } from "@node-rs/argon2";
+import { isWithinExpirationDate } from "oslo";
+
+const authWithAccessTokenBodySchema = z.object({
+    accessToken: z.string()
+});
+
+const authWithAccessTokenParamsSchema = z.object({
+    resourceId: z.string().transform(Number).pipe(z.number().int().positive())
+});
+
+export type AuthWithAccessTokenResponse = {
+    session?: string;
+};
+
+export async function authWithAccessToken(
+    req: Request,
+    res: Response,
+    next: NextFunction
+): Promise<any> {
+    const parsedBody = authWithAccessTokenBodySchema.safeParse(req.body);
+
+    if (!parsedBody.success) {
+        return next(
+            createHttpError(
+                HttpCode.BAD_REQUEST,
+                fromError(parsedBody.error).toString()
+            )
+        );
+    }
+
+    const parsedParams = authWithAccessTokenParamsSchema.safeParse(req.params);
+
+    if (!parsedParams.success) {
+        return next(
+            createHttpError(
+                HttpCode.BAD_REQUEST,
+                fromError(parsedParams.error).toString()
+            )
+        );
+    }
+
+    const { resourceId } = parsedParams.data;
+    const { accessToken: at } = parsedBody.data;
+
+    const [accessTokenId, accessToken] = at.split(".");
+
+    try {
+        const [result] = await db
+            .select()
+            .from(resourceAccessToken)
+            .where(
+                and(
+                    eq(resourceAccessToken.resourceId, resourceId),
+                    eq(resourceAccessToken.accessTokenId, accessTokenId)
+                )
+            )
+            .leftJoin(
+                resources,
+                eq(resources.resourceId, resourceAccessToken.resourceId)
+            )
+            .limit(1);
+
+        const resource = result?.resources;
+        const tokenItem = result?.resourceAccessToken;
+
+        if (!tokenItem) {
+            return next(
+                createHttpError(
+                    HttpCode.UNAUTHORIZED,
+                    createHttpError(
+                        HttpCode.BAD_REQUEST,
+                        "Email is not whitelisted"
+                    )
+                )
+            );
+        }
+
+        if (!resource) {
+            return next(
+                createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
+            );
+        }
+
+        const validCode = await verify(tokenItem.tokenHash, accessToken, {
+            memoryCost: 19456,
+            timeCost: 2,
+            outputLen: 32,
+            parallelism: 1
+        });
+
+        if (!validCode) {
+            return next(
+                createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token")
+            );
+        }
+
+        if (
+            tokenItem.expiresAt &&
+            !isWithinExpirationDate(new Date(tokenItem.expiresAt))
+        ) {
+            return next(
+                createHttpError(
+                    HttpCode.UNAUTHORIZED,
+                    "Access token has expired"
+                )
+            );
+        }
+
+        const token = generateSessionToken();
+        await createResourceSession({
+            resourceId,
+            token,
+            accessTokenId: tokenItem.accessTokenId,
+            sessionLength: tokenItem.sessionLength,
+            expiresAt: tokenItem.expiresAt,
+            doNotExtend: tokenItem.expiresAt ? false : true
+        });
+        const cookieName = `${config.server.resource_session_cookie_name}_${resource.resourceId}`;
+        const cookie = serializeResourceSessionCookie(cookieName, token);
+        res.appendHeader("Set-Cookie", cookie);
+
+        return response<AuthWithAccessTokenResponse>(res, {
+            data: {
+                session: token
+            },
+            success: true,
+            error: false,
+            message: "Authenticated with resource successfully",
+            status: HttpCode.OK
+        });
+    } catch (e) {
+        logger.error(e);
+        return next(
+            createHttpError(
+                HttpCode.INTERNAL_SERVER_ERROR,
+                "Failed to authenticate with resource"
+            )
+        );
+    }
+}

+ 0 - 2
server/routers/resource/authWithWhitelist.ts

@@ -174,7 +174,6 @@ export async function authWithWhitelist(
         const cookie = serializeResourceSessionCookie(
             cookieName,
             token,
-            resource.fullDomain
         );
         res.appendHeader("Set-Cookie", cookie);
 
@@ -188,7 +187,6 @@ export async function authWithWhitelist(
             status: HttpCode.OK
         });
     } catch (e) {
-        throw e;
         logger.error(e);
         return next(
             createHttpError(

+ 1 - 0
server/routers/resource/index.ts

@@ -15,3 +15,4 @@ export * from "./authWithPincode";
 export * from "./setResourceWhitelist";
 export * from "./getResourceWhitelist";
 export * from "./authWithWhitelist";
+export * from "./authWithAccessToken";

+ 9 - 4
src/app/[orgId]/settings/layout.tsx

@@ -1,6 +1,6 @@
 import { Metadata } from "next";
 import { TopbarNav } from "./components/TopbarNav";
-import { Cog, Combine, Settings, Users, Waypoints } from "lucide-react";
+import { Cog, Combine, Link, Settings, Users, Waypoints } from "lucide-react";
 import Header from "./components/Header";
 import { verifySession } from "@app/lib/auth/verifySession";
 import { redirect } from "next/navigation";
@@ -30,10 +30,15 @@ const topNavItems = [
         icon: <Waypoints className="h-4 w-4" />
     },
     {
-        title: "Access",
+        title: "Users & Roles",
         href: "/{orgId}/settings/access",
         icon: <Users className="h-4 w-4" />
     },
+    {
+        title: "Sharable Links",
+        href: "/{orgId}/settings/links",
+        icon: <Link className="h-4 w-4" />
+    },
     {
         title: "General",
         href: "/{orgId}/settings/general",
@@ -105,7 +110,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
             <div className="container mx-auto sm:px-0 px-3">{children}</div>
 
             <footer className="w-full mt-6 py-3">
-                <div className="container mx-auto flex justify-end items-center px-3 sm:px-0 text-sm text-muted space-x-3">
+                <div className="container mx-auto flex justify-end items-center px-3 sm:px-0 text-sm text-muted space-x-3 select-none">
                     <div>Built by Fossorial</div>
                     <a
                         href="https://github.com/fosrl/pangolin"
@@ -117,7 +122,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
                             xmlns="http://www.w3.org/2000/svg"
                             viewBox="0 0 24 24"
                             fill="currentColor"
-                            className="w-5 h-5"
+                            className="w-4 h-4"
                         >
                             <path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
                         </svg>

+ 2 - 2
src/app/auth/resource/[resourceId]/components/ResourceAuthPortal.tsx

@@ -45,7 +45,7 @@ import { Alert, AlertDescription } from "@app/components/ui/alert";
 import { formatAxiosError } from "@app/lib/utils";
 import { AxiosResponse } from "axios";
 import LoginForm from "@app/components/LoginForm";
-import { AuthWithPasswordResponse, AuthWithWhitelistResponse } from "@server/routers/resource";
+import { AuthWithPasswordResponse, AuthWithAccessTokenResponse } from "@server/routers/resource";
 import { redirect } from "next/dist/server/api-utils";
 import ResourceAccessDenied from "./ResourceAccessDenied";
 import { createApiClient } from "@app/api";
@@ -166,7 +166,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
 
     const onWhitelistSubmit = (values: any) => {
         setLoadingLogin(true);
-        api.post<AxiosResponse<AuthWithWhitelistResponse>>(
+        api.post<AxiosResponse<AuthWithAccessTokenResponse>>(
             `/auth/resource/${props.resource.id}/whitelist`,
             { email: values.email, otp: values.otp }
         )

+ 2 - 5
src/app/auth/resource/[resourceId]/page.tsx

@@ -43,8 +43,8 @@ export default async function ResourceAuthPage(props: {
         );
     }
 
-    const hasAuth = authInfo.password || authInfo.pincode || authInfo.sso;
-    const isSSOOnly = authInfo.sso && !authInfo.password && !authInfo.pincode;
+    const hasAuth = authInfo.password || authInfo.pincode || authInfo.sso || authInfo.whitelist;
+    const isSSOOnly = authInfo.sso && !authInfo.password && !authInfo.pincode && !authInfo.whitelist;
 
     const redirectUrl = searchParams.redirect || authInfo.url;
 
@@ -70,8 +70,6 @@ export default async function ResourceAuthPage(props: {
                 AxiosResponse<CheckResourceSessionResponse>
             >(`/resource-session/${params.resourceId}/${sessionId}`);
 
-            console.log("resource session already exists and is valid");
-
             if (res && res.data.data.valid) {
                 doRedirect = true;
             }
@@ -96,7 +94,6 @@ export default async function ResourceAuthPage(props: {
                 await authCookieHeader(),
             );
 
-            console.log(res.data);
             doRedirect = true;
         } catch (e) {
             userIsUnauthorized = true;