From c3a7a35346978385557e44c5c0f53fa63466a154 Mon Sep 17 00:00:00 2001 From: Location <59411633+James-Lu-none@users.noreply.github.com> Date: Fri, 25 Oct 2024 05:49:06 +0800 Subject: [PATCH 01/16] Alignment crime fixed (#2528) fix alignment crime --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b1f308a14..4edf49482 100644 --- a/README.md +++ b/README.md @@ -150,7 +150,7 @@ This monorepo consists of three main sections: Mintplex Labs & the community maintain a number of deployment methods, scripts, and templates that you can use to run AnythingLLM locally. Refer to the table below to read how to deploy on your preferred environment or to automatically deploy. | Docker | AWS | GCP | Digital Ocean | Render.com | -|----------------------------------------|----:|-----|---------------|------------| +|----------------------------------------|----|-----|---------------|------------| | [![Deploy on Docker][docker-btn]][docker-deploy] | [![Deploy on AWS][aws-btn]][aws-deploy] | [![Deploy on GCP][gcp-btn]][gcp-deploy] | [![Deploy on DigitalOcean][do-btn]][do-deploy] | [![Deploy on Render.com][render-btn]][render-deploy] | | Railway | RepoCloud | Elestio | From 72ba9f7f289047eac3bf1199a7e6892bf40bb3da Mon Sep 17 00:00:00 2001 From: Timothy Carambat Date: Thu, 24 Oct 2024 15:10:50 -0700 Subject: [PATCH 02/16] Add filtering to sessionID for workspace chats (#2531) --- server/endpoints/api/workspace/index.js | 14 +++++++++++++- server/models/workspaceChats.js | 25 +++++++++++++++++++++++++ server/swagger/openapi.json | 9 +++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/server/endpoints/api/workspace/index.js b/server/endpoints/api/workspace/index.js index fca441e25..dca21e49f 100644 --- a/server/endpoints/api/workspace/index.js +++ b/server/endpoints/api/workspace/index.js @@ -339,6 +339,12 @@ function apiWorkspaceEndpoints(app) { required: true, type: 'string' } + #swagger.parameters['apiSessionId'] = { + in: 'query', + description: 'Optional apiSessionId to filter by', + required: false, + type: 'string' + } #swagger.responses[200] = { content: { "application/json": { @@ -370,6 +376,7 @@ function apiWorkspaceEndpoints(app) { */ try { const { slug } = request.params; + const { apiSessionId = null } = request.query; const workspace = await Workspace.get({ slug }); if (!workspace) { @@ -377,7 +384,12 @@ function apiWorkspaceEndpoints(app) { return; } - const history = await WorkspaceChats.forWorkspace(workspace.id); + const history = apiSessionId + ? await WorkspaceChats.forWorkspaceByApiSessionId( + workspace.id, + apiSessionId + ) + : await WorkspaceChats.forWorkspace(workspace.id); response.status(200).json({ history: convertToChatHistory(history) }); } catch (e) { console.error(e.message, e); diff --git a/server/models/workspaceChats.js b/server/models/workspaceChats.js index ef474c4ef..4a2b884f8 100644 --- a/server/models/workspaceChats.js +++ b/server/models/workspaceChats.js @@ -55,6 +55,31 @@ const WorkspaceChats = { } }, + forWorkspaceByApiSessionId: async function ( + workspaceId = null, + apiSessionId = null, + limit = null, + orderBy = null + ) { + if (!workspaceId || !apiSessionId) return []; + try { + const chats = await prisma.workspace_chats.findMany({ + where: { + workspaceId, + user_id: null, + api_session_id: String(apiSessionId), + thread_id: null, + }, + ...(limit !== null ? { take: limit } : {}), + ...(orderBy !== null ? { orderBy } : { orderBy: { id: "asc" } }), + }); + return chats; + } catch (error) { + console.error(error.message); + return []; + } + }, + forWorkspace: async function ( workspaceId = null, limit = null, diff --git a/server/swagger/openapi.json b/server/swagger/openapi.json index b12fbf535..fb343ee83 100644 --- a/server/swagger/openapi.json +++ b/server/swagger/openapi.json @@ -1649,6 +1649,15 @@ "type": "string" }, "description": "Unique slug of workspace to find" + }, + { + "name": "apiSessionId", + "in": "query", + "description": "Optional apiSessionId to filter by", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { From 40800631e3da62a6ddd54116fd892b81ade763ca Mon Sep 17 00:00:00 2001 From: Timothy Carambat Date: Fri, 25 Oct 2024 12:03:19 -0700 Subject: [PATCH 03/16] Patch `v1/document/upload` filename charset encoding (#2535) --- server/endpoints/api/document/index.js | 4 +- server/utils/files/multer.js | 66 ++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/server/endpoints/api/document/index.js b/server/endpoints/api/document/index.js index a646fb584..f49cf0dd4 100644 --- a/server/endpoints/api/document/index.js +++ b/server/endpoints/api/document/index.js @@ -1,6 +1,6 @@ const { Telemetry } = require("../../../models/telemetry"); const { validApiKey } = require("../../../utils/middleware/validApiKey"); -const { handleFileUpload } = require("../../../utils/files/multer"); +const { handleAPIFileUpload } = require("../../../utils/files/multer"); const { viewLocalFiles, findDocumentInDocuments, @@ -23,7 +23,7 @@ function apiDocumentEndpoints(app) { app.post( "/v1/document/upload", - [validApiKey, handleFileUpload], + [validApiKey, handleAPIFileUpload], async (request, response) => { /* #swagger.tags = ['Documents'] diff --git a/server/utils/files/multer.js b/server/utils/files/multer.js index 22f1217e3..a4b90042e 100644 --- a/server/utils/files/multer.js +++ b/server/utils/files/multer.js @@ -3,7 +3,10 @@ const path = require("path"); const fs = require("fs"); const { v4 } = require("uuid"); -// Handle File uploads for auto-uploading. +/** + * Handle File uploads for auto-uploading. + * Mostly used for internal GUI/API uploads. + */ const fileUploadStorage = multer.diskStorage({ destination: function (_, __, cb) { const uploadOutput = @@ -20,6 +23,23 @@ const fileUploadStorage = multer.diskStorage({ }, }); +/** + * Handle API file upload as documents - this does not manipulate the filename + * at all for encoding/charset reasons. + */ +const fileAPIUploadStorage = multer.diskStorage({ + destination: function (_, __, cb) { + const uploadOutput = + process.env.NODE_ENV === "development" + ? path.resolve(__dirname, `../../../collector/hotdir`) + : path.resolve(process.env.STORAGE_DIR, `../../collector/hotdir`); + cb(null, uploadOutput); + }, + filename: function (_, file, cb) { + cb(null, file.originalname); + }, +}); + // Asset storage for logos const assetUploadStorage = multer.diskStorage({ destination: function (_, __, cb) { @@ -38,7 +58,9 @@ const assetUploadStorage = multer.diskStorage({ }, }); -// Asset sub-storage manager for pfp icons. +/** + * Handle PFP file upload as logos + */ const pfpUploadStorage = multer.diskStorage({ destination: function (_, __, cb) { const uploadOutput = @@ -55,7 +77,12 @@ const pfpUploadStorage = multer.diskStorage({ }, }); -// Handle Generic file upload as documents +/** + * Handle Generic file upload as documents from the GUI + * @param {Request} request + * @param {Response} response + * @param {NextFunction} next + */ function handleFileUpload(request, response, next) { const upload = multer({ storage: fileUploadStorage }).single("file"); upload(request, response, function (err) { @@ -73,7 +100,33 @@ function handleFileUpload(request, response, next) { }); } -// Handle logo asset uploads +/** + * Handle API file upload as documents - this does not manipulate the filename + * at all for encoding/charset reasons. + * @param {Request} request + * @param {Response} response + * @param {NextFunction} next + */ +function handleAPIFileUpload(request, response, next) { + const upload = multer({ storage: fileAPIUploadStorage }).single("file"); + upload(request, response, function (err) { + if (err) { + response + .status(500) + .json({ + success: false, + error: `Invalid file upload. ${err.message}`, + }) + .end(); + return; + } + next(); + }); +} + +/** + * Handle logo asset uploads + */ function handleAssetUpload(request, response, next) { const upload = multer({ storage: assetUploadStorage }).single("logo"); upload(request, response, function (err) { @@ -91,7 +144,9 @@ function handleAssetUpload(request, response, next) { }); } -// Handle PFP file upload as logos +/** + * Handle PFP file upload as logos + */ function handlePfpUpload(request, response, next) { const upload = multer({ storage: pfpUploadStorage }).single("file"); upload(request, response, function (err) { @@ -111,6 +166,7 @@ function handlePfpUpload(request, response, next) { module.exports = { handleFileUpload, + handleAPIFileUpload, handleAssetUpload, handlePfpUpload, }; From c870e31aaad99e9f4e17b94203b0a0d286145dbe Mon Sep 17 00:00:00 2001 From: timothycarambat Date: Mon, 28 Oct 2024 11:44:15 -0700 Subject: [PATCH 04/16] add `ino` filetype to text/plain support --- collector/utils/files/mime.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/collector/utils/files/mime.js b/collector/utils/files/mime.js index ad3ff5782..b3f03b131 100644 --- a/collector/utils/files/mime.js +++ b/collector/utils/files/mime.js @@ -38,6 +38,7 @@ class MimeDetector { "pas", "r", "go", + "ino", ], }, true @@ -47,7 +48,7 @@ class MimeDetector { // These are file types that are not detected by the mime library and need to be processed as text files. // You should only add file types that are not detected by the mime library, are parsable as text, and are files // with no extension. Otherwise, their extension should be added to the overrides array. - #specialTextFileTypes = ["dockerfile", "jenkinsfile"]; + #specialTextFileTypes = ["dockerfile", "jenkinsfile", "dockerignore"]; /** * Returns the MIME type of the file. If the file has no extension found, it will be processed as a text file. From 3fe59a7cf5cea9c3c845c362b07a9cf218af0bd7 Mon Sep 17 00:00:00 2001 From: timothycarambat Date: Tue, 29 Oct 2024 10:02:46 -0700 Subject: [PATCH 05/16] patch bad reference for `EphemeralAgentHandler` --- server/utils/agents/ephemeral.js | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/server/utils/agents/ephemeral.js b/server/utils/agents/ephemeral.js index 1ab2a988a..d8258eca8 100644 --- a/server/utils/agents/ephemeral.js +++ b/server/utils/agents/ephemeral.js @@ -20,19 +20,40 @@ const { * not persist between invocations */ class EphemeralAgentHandler extends AgentHandler { + /** @type {string|null} the unique identifier for the agent invocation */ #invocationUUID = null; + /** @type {import("@prisma/client").workspaces|null} the workspace to use for the agent */ #workspace = null; + /** @type {import("@prisma/client").users|null} the user id to use for the agent */ #userId = null; + /** @type {import("@prisma/client").workspace_threads|null} the workspace thread id to use for the agent */ #threadId = null; + /** @type {string|null} the session id to use for the agent */ #sessionId = null; + /** @type {string|null} the prompt to use for the agent */ #prompt = null; + /** @type {string[]} the functions to load into the agent (Aibitat plugins) */ #funcsToLoad = []; + /** @type {AIbitat|null} */ aibitat = null; + /** @type {string|null} */ channel = null; + /** @type {string|null} */ provider = null; + /** @type {string|null} the model to use for the agent */ model = null; + /** + * @param {{ + * uuid: string, + * workspace: import("@prisma/client").workspaces, + * prompt: string, + * userId: import("@prisma/client").users["id"]|null, + * threadId: import("@prisma/client").workspace_threads["id"]|null, + * sessionId: string|null + * }} parameters + */ constructor({ uuid, workspace, @@ -148,8 +169,7 @@ class EphemeralAgentHandler extends AgentHandler { } // The provider was explicitly set, so check if the workspace has an agent model set. - if (this.invocation.workspace.agentModel) - return this.invocation.workspace.agentModel; + if (this.#workspace.agentModel) return this.#workspace.agentModel; // Otherwise, we have no model to use - so guess a default model to use via the provider // and it's system ENV params and if that fails - we return either a base model or null. From 2c9cb28d5f1c8d7ac615ecfca0f847389dfadc2f Mon Sep 17 00:00:00 2001 From: Timothy Carambat Date: Tue, 29 Oct 2024 15:30:53 -0700 Subject: [PATCH 06/16] Simple SSO feature for login flows from external services (#2553) * Simple SSO feature for login flows from external services * linting --- docker/.env.example | 6 +- frontend/src/App.jsx | 3 + frontend/src/models/system.js | 24 ++++ frontend/src/pages/Login/SSO/simple.jsx | 54 +++++++++ server/.env.example | 6 +- server/endpoints/api/userManagement/index.js | 60 ++++++++++ server/endpoints/system.js | 45 ++++++++ server/models/temporaryAuthToken.js | 104 ++++++++++++++++++ .../20241029203722_init/migration.sql | 12 ++ server/prisma/schema.prisma | 13 +++ server/swagger/openapi.json | 59 ++++++++++ server/utils/helpers/updateENV.js | 2 + server/utils/middleware/simpleSSOEnabled.js | 39 +++++++ 13 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/Login/SSO/simple.jsx create mode 100644 server/models/temporaryAuthToken.js create mode 100644 server/prisma/migrations/20241029203722_init/migration.sql create mode 100644 server/utils/middleware/simpleSSOEnabled.js diff --git a/docker/.env.example b/docker/.env.example index 7bb07ebef..2f6f896b0 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -291,4 +291,8 @@ GID='1000' # Disable viewing chat history from the UI and frontend APIs. # See https://docs.anythingllm.com/configuration#disable-view-chat-history for more information. -# DISABLE_VIEW_CHAT_HISTORY=1 \ No newline at end of file +# DISABLE_VIEW_CHAT_HISTORY=1 + +# Enable simple SSO passthrough to pre-authenticate users from a third party service. +# See https://docs.anythingllm.com/configuration#simple-sso-passthrough for more information. +# SIMPLE_SSO_ENABLED=1 \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cb3bac7f7..6ce42fadb 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -9,6 +9,7 @@ import PrivateRoute, { import { ToastContainer } from "react-toastify"; import "react-toastify/dist/ReactToastify.css"; import Login from "@/pages/Login"; +import SimpleSSOPassthrough from "@/pages/Login/SSO/simple"; import OnboardingFlow from "@/pages/OnboardingFlow"; import i18n from "./i18n"; @@ -77,6 +78,8 @@ export default function App() { } /> } /> + } /> + } diff --git a/frontend/src/models/system.js b/frontend/src/models/system.js index 1039d6de2..4231c83c8 100644 --- a/frontend/src/models/system.js +++ b/frontend/src/models/system.js @@ -706,6 +706,30 @@ const System = { ); return { viewable: isViewable, error: null }; }, + + /** + * Validates a temporary auth token and logs in the user if the token is valid. + * @param {string} publicToken - the token to validate against + * @returns {Promise<{valid: boolean, user: import("@prisma/client").users | null, token: string | null, message: string | null}>} + */ + simpleSSOLogin: async function (publicToken) { + return fetch(`${API_BASE}/request-token/sso/simple?token=${publicToken}`, { + method: "GET", + }) + .then(async (res) => { + if (!res.ok) { + const text = await res.text(); + if (!text.startsWith("{")) throw new Error(text); + return JSON.parse(text); + } + return await res.json(); + }) + .catch((e) => { + console.error(e); + return { valid: false, user: null, token: null, message: e.message }; + }); + }, + experimentalFeatures: { liveSync: LiveDocumentSync, agentPlugins: AgentPlugins, diff --git a/frontend/src/pages/Login/SSO/simple.jsx b/frontend/src/pages/Login/SSO/simple.jsx new file mode 100644 index 000000000..1ceedcfb8 --- /dev/null +++ b/frontend/src/pages/Login/SSO/simple.jsx @@ -0,0 +1,54 @@ +import React, { useEffect, useState } from "react"; +import { FullScreenLoader } from "@/components/Preloader"; +import { Navigate } from "react-router-dom"; +import paths from "@/utils/paths"; +import useQuery from "@/hooks/useQuery"; +import System from "@/models/system"; +import { AUTH_TIMESTAMP, AUTH_TOKEN, AUTH_USER } from "@/utils/constants"; + +export default function SimpleSSOPassthrough() { + const query = useQuery(); + const redirectPath = query.get("redirectTo") || paths.home(); + const [ready, setReady] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + try { + if (!query.get("token")) throw new Error("No token provided."); + + // Clear any existing auth data + window.localStorage.removeItem(AUTH_USER); + window.localStorage.removeItem(AUTH_TOKEN); + window.localStorage.removeItem(AUTH_TIMESTAMP); + + System.simpleSSOLogin(query.get("token")) + .then((res) => { + if (!res.valid) throw new Error(res.message); + + window.localStorage.setItem(AUTH_USER, JSON.stringify(res.user)); + window.localStorage.setItem(AUTH_TOKEN, res.token); + window.localStorage.setItem(AUTH_TIMESTAMP, Number(new Date())); + setReady(res.valid); + }) + .catch((e) => { + setError(e.message); + }); + } catch (e) { + setError(e.message); + } + }, []); + + if (error) + return ( +
+

{error}

+

+ Please contact the system administrator about this error. +

+
+ ); + if (ready) return ; + + // Loading state by default + return ; +} diff --git a/server/.env.example b/server/.env.example index 9c513f62f..199589278 100644 --- a/server/.env.example +++ b/server/.env.example @@ -280,4 +280,8 @@ TTS_PROVIDER="native" # Disable viewing chat history from the UI and frontend APIs. # See https://docs.anythingllm.com/configuration#disable-view-chat-history for more information. -# DISABLE_VIEW_CHAT_HISTORY=1 \ No newline at end of file +# DISABLE_VIEW_CHAT_HISTORY=1 + +# Enable simple SSO passthrough to pre-authenticate users from a third party service. +# See https://docs.anythingllm.com/configuration#simple-sso-passthrough for more information. +# SIMPLE_SSO_ENABLED=1 diff --git a/server/endpoints/api/userManagement/index.js b/server/endpoints/api/userManagement/index.js index 9b4e8c66f..733e1d313 100644 --- a/server/endpoints/api/userManagement/index.js +++ b/server/endpoints/api/userManagement/index.js @@ -1,5 +1,9 @@ const { User } = require("../../../models/user"); +const { TemporaryAuthToken } = require("../../../models/temporaryAuthToken"); const { multiUserMode } = require("../../../utils/http"); +const { + simpleSSOEnabled, +} = require("../../../utils/middleware/simpleSSOEnabled"); const { validApiKey } = require("../../../utils/middleware/validApiKey"); function apiUserManagementEndpoints(app) { @@ -59,6 +63,62 @@ function apiUserManagementEndpoints(app) { response.sendStatus(500).end(); } }); + + app.get( + "/v1/users/:id/issue-auth-token", + [validApiKey, simpleSSOEnabled], + async (request, response) => { + /* + #swagger.tags = ['User Management'] + #swagger.description = 'Issue a temporary auth token for a user' + #swagger.parameters['id'] = { + in: 'path', + description: 'The ID of the user to issue a temporary auth token for', + required: true, + type: 'string' + } + #swagger.responses[200] = { + content: { + "application/json": { + schema: { + type: 'object', + example: { + token: "1234567890", + loginPath: "/sso/simple?token=1234567890" + } + } + } + } + } + } + #swagger.responses[403] = { + schema: { + "$ref": "#/definitions/InvalidAPIKey" + } + } + #swagger.responses[401] = { + description: "Instance is not in Multi-User mode. Permission denied.", + } + */ + try { + const { id: userId } = request.params; + const user = await User.get({ id: Number(userId) }); + if (!user) + return response.status(404).json({ error: "User not found" }); + + const { token, error } = await TemporaryAuthToken.issue(userId); + if (error) return response.status(500).json({ error: error }); + + response.status(200).json({ + token: String(token), + loginPath: `/sso/simple?token=${token}`, + }); + } catch (e) { + console.error(e.message, e); + response.sendStatus(500).end(); + } + } + ); } module.exports = { apiUserManagementEndpoints }; diff --git a/server/endpoints/system.js b/server/endpoints/system.js index 5e631b2f3..6da117ff2 100644 --- a/server/endpoints/system.js +++ b/server/endpoints/system.js @@ -53,6 +53,8 @@ const { BrowserExtensionApiKey } = require("../models/browserExtensionApiKey"); const { chatHistoryViewable, } = require("../utils/middleware/chatHistoryViewable"); +const { simpleSSOEnabled } = require("../utils/middleware/simpleSSOEnabled"); +const { TemporaryAuthToken } = require("../models/temporaryAuthToken"); function systemEndpoints(app) { if (!app) return; @@ -251,6 +253,49 @@ function systemEndpoints(app) { } }); + app.get( + "/request-token/sso/simple", + [simpleSSOEnabled], + async (request, response) => { + const { token: tempAuthToken } = request.query; + const { sessionToken, token, error } = + await TemporaryAuthToken.validate(tempAuthToken); + + if (error) { + await EventLogs.logEvent("failed_login_invalid_temporary_auth_token", { + ip: request.ip || "Unknown IP", + multiUserMode: true, + }); + return response.status(401).json({ + valid: false, + token: null, + message: `[001] An error occurred while validating the token: ${error}`, + }); + } + + await Telemetry.sendTelemetry( + "login_event", + { multiUserMode: true }, + token.user.id + ); + await EventLogs.logEvent( + "login_event", + { + ip: request.ip || "Unknown IP", + username: token.user.username || "Unknown user", + }, + token.user.id + ); + + response.status(200).json({ + valid: true, + user: User.filterFields(token.user), + token: sessionToken, + message: null, + }); + } + ); + app.post( "/system/recover-account", [isMultiUserSetup], diff --git a/server/models/temporaryAuthToken.js b/server/models/temporaryAuthToken.js new file mode 100644 index 000000000..7f0c6b9f4 --- /dev/null +++ b/server/models/temporaryAuthToken.js @@ -0,0 +1,104 @@ +const { makeJWT } = require("../utils/http"); +const prisma = require("../utils/prisma"); + +/** + * Temporary auth tokens are used for simple SSO. + * They simply enable the ability for a time-based token to be used in the query of the /sso/login URL + * to login as a user without the need of a username and password. These tokens are single-use and expire. + */ +const TemporaryAuthToken = { + expiry: 1000 * 60 * 6, // 1 hour + tablename: "temporary_auth_tokens", + writable: [], + + makeTempToken: () => { + const uuidAPIKey = require("uuid-apikey"); + return `allm-tat-${uuidAPIKey.create().apiKey}`; + }, + + /** + * Issues a temporary auth token for a user via its ID. + * @param {number} userId + * @returns {Promise<{token: string|null, error: string | null}>} + */ + issue: async function (userId = null) { + if (!userId) + throw new Error("User ID is required to issue a temporary auth token."); + await this.invalidateUserTokens(userId); + + try { + const token = this.makeTempToken(); + const expiresAt = new Date(Date.now() + this.expiry); + await prisma.temporary_auth_tokens.create({ + data: { + token, + expiresAt, + userId: Number(userId), + }, + }); + + return { token, error: null }; + } catch (error) { + console.error("FAILED TO CREATE TEMPORARY AUTH TOKEN.", error.message); + return { token: null, error: error.message }; + } + }, + + /** + * Invalidates (deletes) all temporary auth tokens for a user via their ID. + * @param {number} userId + * @returns {Promise} + */ + invalidateUserTokens: async function (userId) { + if (!userId) + throw new Error( + "User ID is required to invalidate temporary auth tokens." + ); + await prisma.temporary_auth_tokens.deleteMany({ + where: { userId: Number(userId) }, + }); + return true; + }, + + /** + * Validates a temporary auth token and returns the session token + * to be set in the browser localStorage for authentication. + * @param {string} publicToken - the token to validate against + * @returns {Promise<{sessionToken: string|null, token: import("@prisma/client").temporary_auth_tokens & {user: import("@prisma/client").users} | null, error: string | null}>} + */ + validate: async function (publicToken = "") { + /** @type {import("@prisma/client").temporary_auth_tokens & {user: import("@prisma/client").users} | undefined | null} **/ + let token; + + try { + if (!publicToken) + throw new Error( + "Public token is required to validate a temporary auth token." + ); + token = await prisma.temporary_auth_tokens.findUnique({ + where: { token: String(publicToken) }, + include: { user: true }, + }); + if (!token) throw new Error("Invalid token."); + if (token.expiresAt < new Date()) throw new Error("Token expired."); + if (token.user.suspended) throw new Error("User account suspended."); + + // Create a new session token for the user valid for 30 days + const sessionToken = makeJWT( + { id: token.user.id, username: token.user.username }, + "30d" + ); + + return { sessionToken, token, error: null }; + } catch (error) { + console.error("FAILED TO VALIDATE TEMPORARY AUTH TOKEN.", error.message); + return { sessionToken: null, token: null, error: error.message }; + } finally { + // Delete the token after it has been used under all circumstances if it was retrieved + if (token) + await prisma.temporary_auth_tokens.delete({ where: { id: token.id } }); + } + }, +}; + +module.exports = { TemporaryAuthToken }; diff --git a/server/prisma/migrations/20241029203722_init/migration.sql b/server/prisma/migrations/20241029203722_init/migration.sql new file mode 100644 index 000000000..29ee89ad5 --- /dev/null +++ b/server/prisma/migrations/20241029203722_init/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "temporary_auth_tokens" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "token" TEXT NOT NULL, + "userId" INTEGER NOT NULL, + "expiresAt" DATETIME NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "temporary_auth_tokens_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "temporary_auth_tokens_token_key" ON "temporary_auth_tokens"("token"); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index b96130888..143646e65 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -78,6 +78,7 @@ model users { workspace_agent_invocations workspace_agent_invocations[] slash_command_presets slash_command_presets[] browser_extension_api_keys browser_extension_api_keys[] + temporary_auth_tokens temporary_auth_tokens[] } model recovery_codes { @@ -311,3 +312,15 @@ model browser_extension_api_keys { @@index([user_id]) } + +model temporary_auth_tokens { + id Int @id @default(autoincrement()) + token String @unique + userId Int + expiresAt DateTime + createdAt DateTime @default(now()) + user users @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([token]) + @@index([userId]) +} diff --git a/server/swagger/openapi.json b/server/swagger/openapi.json index fb343ee83..1222b78b2 100644 --- a/server/swagger/openapi.json +++ b/server/swagger/openapi.json @@ -2877,6 +2877,65 @@ } } }, + "/v1/users/{id}/issue-auth-token": { + "get": { + "tags": [ + "User Management" + ], + "description": "Issue a temporary auth token for a user", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "The ID of the user to issue a temporary auth token for" + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "example": { + "token": "1234567890", + "loginPath": "/sso/simple?token=1234567890" + } + } + } + } + }, + "401": { + "description": "Instance is not in Multi-User mode. Permission denied." + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + }, + "application/xml": { + "schema": { + "$ref": "#/components/schemas/InvalidAPIKey" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + } + } + } + }, "/v1/openai/models": { "get": { "tags": [ diff --git a/server/utils/helpers/updateENV.js b/server/utils/helpers/updateENV.js index d705fb730..a884f1324 100644 --- a/server/utils/helpers/updateENV.js +++ b/server/utils/helpers/updateENV.js @@ -899,6 +899,8 @@ function dumpENV() { "HTTPS_KEY_PATH", // Other Configuration Keys "DISABLE_VIEW_CHAT_HISTORY", + // Simple SSO + "SIMPLE_SSO_ENABLED", ]; // Simple sanitization of each value to prevent ENV injection via newline or quote escaping. diff --git a/server/utils/middleware/simpleSSOEnabled.js b/server/utils/middleware/simpleSSOEnabled.js new file mode 100644 index 000000000..903200c03 --- /dev/null +++ b/server/utils/middleware/simpleSSOEnabled.js @@ -0,0 +1,39 @@ +const { SystemSettings } = require("../../models/systemSettings"); + +/** + * Checks if simple SSO is enabled for issuance of temporary auth tokens. + * Note: This middleware must be called after `validApiKey`. + * @param {import("express").Request} request + * @param {import("express").Response} response + * @param {import("express").NextFunction} next + * @returns {void} + */ +async function simpleSSOEnabled(_, response, next) { + if (!("SIMPLE_SSO_ENABLED" in process.env)) { + return response + .status(403) + .send( + "Simple SSO is not enabled. It must be enabled to validate or issue temporary auth tokens." + ); + } + + // If the multi-user mode response local is not set, we need to check if it's enabled. + if (!("multiUserMode" in response.locals)) { + const multiUserMode = await SystemSettings.isMultiUserMode(); + response.locals.multiUserMode = multiUserMode; + } + + if (!response.locals.multiUserMode) { + return response + .status(403) + .send( + "Multi-User mode is not enabled. It must be enabled to use Simple SSO." + ); + } + + next(); +} + +module.exports = { + simpleSSOEnabled, +}; From dd2756b570697b0fcf1426ca5203f6f9e0938d0c Mon Sep 17 00:00:00 2001 From: Timothy Carambat Date: Tue, 29 Oct 2024 16:34:52 -0700 Subject: [PATCH 07/16] add `sessionToken` validation connection auth for AWSbedrock (#2554) --- .../AwsBedrockLLMOptions/index.jsx | 61 +++++++++++++++++++ server/models/systemSettings.js | 3 + server/utils/AiProviders/bedrock/index.js | 25 ++++++++ .../utils/agents/aibitat/providers/bedrock.js | 16 +++++ server/utils/helpers/updateENV.js | 11 ++++ 5 files changed, 116 insertions(+) diff --git a/frontend/src/components/LLMSelection/AwsBedrockLLMOptions/index.jsx b/frontend/src/components/LLMSelection/AwsBedrockLLMOptions/index.jsx index 00f44a35f..569ec4395 100644 --- a/frontend/src/components/LLMSelection/AwsBedrockLLMOptions/index.jsx +++ b/frontend/src/components/LLMSelection/AwsBedrockLLMOptions/index.jsx @@ -1,7 +1,12 @@ import { ArrowSquareOut, Info } from "@phosphor-icons/react"; import { AWS_REGIONS } from "./regions"; +import { useState } from "react"; export default function AwsBedrockLLMOptions({ settings }) { + const [useSessionToken, setUseSessionToken] = useState( + settings?.AwsBedrockLLMConnectionMethod === "sessionToken" + ); + return (
{!settings?.credentialsOnly && ( @@ -24,6 +29,43 @@ export default function AwsBedrockLLMOptions({ settings }) {
)} +
+ +
+ +

+ Select the method to authenticate with AWS Bedrock. +

+
+
+ + IAM + + + + Session Token + +
+
+
+ {useSessionToken && ( +
+ + +
+ )}