Simple SSO feature for login flows from external services ()

* Simple SSO feature for login flows from external services

* linting
This commit is contained in:
Timothy Carambat 2024-10-29 15:30:53 -07:00 committed by GitHub
parent 3fe59a7cf5
commit 2c9cb28d5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 425 additions and 2 deletions
docker
frontend/src
App.jsx
models
pages/Login/SSO
server
.env.example
endpoints
api/userManagement
system.js
models
prisma
migrations/20241029203722_init
schema.prisma
swagger
utils

View file

@ -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

View file

@ -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} />}

View file

@ -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,

View 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 />;
}

View file

@ -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

View file

@ -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 };

View file

@ -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],

View 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 };

View 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");

View file

@ -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])
}

View file

@ -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": [

View file

@ -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.

View 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,
};