diff --git a/docker/.env.example b/docker/.env.example index 7bb07ebef73da303d69d0458890ed7f40941b7ed..2f6f896b0fca0c1519aa8d2ddb49b8343b2e064f 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 cb3bac7f719e25f08edf77332c45ccbd5bc5d3ba..6ce42fadbdfddaf0593448cb6cc328a10e9832e8 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() { <Routes> <Route path="/" element={<PrivateRoute Component={Main} />} /> <Route path="/login" element={<Login />} /> + <Route path="/sso/simple" element={<SimpleSSOPassthrough />} /> + <Route path="/workspace/:slug/settings/:tab" element={<ManagerRoute Component={WorkspaceSettings} />} diff --git a/frontend/src/models/system.js b/frontend/src/models/system.js index 1039d6de25c0e25dc9b2755699c95884f1f1ba1c..4231c83c810adaf24349aaeb98698b5335d73135 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 0000000000000000000000000000000000000000..1ceedcfb8ec6ea36be0c000492221a46dead6e60 --- /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 ( + <div className="w-screen h-screen overflow-hidden bg-sidebar flex items-center justify-center flex-col gap-4"> + <p className="text-white font-mono text-lg">{error}</p> + <p className="text-white/80 font-mono text-sm"> + Please contact the system administrator about this error. + </p> + </div> + ); + if (ready) return <Navigate to={redirectPath} />; + + // Loading state by default + return <FullScreenLoader />; +} diff --git a/server/.env.example b/server/.env.example index 9c513f62f87392cc80f1cf36939b8fa8e6bd7b81..1995892780666664e5e56868b22b3c693f97bb8e 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 9b4e8c66f019e0a2e6c7a9d565388a11923335d9..733e1d3139aa366d4045b2ad64cb5f9002fa5432 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 5e631b2f3f2647a9629f2c08ae08a71651a01701..6da117ff28b23bb8e28ac15ebccc1223d13ca00b 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 0000000000000000000000000000000000000000..7f0c6b9f47e7041902333ede678d9433221db041 --- /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<boolean>} + */ + 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 0000000000000000000000000000000000000000..29ee89ad5a7212441daa22fa0006b9ffebf88f3a --- /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 b96130888530af1dde978fbb574d14f646bbeffc..143646e6579f69f4add5a0df9c03ecca33b1ab8f 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 fb343ee836202f442b60357c76b3e3fa84d44498..1222b78b247e40b791c2d32bc9eca24afc4b8b31 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 d705fb73066b9e8fc2e7161a1fef53885093b7cc..a884f1324ba3d1949de426d09bb99e984a95a2ae 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 0000000000000000000000000000000000000000..903200c037868ae016010f60118c8b9880175e29 --- /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, +};