Skip to content

Commit

Permalink
Simple SSO feature for login flows from external services (#2553)
Browse files Browse the repository at this point in the history
* Simple SSO feature for login flows from external services

* linting
  • Loading branch information
timothycarambat authored Oct 29, 2024
1 parent 3fe59a7 commit 2c9cb28
Show file tree
Hide file tree
Showing 13 changed files with 425 additions and 2 deletions.
6 changes: 5 additions & 1 deletion docker/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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} />}
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/models/system.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/pages/Login/SSO/simple.jsx
Original file line number Diff line number Diff line change
@@ -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 />;
}
6 changes: 5 additions & 1 deletion server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
60 changes: 60 additions & 0 deletions server/endpoints/api/userManagement/index.js
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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 };
45 changes: 45 additions & 0 deletions server/endpoints/system.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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],
Expand Down
104 changes: 104 additions & 0 deletions server/models/temporaryAuthToken.js
Original file line number Diff line number Diff line change
@@ -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 changes: 12 additions & 0 deletions server/prisma/migrations/20241029203722_init/migration.sql
Original file line number Diff line number Diff line change
@@ -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");
13 changes: 13 additions & 0 deletions server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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])
}
Loading

0 comments on commit 2c9cb28

Please sign in to comment.