From 4815a1b334bec647431e3ec4e51a96e365165176 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EB=AF=BC=EC=84=9D?= Date: Mon, 13 Apr 2026 14:10:35 +0900 Subject: [PATCH 1/3] feat: add custom-jwt and api-key auth modes Add two new authentication modes for the universal LangGraph frontend: - custom-jwt: External IdP authentication with PKCE + OIDC Authorization Code Flow, JWKS-based token validation, and CSRF state parameter - api-key: LangGraph Cloud API key authentication with input form and server-side validation via /assistants/search endpoint Refactor binary auth predicates (requiresNextAuth/allowsAnonymousAccess) into fine-grained predicates (usesNextAuth/requiresLoginUI/requiresUserIdentity) with backward-compatible aliases. Migrate 15+ callsites across middleware, proxy route, auth handlers, and layout files. Includes server examples, auth architecture docs, setup guide for Keycloak/Auth0/Supabase, and E2E tests for all new modes. --- docs/00-OVERVIEW.md | 7 + docs/05-AUTH-ARCHITECTURE.md | 353 ++++++++ docs/06-CUSTOM-JWT.md | 632 ++++++++++++++ docs/07-API-KEY.md | 387 +++++++++ docs/08-CUSTOM-SERVER-AUTH.md | 783 ++++++++++++++++++ examples/api-key/server/.env.example | 9 + examples/api-key/server/graph.py | 41 + examples/api-key/server/langgraph.json | 6 + examples/custom-jwt/server/.env.example | 10 + examples/custom-jwt/server/auth.py | 102 +++ examples/custom-jwt/server/graph.py | 41 + examples/custom-jwt/server/langgraph.json | 9 + examples/custom-jwt/server/requirements.txt | 4 + frontend/e2e/auth-api-key.spec.ts | 120 +++ frontend/e2e/auth-custom-jwt.spec.ts | 79 ++ frontend/e2e/auth-standalone-threads.spec.ts | 96 +++ frontend/src/app/(auth)/layout.tsx | 10 +- .../src/app/(auth)/login/ApiKeyLoginForm.tsx | 196 +++++ .../app/(auth)/login/CustomJwtLoginForm.tsx | 134 +++ frontend/src/app/(auth)/login/page.tsx | 6 + frontend/src/app/api/[..._path]/route.ts | 38 +- .../app/api/auth/validate-api-key/route.ts | 59 ++ frontend/src/app/api/langsmith/runs/route.ts | 4 +- frontend/src/app/api/upload/route.ts | 8 +- frontend/src/app/auth/callback/route.ts | 151 ++++ frontend/src/app/layout.tsx | 4 +- .../src/features/auth/hooks/useAuthMode.ts | 32 +- frontend/src/lib/auth/api-key.ts | 82 ++ frontend/src/lib/auth/config.ts | 10 +- frontend/src/lib/auth/custom-jwt.ts | 208 +++++ frontend/src/lib/auth/index.ts | 4 +- frontend/src/lib/auth/jwt.ts | 8 +- frontend/src/lib/auth/mode.ts | 39 +- frontend/src/lib/auth/prisma.ts | 6 +- frontend/src/lib/auth/require-auth.ts | 34 +- frontend/src/lib/services/settings.service.ts | 6 +- frontend/src/middleware.ts | 53 +- frontend/src/types/auth-mode.ts | 49 +- 38 files changed, 3759 insertions(+), 61 deletions(-) create mode 100644 docs/05-AUTH-ARCHITECTURE.md create mode 100644 docs/06-CUSTOM-JWT.md create mode 100644 docs/07-API-KEY.md create mode 100644 docs/08-CUSTOM-SERVER-AUTH.md create mode 100644 examples/api-key/server/.env.example create mode 100644 examples/api-key/server/graph.py create mode 100644 examples/api-key/server/langgraph.json create mode 100644 examples/custom-jwt/server/.env.example create mode 100644 examples/custom-jwt/server/auth.py create mode 100644 examples/custom-jwt/server/graph.py create mode 100644 examples/custom-jwt/server/langgraph.json create mode 100644 examples/custom-jwt/server/requirements.txt create mode 100644 frontend/e2e/auth-api-key.spec.ts create mode 100644 frontend/e2e/auth-custom-jwt.spec.ts create mode 100644 frontend/e2e/auth-standalone-threads.spec.ts create mode 100644 frontend/src/app/(auth)/login/ApiKeyLoginForm.tsx create mode 100644 frontend/src/app/(auth)/login/CustomJwtLoginForm.tsx create mode 100644 frontend/src/app/api/auth/validate-api-key/route.ts create mode 100644 frontend/src/app/auth/callback/route.ts create mode 100644 frontend/src/lib/auth/api-key.ts create mode 100644 frontend/src/lib/auth/custom-jwt.ts diff --git a/docs/00-OVERVIEW.md b/docs/00-OVERVIEW.md index 719e23e..7466ef3 100644 --- a/docs/00-OVERVIEW.md +++ b/docs/00-OVERVIEW.md @@ -11,6 +11,9 @@ This guide explains how to set up authentication when integrating LangGraph back | [03-NEXTAUTH-EMAIL.md](./03-NEXTAUTH-EMAIL.md) | NextAuth + Email (Magic Link) | | [04-OAUTH-DIRECT.md](./04-OAUTH-DIRECT.md) | Direct OAuth Token Verification (without NextAuth) | | [05-STANDALONE.md](./05-STANDALONE.md) | Integration with Backend's Own Auth System | +| [06-CUSTOM-JWT.md](./06-CUSTOM-JWT.md) | External IdP + JWKS Validation (Keycloak, Auth0, etc.) | +| [07-API-KEY.md](./07-API-KEY.md) | API Key Authentication (LangGraph Cloud) | +| [08-CUSTOM-SERVER-AUTH.md](./08-CUSTOM-SERVER-AUTH.md) | Tutorial: Your First Custom LangGraph Server Auth | --- @@ -120,6 +123,8 @@ flowchart TD Q2 -->|OAuth| A1[01-NEXTAUTH-OAUTH] Q2 -->|ID/PW| A2[02-NEXTAUTH-CREDENTIALS] Q2 -->|Email| A3[03-NEXTAUTH-EMAIL] + Q2 -->|External IdP| A6[06-CUSTOM-JWT] + Q2 -->|API Key only| A7[07-API-KEY] Q3 -->|Yes| A5[05-STANDALONE] Q3 -->|No| A4[04-OAUTH-DIRECT] @@ -129,6 +134,8 @@ flowchart TD style A3 fill:#90EE90 style A4 fill:#87CEEB style A5 fill:#FFB6C1 + style A6 fill:#DDA0DD + style A7 fill:#F0E68C ``` --- diff --git a/docs/05-AUTH-ARCHITECTURE.md b/docs/05-AUTH-ARCHITECTURE.md new file mode 100644 index 0000000..57eddfb --- /dev/null +++ b/docs/05-AUTH-ARCHITECTURE.md @@ -0,0 +1,353 @@ +# Authentication Architecture Overview + +This document describes all 7 authentication modes supported by LangGraph Chat UI and how to choose the right mode for your use case. + +## Quick Reference + +| Mode | Frontend | Server Auth | Token Type | NextAuth | Per-User Isolation | Use Case | +|------|----------|-------------|------------|----------|-------------------|----------| +| **standalone** | None required | None | None | No | No | Local dev, demos | +| **credentials** | Next.js + form | NextAuth | JWT (HS256) | Yes | Yes | Traditional login | +| **oauth** | Next.js + OAuth | NextAuth | JWT (HS256) | Yes | Yes | Social login (Google/GitHub) | +| **email** | Next.js + form | NextAuth | JWT (HS256) | Yes | Yes | Magic link login | +| **oauth-direct** | Direct OAuth | Provider API calls | Provider token | No | Yes | CLI, mobile, no frontend | +| **custom-jwt** | None required | JWKS validation | JWT (RS256/ES256) | No | Yes | Keycloak, Auth0, Supabase | +| **api-key** | None required | LangGraph Cloud | API Key | No | No | LangGraph Cloud | + +## Mode Selection Flowchart + +``` +START + | + +-- Have a Next.js frontend? + | | + | +-- YES + | | | + | | +-- What login method? + | | | + | | +-- OAuth (Google/GitHub) --> oauth + | | +-- Email/password DB --> credentials + | | +-- Magic link email --> email + | | + | +-- NO + | | + | +-- Have existing auth system? + | | | + | | +-- YES (Keycloak/Auth0/Supabase) --> custom-jwt + | | +-- NO + | | | + | | +-- Need direct OAuth? --> oauth-direct + | | +-- Using LangGraph Cloud? --> api-key + | | +-- Local dev? --> standalone + | +``` + +## Architecture Diagrams + +### Standalone (No Auth) + +``` +Client --> LangGraph Server + (no validation) +``` + +**Characteristics:** +- No authentication required +- All requests allowed +- No per-user thread isolation +- Best for: local development, public demos, internal testing + +### Credentials & OAuth & Email (NextAuth) + +``` +Client --> Next.js Server --> LangGraph Server + (issues JWT) (validates JWT) +``` + +**Token Flow:** +1. Client logs in with NextAuth (credentials, OAuth, or email) +2. Next.js Server creates JWT token (HS256) +3. Client stores JWT in secure HTTP-only cookie +4. Client sends JWT in Authorization header to LangGraph +5. LangGraph verifies JWT signature (no DB call needed) + +**Per-User Isolation:** +- Each request includes `owner` metadata with user identity +- LangGraph filters threads/data by owner + +### OAuth-Direct + +``` +Client --> OAuth Provider (acquires token) + | + +-> LangGraph Server (validates with provider API) +``` + +**Token Flow:** +1. Client acquires OAuth token directly from provider (Google, GitHub, etc.) +2. Client sends OAuth token to LangGraph +3. LangGraph calls provider's userinfo API to validate token +4. Provider returns user information + +**Pros:** +- Works without Next.js frontend +- Supports CLI, mobile, desktop apps + +**Cons:** +- One API call to provider per request +- Subject to provider rate limits +- Manual per-provider implementation + +### Custom-JWT (OIDC with JWKS) + +``` +Client --> IdP (acquires JWT) + | + +-> LangGraph Server (validates with JWKS public keys) +``` + +**Token Flow:** +1. Client acquires JWT from external IdP (Keycloak, Auth0, Supabase, Okta) +2. Client sends JWT in Authorization header to LangGraph +3. LangGraph fetches public keys from IdP's JWKS endpoint (cached) +4. LangGraph verifies JWT signature cryptographically +5. No IdP API call needed (JWKS is cached) + +**Pros:** +- Standards-based (OIDC) +- Fast verification (no API calls after JWKS cached) +- Works without Next.js +- Supports multiple providers + +**Cons:** +- Requires external IdP +- Initial setup more complex + +### API-Key (LangGraph Cloud) + +``` +Client --> LangGraph Cloud + (validates API key natively) +``` + +**Characteristics:** +- LangGraph Cloud validates API key natively +- No custom auth handler needed +- Only requires `x-api-key` header +- Simplest deployment (no auth.py required) + +## Environment Variables Reference + +### standalone +```env +# Frontend +NEXT_PUBLIC_AUTH_MODE=standalone +NEXT_PUBLIC_API_URL=http://localhost:2024 + +# Server +# No auth configuration needed +``` + +### credentials +```env +# Frontend +NEXT_PUBLIC_AUTH_MODE=credentials +NEXT_PUBLIC_API_URL=http://localhost:2024 +NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_SECRET=your-nextauth-secret +DATABASE_URL=file:./prisma/dev.db + +# Server +NEXTAUTH_SECRET=your-nextauth-secret +# Must match frontend NEXTAUTH_SECRET +``` + +### oauth (Google/GitHub) +```env +# Frontend +NEXT_PUBLIC_AUTH_MODE=oauth +NEXT_PUBLIC_API_URL=http://localhost:2024 +NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_SECRET=your-nextauth-secret +DATABASE_URL=file:./prisma/dev.db +GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=xxx + +# Server +NEXTAUTH_SECRET=your-nextauth-secret +``` + +### email (Magic Link) +```env +# Frontend +NEXT_PUBLIC_AUTH_MODE=email +NEXT_PUBLIC_API_URL=http://localhost:2024 +NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_SECRET=your-nextauth-secret +DATABASE_URL=file:./prisma/dev.db +EMAIL_SERVER_HOST=smtp.example.com +EMAIL_SERVER_PORT=587 +EMAIL_SERVER_USER=your-email@example.com +EMAIL_SERVER_PASSWORD=your-password +EMAIL_FROM=noreply@example.com + +# Server +NEXTAUTH_SECRET=your-nextauth-secret +``` + +### oauth-direct +```env +# Frontend +NEXT_PUBLIC_AUTH_MODE=oauth-direct +NEXT_PUBLIC_API_URL=http://localhost:2024 +GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com + +# Server +GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com +# No NextAuth configuration needed +``` + +### custom-jwt +```env +# Frontend (if using web UI) +NEXT_PUBLIC_AUTH_MODE=custom-jwt +NEXT_PUBLIC_API_URL=http://localhost:2024 + +# Server +JWT_JWKS_URI=https://your-idp/.well-known/jwks.json +JWT_ISSUER=https://your-idp/realms/your-realm # optional +JWT_AUDIENCE=your-client-id # optional +``` + +### api-key +```env +# Frontend (if using web UI) +NEXT_PUBLIC_AUTH_MODE=api-key +NEXT_PUBLIC_API_URL=http://localhost:2024 + +# Server +# No custom auth needed +# LangGraph Cloud validates API key natively +``` + +## LangGraph Server auth.py Pattern + +All modes except `standalone` and `api-key` require an `auth.py` handler: + +### langgraph.json +```json +{ + "auth": { + "path": "src/security/auth.py:auth" + } +} +``` + +### Minimal auth.py Structure +```python +import os +from langgraph_sdk import Auth + +auth = Auth() + +@auth.authenticate +async def authenticate(authorization: str | None) -> Auth.types.MinimalUserDict: + """Extract and validate user identity from authorization header.""" + if not authorization: + raise Auth.exceptions.HTTPException(status_code=401, detail="Unauthorized") + + # Parse authorization header and validate token + scheme, _, token = authorization.partition(" ") + if scheme.lower() != "bearer" or not token: + raise Auth.exceptions.HTTPException(status_code=401, detail="Invalid token") + + # Verify token (mode-specific: JWT decode, JWKS validation, API call, etc.) + # Return user identity + return { + "identity": user_id, + "email": user_email, + } + +@auth.on +async def filter_by_owner(ctx: Auth.types.AuthContext, value: dict) -> dict: + """Isolate threads per user.""" + metadata = value.setdefault("metadata", {}) + metadata["owner"] = ctx.user.identity + return {"owner": ctx.user.identity} +``` + +## MinimalUserDict Interface + +The `@auth.authenticate` handler must return a dict with at least: + +```python +{ + "identity": str, # Required: unique user identifier + "email": str, # Optional: user email + "display_name": str, # Optional: user display name + "is_authenticated": bool, # Optional: auth status + # ... other custom fields +} +``` + +**Key Points:** +- `identity` must be globally unique (user ID, email, or identifier from provider) +- `identity` is used for per-user thread isolation via `owner` metadata +- Additional fields are stored in user dict and available in auth context + +## Decision Matrix + +Choose your mode based on these questions: + +| Question | Answer | Mode | +|----------|--------|------| +| Do you have a Next.js frontend? | Yes | oauth / credentials / email | +| Do you have a Next.js frontend? | No | custom-jwt / oauth-direct / api-key | +| Do you have an existing auth system? | Yes (Keycloak/Auth0/etc) | custom-jwt | +| Do you need to isolate threads per user? | Yes | credentials / oauth / email / oauth-direct / custom-jwt | +| Do you need to isolate threads per user? | No | standalone / api-key | +| Are you using LangGraph Cloud? | Yes | api-key | +| Is this local development? | Yes | standalone | + +## Common Errors + +### JWT Secret Mismatch +**Problem:** 401 errors on every request in credentials/oauth/email modes + +**Solution:** Ensure `NEXTAUTH_SECRET` is identical on frontend and server: +```bash +# Both must be the same value +echo $NEXTAUTH_SECRET # Frontend +echo $NEXTAUTH_SECRET # Server (verify they match) +``` + +### Missing JWKS Endpoint +**Problem:** 401 errors in custom-jwt mode with "Unable to find signing key" + +**Solution:** Verify `JWT_JWKS_URI` is accessible: +```bash +curl https://your-idp/.well-known/jwks.json +# Should return JSON with "keys" array +``` + +### Identity Not Isolated +**Problem:** Users can see other users' threads + +**Solution:** Verify `owner` metadata is set: +```python +@auth.on +async def filter_by_owner(ctx: Auth.types.AuthContext, value: dict) -> dict: + metadata = value.setdefault("metadata", {}) + metadata["owner"] = ctx.user.identity # Must set owner + return {"owner": ctx.user.identity} +``` + +## Next Steps + +- [Credentials Mode](./02-NEXTAUTH-CREDENTIALS.md) +- [OAuth Mode](./01-NEXTAUTH-OAUTH.md) +- [Email Mode](./03-NEXTAUTH-EMAIL.md) +- [OAuth-Direct Mode](./04-OAUTH-DIRECT.md) +- [Custom-JWT Mode](./06-CUSTOM-JWT.md) +- [API-Key Mode](./07-API-KEY.md) +- [Custom Server Auth](./08-CUSTOM-SERVER-AUTH.md) diff --git a/docs/06-CUSTOM-JWT.md b/docs/06-CUSTOM-JWT.md new file mode 100644 index 0000000..34cccd5 --- /dev/null +++ b/docs/06-CUSTOM-JWT.md @@ -0,0 +1,632 @@ +# Custom JWT Authentication with OIDC & JWKS + +This guide explains how to set up authentication using an external Identity Provider (IdP) that issues OIDC-compliant JWT tokens. LangGraph validates these tokens by fetching public keys from the IdP's JWKS (JSON Web Key Set) endpoint. + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [Supported Identity Providers](#supported-identity-providers) +3. [Environment Variables](#environment-variables) +4. [Server-Side Setup](#server-side-setup) +5. [OIDC Authorization Code Flow](#oidc-authorization-code-flow-with-pkce) +6. [Provider-Specific Setup](#provider-specific-setup) +7. [Testing](#testing) +8. [Troubleshooting](#troubleshooting) + +--- + +## Architecture Overview + +```mermaid +sequenceDiagram + autonumber + participant Client as Client + participant IdP as Identity Provider
(Keycloak/Auth0/Supabase) + participant LangGraph as LangGraph Server + participant JWKS as IdP JWKS Endpoint + + rect rgb(240, 248, 255) + Note over Client,IdP: Step 1: OIDC Authorization Code Flow + Client->>IdP: Authorization request + PKCE challenge + IdP->>IdP: User authentication + consent + IdP-->>Client: Authorization code + state + Client->>IdP: Exchange code for tokens + IdP-->>Client: JWT (access_token) + refresh_token + end + + rect rgb(255, 248, 240) + Note over Client,JWKS: Step 2: Token Verification + Client->>LangGraph: API request + Authorization: Bearer JWT + LangGraph->>JWKS: Fetch public keys (first time) + JWKS-->>LangGraph: { keys: [{ kid, n, e, ... }] } + LangGraph->>LangGraph: Cache JWKS for 1 hour + LangGraph->>LangGraph: Verify JWT signature (RS256) + LangGraph->>LangGraph: Verify issuer, audience, expiry + LangGraph-->>Client: Response (authenticated) + end + + rect rgb(255, 240, 240) + Note over Client,LangGraph: Subsequent requests: Cache hit + Client->>LangGraph: API request + JWT + LangGraph->>LangGraph: Verify using cached JWKS + LangGraph-->>Client: Response (fast!) + end +``` + +### Key Characteristics + +| Item | Description | +|------|-------------| +| **Token Issuer** | External IdP (Keycloak, Auth0, Supabase, Okta) | +| **Token Verification** | JWKS public key validation (no API calls after cached) | +| **Frontend** | Not required (can be CLI, mobile, or custom) | +| **Token Algorithm** | RS256 (RSA) or ES256 (ECDSA) | +| **Key Caching** | Automatic, 1-hour lifespan | + +### Pros and Cons + +**Pros:** +- Standards-based (OIDC) +- Fast verification (JWKS cached, no per-request API calls) +- Works without Next.js frontend +- No custom OAuth integration per provider +- One implementation supports Keycloak, Auth0, Supabase, Okta, etc. + +**Cons:** +- Requires external IdP +- Initial IdP setup more complex than email/password +- Clients must implement OIDC flow (or use SDK) + +--- + +## Supported Identity Providers + +All providers that support OIDC and expose a `.well-known/openid-configuration` endpoint are supported: + +- **Keycloak** (open-source) +- **Auth0** (SaaS) +- **Supabase** (SaaS, PostgreSQL-based) +- **Okta** (enterprise) +- **Azure AD** (enterprise) +- **Google Identity** (enterprise) +- Any OIDC-compliant provider + +--- + +## Environment Variables + +### Required + +```env +JWT_JWKS_URI=https://your-idp.example.com/.well-known/jwks.json +``` + +The JWKS endpoint URL. Typically found in the IdP's `.well-known/openid-configuration`. + +### Optional + +```env +# Token validation constraints (enforce specific IdP/audience) +JWT_ISSUER=https://your-idp.example.com/realms/your-realm +JWT_AUDIENCE=your-client-id +``` + +### Example: Keycloak +```env +JWT_JWKS_URI=https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs +JWT_ISSUER=https://keycloak.example.com/realms/my-realm +JWT_AUDIENCE=my-client +``` + +### Example: Auth0 +```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 +``` + +### Example: Supabase +```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 +``` + +--- + +## Server-Side Setup + +### Step 1: Configure langgraph.json + +```json +{ + "auth": { + "path": "src/security/auth.py:auth" + } +} +``` + +### Step 2: Create src/security/auth.py + +```python +"""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 +- JWT_ISSUER: Expected token issuer (optional) +- JWT_AUDIENCE: Expected token audience (optional) +""" + +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 +``` + +### Step 3: Install Dependencies + +```bash +pip install pyjwt[crypto] +``` + +The `[crypto]` extra is required for RSA key validation. + +--- + +## OIDC Authorization Code Flow with PKCE + +If you need to implement a client-side login flow, follow the Authorization Code flow with PKCE: + +### Flow Diagram + +``` +1. Client generates PKCE challenge: + - code_verifier = random(128 bytes) + - code_challenge = SHA256(code_verifier) base64-url-encoded + +2. Client redirects to IdP: + - GET /oauth/authorize + - client_id=your-client-id + - redirect_uri=http://localhost:3000/callback + - scope=openid profile email + - response_type=code + - code_challenge= + - code_challenge_method=S256 + +3. User authenticates at IdP + +4. IdP redirects back to client with authorization code: + - http://localhost:3000/callback?code=&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..031af27 --- /dev/null +++ b/frontend/e2e/auth-api-key.spec.ts @@ -0,0 +1,120 @@ +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..7b78501 --- /dev/null +++ b/frontend/e2e/auth-custom-jwt.spec.ts @@ -0,0 +1,79 @@ +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..57258c4 --- /dev/null +++ b/frontend/src/app/(auth)/login/ApiKeyLoginForm.tsx @@ -0,0 +1,196 @@ +"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} +
+
+

