From bb9b94a983c760583c14ffd4489cf14d3763aba4 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Fri, 17 Jul 2026 13:39:09 -0400 Subject: [PATCH] add persistent session and users to access tokens --- messages/en-US.json | 12 +- server/db/pg/schema/schema.ts | 4 + server/db/sqlite/schema/schema.ts | 6 + server/lib/userOrg.ts | 10 + .../accessToken/generateAccessToken.ts | 46 ++- .../routers/accessToken/listAccessTokens.ts | 12 +- server/routers/badger/verifySession.ts | 313 +++++++++++++++--- server/routers/integration.ts | 10 - .../routers/resource/authWithAccessToken.ts | 33 +- .../resource/createResourceSessionToken.ts | 133 -------- server/routers/resource/index.ts | 1 - src/components/CreateShareLinkForm.tsx | 103 +++++- src/components/ShareLinksTable.tsx | 40 +++ src/components/user-selector.tsx | 106 ++++++ 14 files changed, 629 insertions(+), 200 deletions(-) delete mode 100644 server/routers/resource/createResourceSessionToken.ts create mode 100644 src/components/user-selector.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 678c73ebd..bdd884d45 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -178,7 +178,7 @@ "shareDeleteConfirm": "Confirm Delete Shareable Link", "shareQuestionRemove": "Are you sure you want to delete this share link?", "shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.", - "shareTokenDescription": "The access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.", + "shareTokenDescription": "The access token can be passed as a query parameter or in request headers. By default it must be sent on every request. If session persistence is enabled, the first request exchanges it for a session cookie.", "accessToken": "Access Token", "usageExamples": "Usage Examples", "tokenId": "Token ID", @@ -196,8 +196,14 @@ "shareTitleOptional": "Title (optional)", "sharePathOptional": "Path (optional)", "sharePathDescription": "The link will redirect users to this path after authentication.", + "shareAssociateUserOptional": "Associate User (optional)", + "shareAssociateUserDescription": "When set, requests using this link are attributed to the user in access logs and identity headers. The link is removed if the user leaves the organization.", + "userSelect": "Select user", + "usersNotFound": "No users found", "expireIn": "Expire In", "neverExpire": "Never expire", + "sharePersistSession": "Persist session after first use", + "sharePersistSessionDescription": "When enabled, the first request with this token sets a session cookie so later requests do not need the token. Leave off for API clients that should send the token on every request.", "shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.", "shareSeeOnce": "You will only be able to see this link once. Make sure to copy it.", "shareAccessHint": "Anyone with this link can access the resource. Share it with care.", @@ -3089,8 +3095,8 @@ "sourceAddress": "Source Address", "destinationAddress": "Destination Address", "duration": "Duration", - "licenseRequiredToUse": "An Enterprise Edition license or Pangolin Cloud is required to use this feature. Book a free demo or POC trial to learn more.", - "ossEnterpriseEditionRequired": "The Enterprise Edition is required to use this feature. This feature is also available in Pangolin Cloud. Book a free demo or POC trial to learn more.", + "licenseRequiredToUse": "An Enterprise Edition license or Pangolin Cloud is required to use this feature. Book a free demo or POC trial to learn more.", + "ossEnterpriseEditionRequired": "The Enterprise Edition is required to use this feature. This feature is also available in Pangolin Cloud. Book a free demo or POC trial to learn more.", "certResolver": "Certificate Resolver", "certResolverDescription": "Select the certificate resolver to use for this resource.", "selectCertResolver": "Select Certificate Resolver", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index f3375b0d9..7bb1767bd 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -905,12 +905,16 @@ export const resourceAccessToken = pgTable("resourceAccessToken", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), + userId: varchar("userId").references(() => users.userId, { + onDelete: "cascade" + }), path: varchar("path"), tokenHash: varchar("tokenHash").notNull(), sessionLength: bigint("sessionLength", { mode: "number" }).notNull(), expiresAt: bigint("expiresAt", { mode: "number" }), title: varchar("title"), description: varchar("description"), + persistSession: boolean("persistSession").notNull().default(false), createdAt: bigint("createdAt", { mode: "number" }).notNull() }); diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 3fcf38a41..48f038880 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1125,12 +1125,18 @@ export const resourceAccessToken = sqliteTable("resourceAccessToken", { resourceId: integer("resourceId") .notNull() .references(() => resources.resourceId, { onDelete: "cascade" }), + userId: text("userId").references(() => users.userId, { + onDelete: "cascade" + }), path: text("path"), tokenHash: text("tokenHash").notNull(), sessionLength: integer("sessionLength").notNull(), expiresAt: integer("expiresAt"), title: text("title"), description: text("description"), + persistSession: integer("persistSession", { mode: "boolean" }) + .notNull() + .default(false), createdAt: integer("createdAt").notNull() }); diff --git a/server/lib/userOrg.ts b/server/lib/userOrg.ts index 4bff40c13..ea086d0c0 100644 --- a/server/lib/userOrg.ts +++ b/server/lib/userOrg.ts @@ -2,6 +2,7 @@ import { db, Org, orgs, + resourceAccessToken, resources, siteResources, sites, @@ -83,6 +84,15 @@ export async function removeUserFromOrg( .delete(userOrgs) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId))); + await trx + .delete(resourceAccessToken) + .where( + and( + eq(resourceAccessToken.userId, userId), + eq(resourceAccessToken.orgId, org.orgId) + ) + ); + await trx.delete(userResources).where( and( eq(userResources.userId, userId), diff --git a/server/routers/accessToken/generateAccessToken.ts b/server/routers/accessToken/generateAccessToken.ts index a06068c01..5b259af70 100644 --- a/server/routers/accessToken/generateAccessToken.ts +++ b/server/routers/accessToken/generateAccessToken.ts @@ -1,4 +1,3 @@ -import { hash } from "@node-rs/argon2"; import { generateId, generateIdFromEntropySize, @@ -8,18 +7,18 @@ import { db } from "@server/db"; import { ResourceAccessToken, resourceAccessToken, - resources + resources, + userOrgs } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; -import { eq } from "drizzle-orm"; +import { and, 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"; -import { hashPassword } from "@server/auth/password"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { OpenAPITags, registry } from "@server/openApi"; @@ -28,7 +27,9 @@ export const generateAccessTokenBodySchema = z.strictObject({ validForSeconds: z.int().positive().optional(), // seconds title: z.string().optional(), path: z.string().optional(), - description: z.string().optional() + description: z.string().optional(), + persistSession: z.boolean().optional().default(false), + userId: z.string().optional() }); export const generateAccssTokenParamsSchema = z.strictObject({ @@ -101,7 +102,14 @@ export async function generateAccessToken( } const { resourceId } = parsedParams.data; - const { validForSeconds, title, path, description } = parsedBody.data; + const { + validForSeconds, + title, + path, + description, + persistSession, + userId + } = parsedBody.data; const [resource] = await db .select() @@ -112,6 +120,28 @@ export async function generateAccessToken( return next(createHttpError(HttpCode.NOT_FOUND, "Resource not found")); } + if (userId) { + const [membership] = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, resource.orgId) + ) + ) + .limit(1); + + if (!membership) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User is not a member of this organization" + ) + ); + } + } + try { const sessionLength = validForSeconds ? validForSeconds * 1000 @@ -133,23 +163,27 @@ export async function generateAccessToken( accessTokenId: id, orgId: resource.orgId, resourceId, + userId: userId || null, tokenHash, expiresAt: expiresAt || null, sessionLength: sessionLength, title: title || null, path: path || null, description: description || null, + persistSession, createdAt: new Date().getTime() }) .returning({ accessTokenId: resourceAccessToken.accessTokenId, orgId: resourceAccessToken.orgId, resourceId: resourceAccessToken.resourceId, + userId: resourceAccessToken.userId, expiresAt: resourceAccessToken.expiresAt, sessionLength: resourceAccessToken.sessionLength, title: resourceAccessToken.title, path: resourceAccessToken.path, description: resourceAccessToken.description, + persistSession: resourceAccessToken.persistSession, createdAt: resourceAccessToken.createdAt }) .execute(); diff --git a/server/routers/accessToken/listAccessTokens.ts b/server/routers/accessToken/listAccessTokens.ts index 472d9da40..a5ec3b540 100644 --- a/server/routers/accessToken/listAccessTokens.ts +++ b/server/routers/accessToken/listAccessTokens.ts @@ -6,12 +6,13 @@ import { userResources, roleResources, resourceAccessToken, - sites + sites, + users } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql, eq, or, inArray, and, count, isNull, lt, gt } from "drizzle-orm"; +import { sql, eq, or, inArray, and, count, isNull, gt } from "drizzle-orm"; import logger from "@server/logger"; import stoi from "@server/lib/stoi"; import { fromZodError } from "zod-validation-error"; @@ -55,11 +56,16 @@ function queryAccessTokens( accessTokenId: resourceAccessToken.accessTokenId, orgId: resourceAccessToken.orgId, resourceId: resourceAccessToken.resourceId, + userId: resourceAccessToken.userId, + userName: users.name, + username: users.username, + userEmail: users.email, sessionLength: resourceAccessToken.sessionLength, expiresAt: resourceAccessToken.expiresAt, tokenHash: resourceAccessToken.tokenHash, title: resourceAccessToken.title, description: resourceAccessToken.description, + persistSession: resourceAccessToken.persistSession, createdAt: resourceAccessToken.createdAt, resourceName: resources.name, resourceNiceId: resources.niceId, @@ -75,6 +81,7 @@ function queryAccessTokens( eq(resourceAccessToken.resourceId, resources.resourceId) ) .leftJoin(sites, eq(resources.resourceId, sites.siteId)) + .leftJoin(users, eq(resourceAccessToken.userId, users.userId)) .where( and( inArray( @@ -97,6 +104,7 @@ function queryAccessTokens( eq(resourceAccessToken.resourceId, resources.resourceId) ) .leftJoin(sites, eq(resources.resourceId, sites.siteId)) + .leftJoin(users, eq(resourceAccessToken.userId, users.userId)) .where( and( inArray( diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index cf36fef72..901c881f2 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -1,4 +1,9 @@ -import { validateResourceSessionToken } from "@server/auth/sessions/resource"; +import { + createResourceSession, + serializeResourceSessionCookie, + validateResourceSessionToken +} from "@server/auth/sessions/resource"; +import { generateSessionToken } from "@server/auth/sessions/app"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import { getResourceByDomain, @@ -13,6 +18,7 @@ import { LoginPage, Org, Resource, + ResourceAccessToken, ResourceHeaderAuth, ResourceHeaderAuthExtendedCompatibility, ResourcePassword, @@ -21,7 +27,10 @@ import { ResourcePolicyPassword, ResourcePolicyHeaderAuth, ResourceRule, - ResourceSession + ResourceSession, + db, + resourceAccessToken, + users } from "@server/db"; import config from "@server/lib/config"; import { isIpInCidr, stripPortFromHost } from "@server/lib/ip"; @@ -41,11 +50,13 @@ import { enforceResourceSessionLength } from "#dynamic/lib/checkOrgAccessPolicy"; import { logRequestAudit } from "./logRequestAudit"; +import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; import { REGIONS } from "@server/db/regions"; import { localCache } from "#dynamic/lib/cache"; import { APP_VERSION } from "@server/lib/consts"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { eq } from "drizzle-orm"; const verifyResourceSessionSchema = z.object({ sessions: z.record(z.string(), z.string()).optional(), @@ -350,22 +361,15 @@ export async function verifyResourceSession( } if (valid && tokenItem) { - logRequestAudit( - { - action: true, - reason: 102, // valid access token - resourceId: resource.resourceId, - orgId: resource.orgId, - location: ipCC, - apiKey: { - name: tokenItem.title, - apiKeyId: tokenItem.accessTokenId - } - }, - parsedBody.data + return await allowAccessToken( + res, + resource, + tokenItem, + sessions, + dontStripSession, + parsedBody.data, + ipCC ); - - return allowed(res, undefined, dontStripSession); } } @@ -401,22 +405,15 @@ export async function verifyResourceSession( } if (valid && tokenItem) { - logRequestAudit( - { - action: true, - reason: 102, // valid access token - resourceId: resource.resourceId, - orgId: resource.orgId, - location: ipCC, - apiKey: { - name: tokenItem.title, - apiKeyId: tokenItem.accessTokenId - } - }, - parsedBody.data + return await allowAccessToken( + res, + resource, + tokenItem, + sessions, + dontStripSession, + parsedBody.data, + ipCC ); - - return allowed(res, undefined, dontStripSession); } } @@ -666,22 +663,37 @@ export async function verifyResourceSession( "Resource allowed because access token session is valid" ); - logRequestAudit( + const [tokenItem] = await db + .select() + .from(resourceAccessToken) + .where( + eq( + resourceAccessToken.accessTokenId, + resourceSession.accessTokenId + ) + ) + .limit(1); + + const userData = tokenItem + ? await getAccessTokenUserData( + tokenItem, + resource.orgId + ) + : undefined; + + logAccessTokenRequestAudit( { - action: true, - reason: 102, // valid access token resourceId: resource.resourceId, orgId: resource.orgId, location: ipCC, - apiKey: { - name: null, - apiKeyId: resourceSession.accessTokenId - } + accessTokenId: resourceSession.accessTokenId, + tokenTitle: tokenItem?.title ?? null, + userData }, parsedBody.data ); - return allowed(res, undefined, dontStripSession); + return allowed(res, userData, dontStripSession); } if (resourceSession.userSessionId && sso) { @@ -892,6 +904,227 @@ function allowed( return response(res, data); } +async function allowAccessToken( + res: Response, + resource: Resource, + tokenItem: ResourceAccessToken, + sessions: Record | undefined, + dontStripSession: boolean | undefined, + auditBody: VerifyResourceSessionSchema, + location?: string +) { + const userData = await getAccessTokenUserData(tokenItem, resource.orgId); + + logAccessTokenRequestAudit( + { + resourceId: resource.resourceId, + orgId: resource.orgId, + location, + accessTokenId: tokenItem.accessTokenId, + tokenTitle: tokenItem.title, + userData + }, + auditBody + ); + + if (!tokenItem.persistSession) { + logAccessTokenAccessAudit(tokenItem, resource, userData, auditBody); + return allowed(res, userData, dontStripSession); + } + + const resourceSessionToken = extractResourceSessionToken( + sessions ?? {}, + resource.ssl + ); + + if (resourceSessionToken) { + const sessionCacheKey = `session:${resourceSessionToken}`; + let resourceSession: ResourceSession | null | undefined = + localCache.get(sessionCacheKey); + + if (!resourceSession) { + const result = await validateResourceSessionToken( + resourceSessionToken, + resource.resourceId + ); + resourceSession = result?.resourceSession; + localCache.set(sessionCacheKey, resourceSession, 5); + } + + if ( + resourceSession && + !resourceSession.isRequestToken && + resourceSession.accessTokenId === tokenItem.accessTokenId + ) { + logger.debug( + "Resource allowed because existing access token session is valid" + ); + return allowed(res, userData, dontStripSession); + } + } + + logAccessTokenAccessAudit(tokenItem, resource, userData, auditBody); + return await createAccessTokenSession(res, resource, tokenItem, userData); +} + +async function createAccessTokenSession( + res: Response, + resource: Resource, + tokenItem: ResourceAccessToken, + userData?: BasicUserData +) { + const token = generateSessionToken(); + const sess = await createResourceSession({ + resourceId: resource.resourceId, + token, + accessTokenId: tokenItem.accessTokenId, + sessionLength: tokenItem.sessionLength, + expiresAt: tokenItem.expiresAt, + doNotExtend: tokenItem.expiresAt ? true : false + }); + const cookieName = config.getRawConfig().server.session_cookie_name; + const cookie = serializeResourceSessionCookie( + cookieName, + resource.fullDomain!, + token, + !resource.ssl, + new Date(sess.expiresAt) + ); + res.appendHeader("Set-Cookie", cookie); + logger.debug("Access token is valid, creating new session"); + return allowed(res, userData); +} + +async function getAccessTokenUserData( + tokenItem: ResourceAccessToken, + orgId: string +): Promise { + if (!tokenItem.userId) { + return undefined; + } + + const cacheKey = `accessTokenUser:${tokenItem.userId}:${orgId}`; + const cached = localCache.get(cacheKey) as BasicUserData | null | undefined; + if (cached !== undefined) { + return cached ?? undefined; + } + + const [user] = await db + .select() + .from(users) + .where(eq(users.userId, tokenItem.userId)) + .limit(1); + + if (!user) { + localCache.set(cacheKey, null, 5); + return undefined; + } + + const userOrgRoles = await getUserOrgRoles(user.userId, orgId); + const userData: BasicUserData = { + userId: user.userId, + username: user.username, + email: user.email, + name: user.name, + role: userOrgRoles.map((r) => r.roleName).join(", ") || null + }; + + localCache.set(cacheKey, userData, 12); + return userData; +} + +function logAccessTokenRequestAudit( + data: { + resourceId: number; + orgId: string; + location?: string; + accessTokenId: string; + tokenTitle: string | null; + userData?: BasicUserData; + }, + body: VerifyResourceSessionSchema +) { + if (data.userData) { + logRequestAudit( + { + action: true, + reason: 102, // valid access token + resourceId: data.resourceId, + orgId: data.orgId, + location: data.location, + user: { + username: data.userData.username, + userId: data.userData.userId + }, + metadata: { + accessTokenId: data.accessTokenId, + accessTokenTitle: data.tokenTitle + } + }, + body + ); + return; + } + + logRequestAudit( + { + action: true, + reason: 102, // valid access token + resourceId: data.resourceId, + orgId: data.orgId, + location: data.location, + apiKey: { + name: data.tokenTitle, + apiKeyId: data.accessTokenId + } + }, + body + ); +} + +function logAccessTokenAccessAudit( + tokenItem: ResourceAccessToken, + resource: Resource, + userData: BasicUserData | undefined, + body: VerifyResourceSessionSchema +) { + const userAgent = + body.headers?.["user-agent"] || body.headers?.["User-Agent"]; + + if (userData) { + logAccessAudit({ + orgId: resource.orgId, + resourceId: resource.resourceId, + action: true, + type: "accessToken", + user: { + username: userData.username, + userId: userData.userId + }, + metadata: { + accessTokenId: tokenItem.accessTokenId, + accessTokenTitle: tokenItem.title + }, + userAgent, + requestIp: body.requestIp + }); + return; + } + + logAccessAudit({ + orgId: resource.orgId, + resourceId: resource.resourceId, + action: true, + type: "accessToken", + apiKey: { + name: tokenItem.title, + apiKeyId: tokenItem.accessTokenId + }, + userAgent, + requestIp: body.requestIp + }); +} + async function headerAuthChallenged( res: Response, redirectPath?: string, diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 3abd664eb..567c3060e 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -809,16 +809,6 @@ authenticated.post( accessToken.generateAccessToken ); -authenticated.post( - `/resource/:resourceId/session-token`, - verifyApiKeyResourceAccess, - verifyApiKeyUserAccess, - verifyLimits, - verifyApiKeyHasAction(ActionsEnum.createResourceSessionToken), - logActionAudit(ActionsEnum.createResourceSessionToken), - resource.createResourceSessionToken -); - authenticated.delete( `/access-token/:accessTokenId`, verifyApiKeyAccessTokenAccess, diff --git a/server/routers/resource/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts index 195dbafe2..2e625b6a0 100644 --- a/server/routers/resource/authWithAccessToken.ts +++ b/server/routers/resource/authWithAccessToken.ts @@ -1,6 +1,6 @@ import { generateSessionToken } from "@server/auth/sessions/app"; import { db } from "@server/db"; -import { Resource, resources } from "@server/db"; +import { Resource, resources, users } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq } from "drizzle-orm"; @@ -156,11 +156,42 @@ export async function authWithAccessToken( doNotExtend: true }); + let accessAuditUser: { username: string; userId: string } | undefined; + if (tokenItem.userId) { + const [associatedUser] = await db + .select({ + userId: users.userId, + username: users.username + }) + .from(users) + .where(eq(users.userId, tokenItem.userId)) + .limit(1); + if (associatedUser) { + accessAuditUser = { + userId: associatedUser.userId, + username: associatedUser.username + }; + } + } + logAccessAudit({ orgId: resource.orgId, resourceId: resource.resourceId, action: true, type: "accessToken", + apiKey: accessAuditUser + ? undefined + : { + name: tokenItem.title, + apiKeyId: tokenItem.accessTokenId + }, + user: accessAuditUser, + metadata: accessAuditUser + ? { + accessTokenId: tokenItem.accessTokenId, + accessTokenTitle: tokenItem.title + } + : undefined, userAgent: req.headers["user-agent"], requestIp: req.ip }); diff --git a/server/routers/resource/createResourceSessionToken.ts b/server/routers/resource/createResourceSessionToken.ts deleted file mode 100644 index 82f0d0cbf..000000000 --- a/server/routers/resource/createResourceSessionToken.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db } from "@server/db"; -import { resources, users, userOrgs } from "@server/db"; -import { eq, and } from "drizzle-orm"; -import { createResourceSession } from "@server/auth/sessions/resource"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import { fromError } from "zod-validation-error"; -import logger from "@server/logger"; -import { createSession, generateSessionToken } from "@server/auth/sessions/app"; -import { response } from "@server/lib/response"; - -const createResourceSessionTokenParams = z.strictObject({ - resourceId: z.coerce.number().int().positive() -}); - -const createResourceSessionTokenBody = z.strictObject({ - userId: z.string().nonempty(), - idpId: z.coerce.number().int().positive().optional() -}); - -export type CreateResourceSessionTokenResponse = { - requestToken: string; -}; - -export async function createResourceSessionToken( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const parsedParams = createResourceSessionTokenParams.safeParse( - req.params - ); - if (!parsedParams.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) - ); - } - - const parsedBody = createResourceSessionTokenBody.safeParse(req.body); - if (!parsedBody.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() - ) - ); - } - - const { resourceId } = parsedParams.data; - const { userId, idpId } = parsedBody.data; - - const [resource] = await db - .select() - .from(resources) - .where(eq(resources.resourceId, resourceId)) - .limit(1); - - if (!resource) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource with ID ${resourceId} not found` - ) - ); - } - - const candidates = await db - .select({ userId: users.userId }) - .from(userOrgs) - .innerJoin(users, eq(userOrgs.userId, users.userId)) - .where( - and( - eq(users.userId, userId), - eq(userOrgs.orgId, resource.orgId) - ) - ); - - if (candidates.length === 0) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `User not found in the organization that owns this resource` - ) - ); - } - - if (candidates.length > 1) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Multiple users match this username (external users from different identity providers). Specify idpId to disambiguate." - ) - ); - } - - const targetUserId = candidates[0].userId; - - const appSessionToken = generateSessionToken(); - const appSession = await createSession(appSessionToken, targetUserId); - - const requestToken = generateSessionToken(); - await createResourceSession({ - resourceId, - token: requestToken, - userSessionId: appSession.sessionId, - isRequestToken: true, - expiresAt: Date.now() + 1000 * 30, // 30 seconds - sessionLength: 1000 * 30, - doNotExtend: true - }); - - logger.debug("Resource session token created successfully"); - - return response(res, { - data: { requestToken }, - success: true, - error: false, - message: "Resource session token created successfully", - status: HttpCode.OK - }); - } catch (error) { - logger.error(error); - return next( - createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") - ); - } -} diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index 4bc9d20f0..83dbdea2a 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -17,7 +17,6 @@ export * from "./getResourceWhitelist"; export * from "./authWithWhitelist"; export * from "./authWithAccessToken"; export * from "./getExchangeToken"; -export * from "./createResourceSessionToken"; export * from "./createResourceRule"; export * from "./deleteResourceRule"; export * from "./listResourceRules"; diff --git a/src/components/CreateShareLinkForm.tsx b/src/components/CreateShareLinkForm.tsx index 95884b486..e6bdc60b0 100644 --- a/src/components/CreateShareLinkForm.tsx +++ b/src/components/CreateShareLinkForm.tsx @@ -52,7 +52,6 @@ import { ChevronsUpDown } from "lucide-react"; import { Checkbox } from "@app/components/ui/checkbox"; import { GenerateAccessTokenResponse } from "@server/routers/accessToken"; import { constructShareLink } from "@app/lib/shareLinks"; -import { ShareLinkRow } from "@app/components/ShareLinksTable"; import { QRCodeCanvas, QRCodeSVG } from "qrcode.react"; import { Collapsible, @@ -63,11 +62,26 @@ import AccessTokenSection from "@app/components/AccessTokenUsage"; import { useTranslations } from "next-intl"; import { toUnicode } from "punycode"; import { ResourceSelector, type SelectedResource } from "./resource-selector"; +import { UserSelector, type SelectedUser } from "@app/components/user-selector"; + +type CreatedShareLink = { + accessTokenId: string; + resourceId: number; + resourceName: string; + resourceNiceId: string; + title: string | null; + createdAt: number; + expiresAt: number | null; + userId?: string | null; + userName?: string | null; + username?: string | null; + userEmail?: string | null; +}; type FormProps = { open: boolean; setOpen: (open: boolean) => void; - onCreated?: (result: ShareLinkRow) => void; + onCreated?: (result: CreatedShareLink) => void; }; export default function CreateShareLinkForm({ @@ -85,6 +99,8 @@ export default function CreateShareLinkForm({ const [accessToken, setAccessToken] = useState(null); const [loading, setLoading] = useState(false); const [neverExpire, setNeverExpire] = useState(false); + const [persistSession, setPersistSession] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); const [isOpen, setIsOpen] = useState(false); const t = useTranslations(); @@ -175,7 +191,9 @@ export default function CreateShareLinkForm({ values.resourceName || "Resource" + values.resourceId }), - path: values.path + path: values.path, + persistSession, + userId: selectedUser?.id } ) .catch((e) => { @@ -205,7 +223,11 @@ export default function CreateShareLinkForm({ resourceNiceId: selectedResource ? selectedResource.niceId : "", title: token.title, createdAt: token.createdAt, - expiresAt: token.expiresAt + expiresAt: token.expiresAt, + userId: token.userId, + userName: selectedUser?.text ?? null, + username: null, + userEmail: null }); } @@ -220,6 +242,9 @@ export default function CreateShareLinkForm({ setOpen(val); setLink(null); setLoading(false); + setNeverExpire(false); + setPersistSession(false); + setSelectedUser(null); form.reset(); }} > @@ -344,6 +369,48 @@ export default function CreateShareLinkForm({ )} /> +
+ + {t( + "shareAssociateUserOptional" + )} + + + + + + + + + +

+ {t( + "shareAssociateUserDescription" + )} +

+
+
@@ -437,6 +504,34 @@ export default function CreateShareLinkForm({
+
+ + setPersistSession( + val as boolean + ) + } + className="mt-0.5" + /> +
+ +

+ {t( + "sharePersistSessionDescription" + )} +

+
+
+

{t("shareExpireDescription")}

diff --git a/src/components/ShareLinksTable.tsx b/src/components/ShareLinksTable.tsx index 8394edf89..b4df1966f 100644 --- a/src/components/ShareLinksTable.tsx +++ b/src/components/ShareLinksTable.tsx @@ -35,6 +35,7 @@ import moment from "moment"; import CreateShareLinkForm from "@app/components/CreateShareLinkForm"; import { constructShareLink } from "@app/lib/shareLinks"; import { useTranslations } from "next-intl"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; export type ShareLinkRow = { accessTokenId: string; @@ -44,6 +45,10 @@ export type ShareLinkRow = { title: string | null; createdAt: number; expiresAt: number | null; + userId?: string | null; + userName?: string | null; + username?: string | null; + userEmail?: string | null; }; type ShareLinksTableProps = { @@ -155,6 +160,41 @@ export default function ShareLinksTable({ ); } }, + { + accessorKey: "userId", + friendlyName: t("user"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const r = row.original; + if (!r.userId) { + return -; + } + return ( + + + + ); + } + }, // { // accessorKey: "domain", // header: "Link", diff --git a/src/components/user-selector.tsx b/src/components/user-selector.tsx new file mode 100644 index 000000000..f8ad85eea --- /dev/null +++ b/src/components/user-selector.tsx @@ -0,0 +1,106 @@ +import { orgQueries } from "@app/lib/queries"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { useQuery } from "@tanstack/react-query"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "./ui/command"; +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { CheckIcon } from "lucide-react"; +import { cn } from "@app/lib/cn"; +import { useDebounce } from "use-debounce"; +import type { SelectedUser } from "./users-selector"; + +export type { SelectedUser }; + +export type UserSelectorProps = { + orgId: string; + selectedUser?: SelectedUser | null; + onSelectUser: (user: SelectedUser | null) => void; + allowClear?: boolean; +}; + +export function UserSelector({ + orgId, + selectedUser, + onSelectUser, + allowClear = true +}: UserSelectorProps) { + const t = useTranslations(); + const [userSearchQuery, setUserSearchQuery] = useState(""); + const [debouncedValue] = useDebounce(userSearchQuery, 150); + + const { data: users = [] } = useQuery( + orgQueries.users({ orgId, perPage: 10, query: debouncedValue }) + ); + + const usersShown = useMemo(() => { + const allUsers: Array = users.map((u) => ({ + id: u.id, + text: getUserDisplayName(u) + })); + if ( + debouncedValue.trim().length === 0 && + selectedUser && + !allUsers.find((user) => user.id === selectedUser.id) + ) { + allUsers.unshift(selectedUser); + } + return allUsers; + }, [users, selectedUser, debouncedValue]); + + return ( + + + + {t("usersNotFound")} + + {allowClear && ( + { + onSelectUser(null); + }} + > + + {t("none")} + + )} + {usersShown.map((user) => ( + { + onSelectUser(user); + }} + > + + {user.text} + + ))} + + + + ); +}