diff --git a/docs/_scripts/notebook_hooks.py b/docs/_scripts/notebook_hooks.py index 23c460e7a..c993b6d22 100644 --- a/docs/_scripts/notebook_hooks.py +++ b/docs/_scripts/notebook_hooks.py @@ -1,4 +1,5 @@ import logging +import os from typing import Any, Dict from mkdocs.structure.pages import Page @@ -8,6 +9,7 @@ logger = logging.getLogger(__name__) logging.basicConfig() logger.setLevel(logging.INFO) +DISABLED = os.getenv("DISABLE_NOTEBOOK_CONVERT") in ("1", "true", "True") class NotebookFile(File): @@ -16,6 +18,8 @@ def is_documentation_page(self): def on_files(files: Files, **kwargs: Dict[str, Any]): + if DISABLED: + return files new_files = Files([]) for file in files: if file.src_path.endswith(".ipynb"): @@ -32,6 +36,8 @@ def on_files(files: Files, **kwargs: Dict[str, Any]): def on_page_markdown(markdown: str, page: Page, **kwargs: Dict[str, Any]): + if DISABLED: + return markdown if page.file.src_path.endswith(".ipynb"): logger.info("Processing Jupyter notebook: %s", page.file.src_path) body = convert_notebook(page.file.abs_src_path) diff --git a/docs/docs/cloud/reference/cli.md b/docs/docs/cloud/reference/cli.md index cf42d533a..1daff19ee 100644 --- a/docs/docs/cloud/reference/cli.md +++ b/docs/docs/cloud/reference/cli.md @@ -21,14 +21,15 @@ The LangGraph command line interface includes commands to build and run a LangGr [](){#langgraph.json} -## Configuration File +## Configuration File {#configuration-file} The LangGraph CLI requires a JSON configuration file with the following keys: -| Key | Description | +| Key | Description | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `dependencies` | **Required**. Array of dependencies for LangGraph Cloud API server. Dependencies can be one of the following: (1) `"."`, which will look for local Python packages, (2) `pyproject.toml`, `setup.py` or `requirements.txt` in the app directory `"./local_package"`, or (3) a package name. | | `graphs` | **Required**. Mapping from graph ID to path where the compiled graph or a function that makes a graph is defined. Example: | +| `auth` | _(Added in v0.0.11)_ Auth configuration containing the path to your authentication handler. Example: `./your_package/auth.py:auth`, where `auth` is an instance of `langgraph_sdk.Auth`. See [authentication guide](../../concepts/auth.md) for details. | | `env` | Path to `.env` file or a mapping from environment variable to its value. | | `store` | Configuration for adding semantic search to the BaseStore. Contains the following fields: | | `python_version` | `3.11` or `3.12`. Defaults to `3.11`. | @@ -120,6 +121,35 @@ def embed_texts(texts: list[str]) -> list[list[float]]: return [[0.1, 0.2, ...] for _ in texts] # dims-dimensional vectors ``` +#### Adding custom authentication + +```json +{ + "dependencies": ["."], + "graphs": { + "chat": "./chat/graph.py:graph" + }, + "auth": { + "path": "./auth.py:auth", + "openapi": { + "securitySchemes": { + "apiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key" + } + }, + "security": [ + {"apiKeyAuth": []} + ] + }, + "disable_studio_auth": false + } +} +``` + +See the [authentication conceptual guide](../../concepts/auth.md) for details, and the [setting up custom authentication](../../tutorials/auth/getting_started.md) guide for a practical walk through of the process. + ## Commands The base command for the LangGraph CLI is `langgraph`. diff --git a/docs/docs/cloud/reference/sdk/python_sdk_ref.md b/docs/docs/cloud/reference/sdk/python_sdk_ref.md index 4126ca306..7ac7aa165 100644 --- a/docs/docs/cloud/reference/sdk/python_sdk_ref.md +++ b/docs/docs/cloud/reference/sdk/python_sdk_ref.md @@ -7,23 +7,10 @@ ::: langgraph_sdk.schema handler: python - ::: langgraph_sdk.auth handler: python -::: langgraph_sdk.auth.types.Authenticator - handler: python - -::: langgraph_sdk.auth.types.Handler - handler: python - -::: langgraph_sdk.auth.types.HandlerResult - handler: python - -::: langgraph_sdk.auth.types.FilterType - handler: python - -::: langgraph_sdk.auth.types.AuthContext +::: langgraph_sdk.auth.types handler: python ::: langgraph_sdk.auth.exceptions diff --git a/docs/docs/concepts/auth.md b/docs/docs/concepts/auth.md new file mode 100644 index 000000000..ecbbacc6b --- /dev/null +++ b/docs/docs/concepts/auth.md @@ -0,0 +1,431 @@ +# Authentication & Access Control + +LangGraph Platform provides a flexible authentication and authorization system that can integrate with most authentication schemes. + +!!! note "Python only" + + We currently only support custom authentication and authorization in Python deployments with `langgraph-api>=0.0.11`. Support for LangGraph.JS will be added soon. + +## Core Concepts + +### Authentication vs Authorization + +While often used interchangeably, these terms represent distinct security concepts: + +- [**Authentication**](#authentication) ("AuthN") verifies _who_ you are. This runs as middleware for every request. +- [**Authorization**](#authorization) ("AuthZ") determines _what you can do_. This validates the user's privileges and roles on a per-resource basis. + +In LangGraph Platform, authentication is handled by your [`@auth.authenticate`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.authenticate) handler, and authorization is handled by your [`@auth.on`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.on) handlers. + +## System Architecture + +A typical authentication setup involves three main components: + +1. **Authentication Provider** (Identity Provider/IdP) + + * A dedicated service that manages user identities and credentials + * Handles user registration, login, password resets, etc. + * Issues tokens (JWT, session tokens, etc.) after successful authentication + * Examples: Auth0, Supabase Auth, Okta, or your own auth server + +2. **LangGraph Backend** (Resource Server) + + * Your LangGraph application that contains business logic and protected resources + * Validates tokens with the auth provider + * Enforces access control based on user identity and permissions + * Doesn't store user credentials directly + +3. **Client Application** (Frontend) + + * Web app, mobile app, or API client + * Collects time-sensitive user credentials and sends to auth provider + * Receives tokens from auth provider + * Includes these tokens in requests to LangGraph backend + +Here's how these components typically interact: + +```mermaid +sequenceDiagram + participant Client as Client App + participant Auth as Auth Provider + participant LG as LangGraph Backend + + Client->>Auth: 1. Login (username/password) + Auth-->>Client: 2. Return token + Client->>LG: 3. Request with token + Note over LG: 4. Validate token (@auth.authenticate) + LG-->>Auth: 5. Fetch user info + Auth-->>LG: 6. Confirm validity + Note over LG: 7. Apply access control (@auth.on.*) + LG-->>Client: 8. Return resources +``` + +Your [`@auth.authenticate`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.authenticate) handler in LangGraph handles steps 4-6, while your [`@auth.on`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.on) handlers implement step 7. + +## Authentication + +Authentication in LangGraph runs as middleware on every request. Your [`@auth.authenticate`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.authenticate) handler receives request information and should: + +1. Validate the credentials +2. Return [user info](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.MinimalUserDict) containing the user's identity and user information if valid +3. Raise an [HTTP exception](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.exceptions.HTTPException) or AssertionError if invalid + +```python +from langgraph_sdk import Auth + +auth = Auth() + +@auth.authenticate +async def authenticate(headers: dict) -> Auth.types.MinimalUserDict: + # Validate credentials (e.g., API key, JWT token) + api_key = headers.get("x-api-key") + if not api_key or not is_valid_key(api_key): + raise Auth.exceptions.HTTPException( + status_code=401, + detail="Invalid API key" + ) + + # Return user info - only identity and is_authenticated are required + # Add any additional fields you need for authorization + return { + "identity": "user-123", # Required: unique user identifier + "is_authenticated": True, # Optional: assumed True by default + "permissions": ["read", "write"] # Optional: for permission-based auth + # You can add more custom fields if you want to implement other auth patterns + "role": "admin", + "org_id": "org-456" + + } +``` + +The returned user information is available: + +- To your authorization handlers via [`ctx.user`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.AuthContext) +- In your application via `config["configuration"]["langgraph_auth_user"]` + +??? tip "Supported Parameters" + + The [`@auth.authenticate`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.authenticate) handler can accept any of the following parameters by name: + + * request (Request): The raw ASGI request object + * body (dict): The parsed request body + * path (str): The request path, e.g., "/threads/abcd-1234-abcd-1234/runs/abcd-1234-abcd-1234/stream" + * method (str): The HTTP method, e.g., "GET" + * path_params (dict[str, str]): URL path parameters, e.g., {"thread_id": "abcd-1234-abcd-1234", "run_id": "abcd-1234-abcd-1234"} + * query_params (dict[str, str]): URL query parameters, e.g., {"stream": "true"} + * headers (dict[bytes, bytes]): Request headers + * authorization (str | None): The Authorization header value (e.g., "Bearer ") + + In many of our tutorials, we will just show the "authorization" parameter to be concise, but you can opt to accept more information as needed + to implement your custom authentication scheme. + +## Authorization + +After authentication, LangGraph calls your [`@auth.on`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.on) handlers to control access to specific resources (e.g., threads, assistants, crons). These handlers can: + +1. Add metadata to be saved during resource creation by mutating the `value["metadata"]` dictionary directly. +2. Filter resources by metadata during search/list or read operations by returning a [filter dictionary](#filter-operations). +3. Raise an HTTP exception if access is denied. + +If you want to just implement simple user-scoped access control, you can use a single [`@auth.on`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.on) handler for all resources and actions. If you want to have different control depending on the resource and action, you can use [resource-specific handlers](#resource-specific-handlers). See the [Supported Resources](#supported-resources) section for a full list of the resources that support access control. + +```python +@auth.on +async def add_owner( + ctx: Auth.types.AuthContext, + value: dict # The payload being sent to this access method +) -> dict: # Returns a filter dict that restricts access to resources + """Authorize all access to threads, runs, crons, and assistants. + + This handler does two things: + - Adds a value to resource metadata (to persist with the resource so it can be filtered later) + - Returns a filter (to restrict access to existing resources) + + Args: + ctx: Authentication context containing user info, permissions, the path, and + value: The request payload sent to the endpoint. For creation + operations, this contains the resource parameters. For read + operations, this contains the resource being accessed. + + Returns: + A filter dictionary that LangGraph uses to restrict access to resources. + See [Filter Operations](#filter-operations) for supported operators. + """ + # Create filter to restrict access to just this user's resources + filters = {"owner": ctx.user.identity} + + # Get or create the metadata dictionary in the payload + # This is where we store persistent info about the resource + metadata = value.setdefault("metadata", {}) + + # Add owner to metadata - if this is a create or update operation, + # this information will be saved with the resource + # So we can filter by it later in read operations + metadata.update(filters) + + # Return filters to restrict access + # These filters are applied to ALL operations (create, read, update, search, etc.) + # to ensure users can only access their own resources + return filters +``` + +### Resource-Specific Handlers {#resource-specific-handlers} + +You can register handlers for specific resources and actions by chaining the resource and action names together with the [`@auth.on`](../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.on) decorator. +When a request is made, the most specific handler that matches that resource and action is called. Below is an example of how to register handlers for specific resources and actions. For the following setup: + +1. Authenticated users are able to create threads, read thread, create runs on threads +2. Only users with the "assistants:create" permission are allowed to create new assistants +3. All other endpoints (e.g., e.g., delete assistant, crons, store) are disabled for all users. + +!!! tip "Supported Handlers" + + For a full list of supported resources and actions, see the [Supported Resources](#supported-resources) section below. + +```python +# Generic / global handler catches calls that aren't handled by more specific handlers +@auth.on +async def reject_unhandled_requests(ctx: Auth.types.AuthContext, value: Any) -> False: + print(f"Request to {ctx.path} by {ctx.user.identity}") + raise Auth.exceptions.HTTPException( + status_code=403, + detail="Forbidden" + ) + +# Matches the "thread" resource and all actions - create, read, update, delete, search +# Since this is **more specific** than the generic @auth.on handler, it will take precedence +# over the generic handler for all actions on the "threads" resource +@auth.on.threads +async def on_thread_create( + ctx: Auth.types.AuthContext, + value: Auth.types.threads.create.value +): + if "write" not in ctx.permissions: + raise Auth.exceptions.HTTPException( + status_code=403, + detail="User lacks the required permissions." + ) + # Setting metadata on the thread being created + # will ensure that the resource contains an "owner" field + # Then any time a user tries to access this thread or runs within the thread, + # we can filter by owner + metadata = value.setdefault("metadata", {}) + metadata["owner"] = ctx.user.identity + return {"owner": ctx.user.identity} + +# Thread creation. This will match only on thread create actions +# Since this is **more specific** than both the generic @auth.on handler and the @auth.on.threads handler, +# it will take precedence for any "create" actions on the "threads" resources +@auth.on.threads.create +async def on_thread_create( + ctx: Auth.types.AuthContext, + value: Auth.types.threads.create.value +): + # Setting metadata on the thread being created + # will ensure that the resource contains an "owner" field + # Then any time a user tries to access this thread or runs within the thread, + # we can filter by owner + metadata = value.setdefault("metadata", {}) + metadata["owner"] = ctx.user.identity + return {"owner": ctx.user.identity} + +# Reading a thread. Since this is also more specific than the generic @auth.on handler, and the @auth.on.threads handler, +# it will take precedence for any "read" actions on the "threads" resource +@auth.on.threads.read +async def on_thread_read( + ctx: Auth.types.AuthContext, + value: Auth.types.threads.read.value +): + # Since we are reading (and not creating) a thread, + # we don't need to set metadata. We just need to + # return a filter to ensure users can only see their own threads + return {"owner": ctx.user.identity} + +# Run creation, streaming, updates, etc. +# This takes precedenceover the generic @auth.on handler and the @auth.on.threads handler +@auth.on.threads.create_run +async def on_run_create( + ctx: Auth.types.AuthContext, + value: Auth.types.threads.create_run.value +): + metadata = value.setdefault("metadata", {}) + metadata["owner"] = ctx.user.identity + # Inherit thread's access control + return {"owner": ctx.user.identity} + +# Assistant creation +@auth.on.assistants.create +async def on_assistant_create( + ctx: Auth.types.AuthContext, + value: Auth.types.assistants.create.value +): + if "assistants:create" not in ctx.permissions: + raise Auth.exceptions.HTTPException( + status_code=403, + detail="User lacks the required permissions." + ) +``` + +Notice that we are mixing global and resource-specific handlers in the above example. Since each request is handled by the most specific handler, a request to create a `thread` would match the `on_thread_create` handler but NOT the `reject_unhandled_requests` handler. A request to `update` a thread, however would be handled by the global handler, since we don't have a more specific handler for that resource and action. Requests to create, update, + +### Filter Operations {#filter-operations} + +Authorization handlers can return `None`, a boolean, or a filter dictionary. +- `None` and `True` mean "authorize access to all underling resources" +- `False` means "deny access to all underling resources (raises a 403 exception)" +- A metadata filter dictionary will restrict access to resources + +A filter dictionary is a dictionary with keys that match the resource metadata. It supports three operators: + +- The default value is a shorthand for exact match, or "$eq", below. For example, `{"owner": user_id}` will include only resources with metadata containing `{"owner": user_id}` +- `$eq`: Exact match (e.g., `{"owner": {"$eq": user_id}}`) - this is equivalent to the shorthand above, `{"owner": user_id}` +- `$contains`: List membership (e.g., `{"allowed_users": {"$contains": user_id}}`) The value here must be an element of the list. The metadata in the stored resource must be a list/container type. + +A dictionary with multiple keys is treated using a logical `AND` filter. For example, `{"owner": org_id, "allowed_users": {"$contains": user_id}}` will only match resources with metadata whose "owner" is `org_id` and whose "allowed_users" list contains `user_id`. + +See the reference [here](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.FilterType) for more information. + +## Common Access Patterns + +Here are some typical authorization patterns: + +### Single-Owner Resources + +This common pattern lets you scope all threads, assistants, crons, and runs to a single user. It's useful for common single-user use cases like regular chatbot-style apps. + +```python +@auth.on +async def owner_only(ctx: Auth.types.AuthContext, value: dict): + metadata = value.setdefault("metadata", {}) + metadata["owner"] = ctx.user.identity + return {"owner": ctx.user.identity} +``` + +### Permission-based Access + +This pattern lets you control access based on **permissions**. It's useful if you want certain roles to have broader or more restricted access to resources. + +```python +# In your auth handler: +@auth.authenticate +async def authenticate(headers: dict) -> Auth.types.MinimalUserDict: + ... + return { + "identity": "user-123", + "is_authenticated": True, + "permissions": ["threads:write", "threads:read"] # Define permissions in auth + } + +def _default(ctx: Auth.types.AuthContext, value: dict): + metadata = value.setdefault("metadata", {}) + metadata["owner"] = ctx.user.identity + return {"owner": ctx.user.identity} + +@auth.on.threads.create +async def create_thread(ctx: Auth.types.AuthContext, value: dict): + if "threads:write" not in ctx.permissions: + raise Auth.exceptions.HTTPException( + status_code=403, + detail="Unauthorized" + ) + return _default(ctx, value) + + +@auth.on.threads.read +async def rbac_create(ctx: Auth.types.AuthContext, value: dict): + if "threads:read" not in ctx.permissions and "threads:write" not in ctx.permissions: + raise Auth.exceptions.HTTPException( + status_code=403, + detail="Unauthorized" + ) + return _default(ctx, value) +``` + +## Supported Resources + +LangGraph provides authorization handlers for the following resource types: + +## Supported Resources + +LangGraph provides three levels of authorization handlers, from most general to most specific: + +1. **Global Handler** (`@auth.on`): Matches all resources and actions +2. **Resource Handler** (e.g., `@auth.on.threads`, `@auth.on.assistants`, `@auth.on.crons`): Matches all actions for a specific resource +3. **Action Handler** (e.g., `@auth.on.threads.create`, `@auth.on.threads.read`): Matches a specific action on a specific resource + +The most specific matching handler will be used. For example, `@auth.on.threads.create` takes precedence over `@auth.on.threads` for thread creation. +If a more specific handler is registered, the more general handler will not be called for that resource and action. + +???+ tip "Type Safety" + Each handler has type hints available for its `value` parameter at `Auth.types.on...value`. For example: + ```python + @auth.on.threads.create + async def on_thread_create( + ctx: Auth.types.AuthContext, + value: Auth.types.on.threads.create.value # Specific type for thread creation + ): + ... + + @auth.on.threads + async def on_threads( + ctx: Auth.types.AuthContext, + value: Auth.types.on.threads.value # Union type of all thread actions + ): + ... + + @auth.on + async def on_all( + ctx: Auth.types.AuthContext, + value: dict # Union type of all possible actions + ): + ... + ``` + More specific handlers provide better type hints since they handle fewer action types. + +Here are all the supported action handlers: + +| Resource | Handler | Description | +|----------|---------|-------------| +| **Threads** | `@auth.on.threads.create` | Thread creation | +| | `@auth.on.threads.read` | Thread retrieval | +| | `@auth.on.threads.update` | Thread updates | +| | `@auth.on.threads.delete` | Thread deletion | +| | `@auth.on.threads.search` | Listing threads | +| | `@auth.on.threads.create_run` | Creating or updating a run | +| **Assistants** | `@auth.on.assistants.create` | Assistant creation | +| | `@auth.on.assistants.read` | Assistant retrieval | +| | `@auth.on.assistants.update` | Assistant updates | +| | `@auth.on.assistants.delete` | Assistant deletion | +| | `@auth.on.assistants.search` | Listing assistants | +| **Crons** | `@auth.on.crons.create` | Cron job creation | +| | `@auth.on.crons.read` | Cron job retrieval | +| | `@auth.on.crons.update` | Cron job updates | +| | `@auth.on.crons.delete` | Cron job deletion | +| | `@auth.on.crons.search` | Listing cron jobs | + +???+ note "About Runs" + Runs are scoped to their parent thread for access control. This means permissions are typically inherited from the thread, reflecting the conversational nature of the data model. All run operations (reading, listing) except creation are controlled by the thread's handlers. + There is a specific `create_run` handler for creating new runs because it had more arguments that you can view in the handler. + +## Default Security Models + +LangGraph Platform provides different security defaults: + +### LangGraph Cloud + +- Uses LangSmith API keys by default +- Requires valid API key in `x-api-key` header +- Can be customized with your auth handler + +### Self-Hosted + +- No default authentication +- Complete flexibility to implement your security model +- You control all aspects of authentication and authorization + +## Next Steps + +For implementation details: + +- Check out the introductory tutorial on [setting up authentication](../tutorials/auth/getting_started.md) +- See the how-to guide on implementing a [custom auth handlers](../how-tos/auth/custom_auth.md) diff --git a/docs/docs/concepts/index.md b/docs/docs/concepts/index.md index 4d8e5f06f..ea56af5ba 100644 --- a/docs/docs/concepts/index.md +++ b/docs/docs/concepts/index.md @@ -68,6 +68,7 @@ The LangGraph Platform comprises several components that work together to suppor - [Web-hooks](./langgraph_server.md#webhooks): Webhooks allow your running LangGraph application to send data to external services on specific events. - [Cron Jobs](./langgraph_server.md#cron-jobs): Cron jobs are a way to schedule tasks to run at specific times in your LangGraph application. - [Double Texting](./double_texting.md): Double texting is a common issue in LLM applications where users may send multiple messages before the graph has finished running. This guide explains how to handle double texting with LangGraph Deploy. +- [Authentication & Access Control](./auth.md): Learn about options for authentication and access control when deploying the LangGraph Platform. ### Deployment Options diff --git a/docs/docs/how-tos/auth/custom_auth.md b/docs/docs/how-tos/auth/custom_auth.md new file mode 100644 index 000000000..456b893b5 --- /dev/null +++ b/docs/docs/how-tos/auth/custom_auth.md @@ -0,0 +1,133 @@ +# How to add custom authentication + +!!! tip "Prerequisites" + + This guide assumes familiarity with the following concepts: + + * [**Authentication & Access Control**](../../concepts/auth.md) + * [**LangGraph Platform**](../../concepts/index.md#langgraph-platform) + + For a more guided walkthrough, see [**setting up custom authentication**](../../tutorials/auth/getting_started.md) tutorial. + +???+ note "Python only" + + We currently only support custom authentication and authorization in Python deployments with `langgraph-api>=0.0.11`. Support for LangGraph.JS will be added soon. + +This guide shows how to add custom authentication to your LangGraph Platform application. This guide applies to both LangGraph Cloud, BYOC, and self-hosted deployments. It does not apply to isolated usage of the LangGraph open source library in your own custom server. + +## 1. Implement authentication + +Create `auth.py` file, with a basic JWT authentication handler: + +```python +from langgraph_sdk import Auth + +my_auth = Auth() + +@my_auth.authenticate +async def authenticate(authorization: str) -> str: + token = authorization.split(" ", 1)[-1] # "Bearer " + try: + # Verify token with your auth provider + user_id = await verify_token(token) + return user_id + except Exception: + raise Auth.exceptions.HTTPException( + status_code=401, + detail="Invalid token" + ) + +# Optional: Add authorization rules +@my_auth.on +async def add_owner( + ctx: Auth.types.AuthContext, + value: dict, +): + """Add owner to resource metadata and filter by owner.""" + filters = {"owner": ctx.user.identity} + metadata = value.setdefault("metadata", {}) + metadata.update(filters) + return filters +``` + +## 2. Update configuration + +In your `langgraph.json`, add the path to your auth file: + +```json hl_lines="7-9" +{ + "dependencies": ["."], + "graphs": { + "agent": "./agent.py:graph" + }, + "env": ".env", + "auth": { + "path": "./auth.py:my_auth" + } +} +``` + +## 3. Connect from the client + +Once you've set up authentication in your server, requests must include the the required authorization information based on your chosen scheme. +Assuming you are using JWT token authentication, you could access your deployments using any of the following methods: + +=== "Python Client" + + ```python + from langgraph_sdk import get_client + + my_token = "your-token" # In practice, you would generate a signed token with your auth provider + client = get_client( + url="http://localhost:2024", + headers={"Authorization": f"Bearer {my_token}"} + ) + threads = await client.threads.list() + ``` + +=== "Python RemoteGraph" + + ```python + from langgraph.pregel.remote import RemoteGraph + + my_token = "your-token" # In practice, you would generate a signed token with your auth provider + remote_graph = RemoteGraph( + "agent", + url="http://localhost:2024", + headers={"Authorization": f"Bearer {my_token}"} + ) + threads = await remote_graph.threads.list() + ``` + +=== "JavaScript Client" + + ```javascript + import { Client } from "@langchain/langgraph-sdk"; + + const my_token = "your-token"; // In practice, you would generate a signed token with your auth provider + const client = new Client({ + apiUrl: "http://localhost:2024", + headers: { Authorization: `Bearer ${my_token}` }, + }); + const threads = await client.threads.list(); + ``` + +=== "JavaScript RemoteGraph" + + ```javascript + import { RemoteGraph } from "@langchain/langgraph/remote"; + + const my_token = "your-token"; // In practice, you would generate a signed token with your auth provider + const remoteGraph = new RemoteGraph({ + graphId: "agent", + url: "http://localhost:2024", + headers: { Authorization: `Bearer ${my_token}` }, + }); + const threads = await remoteGraph.threads.list(); + ``` + +=== "CURL" + + ```bash + curl -H "Authorization: Bearer ${your-token}" http://localhost:2024/threads + ``` diff --git a/docs/docs/how-tos/auth/openapi_security.md b/docs/docs/how-tos/auth/openapi_security.md new file mode 100644 index 000000000..87e1e0229 --- /dev/null +++ b/docs/docs/how-tos/auth/openapi_security.md @@ -0,0 +1,98 @@ +# How to document API authentication in OpenAPI + +This guide shows how to customize the OpenAPI security schema for your LangGraph Platform API documentation. A well-documented security schema helps API consumers understand how to authenticate with your API and even enables automatic client generation. See the [Authentication & Access Control conceptual guide](../../concepts/auth.md) for more details about LangGraph's authentication system. + +!!! note "Implementation vs Documentation" + This guide only covers how to document your security requirements in OpenAPI. To implement the actual authentication logic, see [How to add custom authentication](./custom_auth.md). + +This guide applies to all LangGraph Platform deployments (Cloud, BYOC, and self-hosted). It does not apply to usage of the LangGraph open source library if you are not using LangGraph Platform. + +## Default Schema + +The default security scheme varies by deployment type: + +=== "LangGraph Cloud" + +By default, LangGraph Cloud requires a LangSmith API key in the `x-api-key` header: + +```yaml +components: + securitySchemes: + apiKeyAuth: + type: apiKey + in: header + name: x-api-key +security: + - apiKeyAuth: [] +``` + +When using one of the LangGraph SDK's, this can be inferred from environment variables. + +=== "Self-hosted" + +By default, self-hosted deployments have no security scheme. This means they are to be deployed only on a secured network or with authentication. To add custom authentication, see [How to add custom authentication](./custom_auth.md). + +## Custom Security Schema + +To customize the security schema in your OpenAPI documentation, add an `openapi` field to your `auth` configuration in `langgraph.json`. Remember that this only updates the API documentation - you must also implement the corresponding authentication logic as shown in [How to add custom authentication](./custom_auth.md). + +Note that LangGraph Platform does not provide authentication endpoints - you'll need to handle user authentication in your client application and pass the resulting credentials to the LangGraph API. + +=== "OAuth2 with Bearer Token" + + ```json + { + "auth": { + "path": "./auth.py:my_auth", // Implement auth logic here + "openapi": { + "securitySchemes": { + "OAuth2": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "https://your-auth-server.com/oauth/authorize", + "scopes": { + "me": "Read information about the current user", + "threads": "Access to create and manage threads" + } + } + } + } + }, + "security": [ + {"OAuth2": ["me", "threads"]} + ] + } + } + } + ``` + +=== "API Key" + + ```json + { + "auth": { + "path": "./auth.py:my_auth", // Implement auth logic here + "openapi": { + "securitySchemes": { + "apiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key" + } + }, + "security": [ + {"apiKeyAuth": []} + ] + } + } + } + ``` + +## Testing + +After updating your configuration: + +1. Deploy your application +2. Visit `/docs` to see the updated OpenAPI documentation +3. Try out the endpoints using credentials from your authentication server (make sure you've implemented the authentication logic first) diff --git a/docs/docs/how-tos/index.md b/docs/docs/how-tos/index.md index cd4910aca..9be3251f4 100644 --- a/docs/docs/how-tos/index.md +++ b/docs/docs/how-tos/index.md @@ -180,6 +180,11 @@ LangGraph applications can be deployed using LangGraph Cloud, which provides a r - [How to deploy to a self-hosted environment](./deploy-self-hosted.md) - [How to interact with the deployment using RemoteGraph](./use-remote-graph.md) +### Authentication & Access Control + +- [How to add custom authentication](./auth/custom_auth.md) +- [How to update the security schema of your OpenAPI spec](./auth/openapi_security.md) + ### Assistants [Assistants](../concepts/assistants.md) is a configured instance of a template. diff --git a/docs/docs/tutorials/auth/add_auth_server.md b/docs/docs/tutorials/auth/add_auth_server.md new file mode 100644 index 000000000..8aa4323da --- /dev/null +++ b/docs/docs/tutorials/auth/add_auth_server.md @@ -0,0 +1,300 @@ +# Connecting an Authentication Provider (Part 3/3) + +!!! note "This is part 3 of our authentication series:" + + 1. [Basic Authentication](getting_started.md) - Control who can access your bot + 2. [Resource Authorization](resource_auth.md) - Let users have private conversations + 3. Production Auth (you are here) - Add real user accounts and validate using OAuth2 + +In the [Making Conversations Private](resource_auth.md) tutorial, we added [resource authorization](../../concepts/auth.md#resource-authorization) to give users private conversations. However, we were still using hard-coded tokens for authentication, which is not secure. Now we'll replace those tokens with real user accounts using [OAuth2](../../concepts/auth.md#oauth2-authentication). + +We'll keep the same [`Auth`](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth) object and [resource-level access control](../../concepts/auth.md#resource-level-access-control), but upgrade our authentication to use Supabase as our identity provider. While we use Supabase in this tutorial, the concepts apply to any OAuth2 provider. You'll learn how to: + +1. Replace test tokens with real [JWT tokens](../../concepts/auth.md#jwt-tokens) +2. Integrate with OAuth2 providers for secure user authentication +3. Handle user sessions and metadata while maintaining our existing authorization logic + +## Requirements + +You will need to set up a Supabase project to use its authentication server for this tutorial. You can do so [here](https://supabase.com/dashboard). + +## Background + +OAuth2 involves three main roles: + +1. **Authorization server**: The identity provider (e.g., Supabase, Auth0, Google) that handles user authentication and issues tokens +2. **Application backend**: Your LangGraph application. This validates tokens and serves protected resources (conversation data) +3. **Client application**: The web or mobile app where users interact with your service + +A standard OAuth2 flow works something like this: + + +```mermaid +sequenceDiagram + participant User + participant Client + participant AuthServer + participant LangGraph Backend + + User->>Client: Initiate login + User->>AuthServer: Enter credentials + AuthServer->>Client: Send tokens + Client->>LangGraph Backend: Request with token + LangGraph Backend->>AuthServer: Validate token + AuthServer->>LangGraph Backend: Token valid + LangGraph Backend->>Client: Serve request (e.g., run agent or graph) +``` + +In the following example, we'll use Supabase as our auth server. The LangGraph application will provide the backend for your app, and we will write test code for the client app. +Let's get started! + +## Setting Up Authentication Provider {#setup-auth-provider} + +First, let's install the required dependencies. Start in your `custom-auth` directory and ensure you have the `langgraph-cli` installed: + +```bash +cd custom-auth +pip install -U "langgraph-cli[inmem]" +``` + +Next, we'll need to fech the URL of our auth server and the private key for authentication. +Since we're using Supabase for this, we can do this in the Supabase dashboard: + +1. In the left sidebar, click on t️⚙ Project Settings" and then click "API" +2. Copy your project URL and add it to your `.env` file + +```shell +echo "SUPABASE_URL=your-project-url" >> .env +``` + +3. Next, copy your service role secret key and add it to your `.env` file + +```shell +echo "SUPABASE_SERVICE_KEY=your-service-role-key" >> .env +``` + +4. Finally, copy your "anon public" key and note it down. This will be used later when we set up our client code. + +```bash +SUPABASE_URL=your-project-url +SUPABASE_SERVICE_KEY=your-service-role-key +``` + +## Implementing Token Validation + +In the previous tutorials, we used the [`Auth`](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth) object to: + +1. Validate hard-coded tokens in the [authentication tutorial](getting_started.md) +2. Add resource ownership in the [authorization tutorial](resource_auth.md) + +Now we'll upgrade our authentication to validate real JWT tokens from Supabase. The key changes will all be in the [`@auth.authenticate`](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.authenticate) decorated function: + +1. Instead of checking against a hard-coded list of tokens, we'll make an HTTP request to Supabase to validate the token +2. We'll extract real user information (ID, email) from the validated token + +And we'll keep our existing resource authorization logic unchanged + +Let's update `src/security/auth.py` to implement this: + +```python +import os +import httpx +from langgraph_sdk import Auth + +auth = Auth() + +# This is loaded from the `.env` file you created above +SUPABASE_URL = os.environ["SUPABASE_URL"] +SUPABASE_SERVICE_KEY = os.environ["SUPABASE_SERVICE_KEY"] + + +@auth.authenticate +async def get_current_user(authorization: str | None): + """Validate JWT tokens and extract user information.""" + assert authorization + scheme, token = authorization.split() + assert scheme.lower() == "bearer" + + try: + # Verify token with auth provider + async with httpx.AsyncClient() as client: + response = await client.get( + f"{SUPABASE_URL}/auth/v1/user", + headers={ + "Authorization": authorization, + "apiKey": SUPABASE_SERVICE_KEY, + }, + ) + assert response.status_code == 200 + user = response.json() + return { + "identity": user["id"], # Unique user identifier + "email": user["email"], + "is_authenticated": True, + } + except Exception as e: + raise Auth.exceptions.HTTPException(status_code=401, detail=str(e)) + + +# Keep our resource authorization from the previous tutorial +@auth.on +async def add_owner(ctx, value): + """Make resources private to their creator using resource metadata.""" + filters = {"owner": ctx.user.identity} + metadata = value.setdefault("metadata", {}) + metadata.update(filters) + return filters +``` + +The most important change is that we're now validating tokens with a real authentication server. Our authentication handler has the private key for our Supabase project, which we can use to validate the user's token and extract their information. + +Let's test this with a real user account! + +## Testing Authentication Flow + +Let's test out our new authentication flow. You can run the following code in a file or notebook. You will need to provide: +- A valid email address +- A Supabase project URL (from [above](#setup-auth-provider)) +- A Supabase service role key (also from [above](#setup-auth-provider)) + +```python +import os +import httpx +from getpass import getpass +from langgraph_sdk import get_client + + +# Get email from command line +email = getpass("Enter your email: ") +base_email = email.split("@") +password = "secure-password" # CHANGEME +email1 = f"{base_email[0]}+1@{base_email[1]}" +email2 = f"{base_email[0]}+2@{base_email[1]}" + +SUPABASE_URL = os.environ.get("SUPABASE_URL") +if not SUPABASE_URL: + SUPABASE_URL = getpass("Enter your Supabase project URL: ") + +SUPABASE_SERVICE_KEY = os.environ.get("SUPABASE_SERVICE_KEY") +if not SUPABASE_SERVICE_KEY: + SUPABASE_SERVICE_KEY = getpass("Enter your Supabase service role key: ") + + +async def sign_up(email: str, password: str): + """Create a new user account.""" + async with httpx.AsyncClient() as client: + response = await client.post( + f"{SUPABASE_URL}/auth/v1/signup", + json={"email": email, "password": password}, + headers={"apiKey": SUPABASE_SERVICE_KEY}, + ) + assert response.status_code == 200 + return response.json() + +# Create two test users +print(f"Creating test users: {email1} and {email2}") +await sign_up(email1, password) +await sign_up(email2, password) +``` + +Then run the code. + +!!! tip "About test emails" + We'll create two test accounts by adding "+1" and "+2" to your email. For example, if you use "myemail@gmail.com", we'll create "myemail+1@gmail.com" and "myemail+2@gmail.com". All emails will be delivered to your original address. + +⚠️ Before continuing: Check your email and click both confirmation links. Supabase will will reject `/login` requests until after you have confirmed your users' email. + +Now let's test that users can only see their own data. Make sure the server is running (run `langgraph dev`) before proceeding. The following snippet requires the "anon public" key that you copied from the Supabase dashboard while [setting up the auth provider](#setup-auth-provider) previously. + +```python +import os +import httpx + +from langgraph_sdk import get_client + +SUPABASE_ANON_KEY = os.environ.get("SUPABASE_ANON_KEY") +if not SUPABASE_ANON_KEY: + SUPABASE_ANON_KEY = getpass("Enter your Supabase anon key: ") + +async def login(email: str, password: str): + """Get an access token for an existing user.""" + async with httpx.AsyncClient() as client: + response = await client.post( + f"{SUPABASE_URL}/auth/v1/token?grant_type=password", + json={ + "email": email, + "password": password + }, + headers={ + "apikey": SUPABASE_ANON_KEY, + "Content-Type": "application/json" + }, + ) + if response.status_code == 200: + return response.json()["access_token"] + else: + raise ValueError(f"Login failed: {response.status_code} - {response.text}") + + +# Log in as user 1 +user1_token = await login(email1, password) +user1_client = get_client( + url="http://localhost:2024", headers={"Authorization": f"Bearer {user1_token}"} +) + +# Create a thread as user 1 +thread = await user1_client.threads.create() +print(f"✅ User 1 created thread: {thread['thread_id']}") + +# Try to access without a token +unauthenticated_client = get_client(url="http://localhost:2024") +try: + await unauthenticated_client.threads.create() + print("❌ Unauthenticated access should fail!") +except Exception as e: + print("✅ Unauthenticated access blocked:", e) + +# Try to access user 1's thread as user 2 +user2_token = await login(email2, password) +user2_client = get_client( + url="http://localhost:2024", headers={"Authorization": f"Bearer {user2_token}"} +) + +try: + await user2_client.threads.get(thread["thread_id"]) + print("❌ User 2 shouldn't see User 1's thread!") +except Exception as e: + print("✅ User 2 blocked from User 1's thread:", e) +``` +The output should look like this: + +> ➜ custom-auth SUPABASE_ANON_KEY=eyJh... python test_oauth.py CHANGEME@example.com +> ✅ User 1 created thread: d6af3754-95df-4176-aa10-dbd8dca40f1a +> ✅ Unauthenticated access blocked: Client error '403 Forbidden' for url 'http://localhost:2024/threads' +> ✅ User 2 blocked from User 1's thread: Client error '404 Not Found' for url 'http://localhost:2024/threads/d6af3754-95df-4176-aa10-dbd8dca40f1a' + +Perfect! Our authentication and authorization are working together: +1. Users must log in to access the bot +2. Each user can only see their own threads + +All our users are managed by the Supabase auth provider, so we don't need to implement any additional user management logic. + +## Congratulations! 🎉 + +You've successfully built a production-ready authentication system for your LangGraph application! Let's review what you've accomplished: + +1. Set up an authentication provider (Supabase in this case) +2. Added real user accounts with email/password authentication +3. Integrated JWT token validation into your LangGraph server +4. Implemented proper authorization to ensure users can only access their own data +5. Created a foundation that's ready to handle your next authentication challenge 🚀 + +This completes our authentication tutorial series. You now have the building blocks for a secure, production-ready LangGraph application. + +## What's Next? + +Now that you have production authentication, consider: + +1. Building a web UI with your preferred framework (see the [Custom Auth](https://github.com/langchain-ai/custom-auth) template for an example) +2. Learn more about the other aspects of authentication and authorization in the [conceptual guide on authentication](../../concepts/auth.md). +3. Customize your handlers and setup further after reading the [reference docs](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth). \ No newline at end of file diff --git a/docs/docs/tutorials/auth/getting_started.md b/docs/docs/tutorials/auth/getting_started.md new file mode 100644 index 000000000..c8df6592d --- /dev/null +++ b/docs/docs/tutorials/auth/getting_started.md @@ -0,0 +1,156 @@ +# Setting up Custom Authentication (Part 1/3) + +!!! note "This is part 1 of our authentication series:" + + 1. Basic Authentication (you are here) - Control who can access your bot + 2. [Resource Authorization](resource_auth.md) - Let users have private conversations + 3. [Production Auth](add_auth_server.md) - Add real user accounts and validate using OAuth2 + +In this tutorial, we will build a chatbot that only lets specific users access it. We'll start with the LangGraph template and add token-based security step by step. By the end, you'll have a working chatbot that checks for valid tokens before allowing access. + +## Setting up our project + +First, let's create a new chatbot using the LangGraph starter template: + +```bash +pip install -U "langgraph-cli[inmem]" +langgraph new --template=new-langgraph-project-python custom-auth +cd custom-auth +``` + +The template gives us a placeholder LangGraph app. Let's try it out by installing the local dependencies and running the development server. +```shell +pip install -e . +langgraph dev +``` +If everything works, the server should start and open the studio in your browser. + +> - 🚀 API: http://127.0.0.1:2024 +> - 🎨 Studio UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024 +> - 📚 API Docs: http://127.0.0.1:2024/docs +> +> This in-memory server is designed for development and testing. +> For production use, please use LangGraph Cloud. + +Now that we've seen the base LangGraph app, let's add authentication to it! In part 1, we will start with a hard-coded token for illustration purposes. +We will get to a "production-ready" authentication scheme in part 3, after mastering the basics. + +## Adding Authentication + +The [`Auth`](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth) object lets you register an authentication function that the LangGraph platform will run on every request. This function receives each request and decides whether to accept or reject. + +Create a new file `src/security/auth.py`. This is where our code will live to check if users are allowed to access our bot: + +```python +from langgraph_sdk import Auth + +# This is our toy user database +VALID_TOKENS = { + "user1-token": {"id": "user1", "name": "Alice"}, + "user2-token": {"id": "user2", "name": "Bob"}, +} + +# The "Auth" object is a container that LangGraph will use to mark our authentication function +auth = Auth() + + +# The `authenticate` decorator tells LangGraph to call this function as middleware +# for every request. This will determine whether the request is allowed or not +@auth.authenticate +async def get_current_user(authorization: str | None) -> Auth.types.MinimalUserDict: + """Check if the user's token is valid.""" + assert authorization + scheme, token = authorization.split() + assert scheme.lower() == "bearer" + # Check if token is valid + if token not in VALID_TOKENS: + raise Auth.exceptions.HTTPException(status_code=401, detail="Invalid token") + + # Return user info if valid + user_data = VALID_TOKENS[token] + return { + "identity": user_data["id"], + } +``` + +Notice that our [authentication](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth.authenticate) handler does two important things: + +1. Checks if a valid token is provided in the request's [Authorization header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization) +2. Returns the user's [identity](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.MinimalUserDict) + +Now tell LangGraph to use our authentication by adding the following to the [`langgraph.json`](../../cloud/reference/cli.md#configuration-file) configuration: + +```json +{ + "auth": { + "path": "src/security/auth.py:auth" + } +} +``` + +## Testing Our "Secure" Bot + +Let's start the server again to test everything out! + +```bash +langgraph dev --no-browser +``` + +??? note "Custom auth in the studio" + + If you didn't add the `--no-browser`, the studio UI will open in the browser. You may wonder, how is the studio able to still connect to our server? By default, we also permit access from the LangGraph studio, even when using custom auth. This makes it easier to develop and test your bot in the studio. You can remove this alternative authentication option by + setting `disable_studio_auth: "true"` in your auth configuration: + ```json + { + "auth": { + "path": "src/security/auth.py:auth", + "disable_studio_auth": "true" + } + } + ``` + +Now let's try to chat with our bot. Run the following code in a file or notebook: + +```python +from langgraph_sdk import get_client + +# Try without a token (should fail) +client = get_client(url="http://localhost:2024") +try: + thread = await client.threads.create() + print("❌ Should have failed without token!") +except Exception as e: + print("✅ Correctly blocked access:", e) + +# Try with a valid token +client = get_client( + url="http://localhost:2024", headers={"Authorization": "Bearer user1-token"} +) + +# Create a thread and chat +thread = await client.threads.create() +print(f"✅ Created thread as Alice: {thread['thread_id']}") + +response = await client.runs.create( + thread_id=thread["thread_id"], + assistant_id="agent", + input={"messages": [{"role": "user", "content": "Hello!"}]}, +) +print("✅ Bot responded:") +print(response) +``` + +You should see that: + +1. Without a valid token, we can't access the bot +2. With a valid token, we can create threads and chat + +Congratulations! You've built a chatbot that only lets "authenticated" users access it. While this system doesn't (yet) implement a production-ready security scheme, we've learned the basic mechanics of how to control access to our bot. In the next tutorial, we'll learn how to give each user their own private conversations. + +## What's Next? + +Now that you can control who accesses your bot, you might want to: + +1. Continue the tutorial by going to [Making Conversations Private (Part 2/3)](resource_auth.md) to learn about resource authorization. +2. Read more about [authentication concepts](../../concepts/auth.md). +3. Check out the [API reference](../../cloud/reference/sdk/python_sdk_ref.md) for more authentication details. \ No newline at end of file diff --git a/docs/docs/tutorials/auth/resource_auth.md b/docs/docs/tutorials/auth/resource_auth.md new file mode 100644 index 000000000..b5b67e812 --- /dev/null +++ b/docs/docs/tutorials/auth/resource_auth.md @@ -0,0 +1,266 @@ +# Making Conversations Private (Part 2/3) + +!!! note "This is part 2 of our authentication series:" + + 1. [Basic Authentication](getting_started.md) - Control who can access your bot + 2. Resource Authorization (you are here) - Let users have private conversations + 3. [Production Auth](add_auth_server.md) - Add real user accounts and validate using OAuth2 + +In this tutorial, we will extend our chatbot to give each user their own private conversations. We'll add [resource-level access control](../../concepts/auth.md#resource-level-access-control) so users can only see their own threads. + +## Understanding Resource Authorization + +In the last tutorial, we controlled who could access our bot. But right now, any authenticated user can see everyone else's conversations! Let's fix that by adding [resource authorization](../../concepts/auth.md#resource-authorization). + +First, make sure you have completed the [Basic Authentication](getting_started.md) tutorial and that your secure bot can be run without errors: + +```bash +cd custom-auth +pip install -e . +langgraph dev --no-browser +``` + +> - 🚀 API: http://127.0.0.1:2024 +> - 🎨 Studio UI: https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024 +> - 📚 API Docs: http://127.0.0.1:2024/docs + +## Adding Resource Authorization + +Recall that in the last tutorial, the [`Auth`](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth) object let us register an [authentication function](../../concepts/auth.md#authentication), which the LangGraph platform uses to validate the bearer tokens in incoming requests. Now we'll use it to register an **authorization** handler. + +Authorization handlers are functions that run **after** authentication succeeds. These handlers can add [metadata](../../concepts/auth.md#resource-metadata) to resources (like who owns them) and filter what each user can see. + +Let's update our `src/security/auth.py` and add one authorization handler that is run on every request: + +```python hl_lines="29-39" +from langgraph_sdk import Auth + +# Keep our test users from the previous tutorial +VALID_TOKENS = { + "user1-token": {"id": "user1", "name": "Alice"}, + "user2-token": {"id": "user2", "name": "Bob"}, +} + +auth = Auth() + + +@auth.authenticate +async def get_current_user(authorization: str | None) -> Auth.types.MinimalUserDict: + """Our authentication handler from the previous tutorial.""" + assert authorization + scheme, token = authorization.split() + assert scheme.lower() == "bearer" + + if token not in VALID_TOKENS: + raise Auth.exceptions.HTTPException(status_code=401, detail="Invalid token") + + user_data = VALID_TOKENS[token] + return { + "identity": user_data["id"], + } + + +@auth.on +async def add_owner( + ctx: Auth.types.AuthContext, # Contains info about the current user + value: dict, # The resource being created/accessed +): + """Make resources private to their creator.""" + # Add owner when creating resources + filters = {"owner": ctx.user.identity} + metadata = value.setdefault("metadata", {}) + metadata.update(filters) + + # Only let users see their own resources + return filters +``` + +The handler receives two parameters: + +1. `ctx` ([AuthContext](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.types.AuthContext)): contains info about the current `user`, the user's `permissions`, the `resource` ("threads", "crons", "assistants"), and the `action` being taken ("create", "read", "update", "delete", "search", "create_run") +2. `value` (`dict`): data that is being created or accessed. The contents of this dict depend on the resource and action being accessed. See [adding scoped authorization handlers](#scoped-authorization) below for information on how to get more tightly scoped access control. + +Notice that our simple handler does two things: + +1. Adds the user's ID to the resource's metadata. +2. Returns a metadata filter so users only see resources they own. + +## Testing Private Conversations + +Let's test our authorization. If we have set things up correctly, we should expect to see all ✅ messages. Be sure to have your development server running (run `langgraph dev`): + +```python +from langgraph_sdk import get_client + +# Create clients for both users +alice = get_client( + url="http://localhost:2024", + headers={"Authorization": "Bearer user1-token"} +) + +bob = get_client( + url="http://localhost:2024", + headers={"Authorization": "Bearer user2-token"} +) + +# Alice creates a thread and chats +alice_thread = await alice.threads.create() +print(f"✅ Alice created thread: {alice_thread['thread_id']}") + +await alice.runs.create( + thread_id=alice_thread["thread_id"], + assistant_id="agent", + input={"messages": [{"role": "user", "content": "Hi, this is Alice's private chat"}]} +) + +# Bob tries to access Alice's thread +try: + await bob.threads.get(alice_thread["thread_id"]) + print("❌ Bob shouldn't see Alice's thread!") +except Exception as e: + print("✅ Bob correctly denied access:", e) + +# Bob creates his own thread +bob_thread = await bob.threads.create() +await bob.runs.create( + thread_id=bob_thread["thread_id"], + assistant_id="agent", + input={"messages": [{"role": "user", "content": "Hi, this is Bob's private chat"}]} +) +print(f"✅ Bob created his own thread: {bob_thread['thread_id']}") + +# List threads - each user only sees their own +alice_threads = await alice.threads.list() +bob_threads = await bob.threads.list() +print(f"✅ Alice sees {len(alice_threads)} thread") +print(f"✅ Bob sees {len(bob_threads)} thread") + +``` + +Run the test code and you should see output like this: + +```bash +✅ Alice created thread: 533179b7-05bc-4d48-b47a-a83cbdb5781d +✅ Bob correctly denied access: Client error '404 Not Found' for url 'http://localhost:2024/threads/533179b7-05bc-4d48-b47a-a83cbdb5781d' +For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404 +✅ Bob created his own thread: 437c36ed-dd45-4a1e-b484-28ba6eca8819 +✅ Alice sees 1 thread +✅ Bob sees 1 thread +``` + +This means: + +1. Each user can create and chat in their own threads +2. Users can't see each other's threads +3. Listing threads only shows your own + +## Adding scoped authorization handlers {#scoped-authorization} + +The broad `@auth.on` handler matches on all [authorization events](../../concepts/auth.md#authorization-events). This is concise, but it means the contents of the `value` dict are not well-scoped, and we apply the same user-level access control to every resource. If we want to be more fine-grained, we can also control specific actions on resources. + +Update `src/security/auth.py` to add handlers for specific resource types: + +```python +# Keep our previous handlers... + +from langgraph_sdk import Auth + +@auth.on.threads.create +async def on_thread_create( + ctx: Auth.types.AuthContext, + value: Auth.types.on.threads.create.value, +): + """Add owner when creating threads. + + This handler runs when creating new threads and does two things: + 1. Sets metadata on the thread being created to track ownership + 2. Returns a filter that ensures only the creator can access it + """ + # Add owner metadata to the thread being created + # This metadata is stored with the thread and persists + metadata = value.setdefault("metadata", {}) + metadata["owner"] = ctx.user.identity + + # Return filter to restrict access to just the creator + return {"owner": ctx.user.identity} + +@auth.on.threads.read +async def on_thread_read( + ctx: Auth.types.AuthContext, + value: Auth.types.on.threads.read.value, +): + """Only let users read their own threads. + + This handler runs on read operations. We don't need to set + metadata since the thread already exists - we just need to + return a filter to ensure users can only see their own threads. + """ + return {"owner": ctx.user.identity} + +@auth.on.threads.create_run +async def on_run_create( + ctx: Auth.types.AuthContext, + value: Auth.types.on.threads.create_run.value, +): + """Only let thread owners create runs. + + This handler runs when creating runs on a thread. The filter + applies to the parent thread, not the run being created. + This ensures only thread owners can create runs on their threads. + """ + return {"owner": ctx.user.identity} +``` + +Notice that instead of one global handler, we now have specific handlers for: + +1. Creating threads +2. Reading threads +3. Creating runs +4. Accessing assistants + +The first three of these match specific **actions** on each resource (see [resource actions](../../concepts/auth.md#resource-actions)), while the last one (`@auth.on.assistants`) matches _any_ action on the `assistants` resource. For each request, LangGraph will run the most specific handler that matches the resource and action being accessed. This means that the four handlers above will run rather than the broad "@auth.on" handler. + +Try adding the following test code to `test_private.py`: + +```python +async def test_private(): + # ... Same as before + # Try creating an assistant. This should fail + try: + await alice.assistants.create("agent") + print("❌ Alice shouldn't be able to create assistants!") + except Exception as e: + print("✅ Alice correctly denied access:", e) + + # Try searching for assistants. This also should fail + try: + await alice.assistants.search() + print("❌ Alice shouldn't be able to search assistants!") + except Exception as e: + print("✅ Alice correctly denied access to searching assistants:", e) +``` + +And then run the test code again: + +```bash +> python test_private.py +✅ Alice created thread: dcea5cd8-eb70-4a01-a4b6-643b14e8f754 +✅ Bob correctly denied access: Client error '404 Not Found' for url 'http://localhost:2024/threads/dcea5cd8-eb70-4a01-a4b6-643b14e8f754' +For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404 +✅ Bob created his own thread: 400f8d41-e946-429f-8f93-4fe395bc3eed +✅ Alice sees 1 thread +✅ Bob sees 1 thread +✅ Alice correctly denied access: +For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500 +✅ Alice correctly denied access to searching assistants: +``` + +Congratulations! You've built a chatbot where each user has their own private conversations. While this system uses simple token-based authentication, the authorization patterns we've learned will work with implementing any real authentication system. In the next tutorial, we'll replace our test users with real user accounts using OAuth2. + +## What's Next? + +Now that you can control access to resources, you might want to: + +1. Move on to [Production Auth](add_auth_server.md) to add real user accounts +2. Read more about [authorization patterns](../../concepts/auth.md#authorization) +3. Check out the [API reference](../../cloud/reference/sdk/python_sdk_ref.md#langgraph_sdk.auth.Auth) for details about the interfaces and methods used in this tutorial diff --git a/docs/docs/tutorials/index.md b/docs/docs/tutorials/index.md index f6eefe87c..c670d4179 100644 --- a/docs/docs/tutorials/index.md +++ b/docs/docs/tutorials/index.md @@ -15,7 +15,8 @@ New to LangGraph or LLM app development? Read this material to get up and runnin - [LangGraph Template Quickstart](../concepts/template_applications.md): Start building with LangGraph Platform using a template application. - [Deploy with LangGraph Cloud Quickstart](../cloud/quick_start.md): Deploy a LangGraph app using LangGraph Cloud. -## Use cases 🛠️ +## Use cases 🛠️ {#use-cases} + Explore practical implementations tailored for specific scenarios: @@ -71,3 +72,13 @@ Explore practical implementations tailored for specific scenarios: - [Web Navigation](web-navigation/web_voyager.ipynb): Build an agent that can navigate and interact with websites - [Competitive Programming](usaco/usaco.ipynb): Build an agent with few-shot "episodic memory" and human-in-the-loop collaboration to solve problems from the USA Computing Olympiad; adapted from the ["Can Language Models Solve Olympiad Programming?"](https://arxiv.org/abs/2404.10952v1) paper by Shi, Tang, Narasimhan, and Yao. - [Complex data extraction](extraction/retries.ipynb): Build an agent that can use function calling to do complex extraction tasks + +## LangGraph Platform 🧱 {#platform} + +### Authentication & Access Control + +Add custom authentication and authorization to an existing LangGraph Platform deployment in the following three-part guide: + +1. [Setting Up Custom Authentication](./auth/getting_started.md): Implement OAuth2 authentication to authorize users on your deployment +2. [Resource Authorization](./auth/resource_auth.md): Let users have private conversations +3. [Connecting an Authentication Provider](./auth/add_auth_server.md): Add real user accounts and validate using OAuth2 \ No newline at end of file diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index afb72fffe..cc780fde2 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -141,6 +141,11 @@ nav: - tutorials/web-navigation/web_voyager.ipynb - tutorials/usaco/usaco.ipynb - tutorials/extraction/retries.ipynb + - LangGraph Platform: + - LangGraph Platform: concepts#langgraph-platform + - docs/docs/tutorials/auth/getting_started.md + - docs/docs/tutorials/auth/resource_auth.md + - docs/docs/tutorials/auth/add_auth_server.md - How-to Guides: - how-tos/index.md @@ -242,6 +247,10 @@ nav: - cloud/deployment/cloud.md - how-tos/deploy-self-hosted.md - how-tos/use-remote-graph.md + - Authentication & Access Control: + - Authentication & Access Control: how-tos#authentication-access-control + - cloud/how-tos/auth/custom_auth_new.md + - cloud/how-tos/auth/openapi_security_new.md - Assistants: - Assistants: how-tos#assistants - cloud/how-tos/configuration_cloud.md diff --git a/libs/sdk-py/langgraph_sdk/auth/exceptions.py b/libs/sdk-py/langgraph_sdk/auth/exceptions.py index bee227e0d..cd3019259 100644 --- a/libs/sdk-py/langgraph_sdk/auth/exceptions.py +++ b/libs/sdk-py/langgraph_sdk/auth/exceptions.py @@ -16,11 +16,6 @@ class HTTPException(Exception): headers (typing.Mapping[str, str] | None, optional): Additional HTTP headers to include in the error response. - Attributes: - status_code (int): The HTTP status code of the error - detail (str): The error message or description - headers (typing.Mapping[str, str] | None): Additional HTTP headers - Example: Default: ```python @@ -53,19 +48,9 @@ def __init__( self.headers = headers def __str__(self) -> str: - """Return a string representation of the HTTP exception. - - Returns: - str: A string in the format 'status_code: detail' - """ return f"{self.status_code}: {self.detail}" def __repr__(self) -> str: - """Return a detailed string representation of the HTTP exception. - - Returns: - str: A string representation showing the class name and all attributes - """ class_name = self.__class__.__name__ return f"{class_name}(status_code={self.status_code!r}, detail={self.detail!r})" diff --git a/libs/sdk-py/langgraph_sdk/auth/types.py b/libs/sdk-py/langgraph_sdk/auth/types.py index 2320586b9..b057aeb84 100644 --- a/libs/sdk-py/langgraph_sdk/auth/types.py +++ b/libs/sdk-py/langgraph_sdk/auth/types.py @@ -61,23 +61,32 @@ ], typing.Dict[str, str], ] -"""Type for filtering queries. +"""Response type for authorization handlers. Supports exact matches and operators: - - Simple match: {"field": "value"} - - Equals: {"field": {"$eq": "value"}} + - Exact match shorthand: {"field": "value"} + - Exact match: {"field": {"$eq": "value"}} - Contains: {"field": {"$contains": "value"}} ???+ example "Examples" + Simple exact match filter for the resource owner: ```python - # Simple match - filter = {"status": "pending"} + filter = {"owner": "user-abcd123"} + ``` - # Equals operator - filter = {"status": {"$eq": "success"}} + Explicit version of the exact match filter: + ```python + filter = {"owner": {"$eq": "user-abcd123"}} + ``` - # Contains operator - filter = {"metadata.tags": {"$contains": "important"}} + Containment: + ```python + filter = {"participants": {"$contains": "user-abcd123"}} + ``` + + Combining filters (treated as a logical `AND`): + ```python + filter = {"owner": "user-abcd123", "participants": {"$contains": "user-efgh456"}} ``` """ @@ -109,9 +118,9 @@ HandlerResult = typing.Union[None, bool, FilterType] """The result of a handler can be: -- None | True: accept the request. -- False: reject the request with a 403 error -- FilterType: filter to apply + * None | True: accept the request. + * False: reject the request with a 403 error + * FilterType: filter to apply """ Handler = Callable[..., Awaitable[HandlerResult]] @@ -143,12 +152,20 @@ def identity(self) -> str: class MinimalUserDict(typing.TypedDict, total=False): - """The minimal user dictionary.""" + """The dictionary representation of a user.""" identity: typing_extensions.Required[str] + """The required unique identifier for the user.""" display_name: str + """The optional display name for the user.""" is_authenticated: bool + """Whether the user is authenticated. Defaults to True.""" permissions: Sequence[str] + """A list of permissions associated with the user. + + You can use these in your `@auth.on` authorization logic to determine + access permissions to different resources. + """ @typing.runtime_checkable