Skip to content

Commit 0ac12be

Browse files
Cristhianzlautofix-ci[bot]
authored andcommitted
feat(playground): Add auth gate, session persistence and token display to shareable playground (#12519)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent 39558be commit 0ac12be

38 files changed

Lines changed: 3599 additions & 129 deletions

File tree

src/backend/base/langflow/api/utils/flow_utils.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -104,33 +104,52 @@ async def cascade_delete_flow(session: AsyncSession, flow_id: uuid.UUID) -> None
104104
raise RuntimeError(msg, e) from e
105105

106106

107-
async def verify_public_flow_and_get_user(flow_id: uuid.UUID, client_id: str | None) -> tuple[User, uuid.UUID]:
107+
def compute_virtual_flow_id(identifier: str | uuid.UUID, flow_id: uuid.UUID) -> uuid.UUID:
108+
"""Compute a deterministic virtual flow ID for session/message isolation.
109+
110+
Args:
111+
identifier: A unique identifier (user_id for authenticated users, client_id for anonymous).
112+
flow_id: The original flow ID.
113+
114+
Returns:
115+
A deterministic UUID v5 derived from the identifier and flow_id.
116+
"""
117+
return uuid.uuid5(uuid.NAMESPACE_DNS, f"{identifier}_{flow_id}")
118+
119+
120+
async def verify_public_flow_and_get_user(
121+
flow_id: uuid.UUID,
122+
client_id: str | None,
123+
authenticated_user_id: uuid.UUID | None = None,
124+
) -> tuple[User, uuid.UUID]:
108125
"""Verify a public flow request and generate a deterministic flow ID.
109126
110127
This utility function:
111-
1. Checks that a client_id cookie is provided
128+
1. Checks that a client_id cookie or authenticated_user_id is provided
112129
2. Verifies the flow exists and is marked as PUBLIC
113-
3. Creates a deterministic UUID based on client_id and original flow_id
130+
3. Creates a deterministic UUID based on the identifier and original flow_id
114131
4. Retrieves the flow owner user for permission purposes
115132
116-
This function is used to support public flow endpoints that don't require
117-
authentication but still need to operate within the permission model.
133+
When an authenticated_user_id is provided, it takes precedence over client_id
134+
for UUID v5 generation. This enables DB-persisted sessions for logged-in users
135+
on the shareable playground.
118136
119137
Args:
120138
flow_id: The original flow ID to verify
121139
client_id: The client ID from the request cookie
140+
authenticated_user_id: The authenticated user's ID (takes precedence over client_id)
122141
123142
Returns:
124143
tuple: (flow owner user, deterministic flow ID for tracking)
125144
126145
Raises:
127146
HTTPException:
128-
- 400 if no client_id is provided
147+
- 400 if neither client_id nor authenticated_user_id is provided
129148
- 403 if flow doesn't exist or isn't public
130149
- 403 if unable to retrieve the flow owner user
131150
- 403 if user is not found for public flow
132151
"""
133-
if not client_id:
152+
if not client_id and not authenticated_user_id:
134153
raise HTTPException(status_code=400, detail="No client_id cookie found")
135154

136155
# Check if the flow is public
@@ -143,9 +162,9 @@ async def verify_public_flow_and_get_user(flow_id: uuid.UUID, client_id: str | N
143162
if not flow or flow.access_type is not AccessTypeEnum.PUBLIC:
144163
raise HTTPException(status_code=403, detail="Flow is not public")
145164

146-
# Create a new flow ID using the client_id and flow_id
147-
new_id = f"{client_id}_{flow_id}"
148-
new_flow_id = uuid.uuid5(uuid.NAMESPACE_DNS, new_id)
165+
# Use authenticated user_id for deterministic UUID when available, otherwise client_id
166+
identifier = str(authenticated_user_id) if authenticated_user_id else client_id
167+
new_flow_id = compute_virtual_flow_id(identifier, flow_id)
149168

150169
# Get the user associated with the flow
151170
try:

src/backend/base/langflow/api/v1/chat.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,14 @@
4242
VerticesOrderResponse,
4343
)
4444
from langflow.exceptions.component import ComponentBuildError
45-
from langflow.services.auth.utils import get_current_active_user
45+
from langflow.services.auth.utils import get_current_active_user, get_current_user_optional
4646
from langflow.services.chat.service import ChatService
4747
from langflow.services.database.models.flow.model import AccessTypeEnum, Flow
48+
from langflow.services.database.models.user.model import User
4849
from langflow.services.deps import (
4950
get_chat_service,
5051
get_queue_service,
52+
get_settings_service,
5153
get_telemetry_service,
5254
session_scope,
5355
)
@@ -648,6 +650,7 @@ async def build_public_tmp(
648650
flow_name: str | None = None,
649651
request: Request,
650652
queue_service: Annotated[JobQueueService, Depends(get_queue_service)],
653+
authenticated_user: Annotated[User | None, Depends(get_current_user_optional)] = None,
651654
event_delivery: EventDeliveryType = EventDeliveryType.POLLING,
652655
):
653656
"""Build a public flow without requiring authentication.
@@ -676,6 +679,7 @@ async def build_public_tmp(
676679
flow_name: Optional name for the flow
677680
request: FastAPI request object (needed for cookie access)
678681
queue_service: Queue service for job management
682+
authenticated_user: Optional authenticated user (resolved from cookie/token if present)
679683
event_delivery: Optional event delivery type - default is streaming
680684
681685
Returns:
@@ -684,7 +688,16 @@ async def build_public_tmp(
684688
try:
685689
# Verify this is a public flow and get the associated user
686690
client_id = request.cookies.get("client_id")
687-
owner_user, new_flow_id = await verify_public_flow_and_get_user(flow_id=flow_id, client_id=client_id)
691+
# Only use authenticated user_id when auto-login is disabled.
692+
# When AUTO_LOGIN=TRUE, the frontend uses client_id for UUID v5,
693+
# so the backend must match to avoid flow_id mismatch.
694+
auth_settings = get_settings_service().auth_settings
695+
authenticated_user_id = authenticated_user.id if authenticated_user and not auth_settings.AUTO_LOGIN else None
696+
owner_user, new_flow_id = await verify_public_flow_and_get_user(
697+
flow_id=flow_id,
698+
client_id=client_id,
699+
authenticated_user_id=authenticated_user_id,
700+
)
688701

689702
# Validate the stored flow data after the public-access boundary.
690703
# Public flows never accept client-supplied data.
@@ -707,7 +720,7 @@ async def build_public_tmp(
707720
log_builds=log_builds or False,
708721
current_user=owner_user,
709722
queue_service=queue_service,
710-
flow_name=flow_name or f"{client_id}_{flow_id}",
723+
flow_name=flow_name or f"{authenticated_user_id or client_id}_{flow_id}",
711724
)
712725
except CustomComponentValidationError as exc:
713726
await logger.awarning(f"Public flow validation failed: {exc}")

src/backend/base/langflow/api/v1/monitor.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from sqlmodel import col, delete, select
88

99
from langflow.api.utils import DbSession, custom_params
10+
from langflow.api.utils.flow_utils import compute_virtual_flow_id
1011
from langflow.schema.message import MessageResponse
1112
from langflow.services.auth.utils import get_current_active_user
1213
from langflow.services.database.models.flow.model import Flow
@@ -304,6 +305,164 @@ async def delete_messages_sessions(
304305
}
305306

306307

308+
@router.get("/messages/shared/sessions")
309+
async def get_shared_message_sessions(
310+
session: DbSession,
311+
current_user: Annotated[User, Depends(get_current_active_user)],
312+
source_flow_id: Annotated[UUID, Query(description="The original public flow ID")],
313+
) -> list[str]:
314+
"""Get session IDs for a shared/public flow, scoped to the authenticated user.
315+
316+
Uses a deterministic virtual flow_id derived from the user's ID and the
317+
original flow ID. Only messages stored under this virtual flow_id are returned.
318+
"""
319+
try:
320+
virtual_flow_id = compute_virtual_flow_id(current_user.id, source_flow_id)
321+
stmt = select(MessageTable.session_id).distinct()
322+
stmt = stmt.where(MessageTable.flow_id == virtual_flow_id)
323+
stmt = stmt.where(col(MessageTable.session_id).isnot(None))
324+
325+
session_ids = await session.exec(stmt)
326+
return list(session_ids)
327+
except Exception as e:
328+
raise HTTPException(status_code=500, detail=str(e)) from e
329+
330+
331+
@router.get("/messages/shared")
332+
async def get_shared_messages(
333+
session: DbSession,
334+
current_user: Annotated[User, Depends(get_current_active_user)],
335+
source_flow_id: Annotated[UUID, Query(description="The original public flow ID")],
336+
session_id: Annotated[str | None, Query()] = None,
337+
order_by: Annotated[str | None, Query()] = "timestamp",
338+
) -> list[MessageResponse]:
339+
"""Get messages for a shared/public flow, scoped to the authenticated user.
340+
341+
Uses a deterministic virtual flow_id derived from the user's ID and the
342+
original flow ID. Only messages stored under this virtual flow_id are returned.
343+
"""
344+
try:
345+
virtual_flow_id = compute_virtual_flow_id(current_user.id, source_flow_id)
346+
stmt = select(MessageTable)
347+
stmt = stmt.where(MessageTable.flow_id == virtual_flow_id)
348+
349+
if session_id:
350+
from urllib.parse import unquote
351+
352+
decoded_session_id = unquote(session_id)
353+
stmt = stmt.where(MessageTable.session_id == decoded_session_id)
354+
allowed_order_fields = {"timestamp", "sender", "sender_name", "session_id", "text"}
355+
if order_by:
356+
if order_by not in allowed_order_fields:
357+
raise HTTPException(status_code=400, detail=f"Invalid order_by field: {order_by}")
358+
order_col = getattr(MessageTable, order_by).asc()
359+
stmt = stmt.order_by(order_col)
360+
361+
messages = await session.exec(stmt)
362+
return [MessageResponse.model_validate(d, from_attributes=True) for d in messages]
363+
except HTTPException:
364+
raise
365+
except Exception as e:
366+
raise HTTPException(status_code=500, detail=str(e)) from e
367+
368+
369+
@router.delete("/messages/shared/session/{session_id}", status_code=204)
370+
async def delete_shared_messages_session(
371+
session_id: str,
372+
session: DbSession,
373+
current_user: Annotated[User, Depends(get_current_active_user)],
374+
source_flow_id: Annotated[UUID, Query(description="The original public flow ID")],
375+
):
376+
"""Delete messages for a session on a shared/public flow, scoped to the authenticated user."""
377+
try:
378+
virtual_flow_id = compute_virtual_flow_id(current_user.id, source_flow_id)
379+
stmt = (
380+
delete(MessageTable)
381+
.where(MessageTable.flow_id == virtual_flow_id)
382+
.where(MessageTable.session_id == session_id)
383+
)
384+
await session.exec(stmt)
385+
except Exception as e:
386+
await session.rollback()
387+
raise HTTPException(status_code=500, detail=str(e)) from e
388+
389+
390+
@router.put("/messages/shared/{message_id}", response_model=MessageRead)
391+
async def update_shared_message(
392+
message_id: UUID,
393+
message: MessageUpdate,
394+
session: DbSession,
395+
current_user: Annotated[User, Depends(get_current_active_user)],
396+
source_flow_id: Annotated[UUID, Query(description="The original public flow ID")],
397+
):
398+
"""Update a message on a shared/public flow, scoped to the authenticated user."""
399+
try:
400+
virtual_flow_id = compute_virtual_flow_id(current_user.id, source_flow_id)
401+
db_message = (
402+
await session.exec(
403+
select(MessageTable).where(
404+
MessageTable.id == message_id,
405+
MessageTable.flow_id == virtual_flow_id,
406+
)
407+
)
408+
).first()
409+
except Exception as e:
410+
raise HTTPException(status_code=500, detail=str(e)) from e
411+
412+
if not db_message:
413+
raise HTTPException(status_code=404, detail="Message not found")
414+
415+
try:
416+
message_dict = message.model_dump(exclude_unset=True, exclude_none=True)
417+
if "text" in message_dict and message_dict["text"] != db_message.text:
418+
message_dict["edit"] = True
419+
db_message.sqlmodel_update(message_dict)
420+
session.add(db_message)
421+
await session.flush()
422+
await session.refresh(db_message)
423+
except Exception as e:
424+
raise HTTPException(status_code=500, detail=str(e)) from e
425+
return db_message
426+
427+
428+
@router.patch("/messages/shared/session/{old_session_id}")
429+
async def rename_shared_session(
430+
old_session_id: str,
431+
new_session_id: Annotated[str, Query(description="The new session ID")],
432+
session: DbSession,
433+
current_user: Annotated[User, Depends(get_current_active_user)],
434+
source_flow_id: Annotated[UUID, Query(description="The original public flow ID")],
435+
) -> list[MessageResponse]:
436+
"""Rename a session on a shared/public flow, scoped to the authenticated user."""
437+
try:
438+
virtual_flow_id = compute_virtual_flow_id(current_user.id, source_flow_id)
439+
stmt = select(MessageTable).where(
440+
MessageTable.flow_id == virtual_flow_id,
441+
MessageTable.session_id == old_session_id,
442+
)
443+
messages = list(await session.exec(stmt))
444+
except Exception as e:
445+
raise HTTPException(status_code=500, detail=str(e)) from e
446+
447+
if not messages:
448+
raise HTTPException(status_code=404, detail="No messages found with the given session ID")
449+
450+
try:
451+
for message in messages:
452+
message.session_id = new_session_id
453+
session.add_all(messages)
454+
await session.flush()
455+
456+
result = []
457+
for message in messages:
458+
await session.refresh(message)
459+
result.append(MessageResponse.model_validate(message, from_attributes=True))
460+
except Exception as e:
461+
raise HTTPException(status_code=500, detail=str(e)) from e
462+
463+
return result
464+
465+
307466
@router.get("/transactions", dependencies=[Depends(get_current_active_user)])
308467
async def get_transactions(
309468
flow_id: Annotated[UUID, Query()],

src/backend/base/langflow/services/auth/utils.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,30 @@ async def get_webhook_user(flow_id: str, request: Request) -> UserRead:
269269
return await _auth_service().get_webhook_user(flow_id, request)
270270

271271

272+
async def get_current_user_optional(
273+
request: Request,
274+
db: AsyncSession = Depends(injectable_session_scope),
275+
) -> User | None:
276+
"""Resolve the current user if authenticated, otherwise return None.
277+
278+
Checks HttpOnly cookie (access_token_lf), Authorization header, and API key.
279+
Used by endpoints that support both authenticated and unauthenticated access.
280+
"""
281+
token = request.cookies.get("access_token_lf")
282+
api_key = request.query_params.get("x-api-key") or request.headers.get("x-api-key")
283+
auth_header = request.headers.get("Authorization")
284+
if auth_header and auth_header.startswith("Bearer "):
285+
token = token or auth_header[len("Bearer ") :]
286+
287+
if not token and not api_key:
288+
return None
289+
290+
try:
291+
return await _auth_service().get_current_user_for_sse(token, api_key, db)
292+
except (AuthenticationError, HTTPException):
293+
return None
294+
295+
272296
async def get_current_active_user(user: User = Depends(get_current_user)) -> User | UserRead:
273297
result = await _auth_service().get_current_active_user(user)
274298
if result is None:

0 commit comments

Comments
 (0)