mirror of
https://github.com/Mintplex-Labs/anything-llm.git
synced 2025-05-02 17:07:13 +00:00
Simple SSO feature for login flows from external services (#2553)
* Simple SSO feature for login flows from external services * linting
This commit is contained in:
parent
3fe59a7cf5
commit
2c9cb28d5f
13 changed files with 425 additions and 2 deletions
|
@ -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
|
||||
# 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
|
|
@ -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} />}
|
||||
|
|
|
@ -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,
|
||||
|
|
54
frontend/src/pages/Login/SSO/simple.jsx
Normal file
54
frontend/src/pages/Login/SSO/simple.jsx
Normal file
|
@ -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 />;
|
||||
}
|
|
@ -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
|
||||
# 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
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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],
|
||||
|
|
104
server/models/temporaryAuthToken.js
Normal file
104
server/models/temporaryAuthToken.js
Normal file
|
@ -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 };
|
12
server/prisma/migrations/20241029203722_init/migration.sql
Normal file
12
server/prisma/migrations/20241029203722_init/migration.sql
Normal file
|
@ -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");
|
|
@ -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])
|
||||
}
|
||||
|
|
|
@ -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": [
|
||||
|
|
|
@ -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.
|
||||
|
|
39
server/utils/middleware/simpleSSOEnabled.js
Normal file
39
server/utils/middleware/simpleSSOEnabled.js
Normal file
|
@ -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,
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue