&state=
+
+5. Client exchanges code for tokens:
+ - POST /oauth/token
+ - code=
+ - client_id=your-client-id
+ - client_secret=your-client-secret
+ - code_verifier=
+ - grant_type=authorization_code
+
+6. IdP returns tokens:
+ {
+ "access_token": "eyJhbGc...",
+ "refresh_token": "...",
+ "expires_in": 3600,
+ "token_type": "Bearer"
+ }
+
+7. Client stores access_token (in memory or secure storage)
+
+8. Client sends JWT with API requests:
+ - Authorization: Bearer
+```
+
+### Client Implementation (Python)
+
+```python
+from langgraph_sdk import get_client
+
+# Access Token obtained from IdP
+jwt_token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
+
+client = get_client(
+ url="http://localhost:2024",
+ headers={"Authorization": f"Bearer {jwt_token}"}
+)
+
+# Create thread and make requests
+thread = await client.threads.create()
+```
+
+### Client Implementation (cURL)
+
+```bash
+curl -X POST http://localhost:2024/runs \
+ -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
+ -H "Content-Type: application/json" \
+ -d '{
+ "assistant_id": "agent",
+ "input": {"messages": [{"role": "user", "content": "Hello"}]}
+ }'
+```
+
+---
+
+## Provider-Specific Setup
+
+### Keycloak
+
+**Prerequisites:** Keycloak instance running (local or cloud)
+
+#### Step 1: Create Client
+
+1. Login to Keycloak Admin Console
+2. Select your realm
+3. Clients → Create client
+4. Set Name: `my-langgraph-app`
+5. Click Next → Next → Create
+
+#### Step 2: Configure Client
+
+1. Settings tab:
+ - Client Authentication: **ON**
+ - Valid Redirect URIs: `http://localhost:3000/callback` (if using web UI)
+ - Web Origins: `http://localhost:3000`
+
+2. Credentials tab:
+ - Note the **Client Secret**
+
+3. Client Scopes:
+ - Ensure `profile`, `email`, `openid` are included
+
+#### Step 3: Get JWKS URI
+
+Visit: `https://keycloak.example.com/realms/your-realm/.well-known/openid-configuration`
+
+Look for: `"jwks_uri": "https://keycloak.example.com/realms/your-realm/protocol/openid-connect/certs"`
+
+#### Step 4: Environment Variables
+
+```env
+JWT_JWKS_URI=https://keycloak.example.com/realms/your-realm/protocol/openid-connect/certs
+JWT_ISSUER=https://keycloak.example.com/realms/your-realm
+JWT_AUDIENCE=my-langgraph-app
+```
+
+#### Step 5: Verify
+
+```bash
+curl https://keycloak.example.com/realms/your-realm/.well-known/openid-configuration \
+ | jq '.jwks_uri'
+```
+
+---
+
+### Auth0
+
+**Prerequisites:** Auth0 account (https://auth0.com)
+
+#### Step 1: Create Application
+
+1. Dashboard → Applications → Create Application
+2. Name: `LangGraph App`
+3. Type: **Regular Web Application** (or API if headless)
+4. Click Create
+
+#### Step 2: Configure Application
+
+1. Settings tab:
+ - Allowed Callback URLs: `http://localhost:3000/callback`
+ - Allowed Logout URLs: `http://localhost:3000`
+ - Allowed Web Origins: `http://localhost:3000`
+
+2. API Authorization:
+ - Go to APIs → Create API
+ - Name: `langgraph-api`
+ - Identifier: `https://your-api-identifier` (used as audience)
+
+#### Step 3: Get Configuration
+
+Visit: `https://your-tenant.auth0.com/.well-known/openid-configuration`
+
+Note the `jwks_uri` value.
+
+#### Step 4: Environment Variables
+
+```env
+JWT_JWKS_URI=https://your-tenant.auth0.com/.well-known/jwks.json
+JWT_ISSUER=https://your-tenant.auth0.com/
+JWT_AUDIENCE=https://your-api-identifier
+```
+
+#### Step 5: Verify
+
+```bash
+curl https://your-tenant.auth0.com/.well-known/openid-configuration \
+ | jq '.jwks_uri'
+```
+
+---
+
+### Supabase
+
+**Prerequisites:** Supabase project (https://supabase.com)
+
+#### Step 1: Your Project Credentials
+
+1. Login to Supabase Dashboard
+2. Select your project
+3. Settings → API → Project URL and anon/service keys
+
+Your project URL is your IdP endpoint.
+
+#### Step 2: Authentication Configuration
+
+Supabase has OIDC built-in. No additional setup needed.
+
+#### Step 3: Environment Variables
+
+```env
+JWT_JWKS_URI=https://your-project.supabase.co/auth/v1/.well-known/jwks.json
+JWT_ISSUER=https://your-project.supabase.co/auth/v1
+JWT_AUDIENCE=authenticated
+```
+
+#### Step 4: Verify
+
+```bash
+curl https://your-project.supabase.co/auth/v1/.well-known/jwks.json \
+ | jq '.keys | length'
+# Should return number of keys (e.g., 2)
+```
+
+---
+
+## Testing
+
+### Test 1: Verify JWKS Endpoint
+
+```bash
+# Check if JWKS endpoint is accessible
+curl $JWT_JWKS_URI | jq '.keys[0]'
+
+# Should return:
+# {
+# "kty": "RSA",
+# "kid": "...",
+# "use": "sig",
+# "n": "...",
+# "e": "AQAB"
+# }
+```
+
+### Test 2: Obtain a Test Token
+
+Using your IdP's test tools or a client library:
+
+```bash
+# Example for Keycloak:
+curl -X POST https://keycloak.example.com/realms/my-realm/protocol/openid-connect/token \
+ -d "client_id=my-langgraph-app" \
+ -d "client_secret=your-secret" \
+ -d "username=testuser" \
+ -d "password=testpass" \
+ -d "grant_type=password" \
+ -d "scope=openid profile email" \
+ | jq -r '.access_token' > token.txt
+```
+
+### Test 3: Verify Token with LangGraph
+
+```bash
+TOKEN=$(cat token.txt)
+
+curl -X POST http://localhost:2024/runs \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "assistant_id": "agent",
+ "input": {"messages": [{"role": "user", "content": "Hello"}]}
+ }' \
+ | jq '.status'
+
+# Should return: "success" or streaming response
+# Not: "401 Unauthorized"
+```
+
+### Test 4: Inspect Token Claims
+
+```bash
+python3 << 'EOF'
+import jwt
+import json
+
+token = open("token.txt").read().strip()
+
+# Decode without verification (inspect claims)
+claims = jwt.decode(token, options={"verify_signature": False})
+print(json.dumps(claims, indent=2))
+EOF
+```
+
+---
+
+## Troubleshooting
+
+### Error: "Unable to find signing key"
+
+**Cause:** Token has a `kid` (key ID) that doesn't exist in JWKS.
+
+**Solutions:**
+1. Verify JWKS endpoint is correct:
+ ```bash
+ curl $JWT_JWKS_URI | jq '.keys | map(.kid)'
+ ```
+
+2. Check token's `kid`:
+ ```bash
+ python3 -c "import jwt; print(jwt.get_unverified_header('$TOKEN'))"
+ ```
+
+3. If endpoints match but still fails, your IdP may rotate keys frequently. Ensure `cache_jwk_set=True` is set in auth.py.
+
+### Error: "Invalid issuer"
+
+**Cause:** JWT issuer doesn't match `JWT_ISSUER`.
+
+**Solution:** Decode token to check actual issuer:
+```bash
+python3 -c "import jwt, json; print(json.dumps(jwt.decode(open('token.txt').read().strip(), options={'verify_signature': False}), indent=2))" | grep '"iss"'
+```
+
+Compare with:
+```bash
+echo $JWT_ISSUER
+```
+
+They must match exactly (including trailing slashes).
+
+### Error: "Invalid audience"
+
+**Cause:** JWT audience doesn't match `JWT_AUDIENCE`.
+
+**Solution:** Check token's audience claim:
+```bash
+python3 -c "import jwt, json; print(json.dumps(jwt.decode(open('token.txt').read().strip(), options={'verify_signature': False}), indent=2))" | grep '"aud"'
+```
+
+Ensure your IdP is issuing tokens with the correct audience.
+
+### Error: "Token expired"
+
+**Cause:** Token has expired.
+
+**Solution:** Get a fresh token from your IdP (tokens typically expire in 1 hour).
+
+### Error: "Connection refused" to JWKS endpoint
+
+**Cause:** IdP is unreachable.
+
+**Solution:**
+```bash
+curl $JWT_JWKS_URI -v
+# Check firewall, DNS, and IdP status
+```
+
+### Performance: JWKS Fetched on Every Request
+
+**Cause:** Cache is not working (might be disabled or TTL too short).
+
+**Solution:** Verify cache settings in auth.py:
+```python
+jwks_client = PyJWKClient(JWKS_URI, cache_jwk_set=True, lifespan=3600)
+# ^^^^^^^^^^^^^^^^
+# Must be True
+```
+
+---
+
+## Best Practices
+
+1. **Always use HTTPS** for IdP endpoints in production
+2. **Cache JWKS locally** (done automatically by PyJWKClient)
+3. **Validate issuer and audience** if your IdP supports multiple clients
+4. **Handle token expiry gracefully** (return 401, let client refresh)
+5. **Log authentication failures** for debugging (but not tokens themselves)
+6. **Use PKCE** in public clients (mobile, SPAs)
+
+---
+
+## Next Steps
+
+- [OAuth-Direct Mode](./04-OAUTH-DIRECT.md) - for direct provider verification
+- [API-Key Mode](./07-API-KEY.md) - for LangGraph Cloud
+- [Custom Server Auth](./08-CUSTOM-SERVER-AUTH.md) - for advanced patterns
+- Return to [Auth Architecture](./05-AUTH-ARCHITECTURE.md)
diff --git a/docs/07-API-KEY.md b/docs/07-API-KEY.md
new file mode 100644
index 0000000..d658182
--- /dev/null
+++ b/docs/07-API-KEY.md
@@ -0,0 +1,387 @@
+# API Key Authentication (LangGraph Cloud)
+
+This guide explains how to authenticate with LangGraph Cloud using API keys. This is the simplest authentication mode when deploying to LangGraph Cloud.
+
+## Table of Contents
+
+1. [Architecture Overview](#architecture-overview)
+2. [Environment Variables](#environment-variables)
+3. [Setup](#setup)
+4. [Frontend Integration](#frontend-integration)
+5. [Client Usage](#client-usage)
+6. [Troubleshooting](#troubleshooting)
+
+---
+
+## Architecture Overview
+
+```mermaid
+sequenceDiagram
+ autonumber
+ participant Client as Client
+ participant LangGraph as LangGraph Cloud
+
+ Client->>LangGraph: POST /runs
x-api-key: lsv2_pt_...
+
+ alt API key is valid
+ LangGraph->>LangGraph: Validate API key natively
+ LangGraph-->>Client: Response
+ else API key is invalid/expired
+ LangGraph-->>Client: 401 Unauthorized
+ end
+```
+
+### Key Characteristics
+
+| Item | Description |
+|------|-------------|
+| **Token Issuer** | LangGraph Cloud |
+| **Token Format** | API Key (starts with `lsv2_pt_`) |
+| **Token Validation** | LangGraph Cloud validates natively |
+| **Frontend** | Optional (auto-login if env var set) |
+| **Per-User Isolation** | No (API key represents entire deployment) |
+| **Custom Auth Handler** | Not needed |
+
+### Pros and Cons
+
+**Pros:**
+- Simplest setup (no `auth.py` needed)
+- Works with LangGraph Cloud out of the box
+- No token validation logic to implement
+- Secure (API keys rotatable in LangGraph Cloud dashboard)
+
+**Cons:**
+- API key represents entire deployment (not per-user)
+- No per-user thread isolation
+- All requests are authenticated equally
+
+---
+
+## Environment Variables
+
+### Frontend (Optional)
+
+```env
+NEXT_PUBLIC_AUTH_MODE=api-key
+NEXT_PUBLIC_API_URL=http://localhost:2024
+NEXT_PUBLIC_LANGCHAIN_API_KEY=lsv2_pt_...
+```
+
+If `NEXT_PUBLIC_LANGCHAIN_API_KEY` is set, the UI auto-logs in without requiring user input.
+
+If not set, the UI shows an API key input form.
+
+### Server
+
+No custom environment variables needed. LangGraph Cloud validates API keys natively.
+
+---
+
+## Setup
+
+### Step 1: Generate LangGraph Cloud API Key
+
+1. Login to [LangGraph Cloud](https://cloud.langsmith.com)
+2. Navigate to **Settings** → **API Keys**
+3. Click **Create API Key**
+4. Copy the key (starts with `lsv2_pt_`)
+5. Store securely (cannot be retrieved later)
+
+### Step 2: Set Frontend Environment Variable (Optional)
+
+If you want auto-login without a form:
+
+```env
+# Frontend .env
+NEXT_PUBLIC_AUTH_MODE=api-key
+NEXT_PUBLIC_LANGCHAIN_API_KEY=lsv2_pt_...
+```
+
+If you omit this, users see an API key input form.
+
+### Step 3: Deploy to LangGraph Cloud
+
+```bash
+# Package your LangGraph app
+langgraph build
+
+# Deploy (LangGraph Cloud validates API keys automatically)
+langgraph push --remote langgraph-api/your-deployment
+```
+
+No custom auth handler (`auth.py`) is needed.
+
+---
+
+## Frontend Integration
+
+### Option 1: Auto-Login with Environment Variable
+
+If you set `NEXT_PUBLIC_LANGCHAIN_API_KEY`, the UI automatically logs in:
+
+```env
+NEXT_PUBLIC_AUTH_MODE=api-key
+NEXT_PUBLIC_LANGCHAIN_API_KEY=lsv2_pt_your_key_here
+NEXT_PUBLIC_API_URL=https://your-deployment.dev.langsmith.com
+```
+
+The chat UI starts immediately without prompting for credentials.
+
+### Option 2: User-Provided API Key (Form)
+
+If you omit `NEXT_PUBLIC_LANGCHAIN_API_KEY`, users enter their own key:
+
+```env
+NEXT_PUBLIC_AUTH_MODE=api-key
+NEXT_PUBLIC_API_URL=https://your-deployment.dev.langsmith.com
+```
+
+The UI shows an input form where users paste their API key.
+
+### Example Frontend Code
+
+```typescript
+// src/lib/auth.ts
+
+import { headers } from "next/headers";
+
+export async function getAuthHeaders(): Promise> {
+ const authMode = process.env.NEXT_PUBLIC_AUTH_MODE;
+
+ if (authMode === "api-key") {
+ const apiKey = process.env.NEXT_PUBLIC_LANGCHAIN_API_KEY;
+ if (apiKey) {
+ return {
+ "x-api-key": apiKey,
+ };
+ }
+ // If no env var, user provides it via form
+ // Get from localStorage or session
+ const storedKey = localStorage.getItem("langchain_api_key");
+ if (storedKey) {
+ return {
+ "x-api-key": storedKey,
+ };
+ }
+ }
+
+ return {};
+}
+```
+
+---
+
+## Client Usage
+
+### Python Client
+
+```python
+from langgraph_sdk import get_client
+
+api_key = "lsv2_pt_..."
+
+client = get_client(
+ url="https://your-deployment.dev.langsmith.com",
+ headers={"x-api-key": api_key}
+)
+
+# Create thread
+thread = await client.threads.create()
+
+# Stream output
+async for event in client.runs.stream(
+ thread["thread_id"],
+ "agent",
+ input={"messages": [{"role": "user", "content": "Hello"}]},
+):
+ print(event)
+```
+
+### JavaScript/TypeScript Client
+
+```typescript
+const apiKey = "lsv2_pt_...";
+
+const response = await fetch(
+ "https://your-deployment.dev.langsmith.com/threads",
+ {
+ method: "POST",
+ headers: {
+ "x-api-key": apiKey,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({}),
+ }
+);
+
+const thread = await response.json();
+console.log(thread.thread_id);
+```
+
+### cURL
+
+```bash
+API_KEY="lsv2_pt_..."
+URL="https://your-deployment.dev.langsmith.com"
+
+# Create thread
+curl -X POST "$URL/threads" \
+ -H "x-api-key: $API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{}' \
+ | jq '.thread_id'
+
+# Run agent
+curl -X POST "$URL/runs" \
+ -H "x-api-key: $API_KEY" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "thread_id": "your-thread-id",
+ "assistant_id": "agent",
+ "input": {"messages": [{"role": "user", "content": "Hello"}]}
+ }'
+```
+
+---
+
+## Deployment Configuration
+
+### langgraph.json
+
+No auth configuration needed for API key mode:
+
+```json
+{
+ "define": "src/graph.py:graph"
+}
+```
+
+LangGraph Cloud handles API key validation automatically.
+
+### pyproject.toml
+
+```toml
+[tool.poetry]
+name = "my-langgraph-app"
+version = "0.1.0"
+
+[tool.poetry.dependencies]
+python = "^3.11"
+langgraph = "^0.2.0"
+anthropic = "^0.28.0"
+```
+
+### No auth.py Required
+
+You do NOT need to create `src/security/auth.py` for API key mode.
+
+---
+
+## Environment Variable Checklist
+
+### Local Development
+
+```env
+# .env.local
+NEXT_PUBLIC_AUTH_MODE=api-key
+NEXT_PUBLIC_API_URL=http://localhost:2024
+
+# Optional: auto-login (for testing)
+# NEXT_PUBLIC_LANGCHAIN_API_KEY=lsv2_pt_...
+```
+
+### Production (LangGraph Cloud)
+
+```env
+# .env.production
+NEXT_PUBLIC_AUTH_MODE=api-key
+NEXT_PUBLIC_API_URL=https://your-deployment.dev.langsmith.com
+
+# Optional: auto-login for shared deployments
+# NEXT_PUBLIC_LANGCHAIN_API_KEY=lsv2_pt_...
+```
+
+---
+
+## Comparison with Other Modes
+
+| Mode | Auth Handler | Token Validation | Per-User | Use Case |
+|------|--------------|------------------|----------|----------|
+| **api-key** | None | Native (LangGraph Cloud) | No | Cloud deployment, shared key |
+| **credentials** | Yes (JWT HS256) | Signature verify | Yes | Multi-user, email/password |
+| **oauth** | Yes (JWT HS256) | Signature verify | Yes | Multi-user, social login |
+| **custom-jwt** | Yes (JWKS) | Public key verify | Yes | External IdP (Auth0, Keycloak) |
+| **oauth-direct** | Yes (Provider API) | Provider API call | Yes | CLI/mobile, no frontend |
+| **standalone** | None | None | No | Dev/demo, no auth |
+
+---
+
+## Troubleshooting
+
+### Error: "Invalid API key"
+
+**Cause:** API key is malformed or does not exist.
+
+**Solutions:**
+1. Verify key format starts with `lsv2_pt_`
+2. Check for extra spaces or newlines
+3. Regenerate key in LangGraph Cloud dashboard
+
+### Error: "Unauthorized" (401)
+
+**Cause:** API key header is missing or wrong name.
+
+**Solution:** Ensure header is named `x-api-key` (lowercase):
+```bash
+curl -H "x-api-key: lsv2_pt_..." ... # Correct
+curl -H "X-API-Key: lsv2_pt_..." ... # Wrong header name
+```
+
+### Error: "API key has been revoked"
+
+**Cause:** API key was deleted in LangGraph Cloud dashboard.
+
+**Solution:** Generate a new API key and update environment variables.
+
+### Auto-Login Not Working
+
+**Cause:** `NEXT_PUBLIC_LANGCHAIN_API_KEY` not set or wrong format.
+
+**Solution:**
+```bash
+echo $NEXT_PUBLIC_LANGCHAIN_API_KEY
+# Should show: lsv2_pt_...
+```
+
+Verify in `.env` or `.env.local`.
+
+### User Can't Enter API Key
+
+**Cause:** No input form shown (auto-login mode enabled).
+
+**Solution:** Remove `NEXT_PUBLIC_LANGCHAIN_API_KEY` from environment to show the form:
+```bash
+unset NEXT_PUBLIC_LANGCHAIN_API_KEY
+# Restart dev server
+npm run dev
+```
+
+---
+
+## Best Practices
+
+1. **Rotate API keys regularly** (generate new, delete old in dashboard)
+2. **Do not commit API keys** to git (use `.env.local` or secrets manager)
+3. **Use different keys per environment** (dev, staging, production)
+4. **For multi-user deployments**, use per-user tokens via `custom-jwt` or `oauth` instead
+5. **Monitor API key usage** in LangGraph Cloud dashboard
+
+---
+
+## Next Steps
+
+- [Custom JWT Mode](./06-CUSTOM-JWT.md) - for per-user authentication
+- [OAuth Direct Mode](./04-OAUTH-DIRECT.md) - for CLI/mobile clients
+- [Custom Server Auth](./08-CUSTOM-SERVER-AUTH.md) - for advanced auth patterns
+- [LangGraph Cloud Docs](https://docs.smith.langchain.com/)
+- Return to [Auth Architecture](./05-AUTH-ARCHITECTURE.md)
diff --git a/docs/08-CUSTOM-SERVER-AUTH.md b/docs/08-CUSTOM-SERVER-AUTH.md
new file mode 100644
index 0000000..ea3f695
--- /dev/null
+++ b/docs/08-CUSTOM-SERVER-AUTH.md
@@ -0,0 +1,783 @@
+# Your First Custom LangGraph Server Authentication
+
+This tutorial walks you through creating a custom authentication handler for LangGraph. By the end, you'll understand the `Auth()` contract, `MinimalUserDict` interface, and per-user thread isolation via owner metadata.
+
+## Table of Contents
+
+1. [Prerequisites](#prerequisites)
+2. [What We're Building](#what-were-building)
+3. [The Auth() Contract](#the-auth-contract)
+4. [MinimalUserDict Interface](#minimaluserdict-interface)
+5. [Step-by-Step Implementation](#step-by-step-implementation)
+6. [Testing Your Auth Handler](#testing-your-auth-handler)
+7. [Advanced Patterns](#advanced-patterns)
+8. [Common Mistakes](#common-mistakes)
+
+---
+
+## Prerequisites
+
+- Python 3.11+
+- LangGraph installed: `pip install langgraph`
+- Basic understanding of async/await
+- A way to generate test tokens (JWT, OAuth tokens, etc.)
+
+---
+
+## What We're Building
+
+A custom authentication handler that:
+
+1. Extracts a token from the `Authorization: Bearer ` header
+2. Validates the token (method varies by mode)
+3. Returns user identity and metadata
+4. Isolates threads per user via `owner` metadata
+
+### The Handler Must Do Three Things
+
+```python
+@auth.authenticate
+async def authenticate(authorization: str | None) -> Auth.types.MinimalUserDict:
+ """
+ ✓ Parse the Authorization header
+ ✓ Validate the token (your logic here)
+ ✓ Return user identity dict
+ """
+
+@auth.on
+async def filter_by_owner(ctx: Auth.types.AuthContext, value: dict) -> dict:
+ """
+ ✓ Extract user identity from auth context
+ ✓ Add 'owner' metadata to filter by user
+ ✓ Return filter dict for thread isolation
+ """
+```
+
+---
+
+## The Auth() Contract
+
+LangGraph provides the `Auth` class to define authentication rules.
+
+### Import and Initialize
+
+```python
+from langgraph_sdk import Auth
+
+auth = Auth()
+```
+
+### @auth.authenticate Decorator
+
+The function decorated with `@auth.authenticate` is called for every request:
+
+```python
+@auth.authenticate
+async def authenticate(authorization: str | None) -> Auth.types.MinimalUserDict:
+ """
+ Args:
+ authorization: The value of the 'Authorization' HTTP header.
+ Format: "Bearer " or None if missing.
+
+ Returns:
+ A MinimalUserDict with at least 'identity' key.
+
+ Raises:
+ Auth.exceptions.HTTPException: If auth fails (401, 403, etc.)
+ """
+ pass
+```
+
+**Important:**
+- Called on EVERY request
+- Synchronous or async
+- Must raise `HTTPException` on failure, not return None
+- Must return MinimalUserDict on success
+
+### @auth.on Decorator
+
+The function decorated with `@auth.on` is called when resources are created/accessed:
+
+```python
+@auth.on
+async def filter_by_owner(ctx: Auth.types.AuthContext, value: dict) -> dict:
+ """
+ Args:
+ ctx: Authentication context (includes validated user from @auth.authenticate)
+ value: The resource being created (dict)
+
+ Returns:
+ Filter dict to apply for resource isolation
+ """
+ pass
+```
+
+**Important:**
+- Called when creating/accessing threads, runs, etc.
+- Modifies the resource to add owner metadata
+- Returns filter dict for querying
+
+---
+
+## MinimalUserDict Interface
+
+The `@auth.authenticate` handler must return a dict with at least this structure:
+
+```python
+{
+ "identity": "user-123", # Required: unique user identifier
+ "email": "user@example.com", # Optional: user email
+ "display_name": "John Doe", # Optional: human-readable name
+ "is_authenticated": True, # Optional: auth status flag
+ # ... any other custom fields
+}
+```
+
+### Key Constraints
+
+| Field | Type | Required | Notes |
+|-------|------|----------|-------|
+| `identity` | `str` | **Yes** | Must be globally unique per user. Used for thread isolation. Examples: user ID, email, `provider:user-id` |
+| `email` | `str` | No | User's email address |
+| `display_name` | `str` | No | Human-readable name for logging/UI |
+| `is_authenticated` | `bool` | No | Redundant (presence in dict implies authenticated), but allowed |
+| Custom fields | Any | No | Add any fields; accessible in `ctx.user.get()` |
+
+### Examples
+
+```python
+# Minimal
+return {"identity": "user-123"}
+
+# With metadata
+return {
+ "identity": "user-123",
+ "email": "user@example.com",
+ "display_name": "John Doe",
+ "is_authenticated": True,
+}
+
+# With custom fields
+return {
+ "identity": "google:118346091823908",
+ "email": "user@gmail.com",
+ "display_name": "John Doe",
+ "provider": "google",
+ "roles": ["user", "admin"],
+}
+```
+
+---
+
+## Step-by-Step Implementation
+
+### Step 1: Create langgraph.json
+
+Tell LangGraph where to find your auth handler:
+
+```json
+{
+ "define": "src/graph.py:graph",
+ "auth": {
+ "path": "src/security/auth.py:auth"
+ }
+}
+```
+
+### Step 2: Create src/security/auth.py
+
+Start with a basic structure:
+
+```python
+"""Authentication handler for LangGraph.
+
+This module defines how to validate incoming requests and isolate
+resources per user.
+"""
+
+import os
+from langgraph_sdk import Auth
+
+# Your auth configuration (load from env vars)
+SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret")
+
+auth = Auth()
+
+
+@auth.authenticate
+async def authenticate(authorization: str | None) -> Auth.types.MinimalUserDict:
+ """Validate incoming request and return user identity.
+
+ Args:
+ authorization: Authorization header value (e.g., "Bearer ")
+
+ Returns:
+ User identity dict
+
+ Raises:
+ HTTPException: If authorization fails
+ """
+ # Placeholder: we'll add validation logic next
+ raise Auth.exceptions.HTTPException(
+ status_code=401,
+ detail="Not implemented"
+ )
+
+
+@auth.on
+async def filter_by_owner(ctx: Auth.types.AuthContext, value: dict) -> dict:
+ """Add owner metadata for per-user isolation.
+
+ Args:
+ ctx: Auth context (includes validated user)
+ value: Resource being created
+
+ Returns:
+ Filter dict for resource isolation
+ """
+ # Extract user identity from auth context
+ filters = {"owner": ctx.user.identity}
+
+ # Add to resource metadata
+ metadata = value.setdefault("metadata", {})
+ metadata.update(filters)
+
+ return filters
+```
+
+### Step 3: Implement Token Validation
+
+Add your specific validation logic. Here are common patterns:
+
+#### Pattern A: JWT Token (HS256)
+
+```python
+import jwt
+
+@auth.authenticate
+async def authenticate(authorization: str | None) -> Auth.types.MinimalUserDict:
+ """Validate JWT token."""
+ if not authorization:
+ raise Auth.exceptions.HTTPException(status_code=401, detail="Unauthorized")
+
+ # Parse "Bearer "
+ scheme, _, token = authorization.partition(" ")
+ if scheme.lower() != "bearer" or not token:
+ raise Auth.exceptions.HTTPException(status_code=401, detail="Invalid token")
+
+ try:
+ # Decode JWT with secret key
+ payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
+
+ return {
+ "identity": payload["sub"],
+ "email": payload.get("email"),
+ "display_name": payload.get("name"),
+ }
+ except jwt.InvalidTokenError as e:
+ raise Auth.exceptions.HTTPException(
+ status_code=401,
+ detail=f"Invalid token: {e}"
+ )
+```
+
+#### Pattern B: JWKS Validation (RS256)
+
+```python
+from jwt import PyJWKClient
+import jwt
+
+JWKS_URL = os.environ["JWT_JWKS_URI"]
+jwks_client = PyJWKClient(JWKS_URL, cache_jwk_set=True, lifespan=3600)
+
+@auth.authenticate
+async def authenticate(authorization: str | None) -> Auth.types.MinimalUserDict:
+ """Validate JWT with JWKS public key."""
+ if not authorization:
+ raise Auth.exceptions.HTTPException(status_code=401, detail="Unauthorized")
+
+ scheme, _, token = authorization.partition(" ")
+ if scheme.lower() != "bearer" or not token:
+ raise Auth.exceptions.HTTPException(status_code=401, detail="Invalid token")
+
+ try:
+ # Get public key from JWKS endpoint
+ signing_key = jwks_client.get_signing_key_from_jwt(token)
+
+ # Verify signature with public key
+ payload = jwt.decode(
+ token,
+ signing_key.key,
+ algorithms=["RS256"],
+ issuer=os.environ.get("JWT_ISSUER"),
+ audience=os.environ.get("JWT_AUDIENCE"),
+ )
+
+ return {
+ "identity": payload["sub"],
+ "email": payload.get("email"),
+ }
+ except jwt.InvalidTokenError as e:
+ raise Auth.exceptions.HTTPException(
+ status_code=401,
+ detail=f"Invalid token: {e}"
+ )
+```
+
+#### Pattern C: API Key
+
+```python
+@auth.authenticate
+async def authenticate(authorization: str | None) -> Auth.types.MinimalUserDict:
+ """Validate simple API key."""
+ if not authorization:
+ raise Auth.exceptions.HTTPException(status_code=401, detail="Unauthorized")
+
+ # Expect "Bearer "
+ scheme, _, api_key = authorization.partition(" ")
+ if scheme.lower() != "bearer" or not api_key:
+ raise Auth.exceptions.HTTPException(status_code=401, detail="Invalid format")
+
+ # Check against allowed keys
+ VALID_KEYS = {"sk-test-123", "sk-test-456"}
+ if api_key not in VALID_KEYS:
+ raise Auth.exceptions.HTTPException(status_code=401, detail="Invalid key")
+
+ return {
+ "identity": f"api-key:{api_key[:10]}",
+ "display_name": "API User",
+ }
+```
+
+### Step 4: Per-User Isolation
+
+The `@auth.on` handler ensures each user's threads are isolated:
+
+```python
+@auth.on
+async def filter_by_owner(ctx: Auth.types.AuthContext, value: dict) -> dict:
+ """Isolate resources per authenticated user.
+
+ This function is called when creating/accessing threads, runs, etc.
+ It adds 'owner' metadata to automatically filter by user.
+ """
+ # Get user identity from auth context
+ user_identity = ctx.user.identity
+
+ # Create filter dict
+ filters = {"owner": user_identity}
+
+ # Add to resource metadata (threads/runs/etc.)
+ metadata = value.setdefault("metadata", {})
+ metadata["owner"] = user_identity
+
+ return filters
+```
+
+### Step 5: Complete Implementation Example
+
+```python
+"""Authentication handler for LangGraph.
+
+Validates JWT tokens (HS256) and isolates resources per user.
+"""
+
+import os
+import jwt
+from langgraph_sdk import Auth
+
+SECRET_KEY = os.environ.get("SECRET_KEY", "")
+if not SECRET_KEY:
+ raise ValueError("SECRET_KEY environment variable is required")
+
+auth = Auth()
+
+AUTH_EXCEPTION = Auth.exceptions.HTTPException(
+ status_code=401,
+ detail="Invalid or expired token",
+ headers={"WWW-Authenticate": "Bearer"},
+)
+
+
+@auth.authenticate
+async def authenticate(authorization: str | None) -> Auth.types.MinimalUserDict:
+ """Validate JWT token and extract user identity.
+
+ Args:
+ authorization: Authorization header (e.g., "Bearer ")
+
+ Returns:
+ User identity dict
+
+ Raises:
+ HTTPException: If token is invalid
+ """
+ if not authorization:
+ raise AUTH_EXCEPTION
+
+ try:
+ # Parse "Bearer "
+ scheme, token = authorization.split(" ", 1)
+ if scheme.lower() != "bearer":
+ raise AUTH_EXCEPTION
+
+ # Decode JWT
+ payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
+
+ # Return user identity
+ return {
+ "identity": payload["sub"],
+ "email": payload.get("email"),
+ "display_name": payload.get("name"),
+ "is_authenticated": True,
+ }
+
+ except (ValueError, jwt.InvalidTokenError) as e:
+ raise AUTH_EXCEPTION from e
+
+
+@auth.on
+async def add_owner_metadata(ctx: Auth.types.AuthContext, value: dict) -> dict:
+ """Add owner metadata to resources for per-user isolation.
+
+ Ensures users can only access their own threads and runs.
+ """
+ filters = {"owner": ctx.user.identity}
+ metadata = value.setdefault("metadata", {})
+ metadata.update(filters)
+ return filters
+```
+
+---
+
+## Testing Your Auth Handler
+
+### Test 1: Verify Auth Handler Loads
+
+```bash
+# Start LangGraph server
+langgraph up
+
+# Check if auth handler loaded (no errors)
+# Should see in logs: "Auth handler loaded"
+```
+
+### Test 2: Test Without Token (Should Fail)
+
+```bash
+curl -X POST http://localhost:2024/runs \
+ -H "Content-Type: application/json" \
+ -d '{}' \
+ -v
+
+# Expected: 401 Unauthorized
+```
+
+### Test 3: Test With Invalid Token (Should Fail)
+
+```bash
+curl -X POST http://localhost:2024/runs \
+ -H "Authorization: Bearer invalid-token" \
+ -H "Content-Type: application/json" \
+ -d '{}' \
+ -v
+
+# Expected: 401 Unauthorized
+```
+
+### Test 4: Generate Valid Test Token
+
+```python
+import jwt
+import json
+
+SECRET_KEY = "dev-secret"
+
+token = jwt.encode(
+ {
+ "sub": "user-123",
+ "email": "user@example.com",
+ "name": "Test User",
+ },
+ SECRET_KEY,
+ algorithm="HS256"
+)
+
+print(f"Token: {token}")
+```
+
+### Test 5: Test With Valid Token (Should Succeed)
+
+```bash
+TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
+
+curl -X POST http://localhost:2024/runs \
+ -H "Authorization: Bearer $TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "assistant_id": "agent",
+ "input": {"messages": []}
+ }' \
+ -v
+
+# Expected: 200 OK or streaming response
+```
+
+### Test 6: Verify Per-User Isolation
+
+Create two users and verify they can't see each other's threads:
+
+```python
+import jwt
+from langgraph_sdk import get_client
+
+SECRET_KEY = "dev-secret"
+
+# User 1 token
+token1 = jwt.encode({"sub": "user-1", "email": "user1@example.com"}, SECRET_KEY)
+
+# User 2 token
+token2 = jwt.encode({"sub": "user-2", "email": "user2@example.com"}, SECRET_KEY)
+
+# Create clients
+client1 = get_client(
+ url="http://localhost:2024",
+ headers={"Authorization": f"Bearer {token1}"}
+)
+
+client2 = get_client(
+ url="http://localhost:2024",
+ headers={"Authorization": f"Bearer {token2}"}
+)
+
+# User 1 creates thread
+thread1 = await client1.threads.create()
+print(f"User 1 thread: {thread1['thread_id']}")
+
+# User 2 creates thread
+thread2 = await client2.threads.create()
+print(f"User 2 thread: {thread2['thread_id']}")
+
+# User 2 tries to access User 1's thread
+try:
+ await client2.threads.get(thread1['thread_id'])
+ print("ERROR: User 2 should not see User 1's thread!")
+except Exception as e:
+ print(f"GOOD: User 2 cannot access User 1's thread: {e}")
+```
+
+---
+
+## Advanced Patterns
+
+### Pattern: Multi-Provider Identities
+
+When supporting multiple auth providers, prefix identity with provider:
+
+```python
+@auth.authenticate
+async def authenticate(authorization: str | None) -> Auth.types.MinimalUserDict:
+ """Support multiple providers."""
+ # ... token validation ...
+
+ provider = detect_provider(token) # your logic
+ user_id = extract_user_id(token) # your logic
+
+ return {
+ "identity": f"{provider}:{user_id}",
+ "provider": provider,
+ }
+```
+
+### Pattern: Role-Based Access Control
+
+Add roles to user dict for permission checks in your application:
+
+```python
+@auth.authenticate
+async def authenticate(authorization: str | None) -> Auth.types.MinimalUserDict:
+ """Include user roles in auth response."""
+ payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
+
+ return {
+ "identity": payload["sub"],
+ "roles": payload.get("roles", []), # ["user", "admin"]
+ }
+
+# In your graph code, access roles:
+@auth.on
+async def check_permissions(ctx: Auth.types.AuthContext, value: dict) -> dict:
+ if "admin" not in ctx.user.get("roles", []):
+ raise Auth.exceptions.HTTPException(status_code=403, detail="Forbidden")
+```
+
+### Pattern: Token Caching for Performance
+
+Cache token validation results to avoid repeated decryption:
+
+```python
+import hashlib
+import time
+
+_token_cache: dict[str, tuple[dict, float]] = {}
+CACHE_TTL = 300 # 5 minutes
+
+
+@auth.authenticate
+async def authenticate(authorization: str | None) -> Auth.types.MinimalUserDict:
+ """Cache token validation for performance."""
+ scheme, _, token = authorization.partition(" ")
+
+ # Check cache
+ cache_key = hashlib.sha256(token.encode()).hexdigest()
+ if cache_key in _token_cache:
+ user_info, cached_at = _token_cache[cache_key]
+ if time.time() - cached_at < CACHE_TTL:
+ return user_info
+
+ # Validate token
+ payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
+ user_info = {"identity": payload["sub"]}
+
+ # Cache result
+ _token_cache[cache_key] = (user_info, time.time())
+
+ return user_info
+```
+
+---
+
+## Common Mistakes
+
+### Mistake 1: Returning None Instead of Raising Exception
+
+```python
+# WRONG
+@auth.authenticate
+async def authenticate(authorization):
+ if not authorization:
+ return None # Will cause errors!
+
+# CORRECT
+@auth.authenticate
+async def authenticate(authorization):
+ if not authorization:
+ raise Auth.exceptions.HTTPException(status_code=401, detail="Unauthorized")
+```
+
+### Mistake 2: Missing "identity" Field
+
+```python
+# WRONG
+return {
+ "email": "user@example.com",
+ "display_name": "John",
+ # Missing "identity"!
+}
+
+# CORRECT
+return {
+ "identity": "user-123", # Must include
+ "email": "user@example.com",
+ "display_name": "John",
+}
+```
+
+### Mistake 3: Not Adding Owner Metadata
+
+```python
+# WRONG: threads won't be isolated
+@auth.on
+async def filter_by_owner(ctx, value):
+ return {} # Empty filter!
+
+# CORRECT: include owner in metadata
+@auth.on
+async def filter_by_owner(ctx, value):
+ metadata = value.setdefault("metadata", {})
+ metadata["owner"] = ctx.user.identity
+ return {"owner": ctx.user.identity}
+```
+
+### Mistake 4: Exposing Secrets in Error Messages
+
+```python
+# WRONG
+except jwt.InvalidTokenError as e:
+ raise Auth.exceptions.HTTPException(
+ status_code=401,
+ detail=f"Token invalid, SECRET_KEY is {SECRET_KEY}" # Exposes secret!
+ )
+
+# CORRECT
+except jwt.InvalidTokenError:
+ raise Auth.exceptions.HTTPException(
+ status_code=401,
+ detail="Invalid token" # Generic message
+ )
+```
+
+### Mistake 5: Synchronous I/O in Async Handler
+
+```python
+# WRONG: blocks event loop
+@auth.authenticate
+async def authenticate(authorization):
+ response = requests.get("https://api.example.com/user") # Blocks!
+
+# CORRECT: use async HTTP client
+@auth.authenticate
+async def authenticate(authorization):
+ async with httpx.AsyncClient() as client:
+ response = await client.get("https://api.example.com/user")
+```
+
+---
+
+## Next Steps
+
+Now that you understand the auth handler contract:
+
+- [Custom JWT Mode](./06-CUSTOM-JWT.md) - Full implementation for external IdPs
+- [OAuth Direct Mode](./04-OAUTH-DIRECT.md) - Validating OAuth provider tokens
+- [Auth Architecture](./05-AUTH-ARCHITECTURE.md) - Compare all 7 auth modes
+- [LangGraph Auth Docs](https://langchain-ai.github.io/langgraph/cloud/concepts/auth/)
+
+---
+
+## Troubleshooting
+
+### Auth handler not loading
+
+Check `langgraph.json` path is correct:
+```json
+{
+ "auth": {
+ "path": "src/security/auth.py:auth"
+ }
+}
+```
+
+### TypeError: 'NoneType' object is not subscriptable
+
+Your `@auth.authenticate` returned None instead of raising an exception. Fix:
+```python
+if not authorization:
+ raise Auth.exceptions.HTTPException(status_code=401, detail="Unauthorized")
+```
+
+### 401 on valid token
+
+Check token validation logic:
+- Decode token without verification to inspect claims: `jwt.decode(token, options={"verify_signature": False})`
+- Verify `identity` field is present in returned dict
+- Check token expiry: `payload.get("exp")`
+
+### Threads visible across users
+
+Check `@auth.on` is setting owner metadata:
+```python
+metadata = value.setdefault("metadata", {})
+metadata["owner"] = ctx.user.identity # Must set!
+return {"owner": ctx.user.identity}
+```
diff --git a/examples/api-key/server/.env.example b/examples/api-key/server/.env.example
new file mode 100644
index 0000000..4c103d6
--- /dev/null
+++ b/examples/api-key/server/.env.example
@@ -0,0 +1,9 @@
+# API Key Authentication Configuration
+# The API key is validated by LangGraph Cloud natively via x-api-key header.
+# No custom auth handler is needed.
+
+# Anthropic API key for the chatbot
+ANTHROPIC_API_KEY=sk-ant-...
+
+# LangChain API key (used by the frontend to authenticate)
+LANGCHAIN_API_KEY=lsv2_pt_...
diff --git a/examples/api-key/server/graph.py b/examples/api-key/server/graph.py
new file mode 100644
index 0000000..1254b0b
--- /dev/null
+++ b/examples/api-key/server/graph.py
@@ -0,0 +1,41 @@
+"""Chatbot graph for API key authenticated mode.
+
+In API key mode, authentication is handled natively by LangGraph Cloud
+using the x-api-key header. No custom auth.py is needed.
+
+Reference: https://langchain-ai.github.io/langgraph/
+"""
+
+from typing import Annotated
+
+from langchain_anthropic import ChatAnthropic
+from langchain_core.messages import BaseMessage
+from langgraph.graph import StateGraph
+from langgraph.graph.message import add_messages
+from typing_extensions import TypedDict
+
+
+class State(TypedDict):
+ """The state of the chatbot."""
+
+ messages: Annotated[list[BaseMessage], add_messages]
+
+
+# Initialize the LLM
+llm = ChatAnthropic(model="claude-sonnet-4-20250514")
+
+
+def chatbot(state: State) -> dict:
+ """Process messages and generate a response."""
+ response = llm.invoke(state["messages"])
+ return {"messages": [response]}
+
+
+# Build the graph
+builder = StateGraph(State)
+builder.add_node("chatbot", chatbot)
+builder.add_edge("__start__", "chatbot")
+
+# Compile the graph
+graph = builder.compile()
+graph.name = "API Key Chatbot"
diff --git a/examples/api-key/server/langgraph.json b/examples/api-key/server/langgraph.json
new file mode 100644
index 0000000..7e95c18
--- /dev/null
+++ b/examples/api-key/server/langgraph.json
@@ -0,0 +1,6 @@
+{
+ "$schema": "https://langchain-ai.github.io/langgraph/schema.json",
+ "graphs": {
+ "agent": "./graph.py:graph"
+ }
+}
diff --git a/examples/custom-jwt/server/.env.example b/examples/custom-jwt/server/.env.example
new file mode 100644
index 0000000..da7d15f
--- /dev/null
+++ b/examples/custom-jwt/server/.env.example
@@ -0,0 +1,10 @@
+# Custom JWT Authentication Configuration
+# Required: JWKS endpoint for public key verification
+JWT_JWKS_URI=https://your-idp.example.com/.well-known/jwks.json
+
+# Optional: Token validation constraints
+JWT_ISSUER=https://your-idp.example.com/realms/your-realm
+JWT_AUDIENCE=your-client-id
+
+# Anthropic API key for the chatbot
+ANTHROPIC_API_KEY=sk-ant-...
diff --git a/examples/custom-jwt/server/auth.py b/examples/custom-jwt/server/auth.py
new file mode 100644
index 0000000..0ea8baf
--- /dev/null
+++ b/examples/custom-jwt/server/auth.py
@@ -0,0 +1,102 @@
+"""Authentication handler for Custom JWT mode (JWKS-based validation).
+
+This module validates JWT tokens issued by an external Identity Provider
+(e.g., Keycloak, Auth0, Supabase, Okta) using JWKS public key verification.
+
+Environment variables:
+- JWT_JWKS_URI: JWKS endpoint URL (e.g., https://auth.example.com/.well-known/jwks.json)
+- JWT_ISSUER: Expected token issuer (optional, for validation)
+- JWT_AUDIENCE: Expected token audience (optional, for validation)
+
+Reference: https://langchain-ai.github.io/langgraph/tutorials/auth/getting_started/
+"""
+
+import os
+
+import jwt
+from jwt import PyJWKClient
+from jwt.exceptions import InvalidTokenError
+from langgraph_sdk import Auth
+
+# External IdP configuration
+JWKS_URI = os.environ["JWT_JWKS_URI"]
+ISSUER = os.environ.get("JWT_ISSUER")
+AUDIENCE = os.environ.get("JWT_AUDIENCE")
+
+# Initialize JWKS client with caching (auto-refreshes on key rotation)
+jwks_client = PyJWKClient(JWKS_URI, cache_jwk_set=True, lifespan=3600)
+
+auth = Auth()
+
+AUTH_EXCEPTION = Auth.exceptions.HTTPException(
+ status_code=401,
+ detail="Invalid or expired token",
+ headers={"WWW-Authenticate": "Bearer"},
+)
+
+
+@auth.authenticate
+async def get_current_user(
+ authorization: str | None,
+) -> Auth.types.MinimalUserDict:
+ """Validate external IdP JWT token using JWKS public key.
+
+ Args:
+ authorization: The Authorization header value (Bearer )
+
+ Returns:
+ User information dict with identity and metadata
+
+ Raises:
+ HTTPException: If token is invalid or expired
+ """
+ if not authorization:
+ raise AUTH_EXCEPTION
+
+ try:
+ # Extract token from "Bearer " format
+ scheme, token = authorization.split(" ", 1)
+ if scheme.lower() != "bearer":
+ raise AUTH_EXCEPTION
+
+ # Get the signing key from JWKS endpoint
+ signing_key = jwks_client.get_signing_key_from_jwt(token)
+
+ # Decode and validate the JWT token with public key
+ payload = jwt.decode(
+ token,
+ signing_key.key,
+ algorithms=["RS256", "ES256"],
+ issuer=ISSUER if ISSUER else None,
+ audience=AUDIENCE if AUDIENCE else None,
+ )
+
+ # Extract user info from standard OIDC claims
+ user_id = payload.get("sub")
+ if not user_id:
+ raise AUTH_EXCEPTION
+
+ return {
+ "identity": user_id,
+ "display_name": payload.get("name") or payload.get("preferred_username"),
+ "email": payload.get("email"),
+ "is_authenticated": True,
+ }
+
+ except (ValueError, InvalidTokenError) as e:
+ raise AUTH_EXCEPTION from e
+
+
+@auth.on
+async def add_owner(
+ ctx: Auth.types.AuthContext,
+ value: dict,
+):
+ """Add owner metadata to resources for per-user isolation.
+
+ This ensures that users can only access their own threads and data.
+ """
+ filters = {"owner": ctx.user.identity}
+ metadata = value.setdefault("metadata", {})
+ metadata.update(filters)
+ return filters
diff --git a/examples/custom-jwt/server/graph.py b/examples/custom-jwt/server/graph.py
new file mode 100644
index 0000000..3a240d6
--- /dev/null
+++ b/examples/custom-jwt/server/graph.py
@@ -0,0 +1,41 @@
+"""Chatbot graph for custom-jwt authenticated mode.
+
+This graph is the same as other auth modes - authentication is handled
+separately by the auth.py module using JWKS-based JWT validation.
+
+Reference: https://langchain-ai.github.io/langgraph/
+"""
+
+from typing import Annotated
+
+from langchain_anthropic import ChatAnthropic
+from langchain_core.messages import BaseMessage
+from langgraph.graph import StateGraph
+from langgraph.graph.message import add_messages
+from typing_extensions import TypedDict
+
+
+class State(TypedDict):
+ """The state of the chatbot."""
+
+ messages: Annotated[list[BaseMessage], add_messages]
+
+
+# Initialize the LLM
+llm = ChatAnthropic(model="claude-sonnet-4-20250514")
+
+
+def chatbot(state: State) -> dict:
+ """Process messages and generate a response."""
+ response = llm.invoke(state["messages"])
+ return {"messages": [response]}
+
+
+# Build the graph
+builder = StateGraph(State)
+builder.add_node("chatbot", chatbot)
+builder.add_edge("__start__", "chatbot")
+
+# Compile the graph
+graph = builder.compile()
+graph.name = "Custom JWT Chatbot"
diff --git a/examples/custom-jwt/server/langgraph.json b/examples/custom-jwt/server/langgraph.json
new file mode 100644
index 0000000..630b3f9
--- /dev/null
+++ b/examples/custom-jwt/server/langgraph.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "https://langchain-ai.github.io/langgraph/schema.json",
+ "graphs": {
+ "agent": "./graph.py:graph"
+ },
+ "auth": {
+ "path": "./auth.py:auth"
+ }
+}
diff --git a/examples/custom-jwt/server/requirements.txt b/examples/custom-jwt/server/requirements.txt
new file mode 100644
index 0000000..833d20c
--- /dev/null
+++ b/examples/custom-jwt/server/requirements.txt
@@ -0,0 +1,4 @@
+langgraph>=0.2.0
+langgraph-sdk>=0.1.0
+langchain-anthropic>=0.3.0
+pyjwt[crypto]>=2.8.0
diff --git a/frontend/e2e/auth-api-key.spec.ts b/frontend/e2e/auth-api-key.spec.ts
new file mode 100644
index 0000000..cf3ec9f
--- /dev/null
+++ b/frontend/e2e/auth-api-key.spec.ts
@@ -0,0 +1,118 @@
+import { test, expect } from "@playwright/test";
+
+/**
+ * API Key Auth Mode E2E Tests
+ *
+ * Tests the api-key auth flow:
+ * 1. Login page shows API key input form
+ * 2. Enter API key → validate → connect
+ * 3. After connection, thread list is visible in sidebar
+ *
+ * Requires:
+ * - AUTH_MODE=api-key
+ * - A running LangGraph server (LANGGRAPH_API_URL)
+ */
+
+const isApiKeyMode = process.env.AUTH_MODE === "api-key";
+
+test.describe("API Key auth mode", () => {
+ test.skip(!isApiKeyMode, "Requires AUTH_MODE=api-key");
+
+ test("login page shows API key input form", async ({ page }) => {
+ await page.goto("/login");
+ await page.waitForLoadState("networkidle");
+
+ // Should show API key input (password type for masking)
+ const apiKeyInput = page.locator('input[name="apiKey"]');
+ await expect(apiKeyInput).toBeVisible({ timeout: 10_000 });
+
+ // Should have placeholder text
+ await expect(apiKeyInput).toHaveAttribute("placeholder", /lsv2_pt_/);
+
+ // Should show connect button
+ const connectButton = page.getByRole("button", { name: /connect/i });
+ await expect(connectButton).toBeVisible();
+ });
+
+ test("empty API key shows validation feedback", async ({ page }) => {
+ await page.goto("/login");
+ await page.waitForLoadState("networkidle");
+
+ // Click connect without entering key
+ const connectButton = page.getByRole("button", { name: /connect/i });
+ await connectButton.click();
+
+ // Should show error or stay on login page (HTML5 validation or custom error)
+ await page.waitForTimeout(1000);
+ expect(page.url()).toContain("/login");
+
+ // The form should still be visible (not navigated away)
+ const apiKeyInput = page.locator('input[name="apiKey"]');
+ await expect(apiKeyInput).toBeVisible();
+ });
+
+ test("invalid API key shows error", async ({ page }) => {
+ await page.goto("/login");
+ await page.waitForLoadState("networkidle");
+
+ // Enter invalid key
+ const apiKeyInput = page.locator('input[name="apiKey"]');
+ await apiKeyInput.fill("invalid-key-12345");
+
+ // Click connect
+ const connectButton = page.getByRole("button", { name: /connect/i });
+ await connectButton.click();
+
+ // Should show error (validation fails against server)
+ const error = page.locator('[role="alert"]');
+ await expect(error).toBeVisible({ timeout: 15_000 });
+ });
+
+ test("protected route redirects to login", async ({ page }) => {
+ await page.goto("/");
+ // Without API key, should redirect to login
+ await page.waitForURL("**/login**", { timeout: 10_000 });
+ expect(page.url()).toContain("/login");
+ });
+
+ test("admin route redirects away", async ({ page }) => {
+ await page.goto("/admin");
+ await page.waitForURL((url) => !url.pathname.startsWith("/admin"), {
+ timeout: 10_000,
+ });
+ expect(page.url()).not.toContain("/admin");
+ });
+});
+
+test.describe("API Key auth mode — env var auto-login", () => {
+ const hasEnvKey = !!(
+ process.env.LANGCHAIN_API_KEY || process.env.NEXT_PUBLIC_LANGCHAIN_API_KEY
+ );
+
+ test.skip(
+ !isApiKeyMode || !hasEnvKey,
+ "Requires AUTH_MODE=api-key and LANGCHAIN_API_KEY env var",
+ );
+
+ test("auto-redirects to home when env key is set", async ({ page }) => {
+ await page.goto("/login");
+ // Should redirect to / because env key is pre-configured
+ await page.waitForURL((url) => !url.pathname.includes("/login"), {
+ timeout: 10_000,
+ });
+ expect(page.url()).not.toContain("/login");
+ });
+
+ test("sidebar shows thread list after auto-login", async ({ page }) => {
+ await page.goto("/");
+ await page.waitForLoadState("networkidle");
+
+ // Should NOT be on login page
+ expect(page.url()).not.toContain("/login");
+
+ // Should show chat UI with sidebar
+ // The sidebar contains thread list or "new thread" button
+ const sidebar = page.locator('[data-testid="sidebar"], aside, nav');
+ await expect(sidebar.first()).toBeVisible({ timeout: 15_000 });
+ });
+});
diff --git a/frontend/e2e/auth-custom-jwt.spec.ts b/frontend/e2e/auth-custom-jwt.spec.ts
new file mode 100644
index 0000000..192454c
--- /dev/null
+++ b/frontend/e2e/auth-custom-jwt.spec.ts
@@ -0,0 +1,77 @@
+import { test, expect } from "@playwright/test";
+
+/**
+ * Custom JWT Auth Mode E2E Tests
+ *
+ * Tests the custom-jwt auth flow:
+ * 1. Login page shows IdP redirect button
+ * 2. Unauthenticated access redirects to login
+ * 3. Admin routes are blocked
+ *
+ * Note: Full OIDC flow testing requires a mock IdP server.
+ * These tests verify the UI components and routing behavior.
+ *
+ * Requires:
+ * - AUTH_MODE=custom-jwt
+ * - JWT_ISSUER and JWT_CLIENT_ID configured
+ */
+
+const isCustomJwtMode = process.env.AUTH_MODE === "custom-jwt";
+
+test.describe("Custom JWT auth mode", () => {
+ test.skip(!isCustomJwtMode, "Requires AUTH_MODE=custom-jwt");
+
+ test("login page shows IdP login button", async ({ page }) => {
+ await page.goto("/login");
+ await page.waitForLoadState("networkidle");
+
+ // Should show "Sign in with" button for IdP
+ const signInButton = page.getByRole("button", { name: /sign in with/i });
+ await expect(signInButton).toBeVisible({ timeout: 10_000 });
+ });
+
+ test("protected route redirects to login without token", async ({ page }) => {
+ await page.goto("/");
+ // Without IdP token, should redirect to login
+ await page.waitForURL("**/login**", { timeout: 10_000 });
+ expect(page.url()).toContain("/login");
+ });
+
+ test("admin route redirects away", async ({ page }) => {
+ await page.goto("/admin");
+ await page.waitForURL((url) => !url.pathname.startsWith("/admin"), {
+ timeout: 10_000,
+ });
+ expect(page.url()).not.toContain("/admin");
+ });
+
+ test("callback route exists and requires code parameter", async ({
+ page,
+ }) => {
+ // Hitting callback without code should redirect to login
+ await page.goto("/auth/callback");
+ await page.waitForURL("**/login**", { timeout: 10_000 });
+ expect(page.url()).toContain("/login");
+ });
+
+ test("IdP login button triggers authorization request", async ({ page }) => {
+ await page.goto("/login");
+ await page.waitForLoadState("networkidle");
+
+ // Intercept the fetch to /auth/callback?action=authorize at the network level
+ let callbackRequested = false;
+ page.on("request", (req) => {
+ if (req.url().includes("/auth/callback")) {
+ callbackRequested = true;
+ }
+ });
+
+ await page.getByRole("button", { name: /sign in with/i }).click();
+
+ // Wait briefly for the fetch to fire
+ await page.waitForTimeout(2000);
+
+ // The button should have triggered a request to the callback endpoint
+ expect(callbackRequested).toBe(true);
+ });
+});
diff --git a/frontend/e2e/auth-standalone-threads.spec.ts b/frontend/e2e/auth-standalone-threads.spec.ts
new file mode 100644
index 0000000..39d75fa
--- /dev/null
+++ b/frontend/e2e/auth-standalone-threads.spec.ts
@@ -0,0 +1,96 @@
+import { test, expect } from "@playwright/test";
+
+/**
+ * Standalone Mode E2E Tests — Thread List Verification
+ *
+ * Tests that the standalone mode can connect to a real LangGraph server
+ * and display the thread list in the sidebar.
+ *
+ * Requires:
+ * - AUTH_MODE=standalone (default)
+ * - A running LangGraph server at NEXT_PUBLIC_API_URL or LANGGRAPH_API_URL
+ */
+
+const isStandaloneMode =
+ !process.env.AUTH_MODE || process.env.AUTH_MODE === "standalone";
+
+test.describe("Standalone mode — thread list with real server", () => {
+ test.skip(!isStandaloneMode, "Requires AUTH_MODE=standalone");
+
+ test("homepage renders chat UI (not login)", async ({ page }) => {
+ await page.goto("/");
+ await page.waitForLoadState("networkidle");
+
+ // Should NOT redirect to login
+ expect(page.url()).not.toContain("/login");
+
+ // Should show the chat input
+ const chatInput = page.locator(
+ 'textarea, [contenteditable="true"], input[type="text"]',
+ );
+ await expect(chatInput.first()).toBeVisible({ timeout: 15_000 });
+ });
+
+ test("sidebar is visible with thread management", async ({ page }) => {
+ await page.goto("/");
+ await page.waitForLoadState("networkidle");
+
+ // The sidebar uses bg-sidebar class and contains thread history
+ // Look for sidebar toggle button or the sidebar container itself
+ const sidebarToggle = page.locator(
+ 'button[aria-label*="sidebar"], button[aria-label*="Sidebar"]',
+ );
+ const sidebarContainer = page.locator(".bg-sidebar");
+
+ const toggleVisible = await sidebarToggle
+ .first()
+ .isVisible()
+ .catch(() => false);
+ const containerVisible = await sidebarContainer
+ .first()
+ .isVisible()
+ .catch(() => false);
+
+ expect(toggleVisible || containerVisible).toBe(true);
+ });
+
+ test("can access assistants from LangGraph server", async ({ request }) => {
+ // Direct API call to verify server connectivity through proxy
+ const response = await request.post("/api/assistants/search", {
+ data: { limit: 5 },
+ headers: { "Content-Type": "application/json" },
+ });
+
+ expect(response.ok()).toBe(true);
+ const data = await response.json();
+ expect(Array.isArray(data)).toBe(true);
+ expect(data.length).toBeGreaterThan(0);
+ });
+
+ test("can create and list threads via API", async ({ request }) => {
+ // Create a thread
+ const createResponse = await request.post("/api/threads", {
+ data: { metadata: {} },
+ headers: { "Content-Type": "application/json" },
+ });
+ expect(createResponse.ok()).toBe(true);
+
+ const thread = await createResponse.json();
+ expect(thread.thread_id).toBeTruthy();
+
+ // Search for threads
+ const searchResponse = await request.post("/api/threads/search", {
+ data: { limit: 10 },
+ headers: { "Content-Type": "application/json" },
+ });
+ expect(searchResponse.ok()).toBe(true);
+
+ const threads = await searchResponse.json();
+ expect(Array.isArray(threads)).toBe(true);
+ // Our newly created thread should be in the list
+ const found = threads.some(
+ (t: { thread_id: string }) => t.thread_id === thread.thread_id,
+ );
+ expect(found).toBe(true);
+ });
+});
diff --git a/frontend/src/app/(auth)/layout.tsx b/frontend/src/app/(auth)/layout.tsx
index 8b5cce0..76e2979 100644
--- a/frontend/src/app/(auth)/layout.tsx
+++ b/frontend/src/app/(auth)/layout.tsx
@@ -4,7 +4,8 @@ import { getAllSettings } from "@/lib/services/settings.service";
import { siteConfig } from "@/configs/site";
import { AuthLayoutClient } from "./AuthLayoutClient";
import { getAuthMode } from "@/types/auth-mode";
-import { getLangGraphOAuthUrl } from "@/lib/auth/mode";
+import { getLangGraphOAuthUrl, isApiKeyMode } from "@/lib/auth/mode";
+import { hasApiKeyFromEnv } from "@/lib/auth/api-key";
import { getAvailableOAuthProviders } from "@/lib/auth/providers";
export default async function AuthLayout({
@@ -28,6 +29,13 @@ export default async function AuthLayout({
redirect("/");
}
+ // api-key mode: if env key exists, skip login UI
+ if (isApiKeyMode() && hasApiKeyFromEnv()) {
+ redirect("/");
+ }
+
+ // custom-jwt and api-key: show login UI (handled by LoginForm switch)
+
const globalSettings = await getAllSettings();
// Branding fallback chain
diff --git a/frontend/src/app/(auth)/login/ApiKeyLoginForm.tsx b/frontend/src/app/(auth)/login/ApiKeyLoginForm.tsx
new file mode 100644
index 0000000..b53fc37
--- /dev/null
+++ b/frontend/src/app/(auth)/login/ApiKeyLoginForm.tsx
@@ -0,0 +1,199 @@
+"use client";
+
+import { useState, useRef, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { motion } from "framer-motion";
+import { useTranslations } from "next-intl";
+import { Button } from "@/shared/components/ui/button";
+import { Input } from "@/shared/components/ui/input";
+import { LoaderCircle, ArrowRight, Key } from "lucide-react";
+import { useAuthContext } from "../AuthLayoutClient";
+
+const containerVariants = {
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ transition: {
+ staggerChildren: 0.04,
+ delayChildren: 0.02,
+ },
+ },
+};
+
+const itemVariants = {
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ transition: { duration: 0.22, ease: [0.22, 1, 0.36, 1] as const },
+ },
+};
+
+export function ApiKeyLoginForm() {
+ const router = useRouter();
+ const { branding } = useAuthContext();
+ const tc = useTranslations("common");
+
+ const [apiKey, setApiKey] = useState("");
+ const [error, setError] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const inputRef = useRef(null);
+ const errorRef = useRef(null);
+
+ useEffect(() => {
+ if (error && errorRef.current) {
+ errorRef.current.focus();
+ }
+ }, [error]);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError("");
+
+ if (!apiKey.trim()) {
+ setError("Please enter an API key");
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ // Validate the API key via server action
+ const response = await fetch("/api/auth/validate-api-key", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ apiKey: apiKey.trim() }),
+ });
+
+ const data = await response.json();
+
+ if (data.valid) {
+ // Store API key in cookie via the existing connection mechanism
+ document.cookie = `lg_apiKey=${encodeURIComponent(apiKey.trim())}; path=/; max-age=${365 * 24 * 3600}; samesite=lax`;
+ router.push("/");
+ router.refresh();
+ } else {
+ setError(
+ data.error ||
+ "Invalid API key — could not connect to LangGraph server",
+ );
+ }
+ } catch {
+ setError("Failed to validate API key. Please check your connection.");
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+ {/* Branding */}
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
+
+ {branding.appName}
+
+
+ Enter your API key to connect
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/(auth)/login/CustomJwtLoginForm.tsx b/frontend/src/app/(auth)/login/CustomJwtLoginForm.tsx
new file mode 100644
index 0000000..a51ce11
--- /dev/null
+++ b/frontend/src/app/(auth)/login/CustomJwtLoginForm.tsx
@@ -0,0 +1,134 @@
+"use client";
+
+import { useState } from "react";
+import { motion } from "framer-motion";
+import { useTranslations } from "next-intl";
+import { Button } from "@/shared/components/ui/button";
+import { LoaderCircle, ArrowRight, KeyRound } from "lucide-react";
+import { useAuthContext } from "../AuthLayoutClient";
+
+const containerVariants = {
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ transition: {
+ staggerChildren: 0.04,
+ delayChildren: 0.02,
+ },
+ },
+};
+
+const itemVariants = {
+ hidden: { opacity: 0 },
+ visible: {
+ opacity: 1,
+ transition: { duration: 0.22, ease: [0.22, 1, 0.36, 1] as const },
+ },
+};
+
+export function CustomJwtLoginForm() {
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState("");
+ const { branding } = useAuthContext();
+ const tc = useTranslations("common");
+
+ const issuerName =
+ process.env.NEXT_PUBLIC_JWT_ISSUER_NAME || "Identity Provider";
+
+ const handleLogin = async () => {
+ setIsLoading(true);
+ setError("");
+ try {
+ const response = await fetch("/auth/callback?action=authorize", {
+ method: "GET",
+ });
+ const data = await response.json();
+ if (data.authorizationUrl) {
+ window.location.href = data.authorizationUrl;
+ } else {
+ setError(data.error || "Failed to start authentication");
+ setIsLoading(false);
+ }
+ } catch {
+ setError("Failed to connect to authentication service");
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+ {/* Branding */}
+
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
+
+ {branding.appName}
+
+
+ Sign in with your identity provider
+
+
+
+
+ {/* Error message */}
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/app/(auth)/login/page.tsx b/frontend/src/app/(auth)/login/page.tsx
index 6d079d4..34ea6cc 100644
--- a/frontend/src/app/(auth)/login/page.tsx
+++ b/frontend/src/app/(auth)/login/page.tsx
@@ -18,6 +18,8 @@ import {
import { useAuthContext } from "../AuthLayoutClient";
import { OAuthLoginForm } from "./OAuthLoginForm";
import { EmailLoginForm } from "./EmailLoginForm";
+import { CustomJwtLoginForm } from "./CustomJwtLoginForm";
+import { ApiKeyLoginForm } from "./ApiKeyLoginForm";
const containerVariants = {
hidden: { opacity: 0 },
@@ -301,6 +303,10 @@ function LoginForm() {
return ;
case "email":
return ;
+ case "custom-jwt":
+ return ;
+ case "api-key":
+ return ;
case "credentials":
default:
return ;
diff --git a/frontend/src/app/api/[..._path]/route.ts b/frontend/src/app/api/[..._path]/route.ts
index 9a792f4..2ccf6ed 100644
--- a/frontend/src/app/api/[..._path]/route.ts
+++ b/frontend/src/app/api/[..._path]/route.ts
@@ -3,7 +3,7 @@ import { cookies } from "next/headers";
import { CONNECTION_COOKIE_NAMES } from "@/lib/connections/cookies";
import { getAllSettings } from "@/lib/services/settings.service";
import { resolveApiUrl } from "@/lib/connections/resolve";
-import { requiresNextAuth } from "@/types/auth-mode";
+import { getAuthMode, usesNextAuth } from "@/types/auth-mode";
import { generateUserJWT } from "@/lib/auth/jwt";
import { isPrivateUrl } from "@/lib/utils/url-validation";
@@ -36,9 +36,10 @@ function getCorsHeaders(req: NextRequest) {
}
async function handleRequest(req: NextRequest, method: string) {
- const needsAuth = requiresNextAuth();
+ const mode = getAuthMode();
+ const needsNextAuth = usesNextAuth();
- // Get user session (only for auth modes that require it)
+ // Get user session (only for NextAuth modes)
type SessionType = {
user?: {
id: string;
@@ -49,7 +50,7 @@ async function handleRequest(req: NextRequest, method: string) {
};
} | null;
let session: SessionType = null;
- if (needsAuth) {
+ if (needsNextAuth) {
const { auth } = await import("@/lib/auth");
session = (await auth()) as SessionType;
if (!session?.user) {
@@ -101,11 +102,32 @@ async function handleRequest(req: NextRequest, method: string) {
"Content-Type": contentType,
};
- // Generate signed JWT token for LangGraph server using shared utility
- const token = await generateUserJWT();
- if (token) {
- headers["Authorization"] = `Bearer ${token}`;
+ // Inject auth credentials based on mode
+ if (needsNextAuth) {
+ // NextAuth modes: generate signed JWT for LangGraph server
+ const token = await generateUserJWT();
+ if (token) {
+ headers["Authorization"] = `Bearer ${token}`;
+ }
+ } else if (mode === "custom-jwt") {
+ // Custom JWT: forward stored IdP token from cookie
+ const cookieStore = await cookies();
+ const idpToken = cookieStore.get("lg_idp_token")?.value;
+ if (idpToken) {
+ headers["Authorization"] = `Bearer ${idpToken}`;
+ }
+ } else if (mode === "api-key") {
+ // API key: forward from cookie or env
+ const cookieStore = await cookies();
+ const apiKey =
+ cookieStore.get(CONNECTION_COOKIE_NAMES.apiKey)?.value ||
+ process.env.LANGCHAIN_API_KEY ||
+ process.env.NEXT_PUBLIC_LANGCHAIN_API_KEY;
+ if (apiKey) {
+ headers["x-api-key"] = apiKey;
+ }
}
+ // standalone/oauth-direct: no additional auth headers
// Build request options
const options: RequestInit = {
diff --git a/frontend/src/app/api/auth/validate-api-key/route.ts b/frontend/src/app/api/auth/validate-api-key/route.ts
new file mode 100644
index 0000000..799e282
--- /dev/null
+++ b/frontend/src/app/api/auth/validate-api-key/route.ts
@@ -0,0 +1,62 @@
+/**
+ * API Key Validation Endpoint
+ *
+ * Validates an API key by calling the LangGraph server's
+ * /assistants/search endpoint which requires authentication.
+ */
+
+import { NextRequest, NextResponse } from "next/server";
+import { validateApiKey } from "@/lib/auth/api-key";
+import { resolveApiUrl } from "@/lib/connections/resolve";
+import { cookies } from "next/headers";
+import { CONNECTION_COOKIE_NAMES } from "@/lib/connections/cookies";
+import { getAllSettings } from "@/lib/services/settings.service";
+import { isPrivateUrl } from "@/lib/utils/url-validation";
+
+export async function POST(req: NextRequest) {
+ try {
+ const body = await req.json();
+ const apiKey = body.apiKey;
+
+ if (!apiKey || typeof apiKey !== "string") {
+ return NextResponse.json(
+ { valid: false, error: "API key is required" },
+ { status: 400 },
+ );
+ }
+
+ // Resolve the API URL
+ const cookieStore = await cookies();
+ const cookieApiUrl = cookieStore.get(CONNECTION_COOKIE_NAMES.apiUrl)?.value;
+ const globalSettings = await getAllSettings();
+ const adminDefaultApiUrl =
+ globalSettings["features.defaultConnectionApiUrl"];
+ const apiUrl = resolveApiUrl(cookieApiUrl, adminDefaultApiUrl);
+
+ if (!apiUrl) {
+ return NextResponse.json(
+ { valid: false, error: "LangGraph API URL is not configured" },
+ { status: 500 },
+ );
+ }
+
+ // SSRF prevention: reject private network URLs from cookies
+ if (cookieApiUrl && cookieApiUrl === apiUrl && isPrivateUrl(apiUrl)) {
+ return NextResponse.json(
+ {
+ valid: false,
+ error: "Invalid API URL: private network addresses are not allowed",
+ },
+ { status: 400 },
+ );
+ }
+
+ const result = await validateApiKey(apiUrl, apiKey);
+ return NextResponse.json(result);
+ } catch {
+ return NextResponse.json(
+ { valid: false, error: "Validation failed" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/frontend/src/app/api/langsmith/runs/route.ts b/frontend/src/app/api/langsmith/runs/route.ts
index 1f2d1d0..148b95a 100644
--- a/frontend/src/app/api/langsmith/runs/route.ts
+++ b/frontend/src/app/api/langsmith/runs/route.ts
@@ -1,7 +1,7 @@
import { Client, type Run } from "langsmith";
import { NextRequest, NextResponse } from "next/server";
import { LangSmithRun, buildRunHierarchy } from "@/types/langsmith";
-import { requiresNextAuth } from "@/types/auth-mode";
+import { usesNextAuth } from "@/types/auth-mode";
// LangSmith Run 객체를 LangSmithRun 형식으로 변환
function convertRun(run: Run): LangSmithRun {
@@ -47,7 +47,7 @@ const UUID_REGEX =
export async function GET(req: NextRequest) {
// Authenticate: in auth modes, require a valid session
- if (requiresNextAuth()) {
+ if (usesNextAuth()) {
const { auth } = await import("@/lib/auth");
const session = await auth();
if (!session?.user) {
diff --git a/frontend/src/app/api/upload/route.ts b/frontend/src/app/api/upload/route.ts
index 4dc350b..6ce07c3 100644
--- a/frontend/src/app/api/upload/route.ts
+++ b/frontend/src/app/api/upload/route.ts
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth";
-import { isPublicMode } from "@/lib/auth/mode";
+import { usesNextAuth } from "@/types/auth-mode";
import { getSetting } from "@/lib/services/settings.service";
import { writeFile, mkdir, access, constants } from "fs/promises";
import path from "path";
@@ -38,8 +38,10 @@ async function ensureDir(dir: string): Promise {
export async function POST(request: NextRequest) {
try {
- // Allow authenticated users or public mode
- if (!isPublicMode()) {
+ // Only check NextAuth session for NextAuth modes
+ // custom-jwt and api-key: auth handled at proxy/middleware level
+ // standalone and oauth-direct: no auth required
+ if (usesNextAuth()) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
diff --git a/frontend/src/app/auth/callback/route.ts b/frontend/src/app/auth/callback/route.ts
new file mode 100644
index 0000000..e2e23e8
--- /dev/null
+++ b/frontend/src/app/auth/callback/route.ts
@@ -0,0 +1,152 @@
+/**
+ * Custom JWT OAuth Callback Route
+ *
+ * Handles two flows:
+ * 1. GET ?action=authorize — generates PKCE and returns authorization URL
+ * 2. GET ?code=... — exchanges authorization code for tokens
+ */
+
+import { NextRequest, NextResponse } from "next/server";
+import { randomBytes } from "crypto";
+import {
+ getCustomJwtConfig,
+ generatePkce,
+ storePkceVerifier,
+ consumePkceVerifier,
+ storeIdpTokens,
+} from "@/lib/auth/custom-jwt";
+import { cookies } from "next/headers";
+
+const OAUTH_STATE_COOKIE = "lg_oauth_state";
+
+export async function GET(req: NextRequest) {
+ const config = getCustomJwtConfig();
+
+ if (!config) {
+ return NextResponse.json(
+ { error: "Custom JWT not configured. Set JWT_ISSUER and JWT_CLIENT_ID." },
+ { status: 500 },
+ );
+ }
+
+ const action = req.nextUrl.searchParams.get("action");
+
+ // Step 1: Generate authorization URL with PKCE + state
+ if (action === "authorize") {
+ const { codeVerifier, codeChallenge } = generatePkce();
+ await storePkceVerifier(codeVerifier);
+
+ // Generate CSRF state parameter
+ const state = randomBytes(32).toString("base64url");
+ const cookieStore = await cookies();
+ const isProduction = process.env.NODE_ENV === "production";
+ cookieStore.set(OAUTH_STATE_COOKIE, state, {
+ httpOnly: true,
+ secure: isProduction,
+ sameSite: "lax",
+ path: "/",
+ maxAge: 600, // 10 minutes
+ });
+
+ const appUrl =
+ process.env.NEXT_PUBLIC_APP_URL ||
+ `${req.nextUrl.protocol}//${req.nextUrl.host}`;
+
+ const params = new URLSearchParams({
+ response_type: "code",
+ client_id: config.clientId,
+ redirect_uri: `${appUrl}/auth/callback`,
+ scope: "openid email profile",
+ code_challenge: codeChallenge,
+ code_challenge_method: "S256",
+ state,
+ });
+
+ const authorizationUrl = `${config.loginUrl}?${params.toString()}`;
+
+ return NextResponse.json({ authorizationUrl });
+ }
+
+ // Step 2: Exchange authorization code for tokens
+ const code = req.nextUrl.searchParams.get("code");
+ const error = req.nextUrl.searchParams.get("error");
+
+ if (error) {
+ const errorDesc =
+ req.nextUrl.searchParams.get("error_description") || error;
+ return NextResponse.redirect(
+ new URL(`/login?error=${encodeURIComponent(errorDesc)}`, req.url),
+ );
+ }
+
+ if (!code) {
+ return NextResponse.redirect(new URL("/login", req.url));
+ }
+
+ // Verify CSRF state parameter
+ const returnedState = req.nextUrl.searchParams.get("state");
+ const cookieStore = await cookies();
+ const savedState = cookieStore.get(OAUTH_STATE_COOKIE)?.value;
+ cookieStore.delete(OAUTH_STATE_COOKIE);
+
+ if (!returnedState || returnedState !== savedState) {
+ return NextResponse.redirect(
+ new URL("/login?error=Invalid+state.+Please+try+again.", req.url),
+ );
+ }
+
+ // Require PKCE verifier — abort if cookie expired
+ const codeVerifier = await consumePkceVerifier();
+ if (!codeVerifier) {
+ return NextResponse.redirect(
+ new URL("/login?error=Session+expired.+Please+try+again.", req.url),
+ );
+ }
+
+ const appUrl =
+ process.env.NEXT_PUBLIC_APP_URL ||
+ `${req.nextUrl.protocol}//${req.nextUrl.host}`;
+
+ const tokenParams = new URLSearchParams({
+ grant_type: "authorization_code",
+ code,
+ redirect_uri: `${appUrl}/auth/callback`,
+ client_id: config.clientId,
+ code_verifier: codeVerifier,
+ });
+
+ if (config.clientSecret) {
+ tokenParams.set("client_secret", config.clientSecret);
+ }
+
+ try {
+ const tokenResponse = await fetch(config.tokenUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: tokenParams.toString(),
+ });
+
+ if (!tokenResponse.ok) {
+ const errorBody = await tokenResponse.text();
+ console.error("[Custom JWT] Token exchange failed:", errorBody);
+ return NextResponse.redirect(
+ new URL("/login?error=Token+exchange+failed", req.url),
+ );
+ }
+
+ const tokenData = await tokenResponse.json();
+
+ await storeIdpTokens(
+ tokenData.access_token,
+ tokenData.refresh_token,
+ tokenData.expires_in,
+ );
+
+ return NextResponse.redirect(new URL("/", req.url));
+ } catch (err) {
+ console.error("[Custom JWT] Callback error:", err);
+ return NextResponse.redirect(
+ new URL("/login?error=Authentication+failed", req.url),
+ );
+ }
+}
diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx
index 6321049..8bd93ae 100644
--- a/frontend/src/app/layout.tsx
+++ b/frontend/src/app/layout.tsx
@@ -5,7 +5,7 @@ import { NuqsAdapter } from "nuqs/adapters/next/app";
import { siteConfig } from "@/configs/site";
import { AuthProvider, StandaloneAuthProvider } from "@/providers/AuthProvider";
import { getAllSettings } from "@/lib/services/settings.service";
-import { requiresNextAuth } from "@/types/auth-mode";
+import { usesNextAuth } from "@/types/auth-mode";
import { getLocale, getMessages, getTranslations } from "next-intl/server";
import { NextIntlClientProvider } from "next-intl";
@@ -51,7 +51,7 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
- const needsAuth = requiresNextAuth();
+ const needsAuth = usesNextAuth();
const locale = await getLocale();
const messages = await getMessages();
diff --git a/frontend/src/features/auth/hooks/useAuthMode.ts b/frontend/src/features/auth/hooks/useAuthMode.ts
index cc41dd6..0d2c55a 100644
--- a/frontend/src/features/auth/hooks/useAuthMode.ts
+++ b/frontend/src/features/auth/hooks/useAuthMode.ts
@@ -11,6 +11,8 @@ const VALID_AUTH_MODES: AuthMode[] = [
"email",
"oauth-direct",
"standalone",
+ "custom-jwt",
+ "api-key",
];
/**
@@ -43,17 +45,37 @@ export function useAuthMode(): AuthMode {
}
/**
- * Check if the current mode requires NextAuth
+ * Check if the current mode initializes NextAuth
*/
-export function useRequiresNextAuth(): boolean {
+export function useUsesNextAuth(): boolean {
const mode = useAuthMode();
return mode === "oauth" || mode === "credentials" || mode === "email";
}
/**
- * Check if the current mode allows anonymous access
+ * Check if the current mode shows a login UI
*/
-export function useAllowsAnonymousAccess(): boolean {
+export function useRequiresLoginUI(): boolean {
const mode = useAuthMode();
- return mode === "standalone" || mode === "oauth-direct";
+ return (
+ mode === "oauth" ||
+ mode === "credentials" ||
+ mode === "email" ||
+ mode === "custom-jwt" ||
+ mode === "api-key"
+ );
+}
+
+/**
+ * @deprecated Use useUsesNextAuth() instead
+ */
+export function useRequiresNextAuth(): boolean {
+ return useUsesNextAuth();
+}
+
+/**
+ * @deprecated Use !useRequiresLoginUI() instead
+ */
+export function useAllowsAnonymousAccess(): boolean {
+ return !useRequiresLoginUI();
}
diff --git a/frontend/src/lib/auth/api-key.ts b/frontend/src/lib/auth/api-key.ts
new file mode 100644
index 0000000..4d04467
--- /dev/null
+++ b/frontend/src/lib/auth/api-key.ts
@@ -0,0 +1,78 @@
+/**
+ * API Key Authentication
+ *
+ * Manages API key storage and validation for api-key auth mode.
+ */
+
+import { cookies } from "next/headers";
+import { CONNECTION_COOKIE_NAMES } from "@/lib/connections/cookies";
+
+/**
+ * Check if an API key is configured via cookie or environment variable
+ */
+export async function hasApiKeyConfigured(): Promise {
+ const key = await getStoredApiKey();
+ return !!key;
+}
+
+/**
+ * Get stored API key from cookie or environment (server-side only)
+ */
+export async function getStoredApiKey(): Promise {
+ const cookieStore = await cookies();
+ const cookieKey = cookieStore.get(CONNECTION_COOKIE_NAMES.apiKey)?.value;
+
+ return (
+ cookieKey ||
+ process.env.LANGCHAIN_API_KEY ||
+ process.env.NEXT_PUBLIC_LANGCHAIN_API_KEY ||
+ null
+ );
+}
+
+/**
+ * Check if API key is from environment (auto-configured, no login needed)
+ */
+export function hasApiKeyFromEnv(): boolean {
+ return !!(
+ process.env.LANGCHAIN_API_KEY || process.env.NEXT_PUBLIC_LANGCHAIN_API_KEY
+ );
+}
+
+/**
+ * Validate an API key by calling an authenticated LangGraph endpoint
+ * Uses GET /assistants/search?limit=1 which requires valid authentication
+ */
+export async function validateApiKey(
+ apiUrl: string,
+ apiKey: string,
+): Promise<{ valid: boolean; error?: string }> {
+ try {
+ const response = await fetch(`${apiUrl}/assistants/search?limit=1`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "x-api-key": apiKey,
+ },
+ body: JSON.stringify({ limit: 1 }),
+ });
+
+ if (response.ok) {
+ return { valid: true };
+ }
+
+ if (response.status === 401 || response.status === 403) {
+ return { valid: false, error: "Invalid API key" };
+ }
+
+ return {
+ valid: false,
+ error: `Server returned ${response.status}`,
+ };
+ } catch (error) {
+ return {
+ valid: false,
+ error: error instanceof Error ? error.message : "Connection failed",
+ };
+ }
+}
diff --git a/frontend/src/lib/auth/config.ts b/frontend/src/lib/auth/config.ts
index 5df2f71..36a47b1 100644
--- a/frontend/src/lib/auth/config.ts
+++ b/frontend/src/lib/auth/config.ts
@@ -3,7 +3,7 @@ import { prisma } from "./prisma";
import { getAuthProviders } from "./providers";
import {
getAuthMode,
- requiresNextAuth,
+ usesNextAuth,
type UserRole,
type UserStatus,
} from "@/types/auth-mode";
@@ -25,10 +25,10 @@ function getSessionStrategy(): "jwt" | "database" {
export const authConfig: NextAuthConfig = {
// standalone/oauth-direct 모드에서는 더미 시크릿 사용 (실제로 사용되지 않음)
- secret: requiresNextAuth()
+ secret: usesNextAuth()
? process.env.NEXTAUTH_SECRET
: "standalone-dummy-secret-not-used",
- adapter: requiresNextAuth()
+ adapter: usesNextAuth()
? (() => {
// Dynamic import to avoid bundling @auth/prisma-adapter when not needed
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -44,7 +44,7 @@ export const authConfig: NextAuthConfig = {
verifyRequest: "/verify-request", // For email magic link
},
trustHost: true,
- providers: requiresNextAuth() ? getAuthProviders() : [],
+ providers: usesNextAuth() ? getAuthProviders() : [],
callbacks: {
async signIn({ user, account }) {
// For OAuth providers, sync user to database
@@ -123,7 +123,7 @@ export const authConfig: NextAuthConfig = {
},
authorized({ auth, request }) {
// For standalone and oauth-direct modes, always allow
- if (!requiresNextAuth()) {
+ if (!usesNextAuth()) {
return true;
}
// For other modes, check if user is authenticated
diff --git a/frontend/src/lib/auth/custom-jwt.ts b/frontend/src/lib/auth/custom-jwt.ts
new file mode 100644
index 0000000..310935c
--- /dev/null
+++ b/frontend/src/lib/auth/custom-jwt.ts
@@ -0,0 +1,202 @@
+/**
+ * Custom JWT Authentication
+ *
+ * Manages IdP tokens for custom-jwt auth mode.
+ * Handles token storage, retrieval, and OIDC configuration.
+ */
+
+import { cookies } from "next/headers";
+
+const IDP_TOKEN_COOKIE = "lg_idp_token";
+const IDP_REFRESH_COOKIE = "lg_idp_refresh";
+const PKCE_VERIFIER_COOKIE = "lg_pkce_verifier";
+
+export interface CustomJwtConfig {
+ issuer: string;
+ jwksUri: string;
+ audience?: string;
+ loginUrl: string;
+ tokenUrl: string;
+ clientId: string;
+ clientSecret?: string;
+}
+
+/**
+ * Get custom JWT configuration from environment variables
+ */
+export function getCustomJwtConfig(): CustomJwtConfig | null {
+ const issuer = process.env.JWT_ISSUER;
+ const clientId = process.env.JWT_CLIENT_ID;
+
+ if (!issuer || !clientId) {
+ return null;
+ }
+
+ return {
+ issuer,
+ jwksUri:
+ process.env.JWT_JWKS_URI || `${issuer}/.well-known/openid-configuration`,
+ audience: process.env.JWT_AUDIENCE,
+ loginUrl:
+ process.env.JWT_LOGIN_URL || `${issuer}/protocol/openid-connect/auth`,
+ tokenUrl:
+ process.env.JWT_TOKEN_URL || `${issuer}/protocol/openid-connect/token`,
+ clientId,
+ clientSecret: process.env.JWT_CLIENT_SECRET,
+ };
+}
+
+/**
+ * Get stored IdP token from httpOnly cookie (server-side only)
+ */
+export async function getIdpToken(): Promise {
+ const cookieStore = await cookies();
+ return cookieStore.get(IDP_TOKEN_COOKIE)?.value || null;
+}
+
+/**
+ * Get stored refresh token from httpOnly cookie (server-side only)
+ */
+export async function getRefreshToken(): Promise {
+ const cookieStore = await cookies();
+ return cookieStore.get(IDP_REFRESH_COOKIE)?.value || null;
+}
+
+/**
+ * Store IdP tokens in httpOnly cookies
+ */
+export async function storeIdpTokens(
+ accessToken: string,
+ refreshToken?: string,
+ expiresIn?: number,
+): Promise {
+ const cookieStore = await cookies();
+ const isProduction = process.env.NODE_ENV === "production";
+
+ cookieStore.set(IDP_TOKEN_COOKIE, accessToken, {
+ httpOnly: isProduction,
+ secure: isProduction,
+ sameSite: "lax",
+ path: "/",
+ maxAge: expiresIn || 3600,
+ });
+
+ if (refreshToken) {
+ cookieStore.set(IDP_REFRESH_COOKIE, refreshToken, {
+ httpOnly: isProduction,
+ secure: isProduction,
+ sameSite: "lax",
+ path: "/",
+ maxAge: 30 * 24 * 3600, // 30 days
+ });
+ }
+}
+
+/**
+ * Clear IdP token cookies (logout)
+ */
+export async function clearIdpTokens(): Promise {
+ const cookieStore = await cookies();
+ cookieStore.delete(IDP_TOKEN_COOKIE);
+ cookieStore.delete(IDP_REFRESH_COOKIE);
+ cookieStore.delete(PKCE_VERIFIER_COOKIE);
+}
+
+/**
+ * Generate PKCE code verifier and challenge
+ */
+export function generatePkce(): {
+ codeVerifier: string;
+ codeChallenge: string;
+} {
+ // Generate random code verifier (43-128 chars, URL-safe)
+ const array = new Uint8Array(32);
+ crypto.getRandomValues(array);
+ const codeVerifier = Buffer.from(array).toString("base64url").slice(0, 64);
+
+ // Generate code challenge (S256)
+ const encoder = new TextEncoder();
+ const data = encoder.encode(codeVerifier);
+ // Use sync hash for simplicity — crypto.subtle requires async
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const nodeCrypto = require("crypto");
+ const hashBuffer = nodeCrypto.createHash("sha256").update(data).digest();
+ const codeChallenge = Buffer.from(hashBuffer).toString("base64url");
+
+ return { codeVerifier, codeChallenge };
+}
+
+/**
+ * Store PKCE code verifier in short-lived cookie
+ */
+export async function storePkceVerifier(verifier: string): Promise {
+ const cookieStore = await cookies();
+ const isProduction = process.env.NODE_ENV === "production";
+
+ cookieStore.set(PKCE_VERIFIER_COOKIE, verifier, {
+ httpOnly: true,
+ secure: isProduction,
+ sameSite: "lax",
+ path: "/",
+ maxAge: 600, // 10 minutes
+ });
+}
+
+/**
+ * Retrieve and consume PKCE code verifier
+ */
+export async function consumePkceVerifier(): Promise {
+ const cookieStore = await cookies();
+ const verifier = cookieStore.get(PKCE_VERIFIER_COOKIE)?.value || null;
+ if (verifier) {
+ cookieStore.delete(PKCE_VERIFIER_COOKIE);
+ }
+ return verifier;
+}
+
+/**
+ * Refresh the IdP access token using the refresh token
+ */
+export async function refreshIdpToken(): Promise {
+ const config = getCustomJwtConfig();
+ const refreshToken = await getRefreshToken();
+
+ if (!config || !refreshToken) {
+ return null;
+ }
+
+ try {
+ const params = new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: refreshToken,
+ client_id: config.clientId,
+ });
+
+ if (config.clientSecret) {
+ params.set("client_secret", config.clientSecret);
+ }
+
+ const response = await fetch(config.tokenUrl, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: params.toString(),
+ });
+
+ if (!response.ok) {
+ await clearIdpTokens();
+ return null;
+ }
+
+ const data = await response.json();
+ await storeIdpTokens(
+ data.access_token,
+ data.refresh_token,
+ data.expires_in,
+ );
+
+ return data.access_token;
+ } catch {
+ await clearIdpTokens();
+ return null;
+ }
+}
diff --git a/frontend/src/lib/auth/index.ts b/frontend/src/lib/auth/index.ts
index b7f3c97..0c1e6c2 100644
--- a/frontend/src/lib/auth/index.ts
+++ b/frontend/src/lib/auth/index.ts
@@ -1,4 +1,4 @@
-import { requiresNextAuth } from "@/types/auth-mode";
+import { usesNextAuth } from "@/types/auth-mode";
import type { Session } from "next-auth";
import type { NextRequest } from "next/server";
@@ -9,7 +9,7 @@ type HandlersType = {
};
// standalone/oauth-direct 모드에서는 NextAuth 초기화 안함
-const needsAuth = requiresNextAuth();
+const needsAuth = usesNextAuth();
let handlers: HandlersType;
let auth: AuthFunction;
diff --git a/frontend/src/lib/auth/jwt.ts b/frontend/src/lib/auth/jwt.ts
index a74f1d8..46ea456 100644
--- a/frontend/src/lib/auth/jwt.ts
+++ b/frontend/src/lib/auth/jwt.ts
@@ -6,7 +6,7 @@
*/
import { SignJWT } from "jose";
-import { requiresNextAuth } from "@/types/auth-mode";
+import { usesNextAuth } from "@/types/auth-mode";
export interface JWTPayload {
sub: string;
@@ -23,8 +23,10 @@ export interface JWTPayload {
* @returns The signed JWT token string, or null if user is not authenticated
*/
export async function generateUserJWT(): Promise {
- // Skip JWT generation in standalone/oauth-direct mode
- if (!requiresNextAuth()) {
+ // custom-jwt: token is managed by custom-jwt.ts (forwarded from IdP cookie)
+ // api-key: no JWT needed (uses x-api-key header)
+ // standalone/oauth-direct: no JWT needed
+ if (!usesNextAuth()) {
return null;
}
diff --git a/frontend/src/lib/auth/mode.ts b/frontend/src/lib/auth/mode.ts
index fcf741b..7e3f0ca 100644
--- a/frontend/src/lib/auth/mode.ts
+++ b/frontend/src/lib/auth/mode.ts
@@ -8,10 +8,20 @@ import {
getAuthMode,
requiresNextAuth,
allowsAnonymousAccess,
+ usesNextAuth,
+ requiresLoginUI,
+ requiresUserIdentity,
} from "@/types/auth-mode";
// Re-export for convenience
-export { getAuthMode, requiresNextAuth, allowsAnonymousAccess };
+export {
+ getAuthMode,
+ requiresNextAuth,
+ allowsAnonymousAccess,
+ usesNextAuth,
+ requiresLoginUI,
+ requiresUserIdentity,
+};
/**
* Get registration policy from environment
@@ -41,17 +51,17 @@ export function getAuthModeConfig(): AuthModeConfig {
}
/**
- * Check if public/standalone mode is enabled (no auth required)
+ * Check if public/standalone mode is enabled (no login UI needed)
*/
export function isPublicMode(): boolean {
- return allowsAnonymousAccess();
+ return !requiresLoginUI();
}
/**
- * Check if authentication is required
+ * Check if authentication is required (NextAuth-based)
*/
export function isAuthRequired(): boolean {
- return requiresNextAuth();
+ return usesNextAuth();
}
/**
@@ -61,6 +71,20 @@ export function isOAuthDirectMode(): boolean {
return getAuthMode() === "oauth-direct";
}
+/**
+ * Check if current mode is custom-jwt (external IdP handles auth)
+ */
+export function isCustomJwtMode(): boolean {
+ return getAuthMode() === "custom-jwt";
+}
+
+/**
+ * Check if current mode is api-key (API key authentication)
+ */
+export function isApiKeyMode(): boolean {
+ return getAuthMode() === "api-key";
+}
+
/**
* Get the LangGraph OAuth login URL for oauth-direct mode
*/
@@ -126,8 +150,8 @@ export function canAccessAdmin(user: {
role: UserRole;
status: UserStatus;
}): PermissionCheck {
- // Standalone/oauth-direct modes: no admin access
- if (allowsAnonymousAccess()) {
+ // Non-NextAuth modes: no admin access (no user DB)
+ if (!usesNextAuth()) {
return {
allowed: false,
reason: "Admin access is not available in this mode",
@@ -166,6 +190,7 @@ export const ROUTE_CONFIG = {
"/register",
"/verify-request", // For email magic link
"/api/auth",
+ "/auth/callback", // For custom-jwt IdP callback
"/pending-approval",
"/account-suspended",
],
diff --git a/frontend/src/lib/auth/prisma.ts b/frontend/src/lib/auth/prisma.ts
index 9b17139..3617428 100644
--- a/frontend/src/lib/auth/prisma.ts
+++ b/frontend/src/lib/auth/prisma.ts
@@ -1,4 +1,4 @@
-import { requiresNextAuth } from "@/types/auth-mode";
+import { usesNextAuth } from "@/types/auth-mode";
type PrismaClientType = import("@prisma/client").PrismaClient;
@@ -15,10 +15,10 @@ function createPrismaClient(): PrismaClientType {
// Only initialize PrismaClient when auth requires it.
// This allows AUTH_MODE=none to work without prisma generate.
-export const prisma: PrismaClientType = requiresNextAuth()
+export const prisma: PrismaClientType = usesNextAuth()
? (globalForPrisma.prisma ?? createPrismaClient())
: (null as unknown as PrismaClientType);
-if (requiresNextAuth() && process.env.NODE_ENV !== "production") {
+if (usesNextAuth() && process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}
diff --git a/frontend/src/lib/auth/require-auth.ts b/frontend/src/lib/auth/require-auth.ts
index 22c0912..d4f9363 100644
--- a/frontend/src/lib/auth/require-auth.ts
+++ b/frontend/src/lib/auth/require-auth.ts
@@ -5,7 +5,7 @@
* In public modes (standalone, oauth-direct), auth is skipped.
*/
-import { allowsAnonymousAccess } from "@/types/auth-mode";
+import { getAuthMode, usesNextAuth } from "@/types/auth-mode";
interface AuthSession {
user: {
@@ -19,21 +19,35 @@ interface AuthSession {
/**
* Require authentication for a server action.
- * In standalone/oauth-direct modes, returns null (no auth required).
- * In auth modes, returns the session or throws an error.
+ * In standalone/oauth-direct modes, returns null (no auth required by frontend).
+ * In NextAuth modes, returns the session or throws an error.
+ * In custom-jwt/api-key modes, returns null (auth handled at proxy/server level).
*/
export async function requireAuth(): Promise {
- // Public modes don't require authentication
- if (allowsAnonymousAccess()) {
+ const mode = getAuthMode();
+
+ // Modes where frontend doesn't manage sessions
+ if (mode === "standalone" || mode === "oauth-direct") {
return null;
}
- const { auth } = await import("@/lib/auth");
- const session = await auth();
+ // custom-jwt and api-key: auth is handled at the proxy layer, not via NextAuth sessions
+ if (mode === "custom-jwt" || mode === "api-key") {
+ return null;
+ }
+
+ // NextAuth modes: validate session
+ if (usesNextAuth()) {
+ const { auth } = await import("@/lib/auth");
+ const session = await auth();
+
+ if (!session?.user) {
+ throw new Error("Unauthorized");
+ }
- if (!session?.user) {
- throw new Error("Unauthorized");
+ return session as AuthSession;
}
- return session as AuthSession;
+ // All 7 modes are covered above; this should never be reached
+ throw new Error(`Unknown auth mode: ${mode}`);
}
diff --git a/frontend/src/lib/services/settings.service.ts b/frontend/src/lib/services/settings.service.ts
index bb7fbae..c5d4244 100644
--- a/frontend/src/lib/services/settings.service.ts
+++ b/frontend/src/lib/services/settings.service.ts
@@ -6,14 +6,14 @@ import {
SettingCategory,
GlobalSettingRecord,
} from "@/types/global-settings";
-import { requiresNextAuth } from "@/types/auth-mode";
+import { usesNextAuth } from "@/types/auth-mode";
import { getTranslations } from "next-intl/server";
/**
- * Check if database is available (only in modes that require NextAuth)
+ * Check if database is available (only in modes that use NextAuth + Prisma)
*/
function isDatabaseAvailable(): boolean {
- return requiresNextAuth();
+ return usesNextAuth();
}
/**
diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts
index 841722f..e93db2d 100644
--- a/frontend/src/middleware.ts
+++ b/frontend/src/middleware.ts
@@ -2,6 +2,8 @@ import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import {
isPublicMode,
+ isCustomJwtMode,
+ isApiKeyMode,
getRouteType,
canAccessApp,
canAccessAdmin,
@@ -53,7 +55,7 @@ export default async function middleware(req: NextRequest) {
const pathname = nextUrl.pathname;
const routeType = getRouteType(pathname);
- // STANDALONE / OAUTH-DIRECT MODE: No NextAuth required
+ // STANDALONE / OAUTH-DIRECT MODE: No login UI, no NextAuth
if (isPublicMode()) {
// Admin routes and auth pages are blocked in these modes
if (
@@ -66,6 +68,58 @@ export default async function middleware(req: NextRequest) {
return withLocaleCookie(req, NextResponse.next());
}
+ // CUSTOM-JWT / API-KEY MODE: Login UI required but no NextAuth session
+ if (isCustomJwtMode() || isApiKeyMode()) {
+ // Public routes (login, callback) are always accessible
+ if (routeType === "public") {
+ return withLocaleCookie(req, NextResponse.next());
+ }
+
+ // Admin routes: not available in non-NextAuth modes
+ if (routeType === "admin") {
+ return NextResponse.redirect(new URL("/", nextUrl));
+ }
+
+ // API routes with Bearer token or x-api-key: let through
+ if (
+ routeType === "api" &&
+ (hasBearerToken(req) || req.headers.get("x-api-key"))
+ ) {
+ return NextResponse.next();
+ }
+
+ // For custom-jwt: check if IdP token cookie exists
+ if (isCustomJwtMode()) {
+ const hasIdpToken = req.cookies.get("lg_idp_token")?.value;
+ if (!hasIdpToken) {
+ if (routeType === "api") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ const loginUrl = new URL("/login", nextUrl);
+ loginUrl.searchParams.set("callbackUrl", pathname);
+ return NextResponse.redirect(loginUrl);
+ }
+ }
+
+ // For api-key: check if API key exists in cookie or env
+ if (isApiKeyMode()) {
+ const hasApiKey =
+ req.cookies.get("lg_apiKey")?.value ||
+ process.env.LANGCHAIN_API_KEY ||
+ process.env.NEXT_PUBLIC_LANGCHAIN_API_KEY;
+ if (!hasApiKey) {
+ if (routeType === "api") {
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ }
+ const loginUrl = new URL("/login", nextUrl);
+ loginUrl.searchParams.set("callbackUrl", pathname);
+ return NextResponse.redirect(loginUrl);
+ }
+ }
+
+ return withLocaleCookie(req, NextResponse.next());
+ }
+
// CREDENTIALS / OAUTH / EMAIL MODE: Use NextAuth
// Get session using auth()
const { auth } = await import("@/lib/auth");
diff --git a/frontend/src/types/auth-mode.ts b/frontend/src/types/auth-mode.ts
index a532c75..46496ed 100644
--- a/frontend/src/types/auth-mode.ts
+++ b/frontend/src/types/auth-mode.ts
@@ -7,6 +7,8 @@
* - email: NextAuth + email magic link
* - oauth-direct: LangGraph server handles OAuth directly
* - standalone: No authentication required (local/dev use)
+ * - custom-jwt: External IdP handles auth, PKCE flow, tokens stored in cookies
+ * - api-key: API key authentication against LangGraph server
*
* Legacy aliases (for backward compatibility):
* - authenticated: maps to "credentials"
@@ -17,7 +19,9 @@ export type AuthMode =
| "credentials"
| "email"
| "oauth-direct"
- | "standalone";
+ | "standalone"
+ | "custom-jwt"
+ | "api-key";
export type RegistrationPolicy = "open" | "approval";
@@ -74,6 +78,8 @@ export const VALID_AUTH_MODES: AuthMode[] = [
"email",
"oauth-direct",
"standalone",
+ "custom-jwt",
+ "api-key",
];
/**
@@ -110,17 +116,48 @@ export function getAuthMode(): AuthMode {
}
/**
- * Check if the current mode requires NextAuth
+ * Check if the current mode initializes NextAuth (Prisma, session, providers).
+ * True for: oauth, credentials, email
*/
-export function requiresNextAuth(): boolean {
+export function usesNextAuth(): boolean {
const mode = getAuthMode();
return mode === "oauth" || mode === "credentials" || mode === "email";
}
/**
- * Check if the current mode allows anonymous access
+ * Check if the current mode shows a login UI (login page, key form, IdP redirect).
+ * True for: oauth, credentials, email, custom-jwt, api-key
*/
-export function allowsAnonymousAccess(): boolean {
+export function requiresLoginUI(): boolean {
const mode = getAuthMode();
- return mode === "standalone" || mode === "oauth-direct";
+ return (
+ mode === "oauth" ||
+ mode === "credentials" ||
+ mode === "email" ||
+ mode === "custom-jwt" ||
+ mode === "api-key"
+ );
+}
+
+/**
+ * Check if the current mode tracks user identity (per-user thread isolation).
+ * True for all modes except standalone and api-key.
+ */
+export function requiresUserIdentity(): boolean {
+ const mode = getAuthMode();
+ return mode !== "standalone" && mode !== "api-key";
+}
+
+/**
+ * @deprecated Use usesNextAuth() instead
+ */
+export function requiresNextAuth(): boolean {
+ return usesNextAuth();
+}
+
+/**
+ * @deprecated Use !requiresLoginUI() instead
+ */
+export function allowsAnonymousAccess(): boolean {
+ return !requiresLoginUI();
}