From 2fbb57adf8057261508606d1522e42a75237a0c5 Mon Sep 17 00:00:00 2001 From: Parker Rule Date: Wed, 10 Sep 2025 14:41:18 -0700 Subject: [PATCH 1/2] fix(api): call threads:create auth handler when copying a thread --- libs/langgraph-api/src/storage/ops.mts | 17 +++++++++++---- libs/langgraph-api/tests/auth.test.mts | 27 +++++++++++++++++++++++- libs/langgraph-api/tests/graphs/auth.mts | 14 ++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/libs/langgraph-api/src/storage/ops.mts b/libs/langgraph-api/src/storage/ops.mts index 249bfdc5f..788eea21f 100644 --- a/libs/langgraph-api/src/storage/ops.mts +++ b/libs/langgraph-api/src/storage/ops.mts @@ -908,7 +908,7 @@ export class FileSystemThreads implements ThreadsRepo { thread_id, }); - return this.conn.with((STORE) => { + const fromThread = await this.conn.with((STORE) => { const thread = STORE.threads[thread_id]; if (!thread) throw new HTTPException(409, { message: "Thread not found" }); @@ -916,14 +916,23 @@ export class FileSystemThreads implements ThreadsRepo { if (!isAuthMatching(thread["metadata"], filters)) { throw new HTTPException(409, { message: "Thread not found" }); } + return thread; + }); - const newThreadId = uuid4(); - const now = new Date(); + const newThreadId = uuid4(); + const now = new Date(); + const newMetadata = { ...fromThread.metadata, thread_id: newThreadId }; + await handleAuthEvent(auth, "threads:create", { + thread_id: newThreadId, + metadata: newMetadata, + }); + + return this.conn.with((STORE) => { STORE.threads[newThreadId] = { thread_id: newThreadId, created_at: now, updated_at: now, - metadata: { ...thread.metadata, thread_id: newThreadId }, + metadata: newMetadata, config: {}, status: "idle", }; diff --git a/libs/langgraph-api/tests/auth.test.mts b/libs/langgraph-api/tests/auth.test.mts index 62631ecac..7a8211549 100644 --- a/libs/langgraph-api/tests/auth.test.mts +++ b/libs/langgraph-api/tests/auth.test.mts @@ -684,7 +684,7 @@ it.skipIf(process.version.startsWith("v18."))( ); it.skipIf(process.version.startsWith("v18."))( - "thread copy authorization", + "thread copy authorization (by owner)", async () => { const owner = await createJwtClient("johndoe", ["me"]); const otherUser = await createJwtClient("alice", ["me"]); @@ -702,6 +702,31 @@ it.skipIf(process.version.startsWith("v18."))( } ); +it.skipIf(process.version.startsWith("v18."))( + "thread copy authorization (requires read and write)", + async () => { + const writer = await createJwtClient("johndoe", ["me"]); + const reader = await createJwtClient("johndoe", ["threads:read"]); + + const thread = await writer.threads.create(); + + // Can read the thread from both... + const readerThread = await reader.threads.get(thread.thread_id); + const writerThread = await writer.threads.get(thread.thread_id); + expect(thread).toEqual(readerThread); + expect(thread).toEqual(writerThread); + + // But can only copy the thread from the writer + const copiedThread = await writer.threads.copy(thread.thread_id); + expect(copiedThread).not.toBeNull(); + expect(copiedThread.thread_id).not.toBe(thread.thread_id); + + await expect(reader.threads.copy(thread.thread_id)).rejects.toThrow( + "HTTP 403: Not authorized" + ); + } +); + it.skipIf(process.version.startsWith("v18."))( "thread history authorization", async () => { diff --git a/libs/langgraph-api/tests/graphs/auth.mts b/libs/langgraph-api/tests/graphs/auth.mts index fbd7bfd7d..58a867f00 100644 --- a/libs/langgraph-api/tests/graphs/auth.mts +++ b/libs/langgraph-api/tests/graphs/auth.mts @@ -103,4 +103,18 @@ export const auth = new Auth() if (!identity || !value.namespace?.includes(identity)) { throw new HTTPException(403, { message: "Not authorized" }); } + }) + .on("threads:create", ({ value, user, permissions }) => { + if ( + !["me", "threads:write", "threads:*"].some((p) => + permissions?.includes(p) + ) + ) { + throw new HTTPException(403, { message: "Not authorized" }); + } + + const filters = { owner: user.identity }; + value.metadata ??= {}; + value.metadata["owner"] = user.identity; + return filters; }); From d25c6180ef0aa32fa62fa164c97c099ec0aa0f06 Mon Sep 17 00:00:00 2001 From: Parker Rule Date: Wed, 10 Sep 2025 14:44:26 -0700 Subject: [PATCH 2/2] Add changeset --- .changeset/silly-plants-follow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/silly-plants-follow.md diff --git a/.changeset/silly-plants-follow.md b/.changeset/silly-plants-follow.md new file mode 100644 index 000000000..63a5e1efd --- /dev/null +++ b/.changeset/silly-plants-follow.md @@ -0,0 +1,5 @@ +--- +"@langchain/langgraph-api": patch +--- + +fix(api): call threads:create auth handler when copying a thread