+ {branding.appName} +

+

+ Enter your API key to connect +

+
+
+ +
+ {/* Error message */} + {error && ( + + {error} + + )} + + + + setApiKey(e.target.value)} + required + disabled={isLoading} + size="lg" + /> +

+ Your LangGraph Cloud or LangSmith API key +

+
+ + + + +
+
+ ); +} 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} +
+
+

+ {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..51b91df --- /dev/null +++ b/frontend/src/app/api/auth/validate-api-key/route.ts @@ -0,0 +1,59 @@ +/** + * 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..2f52d91 --- /dev/null +++ b/frontend/src/app/auth/callback/route.ts @@ -0,0 +1,151 @@ +/** + * 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..79d2f94 --- /dev/null +++ b/frontend/src/lib/auth/api-key.ts @@ -0,0 +1,82 @@ +/** + * 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..6e382a5 --- /dev/null +++ b/frontend/src/lib/auth/custom-jwt.ts @@ -0,0 +1,208 @@ +/** + * 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..e422395 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,55 @@ 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(); } From 5924edefe138531949bdc4afd9f315f73c00ae3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EB=AF=BC=EC=84=9D?= Date: Mon, 13 Apr 2026 14:29:23 +0900 Subject: [PATCH 2/3] style: fix prettier formatting --- .../src/app/(auth)/login/ApiKeyLoginForm.tsx | 5 ++++- .../app/api/auth/validate-api-key/route.ts | 5 ++++- frontend/src/app/auth/callback/route.ts | 3 ++- frontend/src/lib/auth/api-key.ts | 20 ++++++++----------- frontend/src/lib/auth/custom-jwt.ts | 12 +++-------- frontend/src/middleware.ts | 5 ++++- 6 files changed, 25 insertions(+), 25 deletions(-) diff --git a/frontend/src/app/(auth)/login/ApiKeyLoginForm.tsx b/frontend/src/app/(auth)/login/ApiKeyLoginForm.tsx index 57258c4..b53fc37 100644 --- a/frontend/src/app/(auth)/login/ApiKeyLoginForm.tsx +++ b/frontend/src/app/(auth)/login/ApiKeyLoginForm.tsx @@ -72,7 +72,10 @@ export function ApiKeyLoginForm() { router.push("/"); router.refresh(); } else { - setError(data.error || "Invalid API key — could not connect to LangGraph server"); + setError( + data.error || + "Invalid API key — could not connect to LangGraph server", + ); } } catch { setError("Failed to validate API key. Please check your connection."); diff --git a/frontend/src/app/api/auth/validate-api-key/route.ts b/frontend/src/app/api/auth/validate-api-key/route.ts index 51b91df..799e282 100644 --- a/frontend/src/app/api/auth/validate-api-key/route.ts +++ b/frontend/src/app/api/auth/validate-api-key/route.ts @@ -43,7 +43,10 @@ export async function POST(req: NextRequest) { // 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" }, + { + valid: false, + error: "Invalid API URL: private network addresses are not allowed", + }, { status: 400 }, ); } diff --git a/frontend/src/app/auth/callback/route.ts b/frontend/src/app/auth/callback/route.ts index 2f52d91..e2e23e8 100644 --- a/frontend/src/app/auth/callback/route.ts +++ b/frontend/src/app/auth/callback/route.ts @@ -72,7 +72,8 @@ export async function GET(req: NextRequest) { const error = req.nextUrl.searchParams.get("error"); if (error) { - const errorDesc = req.nextUrl.searchParams.get("error_description") || error; + const errorDesc = + req.nextUrl.searchParams.get("error_description") || error; return NextResponse.redirect( new URL(`/login?error=${encodeURIComponent(errorDesc)}`, req.url), ); diff --git a/frontend/src/lib/auth/api-key.ts b/frontend/src/lib/auth/api-key.ts index 79d2f94..4d04467 100644 --- a/frontend/src/lib/auth/api-key.ts +++ b/frontend/src/lib/auth/api-key.ts @@ -35,8 +35,7 @@ export async function getStoredApiKey(): Promise { */ export function hasApiKeyFromEnv(): boolean { return !!( - process.env.LANGCHAIN_API_KEY || - process.env.NEXT_PUBLIC_LANGCHAIN_API_KEY + process.env.LANGCHAIN_API_KEY || process.env.NEXT_PUBLIC_LANGCHAIN_API_KEY ); } @@ -49,17 +48,14 @@ export async function validateApiKey( 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 }), + 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 }; diff --git a/frontend/src/lib/auth/custom-jwt.ts b/frontend/src/lib/auth/custom-jwt.ts index 6e382a5..310935c 100644 --- a/frontend/src/lib/auth/custom-jwt.ts +++ b/frontend/src/lib/auth/custom-jwt.ts @@ -35,8 +35,7 @@ export function getCustomJwtConfig(): CustomJwtConfig | null { return { issuer, jwksUri: - process.env.JWT_JWKS_URI || - `${issuer}/.well-known/openid-configuration`, + 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`, @@ -113,9 +112,7 @@ export function generatePkce(): { // 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); + const codeVerifier = Buffer.from(array).toString("base64url").slice(0, 64); // Generate code challenge (S256) const encoder = new TextEncoder(); @@ -123,10 +120,7 @@ export function generatePkce(): { // 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 hashBuffer = nodeCrypto.createHash("sha256").update(data).digest(); const codeChallenge = Buffer.from(hashBuffer).toString("base64url"); return { codeVerifier, codeChallenge }; diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index e422395..e93db2d 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -81,7 +81,10 @@ export default async function middleware(req: NextRequest) { } // API routes with Bearer token or x-api-key: let through - if (routeType === "api" && (hasBearerToken(req) || req.headers.get("x-api-key"))) { + if ( + routeType === "api" && + (hasBearerToken(req) || req.headers.get("x-api-key")) + ) { return NextResponse.next(); } From 7dae7b3041d80ac67ae5e651380e8c6673fba1e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=EB=AF=BC=EC=84=9D?= Date: Wed, 15 Apr 2026 13:44:42 +0900 Subject: [PATCH 3/3] style: fix prettier formatting in e2e auth tests --- frontend/e2e/auth-api-key.spec.ts | 10 ++++------ frontend/e2e/auth-custom-jwt.spec.ts | 4 +--- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/frontend/e2e/auth-api-key.spec.ts b/frontend/e2e/auth-api-key.spec.ts index 031af27..cf3ec9f 100644 --- a/frontend/e2e/auth-api-key.spec.ts +++ b/frontend/e2e/auth-api-key.spec.ts @@ -86,8 +86,7 @@ test.describe("API Key auth mode", () => { test.describe("API Key auth mode — env var auto-login", () => { const hasEnvKey = !!( - process.env.LANGCHAIN_API_KEY || - process.env.NEXT_PUBLIC_LANGCHAIN_API_KEY + process.env.LANGCHAIN_API_KEY || process.env.NEXT_PUBLIC_LANGCHAIN_API_KEY ); test.skip( @@ -98,10 +97,9 @@ test.describe("API Key auth mode — env var auto-login", () => { 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 }, - ); + await page.waitForURL((url) => !url.pathname.includes("/login"), { + timeout: 10_000, + }); expect(page.url()).not.toContain("/login"); }); diff --git a/frontend/e2e/auth-custom-jwt.spec.ts b/frontend/e2e/auth-custom-jwt.spec.ts index 7b78501..192454c 100644 --- a/frontend/e2e/auth-custom-jwt.spec.ts +++ b/frontend/e2e/auth-custom-jwt.spec.ts @@ -30,9 +30,7 @@ test.describe("Custom JWT auth mode", () => { await expect(signInButton).toBeVisible({ timeout: 10_000 }); }); - test("protected route redirects to login without token", async ({ - page, - }) => { + 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 });