From 12f2a480cdf88cb9db36a6a60b971ce7d18575b6 Mon Sep 17 00:00:00 2001 From: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com> Date: Tue, 17 Dec 2024 16:25:48 -0800 Subject: [PATCH] Add concepts --- docs/docs/concepts/auth.md | 216 +++++++++++++++++ docs/docs/concepts/index.md | 1 + docs/docs/tutorials/auth/getting_started.md | 242 ++++++++++---------- 3 files changed, 338 insertions(+), 121 deletions(-) create mode 100644 docs/docs/concepts/auth.md diff --git a/docs/docs/concepts/auth.md b/docs/docs/concepts/auth.md new file mode 100644 index 000000000..db7c83621 --- /dev/null +++ b/docs/docs/concepts/auth.md @@ -0,0 +1,216 @@ +# Authentication & Access Control + +LangGraph Platform provides a flexible authentication and authorization system that can integrate with most authentication schemes. This guide explains the core concepts and how they work together. + +!!! note "Python only" +We currently only support custom authentication and authorization in Python deployments. Support for LangGraph.JS will be added soon. + +## Core Concepts + +### Authentication vs Authorization + +While often used interchangeably, these terms represent distinct security concepts: + +- **Authentication** ("AuthN") verifies _who_ you are. This runs as middleware for every request. +- **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` handler, and authorization is handled by your `@auth.on` handlers. + +## Authentication + +Authentication in LangGraph runs as middleware on every request. Your `@auth.authenticate` handler receives request information and must: + +1. Validate the credentials +2. Return user information if valid +3. Raise an HTTP exception if invalid (or AssertionError) + +```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` +- In your application via `config["configuration"]["langgraph_auth_user"]` + +## Authorization + +After authentication, LangGraph calls your `@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. +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` handler for all resources and actions. + +```python +@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 +``` + +### Resource-Specific Handlers + +You can register handlers for specific resources and actions using the `@auth.on` decorator. +When a request is made, the most specific handler that matches that resource and action is called. + +```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) -> None: + print(f"Request to {ctx.path} by {ctx.user.identity}") + return False + +# Thread creation +@auth.on.threads.create +async def on_thread_create( + ctx: Auth.types.AuthContext, + value: Auth.types.threads.create.value +): + metadata = value.setdefault("metadata", {}) + metadata["owner"] = ctx.user.identity + return {"owner": ctx.user.identity} + +# Thread retrieval +@auth.on.threads.read +async def on_thread_read( + ctx: Auth.types.AuthContext, + value: Auth.types.threads.read.value +): + return {"owner": ctx.user.identity} + +# Run creation, streaming, updates, etc. +@auth.on.threads.create_run +async def on_run_create( + ctx: Auth.types.AuthContext, + value: Auth.types.threads.create_run.value +): + # 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 "admin" not in ctx.user.get("role", []): + raise Auth.exceptions.HTTPException( + status_code=403, + detail="Only admins can create assistants" + ) +``` + +Using the setup above, a request to create a `thread` would match the `on_thread_create` handler, since it is the most specific handler for that resource and action. A request to create a `cron`, on the other hand, would match the global handler, since no more specific handler is registered for that resource and action. + +### Filter Operations + +Authorization handlers can return a filter dictionary to filter resources during all operations (both reads and writes). The filter dictionary supports two additional operators: + +- `$eq`: Exact match (e.g., `{"owner": {"$eq": user_id}}`) - this is equivalent to `{"owner": user_id}` +- `$contains`: List membership (e.g., `{"allowed_users": {"$contains": user_id}}`) + +A dictionary with multiple keys is converted to a logical `AND` filter. For example, `{"owner": user_id, "org_id": org_id}` is converted to `{"$and": [{"owner": user_id}, {"org_id": org_id}]}` + +## Common Access Patterns + +Here are some typical authorization patterns: + +### Single-Owner Resources + +```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 + +```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) +``` + +## 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: + +- [Setting up authentication](../tutorials/auth/getting_started.md) +- [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/tutorials/auth/getting_started.md b/docs/docs/tutorials/auth/getting_started.md index 7546a5f74..ffea05cda 100644 --- a/docs/docs/tutorials/auth/getting_started.md +++ b/docs/docs/tutorials/auth/getting_started.md @@ -5,6 +5,14 @@ Let's learn how to add custom authentication to a LangGraph Platform deployment. ??? note "Default authentication" When deploying to LangGraph Cloud, requests are authenticated using LangSmith API keys by default. This gates access to the server but doesn't provide fine-grained access control over threads. Self-hosted LangGraph platform has no default authentication. This guide shows how to add custom authentication handlers that work in both cases, to provide fine-grained access control over threads, runs, and other resources. +!!! note "Prerequisites" + + Before you begin, ensure you have the following: + - [GitHub account](https://github.com/) + - [LangSmith account](https://smith.langchain.com/) + - [Supabase account](https://supabase.com/) + - [Anthropic API key](https://console.anthropic.com/) + ## Understanding authentication flow The key components in a token-based authentication system are: @@ -20,7 +28,6 @@ sequenceDiagram participant User participant AuthServer as Auth Server participant LangGraph - User->>AuthServer: 1. Authenticate (username/password) AuthServer-->>User: 2. Return signed JWT token User->>LangGraph: 3. Request with JWT in header @@ -37,18 +44,120 @@ sequenceDiagram In this tutorial, we'll implement password-based authentication using Supabase as our auth server. -## Setting up the project +## Project Structure -First, clone the example template: +After cloning, you'll see these key files: -```bash -git clone https://github.com/langchain-ai/custom-auth.git -cd custom-auth +```shell +custom-auth/ +├── src/ +│ └── security/ +│ └── auth.py # We'll create this +├── langgraph.json # We'll update this +└── .env.example # Environment variables template ``` -This contains our chatbot code, as well as a custom auth handler (discussed below). +## Setting up authentication -### Configure Supabase +### 1. Create the auth handler + +First, let's create our authentication handler. Create a new file at `src/security/auth.py`: + +```python +import os +import httpx +import jwt +from langgraph_sdk import Auth + +# Load from your .env file +SUPABASE_URL = os.environ["SUPABASE_URL"] +SUPABASE_SERVICE_KEY = os.environ["SUPABASE_SERVICE_KEY"] +SUPABASE_JWT_SECRET = os.environ["SUPABASE_JWT_SECRET"] + +# Create the auth object we'll use to protect our endpoints +auth = Auth() + +@auth.authenticate +async def get_current_user( + authorization: str | None, # "Bearer " +) -> tuple[list[str], Auth.types.MinimalUserDict]: + """Verify the JWT token and return user info.""" + if not authorization: + raise Auth.exceptions.HTTPException( + status_code=401, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + + try: + # Extract and verify JWT token + token = authorization.split(" ", 1)[1] + payload = jwt.decode( + token, + SUPABASE_JWT_SECRET, + algorithms=["HS256"], + audience="authenticated", + ) + + # Double-check with Supabase that token is still valid + async with httpx.AsyncClient() as client: + response = await client.get( + f"{SUPABASE_URL}/auth/v1/user", + headers={"Authorization": f"Bearer {token}"}, + ) + if response.status_code != 200: + raise Auth.exceptions.HTTPException( + status_code=401, + detail="Invalid token" + ) + + user_data = response.json() + return [], { + "identity": user_data["id"], + "display_name": user_data.get("name"), + "is_authenticated": True, + } + except Exception as e: + raise Auth.exceptions.HTTPException( + status_code=401, + detail="Invalid token" + ) +``` + +This handler ensures only users with valid tokens can access our server. However, all users can still see each other's threads. Let's fix that by adding an authorization filter to the bottom of `auth.py`: + +```python +@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 +``` + +Now when users create threads, their ID is automatically added as the owner, and they can only see threads they own. + +### 2. Configure LangGraph + +Next, tell LangGraph about our auth handler. Open `langgraph.json` and add: + +```json +{ + "auth": { + "path": "src/security/auth.py:auth" + } +} +``` + +This points LangGraph to our `auth` object in the `auth.py` file. + +### 3. Set up environment variables + +Copy the example env file and add your Supabase credentials. To get your Supabase credentials: 1. Create a new project at [supabase.com](https://supabase.com) 2. Go to Project Settings > API to find your project's credentials @@ -58,8 +167,7 @@ This contains our chatbot code, as well as a custom auth handler (discussed belo cp .env.example .env ``` -Add the following to your `.env`: - +Add to your `.env`: ```bash SUPABASE_URL=https://your-project.supabase.co SUPABASE_SERVICE_KEY=your-service-key # aka the service_role secret @@ -67,11 +175,11 @@ SUPABASE_JWT_SECRET=your-jwt-secret ANTHROPIC_API_KEY=your-anthropic-key # For the LLM in our chatbot ``` -Additionally, note down your project's "anon public" key. This public key will be used by the user's client to authenticate with Supabase. +Also note down your project's "anon public" key - we'll use this for client authentication. -### Start the server +### 4. Start the server -Install dependencies and start the LangGraph server: +Install dependencies and start LangGraph: ```bash pip install -U "langgraph-cli[inmem]" && pip install -e . @@ -214,113 +322,6 @@ This demonstrates that: 2. Without a token, we get a 401 Unauthorized or 403 Forbidden error 3. Even with a valid token, users can only access their own threads -Now let's look at how this works under the hood. - -## How it works: The authentication handler - -All of this is enabled by our custom authentication handler, which is registered in `auth.py`, configured in our `langgraph.json` file: - -```json -{ - ... - "auth": { - "path": "src/security/auth.py:auth" - } -} -``` - -This tells the LangGraph platform to look for your variable names `auth` (of type `Auth`) in the file located at `src/security/auth.py`. If you open `src/security/auth.py` now, you'll see code that looks similar to the following: - -```python -# src/security/auth.py -import os -import httpx -import jwt -from langgraph_sdk import Auth - -# These are configured in your .env file -SUPABASE_URL = os.environ["SUPABASE_URL"] -SUPABASE_SERVICE_KEY = os.environ["SUPABASE_SERVICE_KEY"] -SUPABASE_JWT_SECRET = os.environ["SUPABASE_JWT_SECRET"] - -auth = Auth() - -@auth.authenticate -async def get_current_user( - authorization: str | None, # "Bearer " -) -> tuple[list[str], Auth.types.MinimalUserDict]: - if not authorization: - raise Auth.exceptions.HTTPException( - status_code=401, - detail="Not authenticated", - headers={"WWW-Authenticate": "Bearer"}, - ) - - try: - # Extract and validate JWT token - token = authorization.split(" ", 1)[1] - payload = jwt.decode( - token, - SUPABASE_JWT_SECRET, - algorithms=["HS256"], - audience="authenticated", - ) - - # Verify with Supabase - async with httpx.AsyncClient() as client: - response = await client.get( - f"{SUPABASE_URL}/auth/v1/user", - headers={"Authorization": f"Bearer {token}"}, - ) - if response.status_code != 200: - raise Auth.exceptions.HTTPException( - status_code=401, - detail="Invalid token" - ) - - user_data = response.json() - return [], { - "identity": user_data["id"], - "display_name": user_data.get("name"), - "is_authenticated": True, - } - except Exception as e: - raise Auth.exceptions.HTTPException( - status_code=401, - detail="Invalid token" - ) -``` - -This handler: - -1. Gets the token from the Authorization header -2. Verifies it was signed by Supabase -3. Double-checks with Supabase that the token is still valid -4. Returns the user's information for use in our app - -## Managing user resources - -We can also ensure users can only access their own resources: - -```python -@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 -``` - -This handler matches ALL requests to threads, runs, assistants, crons, and other resources. It does 2 things: - -1. Adds the user's ID as owner when creating resources -2. Filters resources by owner when reading them - - ## Deploying to LangGraph Cloud Now that you've set everything up, you can deploy your LangGraph application to LangGraph Cloud! Simply: @@ -332,7 +333,6 @@ Now that you've set everything up, you can deploy your LangGraph application to Once deployed, you should be able to run the code above, replacing the `http://localhost:2024` with the URL of your deployment. - ## Next steps Now that you understand token-based authentication: