diff --git a/.gitignore b/.gitignore
index ca311e8..fd81329 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,11 @@ dist/
build/
.env
.env.local
+.env.backup
+.env.app
+.env.agent
+.env.mcp
+.env.*.backup
.DS_Store
*.log
coverage/
@@ -14,4 +19,8 @@ packages/*/node_modules/
packages/*/dist/
packages/*/build/
.github/
-*.pem
\ No newline at end of file
+*.pem
+
+# Okta bootstrap state
+.okta-bootstrap-state.json
+okta-config-report.md
\ No newline at end of file
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..0453efc
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1 @@
+registry=https://registry.npmjs.org
\ No newline at end of file
diff --git a/README.md b/README.md
index 27168b7..7702f80 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
## Overview
-This monorepo demonstrates a full-stack agentic application (agent0) that has a secure integration with another application's (todo0) MCP exposed resources.
+This monorepo demonstrates an agentic application (agent0) that has a secure integration with another application's (todo0) MCP exposed resources.
### Architecture
@@ -11,7 +11,8 @@ graph TB
User[User/Browser]
Anthropic[Anthropic API
Claude]
Okta_Org_AS[Okta Org AS
/oauth2/v1
For human SSO & ID-JAGs]
- Okta_Custom_AS[Okta Custom AS
/oauth2/default/v1
todo0 authorization server]
+ Okta_IDP
+ Okta_MCP_AS[Todo MCP Authorization Server
/oauth2/aus.../v1
For MCP Server protection]
subgraph Agent0[agent0]
subgraph ResourceServer[Resource server :3000]
@@ -27,18 +28,16 @@ graph TB
end
subgraph Todo0[todo0]
- MCP_Server[MCP Server :3001
Tools Layer]
- Todo_API[Todo REST API :5001
Express + Prisma]
+ MCP_Server[MCP Server :5002
Tools Layer
Validates JWT & Scopes]
end
User -->|HTTP Requests| ResourceServer
- ResourceServer-->|oidc client for human sso| Okta_Org_AS
+ ResourceServer-->|oidc client for human sso| Okta_IDP
LLM_Integration -->|AI Requests| Anthropic
AgentIdentity -->| agent client w/ for ID-JAG | Okta_Org_AS
- AgentIdentity -->| agent client use ID-JAG to get todo0 AT | Okta_Custom_AS
- MCP_Client -->|MCP Protocol
:3001| MCP_Server
- MCP_Server -->|Internal Calls| Todo_API
- Todo_API -->|Validates JWT| Okta_Custom_AS
+ AgentIdentity -->| agent client use ID-JAG to get MCP AT | Okta_MCP_AS
+ MCP_Client -->|MCP Protocol
with JWT| MCP_Server
+ MCP_Server -->|Validates JWT| Okta_MCP_AS
Chat-->AgentIdentity
style Auth fill:#99ccff
@@ -47,9 +46,8 @@ graph TB
style MCP_Client fill:#e1f5ff
style LLM_Integration fill:#e1f5ff
style MCP_Server fill:#fff4e1
- style Todo_API fill:#ffe1f5
style Okta_Org_AS fill:#e8f4f8
- style Okta_Custom_AS fill:#f0e8f4
+ style Okta_MCP_AS fill:#f0e8f4
style Anthropic fill:#f0f0f0
style ResourceServer fill:#cce6ff,stroke:#0066cc,stroke-width:1px
style AgentIdentity fill:#d9f0ff,stroke:#0066cc,stroke-width:1px
@@ -68,117 +66,190 @@ graph TB
- **agent0 Agent Identity** (Registered agent in Okta):
- MCP Client: Connects to todo0's MCP Server
- LLM Integration: Interfaces with Anthropic's Claude API
- - Authenticates with **Okta Custom AS** (`/oauth2/default/v1`) using Client Credentials
-- todo0 Package: Port 5001 (API) / Port 3001 (MCP Server)
- - **Todo MCP Server**: Tools layer for todo operations
- - **Todo REST API**: Express + Prisma backend
- - Protected by **Okta Custom AS** (`/oauth2/default/v1`) - validates JWTs from the Custom AS
+ - Authenticates with **Okta Org AS** (`/oauth2/v1`) using Client Credentials to request ID-JAG
+ - Authenticates with **Todo MCP Authorization Server** using JWT Bearer grant to exchange ID-JAG for MCP access token
+- todo0 Package: Port 5002 (MCP Server)
+ - **Todo MCP Server**: Tools layer for todo operations (Express + Prisma)
+ - Validates JWTs issued by **Todo MCP Authorization Server**
+ - Validates scopes (`mcp:connect`, `mcp:tools:read`, `mcp:tools:manage`) for authorization
**Okta Authorization Servers:**
-- **Okta Org AS** (`/oauth2/v1`): Used for human SSO (Single Sign-On)
+- **Okta Org AS** (`/oauth2/v1`): Used for human SSO (Single Sign-On) and ID-JAG issuance
- Handles user authentication for the OIDC Client
- Issues tokens for human users accessing the Resource Server
-- **Okta Custom AS** (`/oauth2/default/v1`): Used for API protection and service-to-service auth
- - Handles Agent Identity authentication via Client Credentials flow
+ - Issues ID-JAG tokens for Agent Identity via Client Credentials flow
+- **Todo MCP Authorization Server** (Custom AS): Used for MCP Server protection
+ - Exchanges ID-JAG tokens for MCP-scoped access tokens via JWT Bearer grant
- Issues tokens that todo0's MCP Server validates
- - Provides fine-grained authorization for API resources
+ - Provides fine-grained authorization with MCP-specific scopes
**Architecture Flow:**
- Users interact with the Resource Server's UI, Auth, and Chat endpoints
- The Resource Server uses the Agent Identity to process AI-powered requests
-- **Human Authentication**: The OIDC Client authenticates users via **Okta Org AS** (`/oauth2/v1`) and shares ID tokens with the Agent Identity
-- **Service Authentication**: The Agent Identity authenticates as a workload principal with the **Okta Custom AS** (`/oauth2/default/v1`) via Client Credentials flow
-- The MCP Client (within Agent Identity) communicates with todo0's MCP Server on port 3001
-- The todo0 MCP Server validates JWTs issued by **Okta Custom AS** (`/oauth2/default/v1`)
+- **Human Authentication**: The OIDC Client authenticates users via **Okta Org AS** (`/oauth2/v1`)
+- **Agent Authentication**:
+ 1. Agent Identity obtains ID-JAG token from **Okta Org AS** via Client Credentials flow
+ 2. Agent Identity exchanges ID-JAG for MCP access token from **Todo MCP Authorization Server** via JWT Bearer grant
+- The MCP Client (within Agent Identity) communicates with todo0's MCP Server on port 5002 using the MCP access token
+- The todo0 MCP Server validates JWTs and scopes issued by **Todo MCP Authorization Server**
- The LLM Integration enables Claude AI capabilities for chat and agent operations
### Features
-- RESTful todo API with authentication (Express + Prisma)
-- MCP server with tools for managing todos (create, list, update, complete, delete)
+- MCP server with tools for managing todos
- MCP client for interacting with the MCP server
-- Okta OAuth2 authentication
+- Okta OAuth2 authentication with ID-JAG token exchange
+- JWT validation and scope-based authorization
- pnpm workspace structure
## Packages
- `agent0`: Contains the MCP client implementation with Anthropic Claude integration
-- `todo0`: Contains the MCP server, Express/Prisma REST API, and web UI
+- `todo0`: Contains the MCP server with Express/Prisma backend
-## MCP Server Tools
+## Setup
-- `create-todo`: Create a new todo (requires create:todos scope)
-- `get-todos`: List todos (admins see all, users see own)
-- `update-todo`: Edit the title/content of a todo
-- `toggle-todo`: Toggle the completed status of a todo
-- `delete-todo`: Delete a todo (own todos or admin access)
+### Prerequisites
-## Run Instructions
+Before running the bootstrap script, you'll need:
-### Install dependencies
+1. **Okta Developer Account**
+ - Sign up for free at [https://developer.okta.com/signup/](https://developer.okta.com/signup/)
+
+2. **Okta API Token** with admin permissions
+ - Create via: Okta Admin Console → Security → API → Tokens → Create Token
+
+3. **Anthropic API Key** (optional, for LLM integration)
+ - Sign up at [https://console.anthropic.com/](https://console.anthropic.com/)
+ - Alternative: Configure AWS Bedrock credentials instead
+ - **Note** These will need to be configured in packages/agent0/.env.agent at a later step.
+
+### Automated Configuration
+
+Run the interactive bootstrap script to automatically configure your Okta tenant and generate all required configuration files:
```sh
-pnpm install
+pnpm run bootstrap:okta
```
-### Bootstrap prisma client
+**The script will prompt you for:**
+
+- Okta domain (e.g., dev-12345.okta.com)
+- Okta API token
+- Audience values for each authorization server (or use defaults)
+- Owner setup method (Standard API recommended)
+
+**What gets automatically created:**
+
+**In Okta:**
+
+- 1 Authorization Server (Todo MCP Authorization Server)
+- Custom scopes for MCP Server:
+ - `mcp:connect` - Establish MCP connection
+ - `mcp:tools:read` - Use tools that read todo data
+ - `mcp:tools:manage` - Use tools that manage todo data
+- 2 OIDC Applications (agent0 web app, todo0 web app)
+- Agent Identity with RSA key pair for workload authentication
+- Agent Connection to Todo MCP Authorization Server
+- Access policies and rules with JWT Bearer grant type
+- 2 Trusted Origins (ports 3000, 5002)
+- User assignment to both OIDC applications
+
+**Locally:**
+
+- `packages/agent0/.env.app` - Agent0 resource server configuration
+- `packages/agent0/.env.agent` - Agent0 agent identity configuration with MCP settings
+- `packages/todo0/.env` - Todo0 MCP server configuration
+- `packages/agent0/agent0-private-key.pem` - RSA private key (600 permissions)
+- `okta-config-report.md` - Detailed configuration report
+- `.okta-bootstrap-state.json` - State file for rollback
+
+### Verification
+
+After bootstrap completes, verify your configuration:
```sh
-pnpm boostrap
+pnpm run validate:okta
```
-### Start REST API (todo0)
+This runs automated checks to ensure:
+
+- All .env files exist with required variables
+- Authorization servers are reachable
+- Private key is valid
+- ID-JAG token exchange flow works
+- Audiences are properly separated
+
+### Rollback
+
+To completely remove all Okta resources and local files created by bootstrap:
```sh
-pnpm run start:todo0
+pnpm run rollback:okta
```
-### Start MCP Server (todo0)
+This will:
+
+- Delete all authorization servers, applications, and agent identities from Okta
+- Remove trusted origins
+- Optionally delete local .env files and private keys
+- Clean up the state file
+
+### Manual Configuration
+
+If you prefer to manually configure Okta and create your own .env files, refer to the `.env.example` files in each package:
+
+- `packages/agent0/.env.agent.example`
+- `packages/agent0/.env.app.example`
+- `packages/todo0/.env.example`
+
+## Install & build
+
+### 1. Install dependencies
```sh
-pnpm run start:mcp
+pnpm install
```
-### Start MCP Client (agent0)
+### 2. Approve builds & build
```sh
-pnpm run start:client0
+pnpm approve-builds
```
-## Environment Variables
+### 3. Init prisma client
-Each package has its own `.env.example` file. Copy it to `.env` and configure with your values.
+```sh
+pnpm init:prisma
+```
-### agent0 Environment Variables
+### 4. Build
-**MCP Client Configuration:**
+```sh
+pnpm build
+```
-- `ANTHROPIC_API_KEY`: Your Anthropic API key
-- `ANTHROPIC_MODEL`: Claude model to use (default: claude-3-5-sonnet-20241022)
+## 6. Make sure env files are correct
-**Okta OAuth Configuration:**
+If you used `pnpm run bootstrap:okta`, then your .env files are 99% ready to go.
-- `OKTA_DOMAIN`: Your Okta domain (e.g., dev-12345.okta.com)
-- `OKTA_CLIENT_ID`: OAuth client ID for user authentication
-- `OKTA_CLIENT_SECRET`: OAuth client secret
-- `OKTA_REDIRECT_URI`: OAuth callback URL
-- `MCP_SERVER_URL`: URL to the MCP server (default: )
+**Note** make sure that you update packages/agent0/.env.agent with either the anthropic keys and values or the aws bedrock keys and values so the agent can interact with an LLM. Comment out the set of key value pairs you are not using.
-### todo0 Environment Variables
+## Running the demo services
-**REST API Configuration:**
+### 7. Start todo0 MCP server
-- `PORT`: API server port (default: 5001)
-- `OKTA_ISSUER`: Okta issuer URL for JWT validation
-- `OKTA_CLIENT_ID`: OAuth client ID
-- `EXPECTED_AUDIENCE`: Expected audience in JWT validation (default: api://default)
+```sh
+pnpm run start:mcp
+```
-**MCP Server Configuration:**
+### 8. Start agent0 application
-- `TODO_API_BASE_URL`: Base URL for the todo API (default: )
-- `TODO_ACCESS_TOKEN`: Access token for todo API authentication
+```sh
+pnpm run start:agent0
+```
## Notes
diff --git a/package.json b/package.json
index ee6aa37..1ddd95d 100644
--- a/package.json
+++ b/package.json
@@ -6,21 +6,31 @@
"packages/*"
],
"devDependencies": {
+ "@okta/okta-sdk-nodejs": "^7.1.0",
"@types/body-parser": "^1.19.6",
"@types/express": "4.17.21",
"@types/express-session": "1.17.10",
"@types/node": "^20.19.19",
- "pnpm": "^8.0.0"
+ "@types/prompts": "^2.4.9",
+ "chalk": "^5.3.0",
+ "ora": "^6.3.1",
+ "pnpm": "^8.0.0",
+ "prompts": "^2.4.2",
+ "tsx": "^4.7.0",
+ "typescript": "^5.9.3"
},
"description": "This is a TypeScript Node.js monorepo managed with pnpm workspaces.",
"main": "index.js",
"scripts": {
- "test": "echo \"Error: no test specified\" && exit 1",
+ "init:prisma": "pnpm --filter todo0 run generate:client && pnpm --filter todo0 run migrate:dev",
+ "build": "pnpm -r run build",
"start:todo0": "pnpm --filter todo0 run start",
"start:mcp": "pnpm --filter todo0 run start:mcp",
"start:agent0": "pnpm --filter agent0 run start",
- "bootstrap": "pnpm --filter todo0 run generate:client && pnpm --filter todo0 run migrate:dev",
- "build": "pnpm -r run build"
+ "bootstrap:okta": "tsx scripts/bootstrap-okta-tenant.ts",
+ "validate:okta": "tsx scripts/validate-okta-config.ts",
+ "rollback:okta": "tsx scripts/rollback-okta-config.ts",
+ "typecheck:scripts": "tsc --project scripts/tsconfig.json --noEmit"
},
"keywords": [],
"author": "",
@@ -34,6 +44,7 @@
"express": "^5.1.0",
"express-session": "^1.18.2",
"id-assert-authz-grant-client": "*",
+ "jose": "^6.1.0",
"jsonwebtoken": "^9.0.2",
"mcp-auth": "^0.1.1"
}
diff --git a/packages/agent0/.env.agent.example b/packages/agent0/.env.agent.example
new file mode 100644
index 0000000..2253c63
--- /dev/null
+++ b/packages/agent0/.env.agent.example
@@ -0,0 +1,40 @@
+# ============================================================================
+# AGENT - MCP CLIENT CONFIGURATION
+# ============================================================================
+MCP_SERVER_URL=http://localhost:5002/mcp
+
+# ============================================================================
+# AGENT - LLM INTEGRATION CONFIGURATION
+# ============================================================================
+# Configure EITHER Anthropic Direct OR AWS Bedrock
+
+# Anthropic Direct
+ANTHROPIC_API_KEY=your_anthropic_api_key_here
+ANTHROPIC_MODEL=claude-3-5-sonnet-20241022
+
+# AWS Bedrock (alternative)
+# AWS_REGION=us-east-1
+# AWS_ACCESS_KEY_ID=your_aws_access_key
+# AWS_SECRET_ACCESS_KEY=your_aws_secret_key
+# AWS_SESSION_TOKEN=your_aws_session_token # Optional for temporary credentials
+# BEDROCK_MODEL_ID=us.anthropic.claude-3-5-sonnet-20241022-v2:0
+
+# ============================================================================
+# AGENT - CROSS-APP ACCESS (ID-JAG TOKEN EXCHANGE)
+# ============================================================================
+# Agent's identity in Okta (workload identity)
+OKTA_DOMAIN=your-domain.okta.com
+AI_AGENT_ID=your_agent_client_id_here
+
+# Path to private key file for JWT authentication (relative to agent0 package)
+AI_AGENT_PRIVATE_KEY_FILE=agent0-private-key.pem
+
+# Key ID for the private key
+AI_AGENT_PRIVATE_KEY_KID=your_key_id_here
+
+# MCP scopes to request
+AI_AGENT_TODO_MCP_SERVER_SCOPES_TO_REQUEST=mcp:connect mcp:tools:read mcp:tools:manage
+
+# MCP Authorization Server configuration
+MCP_AUTHORIZATION_SERVER=https://your-domain.okta.com/oauth2/your_mcp_auth_server_id
+MCP_AUTHORIZATION_SERVER_TOKEN_ENDPOINT=https://your-domain.okta.com/oauth2/your_mcp_auth_server_id/v1/token
diff --git a/packages/agent0/.env.app.example b/packages/agent0/.env.app.example
new file mode 100644
index 0000000..a123e5c
--- /dev/null
+++ b/packages/agent0/.env.app.example
@@ -0,0 +1,13 @@
+# ============================================================================
+# RESOURCE SERVER CONFIGURATION
+# ============================================================================
+PORT=3000
+SESSION_SECRET=your-session-secret-change-in-production
+
+# ============================================================================
+# RESOURCE SERVER - OKTA OAUTH (HUMAN SSO)
+# ============================================================================
+OKTA_DOMAIN=your-domain.okta.com
+OKTA_CLIENT_ID=your_client_id_here
+OKTA_CLIENT_SECRET=your_client_secret_here
+OKTA_REDIRECT_URI=http://localhost:3000/callback
diff --git a/packages/agent0/.env.example b/packages/agent0/.env.example
deleted file mode 100644
index 16722e6..0000000
--- a/packages/agent0/.env.example
+++ /dev/null
@@ -1,96 +0,0 @@
-# ============================================================================
-# RESOURCE SERVER CONFIGURATION
-# ============================================================================
-# These variables configure the Express server that handles HTTP requests,
-# serves the UI, and manages authentication endpoints.
-
-# [Resource Server] Port for the HTTP server
-# Default: 3000
-PORT=3000
-
-# [Resource Server] Secret key for session encryption
-# REQUIRED for production - use a long, random string
-# Default: 'default-secret-change-in-production' (insecure)
-SESSION_SECRET=your_session_secret_here
-
-# ============================================================================
-# AGENT - MCP CLIENT CONFIGURATION
-# ============================================================================
-# These variables configure the Agent's MCP client connection to the todo0
-# MCP server.
-
-# [Agent] URL to the MCP server (running in todo0 package)
-# Default: http://localhost:3001
-MCP_SERVER_URL=http://localhost:3001/sse
-
-# ============================================================================
-# AGENT - LLM INTEGRATION CONFIGURATION
-# ============================================================================
-# These variables configure the Agent's integration with Anthropic's Claude AI.
-
-# [Agent] Your Anthropic API key for Claude
-# REQUIRED for AI chat functionality
-# Get yours at: https://console.anthropic.com/
-ANTHROPIC_API_KEY=your_anthropic_api_key_here
-
-# [Agent] Claude model to use
-# Default: claude-3-5-sonnet-20241022
-ANTHROPIC_MODEL=claude-3-5-sonnet-20241022
-
-# ============================================================================
-# RESOURCE SERVER - OKTA OAUTH (HUMAN SSO)
-# ============================================================================
-# These variables configure user authentication via Okta OAuth for the
-# Resource Server. This is used for human users logging into the web UI.
-
-# [Resource Server] Your Okta domain (e.g., dev-12345.okta.com)
-# REQUIRED for user authentication
-OKTA_DOMAIN=your_okta_domain
-
-# [Resource Server] OAuth client ID for user authentication
-# REQUIRED for user authentication
-OKTA_CLIENT_ID=your_okta_client_id
-
-# [Resource Server] OAuth client secret
-# REQUIRED for user authentication
-OKTA_CLIENT_SECRET=your_okta_client_secret
-
-# [Resource Server] OAuth callback URL
-# Default: http://localhost:{PORT}/callback
-# Must match the redirect URI configured in your Okta application
-OKTA_REDIRECT_URI=http://localhost:3000/callback
-
-# ============================================================================
-# AGENT - CROSS-APP ACCESS (ID-JAG TOKEN EXCHANGE)
-# ============================================================================
-# These variables configure the Agent's ability to exchange user ID tokens
-# for service tokens to access the todo0 API on behalf of users.
-# This implements the ID-JAG (ID Assertion JWT Authorization Grant) flow.
-
-# [Agent] Client ID for the AI agent service account (workload identity)
-# REQUIRED for cross-app access token exchange
-# This is the agent's identity in Okta, separate from the OAuth client
-AI_AGENT_ID=your_ai_agent_client_id
-
-# [Agent] Path to private key file for JWT authentication
-# REQUIRED for cross-app access token exchange
-# Relative to the agent0 package directory
-# Example: keys/private-key.pem
-OKTA_CC_PRIVATE_KEY_FILE=path/to/private-key.pem
-
-# [Agent] Target audience for service tokens
-# REQUIRED for cross-app access token exchange
-# This is typically the API identifier for the todo0 service
-# Default: api://default
-TARGET_SERVICE_AUDIENCE=api://default
-
-# [Agent] Okta token endpoint for ID-JAG exchange (Step 1)
-# REQUIRED for cross-app access token exchange
-# Format: https://{OKTA_DOMAIN}/oauth2/v1/token
-OKTA_TOKEN_ENDPOINT=https://your_okta_domain/oauth2/v1/token
-
-# [Agent] Resource authorization server token endpoint (Step 2)
-# OPTIONAL - defaults to Okta Custom AS if not set
-# Format: https://{OKTA_DOMAIN}/oauth2/default/v1/token
-# Default: https://{OKTA_DOMAIN}/oauth2/default/v1/token
-RESOURCE_TOKEN_ENDPOINT=https://your_okta_domain/oauth2/default/v1/token
diff --git a/packages/agent0/src/agent.ts b/packages/agent0/src/agent.ts
index d43621d..88a56bb 100644
--- a/packages/agent0/src/agent.ts
+++ b/packages/agent0/src/agent.ts
@@ -1,15 +1,150 @@
// agent.ts - Agent Identity: MCP Client + LLM Integration
import path from 'path';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
-import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
+import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import Anthropic from '@anthropic-ai/sdk';
import { BedrockRuntimeClient, InvokeModelCommand } from '@aws-sdk/client-bedrock-runtime';
-import { TokenExchangeHandler, createTokenExchangeConfig } from './auth/token-exchange.js';
+import { TokenExchangeHandler, TokenExchangeConfig } from './auth/token-exchange.js';
import { Request } from 'express';
import * as dotenv from 'dotenv';
-// Load environment variables
-dotenv.config({ path: path.resolve(__dirname, '../.env') });
+// Load environment variables for agent
+dotenv.config({ path: path.resolve(__dirname, '../.env.agent') });
+
+// ============================================================================
+// Agent LLM Configuration Types
+// ============================================================================
+
+/**
+ * Agent LLM configuration (discriminated union for Anthropic vs Bedrock)
+ */
+type AgentLLMConfig = {
+ mcpServerUrl: string;
+} & (
+ | {
+ llmProvider: 'anthropic';
+ anthropicApiKey: string;
+ anthropicModel: string;
+ }
+ | {
+ llmProvider: 'bedrock';
+ awsRegion: string;
+ awsAccessKeyId: string;
+ awsSecretAccessKey: string;
+ awsSessionToken?: string; // Optional
+ bedrockModelId: string;
+ }
+);
+
+// ============================================================================
+// Environment Validation Function
+// ============================================================================
+
+/**
+ * Validate agent LLM environment variables and return typed configuration
+ */
+function validateAgentLLMEnv(): AgentLLMConfig {
+ const missing: string[] = [];
+ const invalid: string[] = [];
+
+ // Check MCP_SERVER_URL
+ if (!process.env.MCP_SERVER_URL || process.env.MCP_SERVER_URL.trim() === '') {
+ missing.push('MCP_SERVER_URL');
+ } else {
+ try {
+ new URL(process.env.MCP_SERVER_URL);
+ } catch {
+ invalid.push('MCP_SERVER_URL (invalid URL format)');
+ }
+ }
+
+ // Detect which LLM provider is being configured
+ const hasAnthropicKey = process.env.ANTHROPIC_API_KEY && process.env.ANTHROPIC_API_KEY.trim() !== '';
+ const hasBedrockVars = (process.env.AWS_REGION && process.env.AWS_REGION.trim() !== '') ||
+ (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_ACCESS_KEY_ID.trim() !== '');
+
+ // Error if both providers are configured
+ if (hasAnthropicKey && hasBedrockVars) {
+ console.error('❌ Environment configuration error in .env.agent');
+ console.error(' Cannot configure both Anthropic and AWS Bedrock providers');
+ console.error(' Please choose one LLM provider:');
+ console.error(' - For Anthropic: Set ANTHROPIC_API_KEY and ANTHROPIC_MODEL');
+ console.error(' - For Bedrock: Set AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, BEDROCK_MODEL_ID');
+ process.exit(1);
+ }
+
+ // Error if neither provider is configured
+ if (!hasAnthropicKey && !hasBedrockVars) {
+ console.error('❌ Environment configuration error in .env.agent');
+ console.error(' No LLM provider configured');
+ console.error(' Please configure one LLM provider:');
+ console.error(' - For Anthropic: Set ANTHROPIC_API_KEY and ANTHROPIC_MODEL');
+ console.error(' - For Bedrock: Set AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, BEDROCK_MODEL_ID');
+ process.exit(1);
+ }
+
+ // Validate Anthropic configuration
+ if (hasAnthropicKey) {
+ if (!process.env.ANTHROPIC_MODEL || process.env.ANTHROPIC_MODEL.trim() === '') {
+ missing.push('ANTHROPIC_MODEL');
+ }
+ }
+
+ // Validate Bedrock configuration
+ if (hasBedrockVars) {
+ if (!process.env.AWS_REGION || process.env.AWS_REGION.trim() === '') {
+ missing.push('AWS_REGION');
+ }
+ if (!process.env.AWS_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID.trim() === '') {
+ missing.push('AWS_ACCESS_KEY_ID');
+ }
+ if (!process.env.AWS_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY.trim() === '') {
+ missing.push('AWS_SECRET_ACCESS_KEY');
+ }
+ if (!process.env.BEDROCK_MODEL_ID || process.env.BEDROCK_MODEL_ID.trim() === '') {
+ missing.push('BEDROCK_MODEL_ID');
+ }
+ // AWS_SESSION_TOKEN is optional, don't validate
+ }
+
+ // Report errors and exit if validation fails
+ if (missing.length > 0 || invalid.length > 0) {
+ console.error('❌ Environment configuration error in .env.agent');
+ if (missing.length > 0) {
+ console.error(' Missing required variables:', missing.join(', '));
+ }
+ if (invalid.length > 0) {
+ console.error(' Invalid variables:', invalid.join(', '));
+ }
+ console.error(' Check packages/agent0/.env.agent file');
+ process.exit(1);
+ }
+
+ console.log('✅ Agent LLM environment variables validated');
+
+ // Return properly typed discriminated union
+ if (hasAnthropicKey) {
+ return {
+ mcpServerUrl: process.env.MCP_SERVER_URL!,
+ llmProvider: 'anthropic',
+ anthropicApiKey: process.env.ANTHROPIC_API_KEY!,
+ anthropicModel: process.env.ANTHROPIC_MODEL!,
+ };
+ } else {
+ return {
+ mcpServerUrl: process.env.MCP_SERVER_URL!,
+ llmProvider: 'bedrock',
+ awsRegion: process.env.AWS_REGION!,
+ awsAccessKeyId: process.env.AWS_ACCESS_KEY_ID!,
+ awsSecretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
+ awsSessionToken: process.env.AWS_SESSION_TOKEN,
+ bedrockModelId: process.env.BEDROCK_MODEL_ID!,
+ };
+ }
+}
+
+// Validate and get typed LLM configuration
+const llmConfig = validateAgentLLMEnv();
// ============================================================================
// Agent Configuration
@@ -24,6 +159,9 @@ export interface AgentConfig {
userContext: UserContext;
idToken: string;
+ // Token Exchange Config
+ tokenExchange?: TokenExchangeConfig;
+
// Anthropic Direct
anthropicApiKey?: string;
anthropicModel?: string;
@@ -42,22 +180,54 @@ export interface UserContext {
sub: string;
}
-const agentConfig: Omit = {
- mcpServerUrl: process.env.MCP_SERVER_URL || 'http://localhost:5002',
- name: 'agent0',
- version: '1.0.0',
- // Anthropic Direct
- anthropicApiKey: process.env.ANTHROPIC_API_KEY,
- anthropicModel: process.env.ANTHROPIC_MODEL || 'claude-3-5-sonnet-20241022',
- // AWS Bedrock
- awsRegion: process.env.AWS_REGION,
- awsAccessKeyId: process.env.AWS_ACCESS_KEY_ID,
- awsSecretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
- awsSessionToken: process.env.AWS_SESSION_TOKEN,
- bedrockModelId: process.env.BEDROCK_MODEL_ID || 'us.anthropic.claude-3-5-sonnet-20241022-v2:0',
- enableLLM: true,
+// Build TokenExchangeConfig from environment variables
+const buildTokenExchangeConfig = (): TokenExchangeConfig | undefined => {
+ const mcpAuthServer = process.env.MCP_AUTHORIZATION_SERVER;
+ const mcpAuthServerTokenEndpoint = process.env.MCP_AUTHORIZATION_SERVER_TOKEN_ENDPOINT;
+ const oktaDomain = process.env.OKTA_DOMAIN;
+ const agentId = process.env.AI_AGENT_ID;
+ const privateKeyFile = process.env.AI_AGENT_PRIVATE_KEY_FILE;
+ const privateKeyKid = process.env.AI_AGENT_PRIVATE_KEY_KID;
+ const agentScopes = process.env.AI_AGENT_TODO_MCP_SERVER_SCOPES_TO_REQUEST;
+
+ if (mcpAuthServer && mcpAuthServerTokenEndpoint && oktaDomain && agentId && privateKeyFile && privateKeyKid && agentScopes) {
+ return {
+ authorizationServer: mcpAuthServer,
+ authorizationServerTokenEndpoint: mcpAuthServerTokenEndpoint,
+ oktaDomain,
+ clientId: agentId,
+ privateKeyFile,
+ privateKeyKid,
+ agentScopes,
+ };
+ }
+ return undefined;
};
+// Build agentConfig using validated LLM configuration
+const agentConfig: Omit = llmConfig.llmProvider === 'anthropic'
+ ? {
+ mcpServerUrl: llmConfig.mcpServerUrl,
+ name: 'agent0',
+ version: '1.0.0',
+ tokenExchange: buildTokenExchangeConfig(),
+ anthropicApiKey: llmConfig.anthropicApiKey,
+ anthropicModel: llmConfig.anthropicModel,
+ enableLLM: true,
+ }
+ : {
+ mcpServerUrl: llmConfig.mcpServerUrl,
+ name: 'agent0',
+ version: '1.0.0',
+ tokenExchange: buildTokenExchangeConfig(),
+ awsRegion: llmConfig.awsRegion,
+ awsAccessKeyId: llmConfig.awsAccessKeyId,
+ awsSecretAccessKey: llmConfig.awsSecretAccessKey,
+ awsSessionToken: llmConfig.awsSessionToken,
+ bedrockModelId: llmConfig.bedrockModelId,
+ enableLLM: true,
+ };
+
export function getAgentForUserContext(idToken: string, userContext: UserContext): Agent {
return new Agent({
...agentConfig,
@@ -109,7 +279,7 @@ export async function disconnectAll(): Promise {
export class Agent {
private client: Client;
- private transport: SSEClientTransport | null = null;
+ private transport: StreamableHTTPClientTransport | null = null;
private config: AgentConfig;
private isConnected = false;
private availableTools: any[] = [];
@@ -119,7 +289,6 @@ export class Agent {
role: 'user' | 'assistant';
content: string | Array;
}> = [];
- private accessToken: string | null = null;
private tokenExchangeHandler: TokenExchangeHandler | null = null;
constructor(config: AgentConfig) {
@@ -136,10 +305,9 @@ export class Agent {
}
);
- // Initialize Token Exchange if configured
- const tokenExchangeConfig = createTokenExchangeConfig();
- if (tokenExchangeConfig) {
- this.tokenExchangeHandler = new TokenExchangeHandler(tokenExchangeConfig);
+ // Initialize Token Exchange Handler if configured
+ if (config.tokenExchange) {
+ this.tokenExchangeHandler = new TokenExchangeHandler(config.tokenExchange);
}
// Initialize LLM client - Priority: Anthropic Direct > AWS Bedrock
@@ -178,30 +346,36 @@ export class Agent {
return false;
}
- if (!this.accessToken) {
- console.warn('⚠️ No access token set. MCP connection not yet viable. Need to perform a token exchange first.');
- const token = await this.tokenExchangeHandler?.exchangeToken(this.config.idToken);
- console.log(' ✅ Got an access token for the MCP server via XAA token exchange.');
- if (token && token.access_token) {
- this.setAccessToken(token.access_token);
- } else {
- console.error('❌ Token exchange failed. Cannot connect to MCP server without access token.');
- return false;
- }
+ if (!this.tokenExchangeHandler) {
+ console.error('❌ Token exchange not configured. Cannot connect to MCP server.');
+ return false;
}
try {
console.log('🔌 Connecting to MCP server...');
console.log(` Server: ${this.config.mcpServerUrl}`);
+ console.log(' Performing token exchange: ID Token → ID-JAG → MCP Access Token');
+
+ // Perform token exchange to get MCP access token
+ const tokenResult = await this.tokenExchangeHandler.exchangeToken(this.config.idToken);
+
+ if (!tokenResult.success || !tokenResult.access_token) {
+ throw new Error('Token exchange failed or did not return access token');
+ }
+
+ console.log('✅ Token exchange successful');
+ console.log(`⏰ Token expires in: ${tokenResult.expires_in}s`);
- this.transport = new SSEClientTransport(
- new URL(`${this.config.mcpServerUrl}/sse`),
+ // Create transport with access token in Authorization header
+ this.transport = new StreamableHTTPClientTransport(
+ new URL(this.config.mcpServerUrl),
{
requestInit: {
headers: {
- 'Authorization': `Bearer ${this.accessToken || ''}`,
+ 'Authorization': `Bearer ${tokenResult.access_token}`
}
- }
+ },
+
}
);
@@ -279,21 +453,16 @@ export class Agent {
}
// ============================================================================
- // Access Token Management
+ // Auth Provider Management
// ============================================================================
- setAccessToken(token: string): void {
- this.accessToken = token;
- console.log('🔑 Access token set for MCP tool calls');
- }
-
- clearAccessToken(): void {
- this.accessToken = null;
- console.log('🔓 Access token cleared');
- }
-
- hasAccessToken(): boolean {
- return this.accessToken !== null;
+ /**
+ * Update the ID token (useful when session is refreshed)
+ * Note: Will need to reconnect to MCP server with new token
+ */
+ updateIdToken(newIdToken: string): void {
+ this.config.idToken = newIdToken;
+ console.log('🔑 ID token updated - reconnect to MCP server for new access token');
}
getAvailableTools(): any[] {
diff --git a/packages/agent0/src/resource-server.ts b/packages/agent0/src/app.ts
similarity index 63%
rename from packages/agent0/src/resource-server.ts
rename to packages/agent0/src/app.ts
index 7ca099d..3771f1c 100644
--- a/packages/agent0/src/resource-server.ts
+++ b/packages/agent0/src/app.ts
@@ -1,50 +1,155 @@
-// resource-server.ts - Agent0 Resource Server (Express)
+// app.ts - Agent0 App Server (Express)
import express, { Request, Response } from 'express';
import * as path from 'path';
import cookieParser from 'cookie-parser';
-import { getAgentForSession, UserContext } from './agent.js';
+import { getAgentForSession } from './agent.js';
import { OktaAuthHelper, OktaConfig, createSessionMiddleware } from './auth/okta-auth.js';
-import { TokenExchangeHandler, TokenExchangeConfig, createTokenExchangeConfig } from './auth/token-exchange.js';
// ============================================================================
-// Resource Server Configuration
+// App Server Configuration Types
// ============================================================================
-export interface ResourceServerConfig {
+/**
+ * App server configuration (discriminated union for optional Okta)
+ */
+type AppServerConfig = {
+ port: number;
+ sessionSecret: string;
+} & (
+ | {
+ hasOkta: true;
+ oktaDomain: string;
+ oktaClientId: string;
+ oktaClientSecret: string;
+ oktaRedirectUri: string;
+ }
+ | {
+ hasOkta: false;
+ }
+);
+
+/**
+ * Internal configuration after processing
+ */
+interface AppServerInternalConfig {
port: number;
sessionSecret: string;
okta?: OktaConfig;
}
// ============================================================================
-// Resource Server Class
+// Environment Validation Function
// ============================================================================
-export class ResourceServer {
+/**
+ * Validate app server environment variables and return typed configuration
+ */
+function validateAppServerEnv(): AppServerConfig {
+ const missing: string[] = [];
+ const invalid: string[] = [];
+
+ // Check required variables
+ if (!process.env.PORT || process.env.PORT.trim() === '') {
+ missing.push('PORT');
+ }
+ if (!process.env.SESSION_SECRET || process.env.SESSION_SECRET.trim() === '') {
+ missing.push('SESSION_SECRET');
+ }
+
+ // Check optional Okta configuration (all or none)
+ const oktaDomain = process.env.OKTA_DOMAIN;
+ const oktaClientId = process.env.OKTA_CLIENT_ID;
+ const oktaClientSecret = process.env.OKTA_CLIENT_SECRET;
+ const oktaRedirectUri = process.env.OKTA_REDIRECT_URI;
+
+ const oktaVarsSet = [oktaDomain, oktaClientId, oktaClientSecret, oktaRedirectUri].filter(v => v && v.trim() !== '');
+ const hasPartialOkta = oktaVarsSet.length > 0 && oktaVarsSet.length < 4;
+
+ if (hasPartialOkta) {
+ if (!oktaDomain || oktaDomain.trim() === '') missing.push('OKTA_DOMAIN');
+ if (!oktaClientId || oktaClientId.trim() === '') missing.push('OKTA_CLIENT_ID');
+ if (!oktaClientSecret || oktaClientSecret.trim() === '') missing.push('OKTA_CLIENT_SECRET');
+ if (!oktaRedirectUri || oktaRedirectUri.trim() === '') missing.push('OKTA_REDIRECT_URI');
+ }
+
+ // Report errors and exit if validation fails
+ if (missing.length > 0 || invalid.length > 0) {
+ console.error('❌ Environment configuration error in .env.app');
+ if (missing.length > 0) {
+ console.error(' Missing required variables:', missing.join(', '));
+ }
+ if (invalid.length > 0) {
+ console.error(' Invalid variables:', invalid.join(', '));
+ }
+ console.error(' Check packages/agent0/.env.app file');
+ console.error(' Note: Okta variables must be all present or all absent');
+ process.exit(1);
+ }
+
+ console.log('✅ App server environment variables validated');
+
+ const baseConfig = {
+ port: parseInt(process.env.PORT!, 10),
+ sessionSecret: process.env.SESSION_SECRET!,
+ };
+
+ // Return discriminated union based on Okta configuration
+ if (oktaVarsSet.length === 4) {
+ return {
+ ...baseConfig,
+ hasOkta: true,
+ oktaDomain: oktaDomain!,
+ oktaClientId: oktaClientId!,
+ oktaClientSecret: oktaClientSecret!,
+ oktaRedirectUri: oktaRedirectUri!,
+ };
+ } else {
+ return {
+ ...baseConfig,
+ hasOkta: false,
+ };
+ }
+}
+
+// ============================================================================
+// App Server Class
+// ============================================================================
+
+export class AppServer {
private app: express.Application;
- private config: ResourceServerConfig;
+ private config: AppServerInternalConfig;
private oktaAuthHelper: OktaAuthHelper | null = null;
- private tokenExchangeHandler: TokenExchangeHandler | null = null;
- constructor(config: ResourceServerConfig) {
- this.config = config;
+ constructor() {
+ // Validate environment and get typed config
+ const envConfig = validateAppServerEnv();
+
+ this.config = {
+ port: envConfig.port,
+ sessionSecret: envConfig.sessionSecret,
+ };
+
this.app = express();
// Initialize Okta Auth if configured
- if (this.config.okta) {
+ if (envConfig.hasOkta) {
+ this.config.okta = {
+ domain: envConfig.oktaDomain,
+ clientId: envConfig.oktaClientId,
+ clientSecret: envConfig.oktaClientSecret,
+ redirectUri: envConfig.oktaRedirectUri,
+ };
this.oktaAuthHelper = new OktaAuthHelper(this.config.okta);
}
- // Initialize Token Exchange if configured
- const tokenExchangeConfig = createTokenExchangeConfig();
- if (tokenExchangeConfig) {
- this.tokenExchangeHandler = new TokenExchangeHandler(tokenExchangeConfig);
- }
-
this.setupMiddleware();
this.setupRoutes();
}
+ public getPort(): number {
+ return this.config.port;
+ }
+
// ============================================================================
// Middleware Setup
// ============================================================================
@@ -121,19 +226,6 @@ export class ResourceServer {
this.app.get('/auth/user', this.oktaAuthHelper.requireAuth(), (req, res) => {
this.oktaAuthHelper!.handleUserInfo(req, res);
});
-
- // Cross-app access: Exchange ID token for ID-JAG token
- if (this.tokenExchangeHandler) {
- this.app.post('/cross-app-access', this.oktaAuthHelper.requireAuth(), async (req, res) => {
- // Wrap the original response to intercept the access token
- const originalJson = res.json.bind(res);
- res.json = (body: any) => {
- return originalJson(body);
- };
-
- await this.tokenExchangeHandler!.handleCrossAppAccess(req, res);
- });
- }
}
// ============================================================================
@@ -189,7 +281,7 @@ export class ResourceServer {
private handleHealth(_req: Request, res: Response): void {
res.json({
status: 'ok',
- service: 'agent0 Resource Server',
+ service: 'agent0 App Server',
oktaEnabled: this.oktaAuthHelper ? true : false,
llmEnabled: true,
timestamp: new Date().toISOString(),
@@ -204,7 +296,7 @@ export class ResourceServer {
return new Promise((resolve) => {
this.app.listen(this.config.port, () => {
console.log('='.repeat(60));
- console.log('🚀 Agent0 Resource Server');
+ console.log('🚀 Agent0 App Server');
console.log('='.repeat(60));
console.log(`✓ Server running on http://localhost:${this.config.port}`);
console.log(`✓ Health check: http://localhost:${this.config.port}/health`);
@@ -218,7 +310,6 @@ export class ResourceServer {
console.log(` - Okta Domain: ${this.config.okta.domain}`);
console.log(` - Login URL: http://localhost:${this.config.port}/login`);
}
- console.log(` - Token Exchange: ${this.tokenExchangeHandler ? '✅ Configured' : '❌ Not Configured'}`);
console.log('='.repeat(60));
console.log('Ready! 🎉');
console.log('');
diff --git a/packages/agent0/src/auth/token-exchange.ts b/packages/agent0/src/auth/token-exchange.ts
index a24a636..d75db92 100644
--- a/packages/agent0/src/auth/token-exchange.ts
+++ b/packages/agent0/src/auth/token-exchange.ts
@@ -14,9 +14,9 @@ export interface TokenExchangeConfig {
clientId: string;
privateKeyFile: string;
privateKeyKid: string;
- targetAudience: string;
- tokenEndpoint: string;
- resourceTokenEndpoint?: string;
+ authorizationServer: string;
+ authorizationServerTokenEndpoint: string;
+ agentScopes: string;
}
// ============================================================================
@@ -82,14 +82,13 @@ export class TokenExchangeHandler {
formData.append('requested_token_type', 'urn:ietf:params:oauth:token-type:id-jag');
formData.append('subject_token', idToken);
formData.append('subject_token_type', 'urn:ietf:params:oauth:token-type:id_token');
- formData.append('audience', this.config.targetAudience);
- // formData.append('client_id', this.config.clientId);
- formData.append('scope', 'read:todo0');
+ formData.append('audience', this.config.authorizationServer);
+ formData.append('scope', this.config.agentScopes);
formData.append('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
formData.append('client_assertion', clientAssertion);
console.log(`🔄 Step 1: Exchanging ID token for ID-JAG token...`);
- console.log(`📍 Audience: ${this.config.targetAudience}`);
+ console.log(`📍 Audience: ${this.config.authorizationServer}`);
console.log(`🆔 Client ID: ${this.config.clientId}`);
const response = await axios.post(
@@ -118,13 +117,13 @@ export class TokenExchangeHandler {
expires_in: number;
scope?: string;
}> {
- const resourceTokenEndpoint = this.config.resourceTokenEndpoint ||
- `https://${this.config.oktaDomain}/oauth2/default/v1/token`;
+ const authorizationServer = this.config.authorizationServer;
+ const authorizationServerTokenEndpoint = this.config.authorizationServerTokenEndpoint;
console.log(`🔄 Step 2: Exchanging ID-JAG for Access Token at Resource Server...`);
- console.log(`📍 Resource Token Endpoint: ${resourceTokenEndpoint}`);
+ console.log(`📍 MCP authorization server: ${authorizationServer}`);
- const clientAssertion = this.createClientAssertion(resourceTokenEndpoint);
+ const clientAssertion = this.createClientAssertion(authorizationServerTokenEndpoint);
const resourceTokenForm = new URLSearchParams();
resourceTokenForm.append('grant_type', 'urn:ietf:params:oauth:grant-type:jwt-bearer');
@@ -133,7 +132,7 @@ export class TokenExchangeHandler {
resourceTokenForm.append('client_assertion', clientAssertion);
const response = await axios.post(
- resourceTokenEndpoint,
+ authorizationServerTokenEndpoint,
resourceTokenForm,
{
headers: {
@@ -251,31 +250,3 @@ export class TokenExchangeHandler {
}
}
-// ============================================================================
-// Configuration Helper
-// ============================================================================
-
-export function createTokenExchangeConfig(): TokenExchangeConfig | null {
- const targetAudience = process.env.TARGET_SERVICE_AUDIENCE;
- const tokenEndpoint = process.env.OKTA_TOKEN_ENDPOINT;
- const clientId = process.env.AI_AGENT_ID;
- const oktaDomain = process.env.OKTA_DOMAIN;
- const privateKeyFile = process.env.OKTA_CC_PRIVATE_KEY_FILE;
- const privateKeyKid = process.env.OKTA_PRIVATE_KEY_KID;
-
- if (!targetAudience || !tokenEndpoint || !clientId || !oktaDomain || !privateKeyFile || !privateKeyKid) {
- console.warn('⚠️ Cross-app access not fully configured. Missing required environment variables.');
- console.warn(' Required: TARGET_SERVICE_AUDIENCE, OKTA_TOKEN_ENDPOINT, AI_AGENT_ID, OKTA_DOMAIN, OKTA_CC_PRIVATE_KEY_FILE, OKTA_PRIVATE_KEY_KID');
- return null;
- }
-
- return {
- oktaDomain,
- clientId,
- privateKeyFile,
- privateKeyKid,
- targetAudience,
- tokenEndpoint,
- resourceTokenEndpoint: process.env.RESOURCE_TOKEN_ENDPOINT,
- };
-}
diff --git a/packages/agent0/src/index.ts b/packages/agent0/src/index.ts
index f4fa007..48f7841 100644
--- a/packages/agent0/src/index.ts
+++ b/packages/agent0/src/index.ts
@@ -2,11 +2,10 @@
import path from 'path';
import * as dotenv from 'dotenv';
import { disconnectAll } from './agent.js';
-import { ResourceServer, ResourceServerConfig } from './resource-server.js';
-import { OktaConfig } from './auth/okta-auth.js';
+import { AppServer } from './app.js';
-// Load environment variables
-dotenv.config({ path: path.resolve(__dirname, '../.env') });
+// Load environment variables for app server
+dotenv.config({ path: path.resolve(__dirname, '../.env.app') });
// ============================================================================
// Main Bootstrap Function
@@ -15,34 +14,14 @@ dotenv.config({ path: path.resolve(__dirname, '../.env') });
async function bootstrap(): Promise {
console.log('🚀 Starting Agent0...\n');
- // ============================================================================
- // 2. Configure and Start Resource Server
- // ============================================================================
-
- const port = parseInt(process.env.PORT || '3000', 10);
- const sessionSecret = process.env.SESSION_SECRET || 'default-secret-change-in-production';
-
- const resourceServerConfig: ResourceServerConfig = {
- port,
- sessionSecret,
- };
-
- // Add Okta configuration if environment variables are set
- if (process.env.OKTA_DOMAIN && process.env.OKTA_CLIENT_ID && process.env.OKTA_CLIENT_SECRET) {
- resourceServerConfig.okta = {
- domain: process.env.OKTA_DOMAIN,
- clientId: process.env.OKTA_CLIENT_ID,
- clientSecret: process.env.OKTA_CLIENT_SECRET,
- redirectUri: process.env.OKTA_REDIRECT_URI || `http://localhost:${port}/callback`,
- } as OktaConfig;
- }
-
- const resourceServer = new ResourceServer(resourceServerConfig);
+ // App server validates its own environment internally
+ const appServer = new AppServer();
- // Start the resource server
- await resourceServer.start();
+ // Start the app server
+ await appServer.start();
// Open browser to the UI
+ const port = appServer.getPort();
try {
const open = (await (0, eval)("import('open')")).default;
console.log(`✅ Opening browser at http://localhost:${port}`);
@@ -52,7 +31,7 @@ async function bootstrap(): Promise {
}
// ============================================================================
- // 3. Handle Graceful Shutdown
+ // Handle Graceful Shutdown
// ============================================================================
process.on('SIGINT', async () => {
@@ -80,7 +59,4 @@ if (require.main === module) {
}
// Export for programmatic use
-export {
- ResourceServer,
- ResourceServerConfig
-};
+export { AppServer };
diff --git a/packages/todo0/.env.app.example b/packages/todo0/.env.app.example
new file mode 100644
index 0000000..0a3c771
--- /dev/null
+++ b/packages/todo0/.env.app.example
@@ -0,0 +1,19 @@
+# ============================================================================
+# TODO0 APP SERVER CONFIGURATION
+# ============================================================================
+PORT=5001
+
+# ============================================================================
+# TODO0 APP - OKTA OAUTH (HUMAN SSO)
+# ============================================================================
+OKTA_ISSUER=https://your-domain.okta.com/oauth2/default
+OKTA_CLIENT_ID=your_client_id_here
+OKTA_CLIENT_SECRET=your_client_secret_here
+OKTA_REDIRECT_URI=http://localhost:5001/callback
+EXPECTED_AUDIENCE=api://todo0
+
+# ============================================================================
+# DATABASE CONFIGURATION
+# ============================================================================
+# Database connection configured in prisma/schema.prisma
+# Default: SQLite with file ./dev.db
diff --git a/packages/todo0/.env.example b/packages/todo0/.env.example
deleted file mode 100644
index 342bc24..0000000
--- a/packages/todo0/.env.example
+++ /dev/null
@@ -1,64 +0,0 @@
-# ============================================================================
-# REST API SERVER CONFIGURATION
-# ============================================================================
-# These variables configure the Express REST API server that handles
-# todo CRUD operations with Prisma database access.
-
-# [REST API] Port for the REST API server
-# Default: 5001
-PORT=5001
-
-# ============================================================================
-# REST API - OKTA JWT AUTHENTICATION
-# ============================================================================
-# These variables configure JWT validation for protecting the REST API
-# endpoints. The REST API validates tokens issued by Okta Custom AS.
-
-# [REST API] Okta issuer URL for JWT validation
-# REQUIRED for API authentication
-# Format: https://{OKTA_DOMAIN}/oauth2/default
-# This should point to your Okta Custom Authorization Server
-OKTA_ISSUER=https://your_okta_domain/oauth2/default
-
-# [REST API] OAuth client ID
-# REQUIRED for JWT validation
-# Used to validate the client_id claim in access tokens
-OKTA_CLIENT_ID=your_okta_client_id
-
-# [REST API] Expected audience in JWT validation
-# REQUIRED for API protection
-# Default: api://default
-# This validates the aud (audience) claim in access tokens
-EXPECTED_AUDIENCE=api://default
-
-# ============================================================================
-# MCP SERVER CONFIGURATION
-# ============================================================================
-# These variables configure the MCP (Model Context Protocol) server that
-# provides tools for the agent0 AI agent to manage todos.
-# The MCP server acts as a bridge between the AI agent and the REST API.
-
-# [MCP Server] Port for the MCP server
-# Default: 3001
-# NOTE: This is different from the REST API port (5001)
-# The MCP server runs separately and exposes tools over SSE
-MCP_PORT=3001
-
-# [MCP Server] Base URL for the todo REST API
-# Default: http://localhost:5001
-# The MCP server makes internal API calls to this URL
-TODO_API_BASE_URL=http://localhost:5001
-
-# NOTE: Access tokens are NOT configured here
-# The MCP server receives access tokens dynamically from agent0 through the
-# MCP protocol on each tool call. Agent0 obtains tokens via the cross-app
-# access flow (ID-JAG) and includes them in the MCP request metadata.
-# This ensures proper per-user, per-request token handling without storing
-# tokens as static configuration.
-
-# ============================================================================
-# DATABASE CONFIGURATION
-# ============================================================================
-# Database connection is configured in prisma/schema.prisma
-# Run 'pnpm run generate:schema' to generate Prisma client
-# The default configuration uses SQLite with file: ./dev.db
diff --git a/packages/todo0/.env.mcp.example b/packages/todo0/.env.mcp.example
new file mode 100644
index 0000000..c92432a
--- /dev/null
+++ b/packages/todo0/.env.mcp.example
@@ -0,0 +1,16 @@
+# ============================================================================
+# MCP SERVER CONFIGURATION
+# ============================================================================
+MCP_PORT=5002
+
+# ============================================================================
+# MCP SERVER - OKTA JWT AUTHENTICATION
+# ============================================================================
+MCP_OKTA_ISSUER=https://your-domain.okta.com/oauth2/your_mcp_auth_server_id
+MCP_EXPECTED_AUDIENCE=mcp://todo0
+
+# ============================================================================
+# DATABASE CONFIGURATION
+# ============================================================================
+# Database connection configured in prisma/schema.prisma
+# Default: SQLite with file ./dev.db
diff --git a/packages/todo0/openapi.yaml b/packages/todo0/openapi.yaml
index e776d1c..5eb31d2 100644
--- a/packages/todo0/openapi.yaml
+++ b/packages/todo0/openapi.yaml
@@ -4,7 +4,7 @@ info:
version: 1.0.0
description: API for managing todos with session or Bearer token authentication
servers:
- - url: http://localhost:3001
+ - url: http://localhost:5001
paths:
/todos:
get:
diff --git a/packages/todo0/package.json b/packages/todo0/package.json
index b927e6a..22208d8 100644
--- a/packages/todo0/package.json
+++ b/packages/todo0/package.json
@@ -1,11 +1,11 @@
{
"name": "todo0",
"version": "1.0.0",
- "main": "dist/index.js",
- "types": "dist/index.d.ts",
+ "main": "dist/app-server.js",
+ "types": "dist/app-server.d.ts",
"scripts": {
"build": "tsc",
- "start": "node dist/index.js",
+ "start": "node dist/app-server.js",
"start:mcp": "node dist/mcp-server.js",
"dev:mcp": "tsc && node dist/mcp-server.js",
"generate:client": "prisma generate",
diff --git a/packages/todo0/src/app-server.ts b/packages/todo0/src/app-server.ts
new file mode 100644
index 0000000..46b1440
--- /dev/null
+++ b/packages/todo0/src/app-server.ts
@@ -0,0 +1,155 @@
+import express from 'express';
+import path from 'path';
+import bodyParser from 'body-parser';
+import session from 'express-session';
+import * as dotenv from 'dotenv';
+import { createRequireAuth } from './middleware/requireAuth';
+import { createAuthRouter } from './routes/auth';
+import { createTodosRouter } from './routes/todos';
+
+// Load environment variables from .env.app
+dotenv.config({ path: path.resolve(__dirname, '../.env.app') });
+
+/**
+ * Configuration interface for app server
+ */
+interface AppServerConfig {
+ port: number;
+ oktaIssuer: string;
+ oktaClientId: string;
+ oktaClientSecret: string;
+ oktaRedirectUri: string;
+}
+
+/**
+ * Validate required environment variables and return typed configuration
+ */
+function validateAppEnv(): AppServerConfig {
+ const missing: string[] = [];
+ const invalid: string[] = [];
+
+ // Check required variables
+ const requiredVars = [
+ 'OKTA_ISSUER',
+ 'OKTA_CLIENT_ID',
+ 'OKTA_CLIENT_SECRET',
+ 'OKTA_REDIRECT_URI'
+ ];
+
+ for (const varName of requiredVars) {
+ if (!process.env[varName] || process.env[varName]!.trim() === '') {
+ missing.push(varName);
+ }
+ }
+
+ // Validate URL formats
+ if (process.env.OKTA_ISSUER) {
+ try {
+ new URL(process.env.OKTA_ISSUER);
+ } catch {
+ invalid.push('OKTA_ISSUER (invalid URL format)');
+ }
+ }
+
+ if (process.env.OKTA_REDIRECT_URI) {
+ try {
+ new URL(process.env.OKTA_REDIRECT_URI);
+ } catch {
+ invalid.push('OKTA_REDIRECT_URI (invalid URL format)');
+ }
+ }
+
+ // Report errors and exit if validation fails
+ if (missing.length > 0 || invalid.length > 0) {
+ console.error('❌ Environment configuration error in .env.app');
+ if (missing.length > 0) {
+ console.error(' Missing required variables:', missing.join(', '));
+ }
+ if (invalid.length > 0) {
+ console.error(' Invalid variables:', invalid.join(', '));
+ }
+ console.error(' Check packages/todo0/.env.app file');
+ process.exit(1);
+ }
+
+ console.log('✅ App server environment variables validated');
+
+ // Return typed configuration object
+ return {
+ port: parseInt(process.env.PORT || '', 10),
+ oktaIssuer: process.env.OKTA_ISSUER!,
+ oktaClientId: process.env.OKTA_CLIENT_ID!,
+ oktaClientSecret: process.env.OKTA_CLIENT_SECRET!,
+ oktaRedirectUri: process.env.OKTA_REDIRECT_URI!,
+ };
+}
+
+// Validate environment and get typed configuration
+const config = validateAppEnv();
+
+// Create configured modules
+const requireAuth = createRequireAuth();
+
+const authRouter = createAuthRouter({
+ oktaIssuer: config.oktaIssuer,
+ oktaClientId: config.oktaClientId,
+ oktaClientSecret: config.oktaClientSecret,
+ oktaRedirectUri: config.oktaRedirectUri,
+});
+
+const todosRouter = createTodosRouter(requireAuth);
+
+declare module 'express-session' {
+ interface SessionData {
+ access_token?: string;
+ id_token?: string;
+ codeVerifier?: string;
+ state?: string;
+ }
+}
+const app = express();
+
+app.set('view engine', 'ejs');
+app.set('views', path.join(__dirname, '../views'));
+
+// Logging middleware - must come first
+app.use((req, res, next) => {
+ console.log(`[REQUEST] ${req.method} ${req.url}`);
+ console.log(`[REQUEST] Path: ${req.path}`);
+ console.log(`[REQUEST] Headers:`, req.headers);
+ next();
+});
+
+app.use(session({
+ name: 'todo0.sid', // Unique session name for todo0 app
+ secret: 'your-secret-key',
+ resave: false,
+ saveUninitialized: false,
+ rolling: true, // Keep session alive with activity
+ cookie: {
+ secure: false,
+ httpOnly: true,
+ maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
+ sameSite: 'lax',
+ }
+}));
+app.use(bodyParser.urlencoded({ extended: false }));
+app.use(express.json()); // Enable JSON body parsing for API routes
+app.use(express.static(path.join(__dirname, '../public')));
+
+console.log('[SERVER] Registering routes...');
+app.use('/', todosRouter);
+app.use('/', authRouter);
+console.log('[SERVER] Routes registered');
+
+// 404 handler - must come after all routes
+app.use((req, res) => {
+ console.error(`[404] Route not found: ${req.method} ${req.url}`);
+ console.error(`[404] Available routes should include: /login, /callback, /logout`);
+ res.status(404).send(`404 Not Found: ${req.method} ${req.url}`);
+});
+
+app.listen(config.port, () => {
+ console.log(`[SERVER] Express server running on http://localhost:${config.port}`);
+ console.log(`[SERVER] Auth routes: /login, /callback, /logout`);
+});
diff --git a/packages/todo0/src/index.ts b/packages/todo0/src/index.ts
deleted file mode 100644
index 0c868e3..0000000
--- a/packages/todo0/src/index.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import express from 'express';
-import path from 'path';
-import bodyParser from 'body-parser';
-import session from 'express-session';
-import * as dotenv from 'dotenv';
-import todosRouter from './routes/todos';
-import authRouter from './routes/auth';
-
-// Load environment variables
-dotenv.config({ path: path.resolve(__dirname, '../.env') });
-
-declare module 'express-session' {
- interface SessionData {
- access_token?: string;
- id_token?: string;
- codeVerifier?: string;
- state?: string;
- }
-}
-const app = express();
-
-app.set('view engine', 'ejs');
-app.set('views', path.join(__dirname, '../views'));
-
-// Logging middleware - must come first
-app.use((req, res, next) => {
- console.log(`[REQUEST] ${req.method} ${req.url}`);
- console.log(`[REQUEST] Path: ${req.path}`);
- console.log(`[REQUEST] Headers:`, req.headers);
- next();
-});
-
-app.use(session({
- name: 'todo0.sid', // Unique session name for todo0 app
- secret: 'your-secret-key',
- resave: false,
- saveUninitialized: false,
- rolling: true, // Keep session alive with activity
- cookie: {
- secure: false,
- httpOnly: true,
- maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
- sameSite: 'lax',
- }
-}));
-app.use(bodyParser.urlencoded({ extended: false }));
-app.use(express.json()); // Enable JSON body parsing for API routes
-app.use(express.static(path.join(__dirname, '../public')));
-
-console.log('[SERVER] Registering routes...');
-app.use('/', todosRouter);
-app.use('/', authRouter);
-console.log('[SERVER] Routes registered');
-
-// 404 handler - must come after all routes
-app.use((req, res) => {
- console.error(`[404] Route not found: ${req.method} ${req.url}`);
- console.error(`[404] Available routes should include: /login, /callback, /logout`);
- res.status(404).send(`404 Not Found: ${req.method} ${req.url}`);
-});
-
-const PORT = process.env.PORT || 5001;
-app.listen(PORT, () => {
- console.log(`[SERVER] Express server running on http://localhost:${PORT}`);
- console.log(`[SERVER] Auth routes: /login, /callback, /logout`);
-});
diff --git a/packages/todo0/src/mcp-server.ts b/packages/todo0/src/mcp-server.ts
index 6f10fee..2a680ff 100644
--- a/packages/todo0/src/mcp-server.ts
+++ b/packages/todo0/src/mcp-server.ts
@@ -1,97 +1,20 @@
// todo-manager.ts
import { z } from 'zod';
-import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
-import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
+import { randomUUID } from 'node:crypto';
import express from 'express';
import * as dotenv from 'dotenv';
-import axios from 'axios';
import * as path from 'path';
+import { mcpAuthMetadataRouter, getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js';
+import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
+import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
+import { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol';
+import { isInitializeRequest, ServerNotification, ServerRequest } from '@modelcontextprotocol/sdk/types.js';
-// Load environment variables
-dotenv.config({ path: path.resolve(__dirname, '../.env') });
-
-interface Todo {
- id: number;
- title: string;
- completed: boolean;
-}
-
-// ============================================================================
-// Todo Service - Makes real API calls to Todo backend
-// ============================================================================
-class TodoService {
- private baseUrl: string;
-
- constructor() {
- this.baseUrl = process.env.TODO_API_BASE_URL || 'http://localhost:5001';
- }
-
- private getHeaders(accessToken?: string) {
- const headers: Record = {
- 'Content-Type': 'application/json',
- };
-
- if (accessToken) {
- headers['Authorization'] = `Bearer ${accessToken}`;
- } else {
- console.warn('⚠️ No access token provided - API calls may fail');
- }
-
- return headers;
- }
-
- async getAllTodos(accessToken?: string): Promise {
- try {
- const response = await axios.get(`${this.baseUrl}/todos`, {
- headers: this.getHeaders(accessToken),
- });
- return response.data.todos;
- } catch (error: any) {
- throw new Error(`Failed to fetch todos: ${error.message}`);
- }
- }
-
- async createTodo(title: string, accessToken?: string): Promise {
- try {
- const response = await axios.post(
- `${this.baseUrl}/todos`,
- { title },
- { headers: this.getHeaders(accessToken) }
- );
- return response.data.todo;
- } catch (error: any) {
- throw new Error(`Failed to create todo: ${error.message}`);
- }
- }
-
- async toggleTodo(id: number, accessToken?: string): Promise {
- try {
- const response = await axios.post(
- `${this.baseUrl}/todos/${id}/complete`,
- {},
- { headers: this.getHeaders(accessToken) }
- );
- return response.data.todo;
- } catch (error: any) {
- throw new Error(`Failed to toggle todo: ${error.message}`);
- }
- }
-
- async deleteTodo(id: number, accessToken?: string): Promise<{ message: string }> {
- try {
- const response = await axios.post(
- `${this.baseUrl}/todos/${id}/delete`,
- {},
- { headers: this.getHeaders(accessToken) }
- );
- return response.data;
- } catch (error: any) {
- throw new Error(`Failed to delete todo: ${error.message}`);
- }
- }
-}
+import { todoService } from './services/todo-service';
+import { createRequireMcpAuth, McpAuthClaims } from './middleware/requireMcpAuth';
-const todoService = new TodoService();
+// Load environment variables from .env.mcp
+dotenv.config({ path: path.resolve(__dirname, '../.env.mcp') });
// ============================================================================
// Create MCP Server
@@ -117,219 +40,469 @@ const deleteTodoParams: z.ZodRawShape = {
};
// ============================================================================
-// Tool 1: Create Todo
+// Tool Registration Function
// ============================================================================
-server.tool(
- 'create-todo',
- 'Create a new todo item.',
- createTodoParams,
- async ({ title }, _extra) => {
- try {
- // Extract access token from metadata if provided
- const accessToken = _extra.requestInfo?.headers['authorization']?.toString().replace('Bearer ', '');
- const todo = await todoService.createTodo(title, accessToken);
-
- return {
- content: [{
- type: 'text',
- text: JSON.stringify({
- success: true,
- todo,
- message: 'Todo created successfully'
- })
- }],
- };
- } catch (error) {
- return {
- content: [{
- type: 'text',
- text: JSON.stringify({
- error: 'Internal Server Error',
- message: error instanceof Error ? error.message : 'Unknown error'
- })
- }],
- isError: true,
- };
- }
+/**
+ * Register MCP tools with scope-based authorization
+ */
+function registerTools(verifyAccessTokenWithScopes: (authHeader: string, scopes: string[]) => Promise): void {
+ const makeProtectedTool = (scopes: string[], cb: (params: any, extra: RequestHandlerExtra) => Promise): ((args: any, extra: RequestHandlerExtra) => Promise) => {
+ return async (params: any, extra: RequestHandlerExtra) => {
+ if (!extra.requestInfo?.headers.authorization) {
+ throw new Error('Missing Authorization header in tool callback');
+ }
+ if (Array.isArray(extra.requestInfo?.headers.authorization)) {
+ throw new Error('Unexpected Authorization header in tool callback');
+ }
+ const authorizationHeader = extra.requestInfo?.headers.authorization;
+
+ console.log('🔍 Verifying access token for tool execution...');
+ const isValidToken = await verifyAccessTokenWithScopes(authorizationHeader, scopes);
+ if (!isValidToken) {
+ return {
+ content: [{
+ type: 'text',
+ text: JSON.stringify({
+ error: 'Unauthorized',
+ message: 'Invalid or expired token'
+ })
+ }],
+ isError: true,
+ };
+ }
+
+ try {
+ return await cb(params, extra);
+ } catch (error) {
+ return {
+ content: [{
+ type: 'text',
+ text: JSON.stringify({
+ error: 'Internal Server Error',
+ message: error instanceof Error ? error.message : 'Unknown error'
+ })
+ }],
+ isError: true,
+ };
+ }
+
+ }
}
-);
+
+ // ============================================================================
+ // Tool 1: Create Todo
+ // ============================================================================
+ // Note: JWT authentication is enforced at the transport layer (/mcp endpoint).
+ // All connections to this server are authenticated before tool execution.
+ // Tools are further authorized via scope checks in makeProtectedTool.
+ server.tool(
+ 'create-todo',
+ 'Create a new todo item.',
+ createTodoParams,
+ makeProtectedTool(
+ [
+ 'mcp:tools:manage'
+ ],
+ async ({ title }) => {
+ const todo = await todoService.createTodo(title);
+
+ return {
+ content: [{
+ type: 'text',
+ text: JSON.stringify({
+ success: true,
+ todo,
+ message: 'Todo created successfully'
+ })
+ }],
+ };
+ }
+ )
+ );
+
+ // ============================================================================
+ // Tool 2: Get Todos
+ // ============================================================================
+
+ server.tool(
+ 'get-todos',
+ 'List all todos.',
+ emptyParams,
+ makeProtectedTool(
+ [
+ 'mcp:tools:read'
+ ],
+ async () => {
+ const todos = await todoService.getAllTodos();
+
+ return {
+ content: [{
+ type: 'text',
+ text: JSON.stringify({
+ success: true,
+ todos,
+ count: todos.length,
+ message: 'Retrieved all todos'
+ })
+ }],
+ };
+ }
+ )
+ );
+
+ // ============================================================================
+ // Tool 3: Toggle Todo Completed Status
+ // ============================================================================
+
+ server.tool(
+ 'toggle-todo',
+ 'Toggle the completed status of a todo.',
+ toggleTodoParams,
+ makeProtectedTool(
+ [
+ 'mcp:tools:manage'
+ ],
+ async ({ id }) => {
+ const todo = await todoService.toggleTodo(id);
+
+ if (!todo) {
+ return {
+ content: [{
+ type: 'text',
+ text: JSON.stringify({
+ error: 'Not Found',
+ message: 'Todo not found'
+ })
+ }],
+ isError: true,
+ };
+ }
+
+ return {
+ content: [{
+ type: 'text',
+ text: JSON.stringify({
+ success: true,
+ todo,
+ message: 'Todo completion status toggled'
+ })
+ }],
+ };
+ }
+ )
+ );
+
+ // ============================================================================
+ // Tool 4: Delete Todo
+ // ============================================================================
+
+ server.tool(
+ 'delete-todo',
+ 'Delete a todo by ID.',
+ deleteTodoParams,
+ makeProtectedTool(
+ [
+ 'mcp:tools:manage'
+ ],
+ async ({ id }) => {
+ const deleted = await todoService.deleteTodo(id);
+
+ if (!deleted) {
+ return {
+ content: [{
+ type: 'text',
+ text: JSON.stringify({
+ error: 'Not Found',
+ message: 'Todo not found or already deleted'
+ })
+ }],
+ isError: true,
+ };
+ }
+
+ return {
+ content: [{
+ type: 'text',
+ text: JSON.stringify({
+ success: true,
+ message: 'Todo deleted successfully'
+ })
+ }],
+ };
+ }
+ )
+ );
+}
// ============================================================================
-// Tool 2: Get Todos
+// Express Server Setup with StreamableHTTP
// ============================================================================
-server.tool(
- 'get-todos',
- 'List all todos.',
- emptyParams,
- async (_args, _extra) => {
- try {
- // Extract access token from metadata if provided
- const accessToken = _extra.requestInfo?.headers['authorization']?.toString().replace('Bearer ', '');
- const todos = await todoService.getAllTodos(accessToken);
-
- return {
- content: [{
- type: 'text',
- text: JSON.stringify({
- success: true,
- todos,
- count: todos.length,
- message: 'Retrieved all todos'
- })
- }],
- };
- } catch (error) {
- return {
- content: [{
- type: 'text',
- text: JSON.stringify({
- error: 'Internal Server Error',
- message: error instanceof Error ? error.message : 'Unknown error'
- })
- }],
- isError: true,
- };
+/**
+ * Configuration interface for MCP server
+ */
+interface McpServerConfig {
+ mcpPort: number;
+ mcpOktaIssuer: string;
+ mcpExpectedAudience: string;
+}
+
+/**
+ * Validate required environment variables and return typed configuration
+ */
+function validateMcpEnv(): McpServerConfig {
+ const missing: string[] = [];
+ const invalid: string[] = [];
+
+ // Check required variables
+ const requiredVars = [
+ 'MCP_OKTA_ISSUER',
+ 'MCP_EXPECTED_AUDIENCE',
+ ];
+
+ for (const varName of requiredVars) {
+ if (!process.env[varName] || process.env[varName]!.trim() === '') {
+ missing.push(varName);
}
}
-);
-// ============================================================================
-// Tool 3: Toggle Todo Completed Status
-// ============================================================================
-
-server.tool(
- 'toggle-todo',
- 'Toggle the completed status of a todo.',
- toggleTodoParams,
- async ({ id }, _extra) => {
+ // Validate URL format for issuer
+ if (process.env.MCP_OKTA_ISSUER) {
try {
- const accessToken = _extra.requestInfo?.headers['authorization']?.toString().replace('Bearer ', '');
- const todo = await todoService.toggleTodo(id, accessToken);
-
- return {
- content: [{
- type: 'text',
- text: JSON.stringify({
- success: true,
- todo,
- message: 'Todo completion status toggled'
- })
- }],
- };
- } catch (error) {
- return {
- content: [{
- type: 'text',
- text: JSON.stringify({
- error: 'Internal Server Error',
- message: error instanceof Error ? error.message : 'Unknown error'
- })
- }],
- isError: true,
- };
+ new URL(process.env.MCP_OKTA_ISSUER);
+ } catch {
+ invalid.push('MCP_OKTA_ISSUER (invalid URL format)');
}
}
-);
-// ============================================================================
-// Tool 4: Delete Todo
-// ============================================================================
-
-server.tool(
- 'delete-todo',
- 'Delete a todo by ID.',
- deleteTodoParams,
- async ({ id }, _extra) => {
- try {
- const accessToken = _extra.requestInfo?.headers['authorization']?.toString().replace('Bearer ', '');
- const result = await todoService.deleteTodo(id, accessToken);
-
- return {
- content: [{
- type: 'text',
- text: JSON.stringify({
- success: true,
- message: result.message
- })
- }],
- };
- } catch (error) {
- return {
- content: [{
- type: 'text',
- text: JSON.stringify({
- error: 'Internal Server Error',
- message: error instanceof Error ? error.message : 'Unknown error'
- })
- }],
- isError: true,
- };
+ // Report errors and exit if validation fails
+ if (missing.length > 0 || invalid.length > 0) {
+ console.error('❌ Environment configuration error in .env.mcp');
+ if (missing.length > 0) {
+ console.error(' Missing required variables:', missing.join(', '));
+ }
+ if (invalid.length > 0) {
+ console.error(' Invalid variables:', invalid.join(', '));
}
+ console.error(' Check packages/todo0/.env.mcp file');
+ process.exit(1);
}
-);
-// ============================================================================
-// Express Server Setup
-// ============================================================================
+ console.log('✅ MCP server environment variables validated');
+
+ // Return typed configuration object
+ return {
+ mcpPort: parseInt(process.env.MCP_PORT || '5002', 10),
+ mcpOktaIssuer: process.env.MCP_OKTA_ISSUER!,
+ mcpExpectedAudience: process.env.MCP_EXPECTED_AUDIENCE!,
+ };
+}
async function bootstrap(): Promise {
- const MCP_PORT = process.env.MCP_PORT || 3001;
+ // Validate environment and get typed configuration
+ const config = validateMcpEnv();
+
+ // Create configured MCP auth middleware with validated config
+ const { requireMcpAuth, verifyAccessTokenWithScopes } = createRequireMcpAuth({
+ mcpOktaIssuer: config.mcpOktaIssuer,
+ mcpExpectedAudience: config.mcpExpectedAudience,
+ });
+
+ // Register tools with validated auth
+ registerTools(verifyAccessTokenWithScopes);
+
const app = express();
- const transports = new Map();
+
+ // Map to store transports by session ID
+ const transports: Record = {};
+ // Map to store auth claims by session ID
+ const sessionAuth: Record = {};
app.use(express.json());
- app.get('/sse', async (_req, res) => {
- console.log('New SSE connection established');
+ const mcpAuthMetadata = await fetch(`${config.mcpOktaIssuer}/.well-known/oauth-authorization-server`).then(res => res.json());
- const transport = new SSEServerTransport('/messages', res);
- transports.set(transport.sessionId, transport);
+ console.log('MCP Auth Metadata:', mcpAuthMetadata);
- res.on('close', () => {
- console.log('SSE connection closed:', transport.sessionId);
- transports.delete(transport.sessionId);
- });
+ /**
+ * MCP Protected Resource Metadata Endpoint
+ */
+ app.use(mcpAuthMetadataRouter({
+ oauthMetadata: mcpAuthMetadata,
+ resourceServerUrl: new URL(`http://localhost:${config.mcpPort}/mcp`)
+ }));
+
+ // MCP POST endpoint - handles initialization and subsequent requests
+ app.post('/mcp', requireMcpAuth, async (req, res) => {
+ const mcpUser = (req as any).mcpUser as McpAuthClaims;
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
+
+ if (sessionId) {
+ console.log(`Received MCP request for session: ${sessionId}`);
+ }
- await server.connect(transport);
+ try {
+ let transport: StreamableHTTPServerTransport;
+
+ if (sessionId && transports[sessionId]) {
+ // Reuse existing transport for this session
+ transport = transports[sessionId];
+
+ // Verify the session belongs to this authenticated user
+ const sessionUser = sessionAuth[sessionId];
+ if (!sessionUser || sessionUser.sub !== mcpUser.sub) {
+ console.error('Session authentication mismatch');
+ return res.status(403).json({
+ jsonrpc: '2.0',
+ error: {
+ code: -32000,
+ message: 'Forbidden: Session does not belong to authenticated user'
+ },
+ id: null
+ });
+ }
+ } else if (!sessionId && isInitializeRequest(req.body)) {
+ // New initialization request - create new transport
+ console.log('New MCP session initializing');
+ console.log(' Authenticated user:', mcpUser.sub);
+ console.log(' Client ID:', mcpUser.cid);
+
+ transport = new StreamableHTTPServerTransport({
+ sessionIdGenerator: () => randomUUID(),
+ onsessioninitialized: (newSessionId) => {
+ console.log(`Session initialized with ID: ${newSessionId}`);
+ transports[newSessionId] = transport;
+ sessionAuth[newSessionId] = mcpUser;
+ }
+ });
+
+ // Set up onclose handler to clean up transport
+ transport.onclose = () => {
+ const sid = transport.sessionId;
+ if (sid) {
+ console.log(`Transport closed for session ${sid}`);
+ delete transports[sid];
+ delete sessionAuth[sid];
+ }
+ };
+
+ // Connect the transport to the MCP server
+ await server.connect(transport);
+ } else {
+ // Invalid request - no session ID or not an initialization request
+ return res.status(400).json({
+ jsonrpc: '2.0',
+ error: {
+ code: -32000,
+ message: 'Bad Request: No valid session ID provided or missing initialization'
+ },
+ id: null
+ });
+ }
+
+ // Handle the request with the transport
+ await transport.handleRequest(req, res, req.body);
+ } catch (error) {
+ console.error('Error handling MCP request:', error);
+ if (!res.headersSent) {
+ res.status(500).json({
+ jsonrpc: '2.0',
+ error: {
+ code: -32603,
+ message: 'Internal server error'
+ },
+ id: null
+ });
+ }
+ }
});
- app.post('/messages', async (req, res) => {
- const sessionId = String(req.query.sessionId);
- const transport = transports.get(sessionId);
+ // MCP GET endpoint - handles SSE streams (for server-to-client messages)
+ app.get('/mcp', requireMcpAuth, async (req, res) => {
+ const mcpUser = (req as any).mcpUser as McpAuthClaims;
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
+
+ if (!sessionId || !transports[sessionId]) {
+ return res.status(400).send('Invalid or missing session ID');
+ }
- if (transport) {
- await transport.handlePostMessage(req, res, req.body);
+ // Verify the session belongs to this authenticated user
+ const sessionUser = sessionAuth[sessionId];
+ if (!sessionUser || sessionUser.sub !== mcpUser.sub) {
+ console.error('Session authentication mismatch for SSE stream');
+ return res.status(403).send('Forbidden: Session does not belong to authenticated user');
+ }
+
+ // Check for Last-Event-ID header for resumability
+ const lastEventId = req.headers['last-event-id'] as string | undefined;
+ if (lastEventId) {
+ console.log(`Client reconnecting with Last-Event-ID: ${lastEventId}`);
} else {
- console.error('No transport found for sessionId:', sessionId);
- res.status(400).json({
- error: 'Invalid session',
- message: 'No transport found for sessionId',
- });
+ console.log(`Establishing SSE stream for session ${sessionId}`);
+ }
+
+ const transport = transports[sessionId];
+ await transport.handleRequest(req, res);
+ });
+
+ // MCP DELETE endpoint - handles session termination
+ app.delete('/mcp', requireMcpAuth, async (req, res) => {
+ const mcpUser = (req as any).mcpUser as McpAuthClaims;
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
+
+ if (!sessionId || !transports[sessionId]) {
+ return res.status(400).send('Invalid or missing session ID');
+ }
+
+ // Verify the session belongs to this authenticated user
+ const sessionUser = sessionAuth[sessionId];
+ if (!sessionUser || sessionUser.sub !== mcpUser.sub) {
+ console.error('Session authentication mismatch for termination');
+ return res.status(403).send('Forbidden: Session does not belong to authenticated user');
+ }
+
+ console.log(`Received session termination request for session ${sessionId}`);
+
+ try {
+ const transport = transports[sessionId];
+ await transport.handleRequest(req, res);
+ } catch (error) {
+ console.error('Error handling session termination:', error);
+ if (!res.headersSent) {
+ res.status(500).send('Error processing session termination');
+ }
}
});
+ // Error handling middleware
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
console.error('Error:', err);
- res.status(500).json({
- error: 'Internal Server Error',
- message: err.message,
- });
+ if (!res.headersSent) {
+ res.status(500).json({
+ error: 'Internal Server Error',
+ message: err.message,
+ });
+ }
});
- app.listen(MCP_PORT, () => {
+ app.listen(config.mcpPort, () => {
console.log('='.repeat(60));
- console.log('🚀 MCP Todo Server');
+ console.log('🚀 MCP Todo Server (StreamableHTTP)');
console.log('='.repeat(60));
- console.log(`✓ Server running on http://localhost:${MCP_PORT}`);
- console.log(`✓ SSE endpoint: http://localhost:${MCP_PORT}/sse`);
- console.log(`✓ Messages endpoint: http://localhost:${MCP_PORT}/messages`);
+ console.log(`✓ Server running on http://localhost:${config.mcpPort}`);
+ console.log(`✓ MCP endpoint: http://localhost:${config.mcpPort}/mcp [SECURED]`);
+ console.log(` - POST: Initialize/Send messages`);
+ console.log(` - GET: SSE stream (server→client)`);
+ console.log(` - DELETE: Terminate session`);
+ console.log(`✓ MCP protected resource metadata: ${getOAuthProtectedResourceMetadataUrl(new URL(`http://localhost:${config.mcpPort}/mcp`))}`);
console.log('='.repeat(60));
console.log('Configuration:');
- console.log(` - MCP Server Port: ${MCP_PORT}`);
- console.log(` - Todo API: ${process.env.TODO_API_BASE_URL || 'http://localhost:5001'}`);
- console.log(` - Auth: Tokens passed per-request via MCP protocol`);
+ console.log(` - Transport: StreamableHTTP (no SSE-only mode)`);
+ console.log(` - MCP Server Port: ${config.mcpPort}`);
+ console.log(` - Data Access: Direct Prisma operations (shared service)`);
+ console.log(` - Auth: JWT verification with MCP-specific audience`);
+ console.log(` - Expected Audience: ${config.mcpExpectedAudience}`);
console.log('='.repeat(60));
console.log('Available Tools:');
console.log(' 1. create-todo - Create a new todo');
@@ -337,12 +510,31 @@ async function bootstrap(): Promise {
console.log(' 3. toggle-todo - Toggle completion state');
console.log(' 4. delete-todo - Delete a todo');
console.log('='.repeat(60));
- console.log('Ready to accept connections! 🎉');
+ console.log('Ready to accept authenticated connections! 🎉');
console.log('');
});
+
+ // Handle server shutdown
+ process.on('SIGINT', async () => {
+ console.log('\nShutting down server...');
+ // Close all active transports to properly clean up resources
+ for (const sessionId in transports) {
+ try {
+ console.log(`Closing transport for session ${sessionId}`);
+ await transports[sessionId].close();
+ delete transports[sessionId];
+ delete sessionAuth[sessionId];
+ } catch (error) {
+ console.error(`Error closing transport for session ${sessionId}:`, error);
+ }
+ }
+ console.log('Server shutdown complete');
+ process.exit(0);
+ });
}
bootstrap().catch((error) => {
console.error('Failed to start MCP server:', error);
process.exit(1);
-});
\ No newline at end of file
+});
+
diff --git a/packages/todo0/src/middleware/requireAuth.ts b/packages/todo0/src/middleware/requireAuth.ts
index 2ff29ca..7fdecaf 100644
--- a/packages/todo0/src/middleware/requireAuth.ts
+++ b/packages/todo0/src/middleware/requireAuth.ts
@@ -1,64 +1,15 @@
import { Request, Response, NextFunction } from 'express';
-import OktaJwtVerifier from '@okta/jwt-verifier';
-import * as dotenv from 'dotenv';
-// Load environment variables
-dotenv.config();
-
-// Use environment variables for configuration
-const OKTA_ISSUER = process.env.OKTA_ISSUER ?? '{yourIssuerUrl}';
-const OKTA_CLIENT_ID = process.env.OKTA_CLIENT_ID ?? '{yourClientId}';
-const EXPECTED_AUDIENCE = process.env.EXPECTED_AUDIENCE ?? '{yourExpectedAudience}';
-
-console.log('🔐 Auth Middleware Configuration:');
-console.log(` Issuer: ${OKTA_ISSUER}`);
-console.log(` Client ID: ${OKTA_CLIENT_ID}`);
-console.log(` Expected Audience: ${EXPECTED_AUDIENCE}`);
-
-const oktaJwtVerifier = new OktaJwtVerifier({
- issuer: OKTA_ISSUER,
- clientId: OKTA_CLIENT_ID,
- assertClaims: {
- aud: EXPECTED_AUDIENCE,
- },
-});
-
-export async function requireAuth(req: Request, res: Response, next: NextFunction) {
- // 1. Check for session-based authentication
- if (req.session && (req.session as any).access_token) {
- console.log('✓ Session-based authentication found');
- return next();
- }
-
- // 2. Check for Bearer token authentication
- const authHeader = req.headers.authorization || '';
- const match = authHeader.match(/^Bearer (.+)$/);
- if (!match) {
- console.log('✗ No Bearer token found in Authorization header');
- return res.status(401).json({ error: 'Missing or invalid Authorization header or session' });
- }
-
- const accessToken = match[1];
- console.log('🔍 Verifying access token...');
-
- try {
- // Verify the access token
- const jwt = await oktaJwtVerifier.verifyAccessToken(accessToken, EXPECTED_AUDIENCE);
-
- console.log('✅ Token verified successfully');
- console.log(' Subject:', jwt.claims.sub);
- console.log(' Scopes:', jwt.claims.scp);
- console.log(' Client ID:', jwt.claims.cid);
-
- (req as any).user = jwt.claims;
- return next();
- } catch (err: any) {
- console.error('❌ Token verification failed:', err.message);
- return res.status(401).json({
- error: 'Invalid or expired token',
- details: err.message
- });
- }
+export function createRequireAuth() {
+ return async function requireAuth(req: Request, res: Response, next: NextFunction) {
+ // 1. Check for session-based authentication
+ if (req.session && (req.session as any).access_token) {
+ console.log('✓ Session-based authentication found');
+ return next();
+ } else {
+ return res.status(401).json({ error: 'Missing or invalid session' });
+ }
+ };
}
declare global {
diff --git a/packages/todo0/src/middleware/requireMcpAuth.ts b/packages/todo0/src/middleware/requireMcpAuth.ts
new file mode 100644
index 0000000..c27a14a
--- /dev/null
+++ b/packages/todo0/src/middleware/requireMcpAuth.ts
@@ -0,0 +1,124 @@
+import { Request, Response, NextFunction } from 'express';
+import OktaJwtVerifier from '@okta/jwt-verifier';
+
+export interface McpAuthConfig {
+ mcpOktaIssuer: string;
+ mcpExpectedAudience: string;
+}
+
+export function createRequireMcpAuth(config: McpAuthConfig) {
+ const { mcpOktaIssuer, mcpExpectedAudience } = config;
+
+ console.log('🔐 MCP Auth Middleware Configuration:');
+ console.log(` Issuer: ${mcpOktaIssuer}`);
+ console.log(` Expected Audience: ${mcpExpectedAudience}`);
+
+ const oktaJwtVerifier = new OktaJwtVerifier({
+ issuer: mcpOktaIssuer,
+ assertClaims: {
+ aud: mcpExpectedAudience,
+ },
+ });
+
+ /**
+ * Middleware to verify JWT tokens for MCP server connections.
+ * Extracts Bearer token from Authorization header and validates it.
+ */
+ async function requireMcpAuth(req: Request, res: Response, next: NextFunction) {
+ // Check for Bearer token authentication
+ const authHeader = req.headers.authorization || '';
+ const match = authHeader.match(/^Bearer (.+)$/);
+
+ if (!match) {
+ console.log('✗ No Bearer token found in Authorization header for MCP connection');
+ return res.status(401).json({
+ error: 'Unauthorized',
+ message: 'Missing or invalid Authorization header. MCP connections require a valid Bearer token.'
+ });
+ }
+
+ const accessToken = match[1];
+ console.log('🔍 Verifying MCP access token...');
+
+ try {
+ // Verify the access token
+ const jwt = await oktaJwtVerifier.verifyAccessToken(accessToken, mcpExpectedAudience);
+
+ console.log('✅ MCP token verified successfully');
+ console.log(' Subject:', jwt.claims.sub);
+ console.log(' Scopes:', jwt.claims.scp);
+ console.log(' Client ID:', jwt.claims.cid);
+
+ if (!verifyScopesClaim(jwt.claims, ['mcp:connect'])) {
+ console.log('✗ Missing required scopes');
+ return res.status(403).json({
+ error: 'Forbidden',
+ message: 'Insufficient scope'
+ });
+ }
+
+ // Attach verified claims to request
+ (req as any).mcpUser = jwt.claims as McpAuthClaims;
+ return next();
+ } catch (err: any) {
+ console.error('❌ MCP token verification failed:', err.message);
+ return res.status(401).json({
+ error: 'Unauthorized',
+ message: 'Invalid or expired token',
+ details: err.message
+ });
+ }
+ }
+
+ async function verifyAccessTokenWithScopes(authorizationHeader: string, expectedScopes: string[]): Promise {
+ console.log('🔍 Verifying MCP access token with scopes:', expectedScopes);
+
+ const match = authorizationHeader.match(/^Bearer (.+)$/);
+
+ if (!match) {
+ console.log('✗ No Bearer token found in Authorization header for MCP connection');
+ return false;
+ }
+
+ const accessToken = match[1];
+ console.log('🔍 Verifying MCP access token...');
+
+ const jwt = await oktaJwtVerifier.verifyAccessToken(
+ accessToken,
+ mcpExpectedAudience
+ );
+
+ return verifyScopesClaim(jwt.claims, expectedScopes);
+ }
+
+ return { requireMcpAuth, verifyAccessTokenWithScopes };
+}
+
+function verifyScopesClaim(claims: OktaJwtVerifier.JwtClaims, expectedScopes: string[]): boolean {
+ if (claims.scp) {
+ for (const expectedScope of expectedScopes) {
+ if (!claims.scp.includes(expectedScope)) {
+ console.log(`✗ Missing required scope: ${expectedScope}`);
+ return false;
+ }
+ }
+ return true;
+ } else {
+ return false;
+ }
+}
+
+export interface McpAuthClaims {
+ sub: string;
+ scp?: string[];
+ cid?: string;
+ [key: string]: any;
+}
+
+declare global {
+ namespace Express {
+ interface Request {
+ mcpUser?: McpAuthClaims;
+ }
+ }
+}
diff --git a/packages/todo0/src/routes/auth.ts b/packages/todo0/src/routes/auth.ts
index ea6345c..9333749 100644
--- a/packages/todo0/src/routes/auth.ts
+++ b/packages/todo0/src/routes/auth.ts
@@ -1,29 +1,30 @@
import { Router } from 'express';
import { OktaAuth } from '@okta/okta-auth-js';
-import * as dotenv from 'dotenv';
-// Load environment variables
-dotenv.config();
+export interface AuthConfig {
+ oktaIssuer: string;
+ oktaClientId: string;
+ oktaClientSecret: string;
+ oktaRedirectUri: string;
+}
-const OKTA_ISSUER = process.env.OKTA_ISSUER ?? '{yourIssuerUrl}';
-const OKTA_CLIENT_ID = process.env.OKTA_CLIENT_ID ?? '{yourClientId}';
-const OKTA_CLIENT_SECRET = process.env.OKTA_CLIENT_SECRET ?? '{yourClientSecret}';
-const OKTA_REDIRECT_URI = process.env.OKTA_REDIRECT_URI ?? '{yourRedirectUri}';
+export function createAuthRouter(config: AuthConfig): Router {
+ const { oktaIssuer, oktaClientId, oktaClientSecret, oktaRedirectUri } = config;
-console.log('🔐 Okta Auth Configuration:');
-console.log(` Issuer: ${OKTA_ISSUER}`);
-console.log(` Client ID: ${OKTA_CLIENT_ID}`);
-console.log(` Redirect URI: ${OKTA_REDIRECT_URI}`);
+ console.log('🔐 Okta Auth Configuration:');
+ console.log(` Issuer: ${oktaIssuer}`);
+ console.log(` Client ID: ${oktaClientId}`);
+ console.log(` Redirect URI: ${oktaRedirectUri}`);
-// Initialize OktaAuth for server-side use
-const oktaAuth = new OktaAuth({
- issuer: OKTA_ISSUER,
- clientId: OKTA_CLIENT_ID,
- clientSecret: OKTA_CLIENT_SECRET,
- redirectUri: OKTA_REDIRECT_URI,
-});
+ // Initialize OktaAuth for server-side use
+ const oktaAuth = new OktaAuth({
+ issuer: oktaIssuer,
+ clientId: oktaClientId,
+ clientSecret: oktaClientSecret,
+ redirectUri: oktaRedirectUri,
+ });
-const router = Router();
+ const router = Router();
router.get('/login', async (req, res) => {
console.log('[AUTH] Login endpoint hit');
@@ -40,12 +41,12 @@ router.get('/login', async (req, res) => {
// Build authorization URL with PKCE
const state = Math.random().toString(36).substring(7);
req.session.state = state;
-
- const authUrl = `${OKTA_ISSUER}/v1/authorize?` +
- `client_id=${OKTA_CLIENT_ID}` +
+
+ const authUrl = `${oktaIssuer}/v1/authorize?` +
+ `client_id=${oktaClientId}` +
`&response_type=code` +
`&scope=${encodeURIComponent('openid profile email')}` +
- `&redirect_uri=${encodeURIComponent(OKTA_REDIRECT_URI)}` +
+ `&redirect_uri=${encodeURIComponent(oktaRedirectUri)}` +
`&state=${state}` +
`&code_challenge=${codeChallenge}` +
`&code_challenge_method=S256`;
@@ -130,13 +131,14 @@ router.post('/logout', async (req, res) => {
console.log('[AUTH] Session destroyed');
// Use Okta's proper logout endpoint with id_token_hint
- const oktaLogoutUrl = `${OKTA_ISSUER}/v1/logout?` +
+ const oktaLogoutUrl = `${oktaIssuer}/v1/logout?` +
`id_token_hint=${idToken || ''}` +
`&post_logout_redirect_uri=${encodeURIComponent('http://localhost:5001/')}`;
-
+
console.log('[AUTH] Redirecting to Okta logout:', oktaLogoutUrl);
res.redirect(oktaLogoutUrl);
});
});
-export default router;
+ return router;
+}
diff --git a/packages/todo0/src/routes/todos.ts b/packages/todo0/src/routes/todos.ts
index e817bb1..5ce1e5d 100644
--- a/packages/todo0/src/routes/todos.ts
+++ b/packages/todo0/src/routes/todos.ts
@@ -1,16 +1,15 @@
-import { Router } from 'express';
-import { PrismaClient } from '@prisma/client';
-import { requireAuth } from '../middleware/requireAuth';
+import { Router, RequestHandler } from 'express';
+import { todoService } from '../services/todo-service';
-const prisma = new PrismaClient();
-const router = Router();
+export function createTodosRouter(requireAuth: RequestHandler): Router {
+ const router = Router();
router.get('/', async (req, res) => {
const authenticated = req.session && req.session.access_token;
const accessToken = req.session?.access_token || '';
let todos: any[] = [];
if (authenticated) {
- todos = await prisma.todo.findMany({ orderBy: { id: 'desc' } });
+ todos = await todoService.getAllTodos();
}
res.render('index', { todos, authenticated, accessToken });
});
@@ -18,7 +17,7 @@ router.get('/', async (req, res) => {
// API endpoint to get all todos as JSON (Bearer token required)
router.get('/todos', requireAuth, async (req, res) => {
try {
- const todos = await prisma.todo.findMany({ orderBy: { id: 'desc' } });
+ const todos = await todoService.getAllTodos();
res.json({ todos });
} catch (error: any) {
console.error('Failed to fetch todos:', error);
@@ -36,7 +35,7 @@ router.post('/todos', requireAuth, async (req, res) => {
return res.status(400).json({ error: 'Title is required' });
}
try {
- const todo = await prisma.todo.create({ data: { title } });
+ const todo = await todoService.createTodo(title);
if (req.accepts('html')) return res.redirect('/');
res.status(201).json({ todo });
} catch (error: any) {
@@ -50,12 +49,11 @@ router.post('/todos', requireAuth, async (req, res) => {
router.post('/todos/:id/complete', requireAuth, async (req, res) => {
const id = parseInt(req.params.id, 10);
try {
- const todo = await prisma.todo.findUnique({ where: { id } });
- if (!todo) {
+ const updated = await todoService.toggleTodo(id);
+ if (!updated) {
if (req.accepts('html')) return res.redirect('/');
return res.status(404).json({ error: 'Todo not found' });
}
- const updated = await prisma.todo.update({ where: { id }, data: { completed: !todo.completed } });
if (req.accepts('html')) return res.redirect('/');
res.json({ todo: updated });
} catch (error: any) {
@@ -69,7 +67,11 @@ router.post('/todos/:id/complete', requireAuth, async (req, res) => {
router.post('/todos/:id/delete', requireAuth, async (req, res) => {
const id = parseInt(req.params.id, 10);
try {
- await prisma.todo.delete({ where: { id } });
+ const deleted = await todoService.deleteTodo(id);
+ if (!deleted) {
+ if (req.accepts('html')) return res.redirect('/');
+ return res.status(404).json({ error: 'Todo not found' });
+ }
if (req.accepts('html')) return res.redirect('/');
res.json({ message: 'Todo deleted successfully' });
} catch (error: any) {
@@ -79,4 +81,5 @@ router.post('/todos/:id/delete', requireAuth, async (req, res) => {
}
});
-export default router;
+ return router;
+}
diff --git a/packages/todo0/src/services/todo-service.ts b/packages/todo0/src/services/todo-service.ts
new file mode 100644
index 0000000..86dc5dc
--- /dev/null
+++ b/packages/todo0/src/services/todo-service.ts
@@ -0,0 +1,58 @@
+import { PrismaClient, Todo } from '@prisma/client';
+
+const prisma = new PrismaClient();
+
+/**
+ * Shared business logic for todo operations.
+ * Used by both todo0 app routes and MCP server tools to avoid token passthrough anti-pattern.
+ */
+export class TodoService {
+ /**
+ * Get all todos, ordered by ID descending
+ */
+ async getAllTodos(): Promise {
+ return prisma.todo.findMany({ orderBy: { id: 'desc' } });
+ }
+
+ /**
+ * Create a new todo
+ */
+ async createTodo(title: string): Promise {
+ return prisma.todo.create({ data: { title } });
+ }
+
+ /**
+ * Toggle the completed status of a todo
+ */
+ async toggleTodo(id: number): Promise {
+ const todo = await prisma.todo.findUnique({ where: { id } });
+ if (!todo) {
+ return null;
+ }
+ return prisma.todo.update({
+ where: { id },
+ data: { completed: !todo.completed }
+ });
+ }
+
+ /**
+ * Delete a todo by ID
+ */
+ async deleteTodo(id: number): Promise {
+ try {
+ await prisma.todo.delete({ where: { id } });
+ return true;
+ } catch (error) {
+ return false;
+ }
+ }
+
+ /**
+ * Get a single todo by ID
+ */
+ async getTodoById(id: number): Promise {
+ return prisma.todo.findUnique({ where: { id } });
+ }
+}
+
+export const todoService = new TodoService();
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 4340350..abc3af1 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,2 +1,5 @@
packages:
- - 'packages/*'
\ No newline at end of file
+ - packages/*
+
+onlyBuiltDependencies:
+ - esbuild
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 0000000..bbf9412
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,274 @@
+# Okta Tenant Bootstrap Scripts
+
+Automated scripts to configure an Okta tenant for the Secure AI Agent Example with dual custom authorization servers.
+
+## Overview
+
+These scripts automate the creation and configuration of Okta resources required to run this example, including:
+
+- **Two Custom Authorization Servers**:
+ - `todo0-rest-api` - For REST API endpoints (port 5001)
+ - `todo0-mcp-server` - For MCP server endpoints (port 5002)
+- **Two OIDC Applications**:
+ - `agent0` - OIDC client for agent0 resource server (human SSO)
+ - `todo0` - OIDC client for todo0 application (human SSO)
+- **Agent0 - Agent identity** (NEW Okta entity type):
+ - Separate from OIDC applications
+ - Used for cross-app authentication
+ - Linked to agent0 OIDC application
+ - Has connections to authorization servers
+- **Custom Scopes**: REST API scopes (`create:todos`, etc.) and MCP scopes (`mcp:connect`, etc.)
+- **Access Policies**: Default policies and rules for both authorization servers
+- **RSA Key Pair**: For agent authentication via private key JWT
+- **Configuration Files**: Generated `.env` files for both packages
+
+## Prerequisites
+
+1. **Okta Account**: You need an Okta developer account
+ - Sign up at: https://developer.okta.com/signup/
+
+2. **Okta API Token**: Create an API token with admin permissions
+ - Okta Admin Console → Security → API → Tokens → Create Token
+ - Required scopes: `okta.apps.manage`, `okta.authorizationServers.manage`, `okta.clients.manage`
+
+3. **Dependencies Installed**:
+ ```bash
+ pnpm install
+ ```
+
+## Usage
+
+### 1. Bootstrap Okta Tenant
+
+Run the bootstrap script to create all required resources:
+
+```bash
+pnpm run bootstrap:okta
+```
+
+The script will prompt you for:
+- Okta domain (e.g., `dev-12345.okta.com`)
+- Okta API token
+- REST API audience (default: `api://todo0`)
+- MCP audience (default: `mcp://todo0`)
+
+**What it does:**
+1. Creates two custom authorization servers (REST API + MCP)
+2. Adds custom scopes to each (5 REST + 3 MCP scopes)
+3. Creates two OIDC applications (agent0 + todo0)
+4. Generates RSA key pair for agent authentication
+5. **[PLACEHOLDER]** Creates Agent Identity entity
+6. **[PLACEHOLDER]** Uploads public key to agent
+7. **[PLACEHOLDER]** Activates the agent
+8. **[PLACEHOLDER]** Links agent to agent0 OIDC app
+9. **[PLACEHOLDER]** Creates agent connection to MCP AS
+10. Creates access policies and rules
+11. Adds trusted origins
+12. Generates `.env` files for both packages
+13. Creates configuration report (`okta-config-report.md`)
+14. Saves rollback state (`.okta-bootstrap-state.json`)
+
+**Note**: Steps 5-9 use placeholders for the new Okta Agent Identity API (not yet in public SDK). See `scripts/lib/agent-identity-api.ts` for implementation details.
+
+**Output:**
+- `packages/agent0/.env` - Agent configuration
+- `packages/todo0/.env` - Todo0 configuration
+- `packages/agent0/agent0-private-key.pem` - Private key (600 permissions)
+- `okta-config-report.md` - Detailed configuration report
+- `.okta-bootstrap-state.json` - Rollback information
+
+### 2. Validate Configuration
+
+Test the configuration to ensure everything is working:
+
+```bash
+pnpm run validate:okta
+```
+
+**Validation checks:**
+- ✓ Environment files exist and contain required variables
+- ✓ Audiences are distinct (REST API vs MCP)
+- ✓ Private key exists and is valid
+- ✓ REST API authorization server is reachable
+- ✓ MCP authorization server is reachable
+- ✓ ID-JAG token flow works (client credentials + private key JWT)
+
+### 3. Rollback (Clean Up)
+
+Remove all created resources from Okta:
+
+```bash
+pnpm run rollback:okta
+```
+
+**What it deletes:**
+- Custom authorization servers
+- OAuth applications
+- Trusted origins
+- Optionally: local `.env` files, private key, and configuration report
+
+## Architecture
+
+### Authorization Server Separation
+
+| Server | Audience | Purpose | Scopes |
+|--------|----------|---------|--------|
+| **Org AS** | - | Human SSO, ID-JAG issuance | Default |
+| **REST API AS** | `api://todo0` | Protect REST API (port 5001) | `create:todos`, `read:todos`, etc. |
+| **MCP AS** | `mcp://todo0` | Protect MCP server (port 5002) | `mcp:connect`, `mcp:tools:read`, `mcp:tools:manage` |
+
+### Token Flow
+
+```
+1. User Login
+ User → Resource Server → Org AS
+ Result: ID Token
+
+2. Agent Gets ID-JAG
+ Agent Identity → Org AS (client_credentials + private key JWT)
+ Result: ID-JAG Token
+
+3. Exchange for REST API Token
+ Agent Identity → REST API AS (jwt-bearer grant with ID-JAG)
+ Result: Access Token (aud: api://todo0)
+
+4. Exchange for MCP Token
+ Agent Identity → MCP AS (jwt-bearer grant with ID-JAG)
+ Result: Access Token (aud: mcp://todo0)
+```
+
+### Security Benefits
+
+✅ **Separate Audiences** - Tokens for one service can't be used on another
+✅ **Granular Policies** - Different access rules for REST API vs MCP
+✅ **No Token Passthrough** - Direct database access, no HTTP forwarding
+✅ **JWT Verification** - Both services validate tokens independently
+✅ **Private Key JWT** - Strong authentication for agent identity
+
+## Agent Identity API Implementation
+
+The bootstrap script includes placeholders for the new Okta Agent Identity API, which is not yet available in the public SDK. To complete the bootstrap process, you'll need to provide implementations for:
+
+### Required API Functions
+
+Located in `scripts/lib/agent-identity-api.ts`:
+
+1. **`createAgentIdentity()`** - Create a new agent entity in Okta
+2. **`uploadAgentPublicKey()`** - Upload RSA public key for private key JWT auth
+3. **`activateAgent()`** - Activate the agent for use
+4. **`linkAgentToApplication()`** - Link agent to the agent0 OIDC application
+5. **`createAgentConnection()`** - Create connection to MCP authorization server
+
+### Implementation Steps
+
+1. Replace the placeholder functions in `agent-identity-api.ts` with actual API calls
+2. Update `bootstrap-okta-tenant.ts` to uncomment and use the real implementations
+3. Test the bootstrap script end-to-end
+
+### Manual Alternative
+
+If the API is not yet available, you can:
+1. Run the bootstrap script (it will create everything except the agent identity)
+2. Manually create the agent identity in Okta Admin Console
+3. Link it to the agent0 application
+4. Create the connection to the MCP authorization server
+5. Update the `.env` files with the actual agent client ID
+
+## Files
+
+### Library Modules
+
+- **`lib/okta-api.ts`** - Okta Management API wrapper
+ - Create/delete authorization servers
+ - Manage scopes, policies, and applications
+ - Standard OIDC application operations
+
+- **`lib/agent-identity-api.ts`** - Agent Identity API placeholders
+ - Placeholder functions for new Okta entity type
+ - Ready for actual API implementation
+ - Includes TypeScript interfaces and documentation
+
+- **`lib/key-generator.ts`** - RSA key generation
+ - Generate 2048-bit RSA key pairs
+ - Create self-signed certificates
+ - Secure file permissions (600)
+
+- **`lib/env-writer.ts`** - Configuration file generation
+ - Generate `.env` files for both packages
+ - Create markdown configuration report
+ - Backup existing files
+
+### Main Scripts
+
+- **`bootstrap-okta-tenant.ts`** - Main orchestration script
+- **`validate-okta-config.ts`** - Health checks and validation
+- **`rollback-okta-config.ts`** - Clean up and resource deletion
+
+## Troubleshooting
+
+### Bootstrap Fails
+
+**Issue**: API token doesn't have sufficient permissions
+
+**Solution**:
+- Go to Okta Admin Console → Security → API → Tokens
+- Delete old token and create new one with admin permissions
+- Re-run bootstrap
+
+**Issue**: Resource already exists
+
+**Solution**:
+- Run `pnpm run rollback:okta` to clean up
+- Then re-run bootstrap
+
+### Validation Fails
+
+**Issue**: "Failed to reach authorization server"
+
+**Solution**:
+- Check Okta domain is correct in `.env` files
+- Verify authorization servers were created in Okta Admin Console
+- Check network connectivity
+
+**Issue**: "Failed to get ID-JAG token"
+
+**Solution**:
+- Verify private key file exists and has correct permissions (600)
+- Check Key ID (KID) matches what's in Okta
+- Verify agent identity application has correct grant types enabled
+
+### Private Key Issues
+
+**Issue**: "Private key file is invalid"
+
+**Solution**:
+- Delete the invalid key file
+- Re-run bootstrap to generate a new one
+
+**Issue**: Permission denied when reading private key
+
+**Solution**:
+```bash
+chmod 600 packages/agent0/agent0-private-key.pem
+```
+
+## Manual Configuration
+
+If you prefer to configure Okta manually, see the generated `okta-config-report.md` for a complete checklist of resources and settings.
+
+## Security Notes
+
+⚠️ **Important**:
+- Never commit `.env` files or private keys to git
+- Keep your Okta API token secure
+- Rotate keys periodically
+- Use separate Okta tenants for dev/staging/prod
+- Review access policies before production use
+
+## Support
+
+For issues or questions:
+- Check `okta-config-report.md` for your specific configuration
+- Review validation output for specific error messages
+- See main README.md for architecture details
+- Okta documentation: https://developer.okta.com/docs/
diff --git a/scripts/bootstrap-okta-tenant.ts b/scripts/bootstrap-okta-tenant.ts
new file mode 100644
index 0000000..c8c780e
--- /dev/null
+++ b/scripts/bootstrap-okta-tenant.ts
@@ -0,0 +1,499 @@
+#!/usr/bin/env node
+import prompts from 'prompts';
+import chalk from 'chalk';
+import ora from 'ora';
+import * as path from 'path';
+import { OktaAPIClient } from './lib/okta-api.js';
+import { generateRSAKeyPair, savePrivateKey } from './lib/key-generator.js';
+import {
+ generateAgent0AppEnv,
+ generateAgent0AgentEnv,
+ generateTodo0AppEnv,
+ generateTodo0McpEnv,
+ writeEnvFile,
+ writeConfigReport,
+ BootstrapConfig,
+} from './lib/env-writer.js';
+import {
+ loadRollbackState,
+ updateRollbackState,
+} from './lib/state-manager.js';
+import {
+ AgentIdentityAPIClient,
+ convertPublicKeyToJWK,
+ constructAuthServerORN,
+} from './lib/agent-identity-api.js';
+import type { OpenIdConnectApplication } from '@okta/okta-sdk-nodejs';
+
+interface PromptAnswers {
+ oktaDomain: string;
+ oktaApiToken: string;
+ mcpAudience: string;
+ ownerSetupMethod: 'standard' | 'developer';
+ confirm: boolean;
+}
+
+/**
+ * Main bootstrap function
+ */
+async function bootstrap() {
+ console.log(chalk.bold.blue('\n🚀 Okta Tenant Bootstrap for Secure AI Agent Example\n'));
+ console.log('This script will configure your Okta tenant with:');
+ console.log(' • Two OIDC applications (Agent0 + Todo0)');
+ console.log(' • One custom authorization server (Todo0 MCP Server)');
+ console.log(' • Custom scopes and access policies');
+ console.log(' • Agent0 agent identity');
+ console.log(' • RSA key pair for agent authentication');
+ console.log(' • .env files for both packages\n');
+
+ // Prompt for configuration
+ const answers = await prompts([
+ {
+ type: 'text',
+ name: 'oktaDomain',
+ message: 'Enter your Okta domain (e.g., dev-12345.okta.com):',
+ validate: (value) => {
+ if (!value) return 'Okta domain is required';
+ return true;
+ },
+ },
+ {
+ type: 'password',
+ name: 'oktaApiToken',
+ message: 'Enter your Okta API token:',
+ validate: (value) => (value ? true : 'API token is required'),
+ },
+ {
+ type: 'text',
+ name: 'mcpAudience',
+ message: 'MCP Server audience identifier:',
+ initial: 'mcp://todo0',
+ },
+ {
+ type: 'select',
+ name: 'ownerSetupMethod',
+ message: 'Which method to use for setting agent owners?',
+ choices: [
+ { title: 'Standard API (Governance)', value: 'standard', description: 'Use /governance/api/v1/resource-owners' },
+ { title: 'Developer API (Local Dev)', value: 'developer', description: 'Use /devtools/api for Okta developers' },
+ ],
+ initial: 0,
+ },
+ {
+ type: 'confirm',
+ name: 'confirm',
+ message: 'Ready to create resources in your Okta tenant?',
+ initial: false,
+ },
+ ]);
+
+ if (!answers.confirm) {
+ console.log(chalk.yellow('\n⚠️ Bootstrap cancelled by user'));
+ process.exit(0);
+ }
+
+ const config = answers as PromptAnswers;
+ const oktaClient = new OktaAPIClient({
+ orgUrl: `https://${config.oktaDomain}`,
+ token: config.oktaApiToken,
+ });
+
+ // Initialize Agent Identity API client
+ const agentClient = new AgentIdentityAPIClient({
+ oktaDomain: config.oktaDomain,
+ apiToken: config.oktaApiToken,
+ });
+
+ // Initialize rollback state (loads existing or creates new)
+ let rollbackState = loadRollbackState(config.oktaDomain);
+
+ const bootstrapConfig: Partial = {
+ oktaDomain: config.oktaDomain,
+ mcpAudience: config.mcpAudience,
+ privateKeyFile: 'agent0-private-key.pem',
+ };
+
+ try {
+ // Step 1: Create MCP Authorization Server
+ console.log(chalk.bold('\n📋 Step 1: Creating MCP Authorization Server'));
+ let spinner = ora('Creating authorization server...').start();
+
+ const mcpAS = await oktaClient.createAuthorizationServer({
+ name: 'todo0-mcp-server',
+ description: 'Authorization server for todo0 MCP server',
+ audiences: [config.mcpAudience],
+ });
+
+ bootstrapConfig.mcpAuthServerId = mcpAS.id!;
+ rollbackState = updateRollbackState(rollbackState, {
+ mcpAuthServerIds: [mcpAS.id!],
+ });
+ spinner.succeed(`MCP AS created: ${chalk.cyan(mcpAS.id)}`);
+
+ // Add MCP scopes
+ spinner = ora('Adding MCP scopes...').start();
+ await oktaClient.addScopes(mcpAS.id!, [
+ { name: 'mcp:connect', description: 'Establish MCP SSE connection' },
+ { name: 'mcp:tools:read', description: 'Use tools that read todo data' },
+ { name: 'mcp:tools:manage', description: 'Use tools that manage todo data' },
+ ]);
+ spinner.succeed('MCP scopes added');
+
+ // Step 2: Create agent0 OIDC Application
+ console.log(chalk.bold('\n📋 Step 2: Creating agent0 OIDC Application'));
+ spinner = ora('Creating agent0 OIDC client...').start();
+
+ const agent0App = await oktaClient.createApplication({
+ name: 'oidc_client',
+ label: 'agent0',
+ signOnMode: 'OPENID_CONNECT',
+ credentials: {
+ oauthClient: {
+ token_endpoint_auth_method: 'client_secret_basic',
+ },
+ },
+ settings: {
+ oauthClient: {
+ client_uri: 'http://localhost:3000',
+ redirect_uris: ['http://localhost:3000/callback'],
+ post_logout_redirect_uris: ['http://localhost:3000'],
+ response_types: ['code'],
+ grant_types: ['authorization_code'],
+ application_type: 'web',
+ consent_method: 'REQUIRED',
+ },
+ implicitAssignment: false,
+ },
+ }) as OpenIdConnectApplication;
+
+ bootstrapConfig.agentAppClientId = agent0App.credentials.oauthClient!.client_id!;
+ bootstrapConfig.agentAppClientSecret = agent0App.credentials.oauthClient!.client_secret!;
+ const agent0AppId = agent0App.id!;
+ rollbackState = updateRollbackState(rollbackState, {
+ agent0AppIds: [agent0AppId],
+ });
+ spinner.succeed(`agent0 OIDC app created: ${chalk.cyan(agent0AppId)}`);
+
+ // Step 3: Create todo0 OIDC Application
+ console.log(chalk.bold('\n📋 Step 3: Creating todo0 OIDC Application'));
+ spinner = ora('Creating todo0 OIDC client...').start();
+
+ const todo0App = await oktaClient.createApplication({
+ name: 'oidc_client',
+ label: 'todo0',
+ signOnMode: 'OPENID_CONNECT',
+ credentials: {
+ oauthClient: {
+ token_endpoint_auth_method: 'client_secret_basic',
+ },
+ },
+ settings: {
+ oauthClient: {
+ client_uri: 'http://localhost:5001',
+ redirect_uris: ['http://localhost:5001/callback'],
+ post_logout_redirect_uris: ['http://localhost:5001'],
+ response_types: ['code'],
+ grant_types: ['authorization_code'],
+ application_type: 'web',
+ consent_method: 'REQUIRED',
+ },
+ implicitAssignment: false,
+ },
+ }) as OpenIdConnectApplication;
+
+ const todo0AppId = todo0App.id!;
+ bootstrapConfig.todo0AppClientId = todo0App.credentials.oauthClient!.client_id!;
+ bootstrapConfig.todo0AppClientSecret = todo0App.credentials.oauthClient!.client_secret!;
+ rollbackState = updateRollbackState(rollbackState, {
+ todo0AppIds: [todo0AppId],
+ });
+ spinner.succeed(`todo0 OIDC app created: ${chalk.cyan(todo0AppId)}`);
+
+ // Step 4: Assign Current User to Applications
+ console.log(chalk.bold('\n📋 Step 4: Assigning User to Applications'));
+ spinner = ora('Getting current user...').start();
+
+ const currentUser = await agentClient.getCurrentUser();
+ spinner.succeed(`Current user: ${chalk.cyan(currentUser.login)}`);
+
+ spinner = ora('Assigning user to agent0 application...').start();
+ await oktaClient.assignUserToApplication(agent0AppId, currentUser.id);
+ rollbackState = updateRollbackState(rollbackState, {
+ agent0AppUserIds: [currentUser.id],
+ });
+ spinner.succeed('User assigned to agent0 application');
+
+ spinner = ora('Assigning user to todo0 application...').start();
+ await oktaClient.assignUserToApplication(todo0AppId, currentUser.id);
+ rollbackState = updateRollbackState(rollbackState, {
+ todo0AppUserIds: [currentUser.id],
+ });
+ spinner.succeed('User assigned to todo0 application');
+
+ // Step 5: Generate RSA Key Pair
+ console.log(chalk.bold('\n📋 Step 5: Generating RSA Key Pair for Agent'));
+ spinner = ora('Generating 2048-bit RSA key pair...').start();
+
+ const keyPair = await generateRSAKeyPair();
+ const privateKeyPath = path.resolve('packages/agent0', bootstrapConfig.privateKeyFile!);
+ await savePrivateKey(keyPair.privateKeyPem, privateKeyPath);
+ spinner.succeed('RSA key pair generated');
+
+ // Step 6: Create Agent Identity
+ console.log(chalk.bold('\n📋 Step 6: Creating Agent Identity'));
+ spinner = ora('Registering agent identity...').start();
+
+ let agentIdentityId: string;
+ let agentClientId: string;
+
+ try {
+ // Register agent (async operation)
+ const operationUrl = await agentClient.registerAgent({
+ profile: {
+ name: 'Agent0 Agent',
+ description: 'Agent0 Agent',
+ },
+ appId: agent0AppId,
+ });
+
+ // Poll until registration completes
+ spinner.text = 'Waiting for agent registration to complete...';
+ const operation = await agentClient.pollOperation(operationUrl);
+
+ // Get agent details
+ agentIdentityId = operation.resource.id;
+ agentClientId = agentIdentityId; // Agent ID is the client ID
+
+ // Save to rollback state
+ rollbackState = updateRollbackState(rollbackState, {
+ agentIdentityIds: [agentIdentityId],
+ });
+
+ spinner.succeed(`Agent identity created: ${chalk.cyan(agentIdentityId)}`);
+ } catch (error: any) {
+ spinner.fail(`Agent identity creation failed: ${error.message}`);
+ console.log(chalk.gray(' → Check Okta tenant for partially created resources'));
+ throw error;
+ }
+
+ bootstrapConfig.agentIdentityClientId = agentClientId;
+
+ // Step 7: Set Agent Owners
+ console.log(chalk.bold('\n📋 Step 7: Setting Agent Owners'));
+ spinner = ora('Setting agent owners...').start();
+
+ try {
+ // Get current user and org metadata
+ spinner.text = 'Getting current user and org metadata...';
+ const [currentUser, orgMetadata] = await Promise.all([
+ agentClient.getCurrentUser(),
+ agentClient.getOrgMetadata(),
+ ]);
+
+ spinner.text = `Setting agent owner to: ${currentUser.login}`;
+
+ if (config.ownerSetupMethod === 'developer') {
+ // Use developer API
+ await agentClient.setAgentOwnersDeveloper(agentIdentityId, orgMetadata.id);
+ spinner.succeed(`Agent owners set using Developer API`);
+ } else {
+ // Use standard governance API
+ await agentClient.setAgentOwnersStandard(agentIdentityId, orgMetadata.id, currentUser.id);
+ spinner.succeed(`Agent owners set using Standard API (owner: ${currentUser.login})`);
+ }
+
+ // Save owner setup method to rollback state
+ rollbackState = updateRollbackState(rollbackState, {
+ agentOwnerSetupMethod: config.ownerSetupMethod,
+ });
+ } catch (error: any) {
+ spinner.fail(`Agent owner setup failed: ${error.message}`);
+ console.log(chalk.gray(' → Agent activation may fail without owners'));
+ throw error;
+ }
+
+ // Step 8: Upload Public Key to Agent Identity
+ console.log(chalk.bold('\n📋 Step 8: Uploading Public Key to Agent'));
+ spinner = ora('Uploading public key...').start();
+
+ try {
+ // Convert public key to JWK format and upload
+ const jwk = await convertPublicKeyToJWK(keyPair.publicKeyPem);
+ const { kid } = await agentClient.uploadPublicKey(agentIdentityId, jwk);
+
+ bootstrapConfig.keyId = kid;
+ spinner.succeed(`Public key uploaded: ${chalk.cyan(kid)}`);
+ } catch (error: any) {
+ spinner.fail(`Public key upload failed: ${error.message}`);
+ console.log(chalk.gray(' → Check agent identity in Okta tenant'));
+ throw error;
+ }
+
+ // Step 9: Activate Agent Identity
+ console.log(chalk.bold('\n📋 Step 9: Activating Agent Identity'));
+ spinner = ora('Activating agent...').start();
+
+ try {
+ // Activate agent (async operation)
+ const activationUrl = await agentClient.activateAgent(agentIdentityId);
+ spinner.text = 'Waiting for agent activation to complete...';
+ await agentClient.pollOperation(activationUrl);
+
+ spinner.succeed('Agent identity activated');
+ } catch (error: any) {
+ spinner.fail(`Agent activation failed: ${error.message}`);
+ console.log(chalk.gray(' → Check agent status in Okta Admin Console'));
+ throw error;
+ }
+
+ // Note: Linking agent to agent0 app was done during agent registration (Step 7)
+ // when we provided appId in the POST request body
+
+ // Step 10: Create Agent Connection to MCP Authorization Server
+ console.log(chalk.bold('\n📋 Step 10: Creating Agent Connection to MCP AS'));
+ spinner = ora('Connecting agent to MCP authorization server...').start();
+
+ try {
+ // Get org metadata for ORN construction
+ const orgMetadata = await agentClient.getOrgMetadata();
+ const authServerOrn = constructAuthServerORN(orgMetadata.id, mcpAS.id!);
+
+ // Define MCP scopes granted to the agent
+ const mcpScopes = ['mcp:connect', 'mcp:tools:read', 'mcp:tools:manage'];
+
+ // Create connection
+ const connection = await agentClient.createConnection(agentIdentityId, {
+ connectionType: 'IDENTITY_ASSERTION_CUSTOM_AS',
+ authorizationServer: {
+ orn: authServerOrn,
+ resourceIndicator: config.mcpAudience,
+ },
+ scopeCondition: 'INCLUDE_ONLY',
+ scopes: mcpScopes,
+ });
+
+ // Save MCP scopes to config for .env generation
+ bootstrapConfig.mcpScopes = mcpScopes;
+
+ rollbackState = updateRollbackState(rollbackState, {
+ agentConnections: [{ agentId: agentIdentityId, connectionId: connection.id }],
+ });
+
+ spinner.succeed(`Agent connection created: ${chalk.cyan(connection.id)}`);
+ } catch (error: any) {
+ spinner.fail(`Agent connection creation failed: ${error.message}`);
+ console.log(chalk.gray(` → Check agent and authorization server in Okta Admin Console`));
+ throw error;
+ }
+
+ // Step 11: Create Access Policies
+ console.log(chalk.bold('\n📋 Step 11: Creating Access Policies'));
+
+ // MCP AS Policy (for agent identity)
+ spinner = ora('Creating MCP policy...').start();
+ const mcpPolicy = await oktaClient.createPolicy(mcpAS.id!, {
+ name: 'Default MCP Policy',
+ description: 'Default access policy for MCP server',
+ priority: 1,
+ clientIds: [agentClientId],
+ });
+ rollbackState = updateRollbackState(rollbackState, {
+ mcpPolicyIds: [mcpPolicy.id!],
+ });
+ spinner.succeed('MCP policy created');
+
+ spinner = ora('Creating MCP policy rule...').start();
+ const mcpPolicyRule = await oktaClient.createPolicyRule(mcpAS.id!, mcpPolicy.id!, {
+ name: 'Allow MCP Connection',
+ priority: 1,
+ grantTypes: [
+ 'client_credentials',
+ 'authorization_code',
+ 'urn:ietf:params:oauth:grant-type:device_code',
+ 'urn:ietf:params:oauth:grant-type:token-exchange',
+ 'urn:ietf:params:oauth:grant-type:jwt-bearer',
+ ],
+ scopes: ['mcp:connect', 'mcp:tools:read', 'mcp:tools:manage'],
+ accessTokenLifetimeMinutes: 60,
+ refreshTokenLifetimeMinutes: 129600,
+ refreshTokenWindowMinutes: 10080,
+ });
+ rollbackState = updateRollbackState(rollbackState, {
+ mcpPolicyRuleIds: [mcpPolicyRule.id!],
+ });
+ spinner.succeed('MCP policy rule created');
+
+ // Step 12: Create Trusted Origins
+ console.log(chalk.bold('\n📋 Step 12: Creating Trusted Origins'));
+ spinner = ora('Adding trusted origins...').start();
+
+ const origins = [
+ { name: 'agent0-ui', url: 'http://localhost:3000' },
+ { name: 'todo0-mcp-server', url: 'http://localhost:5002' },
+ ];
+
+ const createdOrigins: string[] = [];
+ for (const { name, url } of origins) {
+ const result = await oktaClient.createTrustedOriginIfNotExists(name, url);
+ if (result.created) {
+ createdOrigins.push(name);
+ }
+ }
+
+ // Only add to rollback state the origins we actually created
+ if (createdOrigins.length > 0) {
+ rollbackState = updateRollbackState(rollbackState, {
+ trustedOriginNames: createdOrigins,
+ });
+ }
+
+ if (origins.length === createdOrigins.length) {
+ spinner.succeed('Trusted origins added');
+ } else {
+ spinner.succeed(`Trusted origins configured (${createdOrigins.length} created, ${origins.length - createdOrigins.length} already existed)`);
+ }
+
+ // Step 13: Generate Configuration Files
+ console.log(chalk.bold('\n📋 Step 13: Generating Configuration Files'));
+ spinner = ora('Writing .env files...').start();
+
+ const agent0AppEnv = generateAgent0AppEnv(bootstrapConfig as BootstrapConfig);
+ writeEnvFile('packages/agent0/.env.app', agent0AppEnv);
+
+ const agent0AgentEnv = generateAgent0AgentEnv(bootstrapConfig as BootstrapConfig);
+ writeEnvFile('packages/agent0/.env.agent', agent0AgentEnv);
+
+ const todo0AppEnv = generateTodo0AppEnv(bootstrapConfig as BootstrapConfig);
+ writeEnvFile('packages/todo0/.env.app', todo0AppEnv);
+
+ const todo0McpEnv = generateTodo0McpEnv(bootstrapConfig as BootstrapConfig);
+ writeEnvFile('packages/todo0/.env.mcp', todo0McpEnv);
+
+ writeConfigReport(bootstrapConfig as BootstrapConfig);
+
+ spinner.succeed('Configuration files generated');
+
+ // Success!
+ console.log(chalk.bold.green('\n✅ Bootstrap Complete!\n'));
+
+ console.log('Next steps:');
+ console.log(` 1. ${chalk.cyan('pnpm install')} - Install dependencies`);
+ console.log(` 2. ${chalk.cyan('pnpm run bootstrap')} - Bootstrap database`);
+ console.log(` 3. ${chalk.cyan('pnpm run start:todo0')} - Start REST API`);
+ console.log(` 4. ${chalk.cyan('pnpm run start:mcp')} - Start MCP Server`);
+ console.log(` 5. ${chalk.cyan('pnpm run start:agent0')} - Start Agent`);
+ console.log(`\n Optional: ${chalk.cyan('pnpm run validate:okta')} - Validate configuration`);
+ console.log(`\n📄 See ${chalk.cyan('okta-config-report.md')} for detailed configuration\n`);
+ } catch (error: any) {
+ console.error(chalk.red('\n❌ Bootstrap failed:'), error.message);
+ console.error(chalk.yellow('\n⚠️ Some resources may have been created.'));
+ console.error(chalk.yellow('Run `pnpm run rollback:okta` to clean up.\n'));
+ process.exit(1);
+ }
+}
+
+// Run bootstrap
+bootstrap().catch((error) => {
+ console.error(chalk.red('Fatal error:'), error);
+ process.exit(1);
+});
diff --git a/scripts/lib/agent-identity-api.ts b/scripts/lib/agent-identity-api.ts
new file mode 100644
index 0000000..8de41dc
--- /dev/null
+++ b/scripts/lib/agent-identity-api.ts
@@ -0,0 +1,560 @@
+/**
+ * Okta Agent Identity API Client
+ *
+ * This file contains the implementation for the Okta Agent Identity API.
+ * These APIs are part of the Workload Principals feature and are not yet
+ * available in the public @okta/okta-sdk-nodejs package.
+ */
+
+import axios, { AxiosInstance } from 'axios';
+import * as jose from 'jose';
+
+// ============================================================================
+// INTERFACES & TYPES
+// ============================================================================
+
+export interface AgentIdentity {
+ id: string;
+ name: string;
+ description?: string;
+ clientId: string;
+ status: 'ACTIVE' | 'INACTIVE' | 'STAGED';
+ created: string;
+ lastUpdated: string;
+}
+
+export interface RegisterAgentRequest {
+ profile: {
+ name: string;
+ description: string;
+ };
+ appId: string;
+}
+
+export interface AgentOperationResult {
+ id: string;
+ status: 'COMPLETED' | 'FAILED' | 'IN_PROGRESS';
+ type: string;
+ resource: {
+ id: string;
+ status: string;
+ type: string;
+ _links: {
+ self: {
+ href: string;
+ };
+ };
+ };
+ created: string;
+ started?: string;
+ completed?: string;
+}
+
+export interface CreateConnectionRequest {
+ connectionType: string;
+ authorizationServer: {
+ orn: string;
+ resourceIndicator: string;
+ };
+ scopeCondition: string;
+ scopes: string[];
+}
+
+export interface AgentConnection {
+ id: string;
+ connectionType: string;
+ authorizationServer: {
+ orn: string;
+ resourceIndicator: string;
+ };
+ scopeCondition: string;
+ scopes: string[];
+ status: string;
+}
+
+export interface OrgMetadata {
+ id: string;
+ [key: string]: any;
+}
+
+export interface AgentIdentityConfig {
+ oktaDomain: string;
+ apiToken: string;
+}
+
+// ============================================================================
+// AGENT IDENTITY API CLIENT
+// ============================================================================
+
+export class AgentIdentityAPIClient {
+ private oktaDomain: string;
+ private apiToken: string;
+ private baseUrl: string;
+
+ constructor(config: AgentIdentityConfig) {
+ this.oktaDomain = config.oktaDomain;
+ this.apiToken = config.apiToken;
+ this.baseUrl = `https://${config.oktaDomain}`;
+ }
+
+ /**
+ * Get axios config with authorization headers
+ */
+ private getAxiosConfig() {
+ return {
+ headers: {
+ 'Authorization': `SSWS ${this.apiToken}`,
+ 'Content-Type': 'application/json',
+ },
+ };
+ }
+
+ /**
+ * Handle axios errors and provide detailed error messages
+ */
+ private handleAxiosError(error: any, context: string, requestBody?: any): never {
+ if (error.response) {
+ // Server responded with error status
+ const status = error.response.status;
+ const data = error.response.data;
+
+ console.error(`\n${context} failed with status ${status}:`);
+
+ // Log request details if available
+ if (error.config?.method) {
+ console.error('Request method:', error.config.method.toUpperCase());
+ }
+ if (error.config?.url) {
+ console.error('Request URL:', error.config.url);
+ }
+ if (requestBody !== undefined) {
+ console.error('Request body:', JSON.stringify(requestBody, null, 2));
+ }
+
+ console.error('Response body:', JSON.stringify(data, null, 2));
+
+ // Extract error message if available
+ const errorMessage = data?.errorSummary || data?.message || error.message;
+ throw new Error(`${context}: ${errorMessage} (HTTP ${status})`);
+ } else if (error.request) {
+ // Request made but no response received
+ throw new Error(`${context}: No response received from server`);
+ } else {
+ // Error in request setup
+ throw new Error(`${context}: ${error.message}`);
+ }
+ }
+
+ // ==========================================================================
+ // AGENT REGISTRATION & LIFECYCLE
+ // ==========================================================================
+
+ /**
+ * Register a new agent identity (async operation)
+ * Returns the operation URL to poll for completion
+ */
+ async registerAgent(request: RegisterAgentRequest): Promise {
+ try {
+ const response = await axios.post(
+ `${this.baseUrl}/workload-principals/api/v1/ai-agents`,
+ request,
+ this.getAxiosConfig()
+ );
+
+ if (response.status !== 202) {
+ throw new Error(`Unexpected status: ${response.status}`);
+ }
+
+ const operationUrl = response.headers['location'];
+ if (!operationUrl) {
+ throw new Error('No Location header in registration response');
+ }
+
+ return operationUrl;
+ } catch (error: any) {
+ this.handleAxiosError(error, 'Register agent', request);
+ }
+ }
+
+ /**
+ * Poll an async operation until it completes
+ */
+ async pollOperation(
+ operationUrl: string,
+ timeoutMs: number = 60000,
+ intervalMs: number = 2000
+ ): Promise {
+ const maxAttempts = Math.ceil(timeoutMs / intervalMs);
+ let attempts = 0;
+
+ while (attempts < maxAttempts) {
+ await new Promise(resolve => setTimeout(resolve, intervalMs));
+ attempts++;
+
+ const response = await axios.get(operationUrl, this.getAxiosConfig());
+ const operation = response.data as AgentOperationResult;
+
+ if (operation.status === 'COMPLETED') {
+ return operation;
+ } else if (operation.status === 'FAILED') {
+ throw new Error('Agent registration failed');
+ }
+ }
+
+ throw new Error(`Operation timed out after ${timeoutMs}ms`);
+ }
+
+ /**
+ * Get agent identity details by ID
+ */
+ async getAgent(agentId: string): Promise {
+ try {
+ const response = await axios.get(
+ `${this.baseUrl}/workload-principals/api/v1/ai-agents/${agentId}`,
+ this.getAxiosConfig()
+ );
+
+ return response.data as AgentIdentity;
+ } catch (error: any) {
+ this.handleAxiosError(error, 'Get agent');
+ }
+ }
+
+ /**
+ * Activate an agent identity (async operation)
+ * Returns the operation URL to poll for completion
+ */
+ async activateAgent(agentId: string): Promise {
+ try {
+ const response = await axios.post(
+ `${this.baseUrl}/workload-principals/api/v1/ai-agents/${agentId}/lifecycle/activate`,
+ {},
+ this.getAxiosConfig()
+ );
+
+ if (response.status !== 202) {
+ throw new Error(`Unexpected status: ${response.status}`);
+ }
+
+ const operationUrl = response.headers['location'];
+ if (!operationUrl) {
+ throw new Error('No Location header in activation response');
+ }
+
+ return operationUrl;
+ } catch (error: any) {
+ this.handleAxiosError(error, 'Activate agent');
+ }
+ }
+
+ /**
+ * Deactivate an agent identity (async operation)
+ * Returns the operation URL to poll for completion
+ */
+ async deactivateAgent(agentId: string): Promise {
+ try {
+ const response = await axios.post(
+ `${this.baseUrl}/workload-principals/api/v1/ai-agents/${agentId}/lifecycle/deactivate`,
+ {},
+ this.getAxiosConfig()
+ );
+
+ if (response.status !== 202) {
+ throw new Error(`Unexpected status: ${response.status}`);
+ }
+
+ const operationUrl = response.headers['location'];
+ if (!operationUrl) {
+ throw new Error('No Location header in deactivation response');
+ }
+
+ return operationUrl;
+ } catch (error: any) {
+ this.handleAxiosError(error, 'Deactivate agent');
+ }
+ }
+
+ /**
+ * Delete an agent identity (async operation)
+ * Returns the operation URL to poll for completion
+ */
+ async deleteAgent(agentId: string): Promise {
+ try {
+ const response = await axios.delete(
+ `${this.baseUrl}/workload-principals/api/v1/ai-agents/${agentId}`,
+ this.getAxiosConfig()
+ );
+
+ if (response.status !== 202) {
+ throw new Error(`Unexpected status: ${response.status}`);
+ }
+
+ const operationUrl = response.headers['location'];
+ if (!operationUrl) {
+ throw new Error('No Location header in deletion response');
+ }
+
+ return operationUrl;
+ } catch (error: any) {
+ this.handleAxiosError(error, 'Delete agent');
+ }
+ }
+
+ // ==========================================================================
+ // KEY MANAGEMENT
+ // ==========================================================================
+
+ /**
+ * Upload a public key to an agent identity
+ */
+ async uploadPublicKey(agentId: string, jwk: jose.JWK): Promise<{ kid: string }> {
+ try {
+ const response = await axios.post(
+ `${this.baseUrl}/workload-principals/api/v1/ai-agents/${agentId}/credentials/jwks`,
+ jwk,
+ this.getAxiosConfig()
+ );
+
+ const kid = response.data.kid;
+ if (!kid) {
+ throw new Error('Public key uploaded but no kid found in response');
+ }
+
+ return { kid };
+ } catch (error: any) {
+ this.handleAxiosError(error, 'Upload public key', jwk);
+ }
+ }
+
+ // ==========================================================================
+ // CONNECTION MANAGEMENT
+ // ==========================================================================
+
+ /**
+ * Create a connection between agent and authorization server
+ */
+ async createConnection(
+ agentId: string,
+ request: CreateConnectionRequest
+ ): Promise {
+ try {
+ const response = await axios.post(
+ `${this.baseUrl}/workload-principals/api/v1/ai-agents/${agentId}/connections`,
+ request,
+ this.getAxiosConfig()
+ );
+
+ return response.data as AgentConnection;
+ } catch (error: any) {
+ this.handleAxiosError(error, 'Create connection', request);
+ }
+ }
+
+ /**
+ * Delete a connection between agent and authorization server
+ */
+ async deleteConnection(agentId: string, connectionId: string): Promise {
+ try {
+ const response = await axios.delete(
+ `${this.baseUrl}/workload-principals/api/v1/ai-agents/${agentId}/connections/${connectionId}`,
+ this.getAxiosConfig()
+ );
+
+ // spec wrong, says 204 but actually returns 200
+ if (response.status !== 200) {
+ throw new Error(`Unexpected status: ${response.status}`);
+ }
+ } catch (error: any) {
+ this.handleAxiosError(error, 'Delete connection');
+ }
+ }
+
+ // ==========================================================================
+ // UTILITIES
+ // ==========================================================================
+
+ /**
+ * Get organization metadata (includes org ID for ORN construction)
+ */
+ async getOrgMetadata(): Promise {
+ try {
+ const response = await axios.get(
+ `${this.baseUrl}/api/v1/org`,
+ this.getAxiosConfig()
+ );
+
+ return response.data as OrgMetadata;
+ } catch (error: any) {
+ this.handleAxiosError(error, 'Get org metadata');
+ }
+ }
+
+ /**
+ * Get the current authenticated user (associated with the API token)
+ */
+ async getCurrentUser(): Promise<{ id: string; login: string }> {
+ try {
+ const response = await axios.get(
+ `${this.baseUrl}/api/v1/users/me`,
+ this.getAxiosConfig()
+ );
+
+ return {
+ id: response.data.id,
+ login: response.data.profile.login,
+ };
+ } catch (error: any) {
+ this.handleAxiosError(error, 'Get current user');
+ }
+ }
+
+ // ==========================================================================
+ // AGENT OWNERSHIP
+ // ==========================================================================
+
+ /**
+ * Set agent owners using the standard governance API
+ */
+ async setAgentOwnersStandard(
+ agentId: string,
+ orgId: string,
+ userId: string
+ ): Promise {
+ try {
+ const principalOrn = `orn:okta:directory:${orgId}:users:${userId}`;
+ const resourceOrn = `orn:okta:directory:${orgId}:workload-principals:ai-agents:${agentId}`;
+
+ const response = await axios.post(
+ `${this.baseUrl}/governance/api/v1/resource-owners`,
+ {
+ principalOrns: [principalOrn],
+ resourceOrns: [resourceOrn],
+ },
+ this.getAxiosConfig()
+ );
+
+ if (response.status !== 200 && response.status !== 201 && response.status !== 204) {
+ throw new Error(`Unexpected status: ${response.status}`);
+ }
+ } catch (error: any) {
+ const requestBody = {
+ principalOrns: [`orn:okta:directory:${orgId}:users:${userId}`],
+ resourceOrns: [`orn:okta:directory:${orgId}:workload-principals:ai-agents:${agentId}`],
+ };
+ this.handleAxiosError(error, 'Set agent owners (standard)', requestBody);
+ }
+ }
+
+ /**
+ * Set agent owners using the developer API (for local development)
+ * This requires two API calls: setupProxy and then set resource owners
+ */
+ async setAgentOwnersDeveloper(agentId: string, orgId: string): Promise {
+ try {
+ // Step 1: Setup proxy
+ const setupResponse = await axios.post(
+ `${this.baseUrl}/devtools/api/ai-agent/ramp/setupProxy?orgId=${orgId}`,
+ {},
+ this.getAxiosConfig()
+ );
+
+ if (setupResponse.status !== 200 && setupResponse.status !== 201 && setupResponse.status !== 204) {
+ throw new Error(`Setup proxy unexpected status: ${setupResponse.status}`);
+ }
+
+ // Step 2: Set resource owners
+ const resourceOrn = `orn:okta:directory:${orgId}:workload-principals:ai-agents:${agentId}`;
+ const ownersResponse = await axios.put(
+ `${this.baseUrl}/devtools/api/ai-agent/ramp/resourceOwners/${encodeURIComponent(resourceOrn)}`,
+ {},
+ this.getAxiosConfig()
+ );
+
+ if (ownersResponse.status !== 200 && ownersResponse.status !== 201 && ownersResponse.status !== 204) {
+ throw new Error(`Set resource owners unexpected status: ${ownersResponse.status}`);
+ }
+ } catch (error: any) {
+ this.handleAxiosError(error, 'Set agent owners (developer)', {});
+ }
+ }
+
+ /**
+ * Remove agent owners using the standard governance API
+ * Sets principalOrns to null to remove all owners
+ */
+ async removeAgentOwnersStandard(agentId: string, orgId: string): Promise {
+ try {
+ const resourceOrn = `orn:okta:directory:${orgId}:workload-principals:ai-agents:${agentId}`;
+
+ const response = await axios.post(
+ `${this.baseUrl}/governance/api/v1/resource-owners`,
+ {
+ principalOrns: null,
+ resourceOrns: [resourceOrn],
+ },
+ this.getAxiosConfig()
+ );
+
+ if (response.status !== 200 && response.status !== 201 && response.status !== 204) {
+ throw new Error(`Unexpected status: ${response.status}`);
+ }
+ } catch (error: any) {
+ const requestBody = {
+ principalOrns: null,
+ resourceOrns: [`orn:okta:directory:${orgId}:workload-principals:ai-agents:${agentId}`],
+ };
+ this.handleAxiosError(error, 'Remove agent owners (standard)', requestBody);
+ }
+ }
+
+ /**
+ * Remove agent owners using the developer API (for local development)
+ */
+ async removeAgentOwnersDeveloper(orgId: string): Promise {
+ try {
+ const response = await axios.delete(
+ `${this.baseUrl}/devtools/api/ai-agent/ramp/setupProxy?orgId=${orgId}`,
+ this.getAxiosConfig()
+ );
+
+ if (response.status !== 200 && response.status !== 204) {
+ throw new Error(`Unexpected status: ${response.status}`);
+ }
+ } catch (error: any) {
+ this.handleAxiosError(error, 'Remove agent owners (developer)');
+ }
+ }
+}
+
+// ============================================================================
+// HELPER UTILITIES
+// ============================================================================
+
+/**
+ * Convert PEM-encoded public key (SPKI format) to JWK format for Okta JWKS endpoint
+ */
+export async function convertPublicKeyToJWK(publicKeyPem: string): Promise {
+ // Import the public key using jose
+ const publicKey = await jose.importSPKI(publicKeyPem, 'RS256');
+
+ // Export as JWK
+ const jwk = await jose.exportJWK(publicKey);
+
+ // Calculate JWK thumbprint for kid (RFC 7638)
+ const kid = await jose.calculateJwkThumbprint(jwk, 'sha256');
+
+ // Add required fields for Okta
+ return {
+ ...jwk,
+ kid,
+ alg: 'RS256',
+ use: 'sig',
+ };
+}
+
+/**
+ * Construct an Okta Resource Name (ORN) for an authorization server
+ */
+export function constructAuthServerORN(orgId: string, authServerId: string): string {
+ return `orn:okta:idp:${orgId}:authorization_servers:${authServerId}`;
+}
diff --git a/scripts/lib/env-writer.ts b/scripts/lib/env-writer.ts
new file mode 100644
index 0000000..30bf741
--- /dev/null
+++ b/scripts/lib/env-writer.ts
@@ -0,0 +1,281 @@
+import * as fs from 'fs';
+import * as path from 'path';
+
+export interface BootstrapConfig {
+ oktaDomain: string;
+
+ // Applications
+ agentAppClientId: string;
+ agentAppClientSecret: string;
+ agentIdentityClientId: string;
+
+ todo0AppClientId: string;
+ todo0AppClientSecret: string;
+
+ // Keys
+ privateKeyFile: string;
+ keyId: string;
+
+ // Authorization Servers
+ mcpAuthServerId: string;
+ mcpAudience: string;
+ mcpScopes: string[];
+}
+
+/**
+ * Generate .env.app file for agent0 package (Resource Server)
+ */
+export function generateAgent0AppEnv(config: BootstrapConfig): string {
+ return `# ============================================================================
+# RESOURCE SERVER CONFIGURATION
+# ============================================================================
+PORT=3000
+SESSION_SECRET=default-secret-change-in-production
+
+# ============================================================================
+# RESOURCE SERVER - OKTA OAUTH (HUMAN SSO)
+# ============================================================================
+OKTA_DOMAIN=${config.oktaDomain}
+OKTA_CLIENT_ID=${config.agentAppClientId}
+OKTA_CLIENT_SECRET=${config.agentAppClientSecret}
+OKTA_REDIRECT_URI=http://localhost:3000/callback
+
+`;
+}
+
+/**
+ * Generate .env.agent file for agent0 package (AI Agent / MCP Client)
+ */
+export function generateAgent0AgentEnv(config: BootstrapConfig): string {
+ return `# ============================================================================
+# AGENT - MCP CLIENT CONFIGURATION
+# ============================================================================
+MCP_SERVER_URL=http://localhost:5002/mcp
+
+# ============================================================================
+# AGENT - LLM INTEGRATION CONFIGURATION
+# ============================================================================
+# Configure EITHER Anthropic Direct OR AWS Bedrock
+
+# Anthropic Direct
+ANTHROPIC_API_KEY=your_anthropic_api_key_here
+ANTHROPIC_MODEL=claude-3-5-sonnet-20241022
+
+# AWS Bedrock (alternative)
+# AWS_REGION=us-east-1
+# AWS_ACCESS_KEY_ID=your_aws_access_key
+# AWS_SECRET_ACCESS_KEY=your_aws_secret_key
+# BEDROCK_MODEL_ID=us.anthropic.claude-3-5-sonnet-20241022-v2:0
+
+# ============================================================================
+# AGENT - CROSS-APP ACCESS (ID-JAG TOKEN EXCHANGE)
+# ============================================================================
+# Agent Identity Configuration
+OKTA_DOMAIN=${config.oktaDomain}
+AI_AGENT_ID=${config.agentIdentityClientId}
+AI_AGENT_PRIVATE_KEY_FILE=${config.privateKeyFile}
+AI_AGENT_PRIVATE_KEY_KID=${config.keyId}
+AI_AGENT_TODO_MCP_SERVER_SCOPES_TO_REQUEST=${config.mcpScopes.join(' ')}
+
+# MCP Authorization Server (for todo0 MCP server)
+MCP_AUTHORIZATION_SERVER=https://${config.oktaDomain}/oauth2/${config.mcpAuthServerId}
+MCP_AUTHORIZATION_SERVER_TOKEN_ENDPOINT=https://${config.oktaDomain}/oauth2/${config.mcpAuthServerId}/v1/token
+
+`;
+}
+
+/**
+ * Generate .env.app file for todo0 package (App server)
+ */
+export function generateTodo0AppEnv(config: BootstrapConfig): string {
+ return `# ============================================================================
+# APP SERVER CONFIGURATION
+# ============================================================================
+PORT=5001
+
+# ============================================================================
+# APP SERVER - OKTA OAUTH (HUMAN SSO)
+# ============================================================================
+OKTA_ISSUER=https://${config.oktaDomain}/oauth2/default
+OKTA_CLIENT_ID=${config.todo0AppClientId}
+OKTA_CLIENT_SECRET=${config.todo0AppClientSecret}
+OKTA_REDIRECT_URI=http://localhost:5001/callback
+
+# ============================================================================
+# DATABASE CONFIGURATION
+# ============================================================================
+# Database connection configured in prisma/schema.prisma
+# Default: SQLite with file ./dev.db
+`;
+}
+
+/**
+ * Generate .env.mcp file for todo0 package (MCP server)
+ */
+export function generateTodo0McpEnv(config: BootstrapConfig): string {
+ return `# ============================================================================
+# MCP SERVER CONFIGURATION
+# ============================================================================
+MCP_PORT=5002
+
+# ============================================================================
+# MCP SERVER - OKTA JWT AUTHENTICATION
+# ============================================================================
+MCP_OKTA_ISSUER=https://${config.oktaDomain}/oauth2/${config.mcpAuthServerId}
+MCP_EXPECTED_AUDIENCE=${config.mcpAudience}
+
+# ============================================================================
+# DATABASE CONFIGURATION
+# ============================================================================
+# Database connection configured in prisma/schema.prisma
+# Default: SQLite with file ./dev.db
+`;
+}
+
+/**
+ * Write .env file to disk
+ */
+export function writeEnvFile(filePath: string, content: string): void {
+ const absolutePath = path.resolve(filePath);
+ const dir = path.dirname(absolutePath);
+
+ // Ensure directory exists
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+
+ // Check if .env already exists
+ if (fs.existsSync(absolutePath)) {
+ const backup = `${absolutePath}.backup`;
+ fs.copyFileSync(absolutePath, backup);
+ console.log(` Backed up existing .env to: ${backup}`);
+ }
+
+ // Write new .env file
+ fs.writeFileSync(absolutePath, content, { mode: 0o600 });
+ console.log(`✓ Created .env file: ${absolutePath}`);
+}
+
+/**
+ * Generate configuration report markdown
+ */
+export function generateConfigReport(config: BootstrapConfig): string {
+ return `# Okta Tenant Bootstrap Report
+
+Generated: ${new Date().toISOString()}
+
+## Authorization Servers
+
+### Org AS (Pre-existing)
+- **URL**: https://${config.oktaDomain}/oauth2/v1
+- **Purpose**: Human SSO, ID-JAG token issuance
+- **Used by**: Resource Server OIDC, Agent Identity client_credentials
+
+### Todo0 MCP Server Authorization Server
+- **ID**: \`${config.mcpAuthServerId}\`
+- **Issuer**: https://${config.oktaDomain}/oauth2/${config.mcpAuthServerId}
+- **Audience**: \`${config.mcpAudience}\`
+- **Purpose**: Protect todo0 MCP server endpoints (port 5002)
+- **Scopes**:
+ - \`mcp:connect\` - Establish MCP connection
+ - \`mcp:tools:read\` - Execute tools to read todo data
+ - \`mcp:tools:manage\` - Execute tools to manage todo data
+
+## Applications
+
+### Resource Server (OIDC Client)
+- **Client ID**: \`${config.agentAppClientId}\`
+- **Type**: Web Application
+- **Grant Types**: Authorization Code with PKCE
+- **Redirect URI**: http://localhost:3000/callback
+- **Purpose**: Human user authentication for web UI
+
+### Agent Identity (MCP Client)
+- **Client ID**: \`${config.agentIdentityClientId}\`
+- **Type**: Service (Native with Private Key JWT)
+- **Grant Types**:
+ - \`client_credentials\` (for ID-JAG from Org AS)
+ - \`urn:ietf:params:oauth:grant-type:jwt-bearer\` (for token exchange)
+- **Authentication**: Private Key JWT
+- **Key ID (KID)**: \`${config.keyId}\`
+- **Private Key**: \`packages/agent0/${config.privateKeyFile}\`
+- **Purpose**: Agent authentication for cross-app access
+
+## Token Exchange Flow
+
+### Step 1: User Login
+\`\`\`
+User → Resource Server → Org AS
+ Grant: Authorization Code + PKCE
+ Result: ID Token + Access Token
+\`\`\`
+
+### Step 2: Agent Gets ID-JAG
+\`\`\`
+Agent Identity → Org AS (/oauth2/v1/token)
+ Grant: client_credentials
+ Auth: Private Key JWT
+ Result: ID-JAG Token
+\`\`\`
+
+### Step 3: Exchange for MCP Token
+\`\`\`
+Agent Identity → MCP AS (/oauth2/${config.mcpAuthServerId}/v1/token)
+ Grant: urn:ietf:params:oauth:grant-type:jwt-bearer
+ Assertion: ID-JAG Token
+ Audience: ${config.mcpAudience}
+ Result: MCP Access Token (aud: ${config.mcpAudience})
+\`\`\`
+
+## Security Boundaries
+
+| Service | Port | Auth Server | Audience | Validates |
+|---------|------|-------------|----------|-----------|
+| **Agent0 Web UI** | 3000 | Org AS | - | Session-based |
+| **Todo0 MCP Server** | 5002 | Todo0 MCP AS | \`${config.mcpAudience}\` | JWT (requireMcpAuth) |
+
+## Files Generated
+
+- \`packages/agent0/.env.app\` - Agent0 web UI configuration
+- \`packages/agent0/.env.agent\` - Agent0 agent identity configuration
+- \`packages/todo0/.env.app\` - Todo0 REST API server configuration
+- \`packages/todo0/.env.mcp\` - Todo0 MCP server configuration
+- \`packages/agent0/${config.privateKeyFile}\` - RSA private key (600 permissions)
+- \`okta-config-report.md\` - This report
+
+## Next Steps
+
+1. **Install dependencies**: \`pnpm install\`
+2. **Bootstrap database**: \`pnpm run bootstrap\`
+3. **Start Todo0 REST API**: \`pnpm run start:todo0\`
+4. **Start MCP Server**: \`pnpm run start:mcp\`
+5. **Start Agent**: \`pnpm run start:agent0\`
+6. **Validate config** (optional): \`pnpm run validate:okta\`
+
+## Important Notes
+
+⚠️ **Security Warnings**:
+- Private key file contains sensitive credentials - never commit to git
+- Ensure \`.env\` files are in \`.gitignore\`
+- Keep your Okta API token secure
+- Rotate keys periodically
+
+🔄 **Rollback**:
+- To remove all created resources: \`pnpm run rollback:okta\`
+- Backup .env files are created before overwriting
+
+📖 **Documentation**:
+- See README.md for architecture details
+- See MCP specification for token passthrough best practices
+`;
+}
+
+/**
+ * Write configuration report to file
+ */
+export function writeConfigReport(config: BootstrapConfig, filePath: string = 'okta-config-report.md'): void {
+ const absolutePath = path.resolve(filePath);
+ const content = generateConfigReport(config);
+ fs.writeFileSync(absolutePath, content);
+ console.log(`✓ Configuration report saved: ${absolutePath}`);
+}
diff --git a/scripts/lib/key-generator.ts b/scripts/lib/key-generator.ts
new file mode 100644
index 0000000..aee835e
--- /dev/null
+++ b/scripts/lib/key-generator.ts
@@ -0,0 +1,81 @@
+import * as jose from 'jose';
+import * as fs from 'fs';
+import * as path from 'path';
+
+export interface KeyPair {
+ privateKeyPem: string;
+ publicKeyPem: string;
+ certificate: string;
+}
+
+/**
+ * Generate an RSA key pair for JWT authentication
+ * Returns private key and public key in PEM format
+ * Note: certificate field is kept for backward compatibility but not used
+ */
+export async function generateRSAKeyPair(): Promise {
+ console.log('Generating 2048-bit RSA key pair...');
+
+ // Generate RSA key pair using jose
+ const { publicKey, privateKey } = await jose.generateKeyPair('RS256', {
+ modulusLength: 2048,
+ extractable: true,
+ });
+
+ // Export keys to PEM format
+ const privateKeyPem = await jose.exportPKCS8(privateKey);
+ const publicKeyPem = await jose.exportSPKI(publicKey);
+
+ console.log('✓ Key pair generated successfully');
+
+ return {
+ privateKeyPem,
+ publicKeyPem,
+ certificate: '', // No longer needed - Okta accepts JWK directly
+ };
+}
+
+/**
+ * Save private key to file with secure permissions
+ */
+export async function savePrivateKey(privateKeyPem: string, filePath: string): Promise {
+ const absolutePath = path.resolve(filePath);
+ const dir = path.dirname(absolutePath);
+
+ // Ensure directory exists
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true });
+ }
+
+ // Write private key to file
+ fs.writeFileSync(absolutePath, privateKeyPem, { mode: 0o600 });
+
+ console.log(`✓ Private key saved to: ${absolutePath}`);
+ console.log(` File permissions: 600 (owner read/write only)`);
+}
+
+/**
+ * Check if private key file already exists
+ */
+export function privateKeyExists(filePath: string): boolean {
+ return fs.existsSync(path.resolve(filePath));
+}
+
+/**
+ * Load existing private key from file
+ */
+export function loadPrivateKey(filePath: string): string {
+ const absolutePath = path.resolve(filePath);
+ if (!fs.existsSync(absolutePath)) {
+ throw new Error(`Private key file not found: ${absolutePath}`);
+ }
+ return fs.readFileSync(absolutePath, 'utf8');
+}
+
+/**
+ * Extract public key from private key PEM
+ */
+export async function extractPublicKeyFromPrivateKey(privateKeyPem: string): Promise {
+ const privateKey = await jose.importPKCS8(privateKeyPem, 'RS256');
+ return await jose.exportSPKI(privateKey);
+}
diff --git a/scripts/lib/okta-api.ts b/scripts/lib/okta-api.ts
new file mode 100644
index 0000000..f0f2d3a
--- /dev/null
+++ b/scripts/lib/okta-api.ts
@@ -0,0 +1,355 @@
+import {
+ Client,
+ AuthorizationServer,
+ Application,
+ OpenIdConnectApplication,
+ OAuth2Scope,
+ AuthorizationServerPolicyRule
+} from '@okta/okta-sdk-nodejs';
+
+export interface OktaConfig {
+ orgUrl: string;
+ token: string;
+}
+
+export interface AuthServerConfig {
+ name: string;
+ description: string;
+ audiences: string[];
+}
+
+export interface ScopeConfig {
+ name: string;
+ description: string;
+ displayName?: string;
+}
+
+export interface PolicyConfig {
+ name: string;
+ description: string;
+ priority: number;
+ clientIds: string[];
+}
+
+export interface PolicyRuleConfig {
+ name: string;
+ priority: number;
+ grantTypes: string[];
+ scopes?: string[];
+ accessTokenLifetimeMinutes: number;
+ refreshTokenLifetimeMinutes?: number;
+ refreshTokenWindowMinutes?: number;
+ userGroups?: string[];
+}
+
+import type { ApplicationSignOnMode } from '@okta/okta-sdk-nodejs';
+
+export interface ApplicationConfig {
+ name: string;
+ label: string;
+ signOnMode: ApplicationSignOnMode;
+ credentials?: {
+ oauthClient?: {
+ token_endpoint_auth_method: string;
+ autoKeyRotation?: boolean;
+ };
+ };
+ settings: {
+ oauthClient: {
+ client_uri?: string;
+ logo_uri?: string;
+ redirect_uris?: string[];
+ post_logout_redirect_uris?: string[];
+ response_types?: string[];
+ grant_types: string[];
+ application_type?: string;
+ consent_method?: string;
+ issuer_mode?: string;
+ };
+ implicitAssignment?: boolean;
+ };
+}
+
+export class OktaAPIClient {
+ private client: Client;
+
+ constructor(config: OktaConfig) {
+ this.client = new Client({
+ orgUrl: config.orgUrl,
+ token: config.token,
+ });
+ }
+
+ /**
+ * Create a new custom authorization server
+ */
+ async createAuthorizationServer(config: AuthServerConfig): Promise {
+ const authServer = await this.client.authorizationServerApi.createAuthorizationServer({
+ authorizationServer: {
+ name: config.name,
+ description: config.description,
+ audiences: config.audiences,
+ },
+ });
+ return authServer;
+ }
+
+ /**
+ * Get authorization server by name
+ */
+ async getAuthorizationServerByName(name: string): Promise {
+ const authorizationServers = await this.client.authorizationServerApi.listAuthorizationServers();
+ for await (const as of authorizationServers) {
+ if (as && as.name === name) {
+ return as;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Delete authorization server by ID
+ */
+ async deleteAuthorizationServer(authServerId: string): Promise {
+ await this.client.authorizationServerApi.deactivateAuthorizationServer({ authServerId });
+ await this.client.authorizationServerApi.deleteAuthorizationServer({ authServerId });
+ }
+
+ /**
+ * Add custom scopes to an authorization server
+ */
+ async addScopes(authServerId: string, scopes: ScopeConfig[]): Promise {
+ const createdScopes: OAuth2Scope[] = [];
+
+ for (const scope of scopes) {
+ const oAuth2Scope = await this.client.authorizationServerApi.createOAuth2Scope({
+ authServerId,
+ oAuth2Scope: {
+ name: scope.name,
+ description: scope.description,
+ displayName: scope.displayName || scope.name,
+ consent: 'REQUIRED',
+ },
+ });
+ createdScopes.push(oAuth2Scope);
+ }
+
+ return createdScopes;
+ }
+
+ /**
+ * Create an access policy for an authorization server
+ */
+ async createPolicy(authServerId: string, config: PolicyConfig): Promise {
+ const policy = await this.client.authorizationServerApi.createAuthorizationServerPolicy({
+ authServerId,
+ policy: {
+ name: config.name,
+ description: config.description,
+ priority: config.priority,
+ conditions: {
+ clients: {
+ include: config.clientIds,
+ },
+ },
+ type: 'OAUTH_AUTHORIZATION_POLICY',
+ },
+ });
+ return policy;
+ }
+
+ /**
+ * Create a policy rule for an authorization server policy
+ */
+ async createPolicyRule(
+ authServerId: string,
+ policyId: string,
+ config: PolicyRuleConfig
+ ): Promise {
+ const rule = await this.client.authorizationServerApi.createAuthorizationServerPolicyRule({
+ authServerId,
+ policyId,
+ policyRule: {
+ name: config.name,
+ priority: config.priority,
+ conditions: {
+ grantTypes: {
+ include: config.grantTypes,
+ },
+ people: {
+ users: {
+ include: [],
+ },
+ groups: {
+ include: config.userGroups || ['EVERYONE'],
+ },
+ },
+ scopes: config.scopes ? {
+ include: config.scopes,
+ } : {
+ include: ['*'],
+ },
+ },
+ actions: {
+ token: {
+ accessTokenLifetimeMinutes: config.accessTokenLifetimeMinutes,
+ refreshTokenLifetimeMinutes: config.refreshTokenLifetimeMinutes || 129600,
+ refreshTokenWindowMinutes: config.refreshTokenWindowMinutes || 10080,
+ },
+ },
+ type: 'RESOURCE_ACCESS',
+ },
+ });
+ return rule;
+ }
+
+ /**
+ * Create an OAuth2 application
+ */
+ async createApplication(config: ApplicationConfig): Promise {
+ const app = await this.client.applicationApi.createApplication({
+ application: config as any,
+ });
+ return app;
+ }
+
+ /**
+ * Get application by label
+ */
+ async getApplicationByLabel(label: string): Promise {
+ const applications = await this.client.applicationApi.listApplications({ q: label });
+ for await (const app of applications) {
+ if (app && app.label === label) {
+ return app;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Delete application by ID
+ */
+ async deleteApplication(appId: string): Promise {
+ await this.client.applicationApi.deactivateApplication({ appId });
+ await this.client.applicationApi.deleteApplication({ appId });
+ }
+
+ /**
+ * Upload public key to application for private key JWT authentication
+ */
+ async uploadPublicKey(appId: string, publicKeyPem: string): Promise<{ kid: string }> {
+ const result = await this.client.applicationApi.generateApplicationKey({
+ appId,
+ validityYears: 2,
+ });
+ return { kid: result.kid! };
+ }
+
+ /**
+ * Create a trusted origin for CORS
+ */
+ async createTrustedOrigin(name: string, origin: string): Promise {
+ await this.client.trustedOriginApi.createTrustedOrigin({
+ trustedOrigin: {
+ name,
+ origin,
+ scopes: [
+ { type: 'CORS' as const },
+ { type: 'REDIRECT' as const },
+ ],
+ },
+ });
+ }
+
+ /**
+ * Get trusted origin by name
+ */
+ async getTrustedOriginByName(name: string): Promise {
+ const origins = await this.client.trustedOriginApi.listTrustedOrigins();
+ for await (const trustedOrigin of origins) {
+ if (trustedOrigin && trustedOrigin.name === name) {
+ return trustedOrigin;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Create a trusted origin if it doesn't already exist (idempotent)
+ */
+ async createTrustedOriginIfNotExists(name: string, origin: string): Promise<{ created: boolean; id: string | null }> {
+ const existing = await this.getTrustedOriginByName(name);
+ if (existing) {
+ return { created: false, id: existing.id };
+ }
+
+ await this.createTrustedOrigin(name, origin);
+ const created = await this.getTrustedOriginByName(name);
+ return { created: true, id: created?.id || null };
+ }
+
+ /**
+ * Delete trusted origin by name
+ */
+ async deleteTrustedOriginByName(name: string): Promise {
+ const origins = await this.client.trustedOriginApi.listTrustedOrigins();
+ for await (const trustedOrigin of origins) {
+ if (trustedOrigin && trustedOrigin.name === name && trustedOrigin.id) {
+ await this.client.trustedOriginApi.deleteTrustedOrigin({ trustedOriginId: trustedOrigin.id });
+ break;
+ }
+ }
+ }
+
+ /**
+ * Grant application access to authorization server
+ */
+ async grantApplicationToAuthServer(authServerId: string, clientId: string): Promise {
+ // This is typically done by adding the client to a policy
+ // The policy creation already handles this via the clientIds parameter
+ console.log(`Application ${clientId} granted access to auth server ${authServerId} via policy`);
+ }
+
+ /**
+ * Delete a policy rule from an authorization server policy
+ */
+ async deletePolicyRule(authServerId: string, policyId: string, ruleId: string): Promise {
+ await this.client.authorizationServerApi.deleteAuthorizationServerPolicyRule({
+ authServerId,
+ policyId,
+ ruleId,
+ });
+ }
+
+ /**
+ * Delete a policy from an authorization server
+ */
+ async deletePolicy(authServerId: string, policyId: string): Promise {
+ await this.client.authorizationServerApi.deleteAuthorizationServerPolicy({
+ authServerId,
+ policyId,
+ });
+ }
+
+ /**
+ * Assign a user to an application
+ */
+ async assignUserToApplication(appId: string, userId: string): Promise {
+ await this.client.applicationApi.assignUserToApplication({
+ appId,
+ appUser: {
+ id: userId,
+ },
+ });
+ }
+
+ /**
+ * Unassign a user from an application
+ */
+ async unassignUserFromApplication(appId: string, userId: string): Promise {
+ await this.client.applicationApi.unassignUserFromApplication({
+ appId,
+ userId,
+ });
+ }
+}
diff --git a/scripts/lib/state-manager.ts b/scripts/lib/state-manager.ts
new file mode 100644
index 0000000..68bf6e3
--- /dev/null
+++ b/scripts/lib/state-manager.ts
@@ -0,0 +1,152 @@
+import * as fs from 'fs';
+import * as path from 'path';
+
+/**
+ * Agent connection info for rollback
+ */
+export interface AgentConnectionInfo {
+ agentId: string;
+ connectionId: string;
+}
+
+/**
+ * Rollback state structure that tracks all resources created across multiple bootstrap runs
+ */
+export interface RollbackState {
+ oktaDomain: string;
+ mcpAuthServerIds: string[];
+ agent0AppIds: string[];
+ todo0AppIds: string[];
+ agent0AppUserIds: string[];
+ todo0AppUserIds: string[];
+ agentIdentityIds: string[];
+ agentConnections: AgentConnectionInfo[];
+ agentOwnerSetupMethod?: 'standard' | 'developer';
+ mcpPolicyIds: string[];
+ mcpPolicyRuleIds: string[];
+ trustedOriginNames: string[];
+}
+
+const STATE_FILE_PATH = '.okta-bootstrap-state.json';
+
+/**
+ * Initialize an empty rollback state
+ */
+export function createEmptyState(oktaDomain: string): RollbackState {
+ return {
+ oktaDomain,
+ mcpAuthServerIds: [],
+ agent0AppIds: [],
+ todo0AppIds: [],
+ agent0AppUserIds: [],
+ todo0AppUserIds: [],
+ agentIdentityIds: [],
+ agentConnections: [],
+ agentOwnerSetupMethod: undefined,
+ mcpPolicyIds: [],
+ mcpPolicyRuleIds: [],
+ trustedOriginNames: [],
+ };
+}
+
+/**
+ * Load existing rollback state or create a new one
+ */
+export function loadRollbackState(oktaDomain: string): RollbackState {
+ if (!fs.existsSync(STATE_FILE_PATH)) {
+ return createEmptyState(oktaDomain);
+ }
+
+ try {
+ const content = fs.readFileSync(STATE_FILE_PATH, 'utf8');
+ const state = JSON.parse(content) as RollbackState;
+
+ // Ensure all array fields exist (for backward compatibility)
+ return {
+ oktaDomain: state.oktaDomain || oktaDomain,
+ mcpAuthServerIds: state.mcpAuthServerIds || [],
+ agent0AppIds: state.agent0AppIds || [],
+ todo0AppIds: state.todo0AppIds || [],
+ agent0AppUserIds: state.agent0AppUserIds || [],
+ todo0AppUserIds: state.todo0AppUserIds || [],
+ agentIdentityIds: state.agentIdentityIds || [],
+ agentConnections: state.agentConnections || [],
+ agentOwnerSetupMethod: state.agentOwnerSetupMethod,
+ mcpPolicyIds: state.mcpPolicyIds || [],
+ mcpPolicyRuleIds: state.mcpPolicyRuleIds || [],
+ trustedOriginNames: state.trustedOriginNames || [],
+ };
+ } catch (error) {
+ console.warn('Warning: Could not parse existing state file, creating new state');
+ return createEmptyState(oktaDomain);
+ }
+}
+
+/**
+ * Atomically update rollback state by merging with existing state
+ * Uses temp file + rename for atomic writes
+ */
+export function updateRollbackState(
+ currentState: RollbackState,
+ updates: Partial
+): RollbackState {
+ // Merge arrays (append new items, avoid duplicates)
+ const mergedState: RollbackState = {
+ oktaDomain: updates.oktaDomain || currentState.oktaDomain,
+ mcpAuthServerIds: mergeArrays(currentState.mcpAuthServerIds, updates.mcpAuthServerIds),
+ agent0AppIds: mergeArrays(currentState.agent0AppIds, updates.agent0AppIds),
+ todo0AppIds: mergeArrays(currentState.todo0AppIds, updates.todo0AppIds),
+ agent0AppUserIds: mergeArrays(currentState.agent0AppUserIds, updates.agent0AppUserIds),
+ todo0AppUserIds: mergeArrays(currentState.todo0AppUserIds, updates.todo0AppUserIds),
+ agentIdentityIds: mergeArrays(currentState.agentIdentityIds, updates.agentIdentityIds),
+ agentConnections: mergeConnectionArrays(currentState.agentConnections, updates.agentConnections),
+ agentOwnerSetupMethod: updates.agentOwnerSetupMethod !== undefined ? updates.agentOwnerSetupMethod : currentState.agentOwnerSetupMethod,
+ mcpPolicyIds: mergeArrays(currentState.mcpPolicyIds, updates.mcpPolicyIds),
+ mcpPolicyRuleIds: mergeArrays(currentState.mcpPolicyRuleIds, updates.mcpPolicyRuleIds),
+ trustedOriginNames: mergeArrays(currentState.trustedOriginNames, updates.trustedOriginNames),
+ };
+
+ // Write to temp file first, then rename for atomic operation
+ const tempPath = `${STATE_FILE_PATH}.tmp`;
+ try {
+ fs.writeFileSync(tempPath, JSON.stringify(mergedState, null, 2), 'utf8');
+ fs.renameSync(tempPath, STATE_FILE_PATH);
+ } catch (error) {
+ // Clean up temp file if it exists
+ if (fs.existsSync(tempPath)) {
+ fs.unlinkSync(tempPath);
+ }
+ throw error;
+ }
+
+ return mergedState;
+}
+
+/**
+ * Merge two arrays, avoiding duplicates
+ */
+function mergeArrays(existing: string[] = [], newItems: string[] = []): string[] {
+ const merged = [...existing];
+ for (const item of newItems) {
+ if (item && !merged.includes(item)) {
+ merged.push(item);
+ }
+ }
+ return merged;
+}
+
+/**
+ * Merge two connection arrays, avoiding duplicates based on connectionId
+ */
+function mergeConnectionArrays(
+ existing: AgentConnectionInfo[] = [],
+ newItems: AgentConnectionInfo[] = []
+): AgentConnectionInfo[] {
+ const merged = [...existing];
+ for (const item of newItems) {
+ if (item && !merged.some(conn => conn.connectionId === item.connectionId)) {
+ merged.push(item);
+ }
+ }
+ return merged;
+}
diff --git a/scripts/rollback-okta-config.ts b/scripts/rollback-okta-config.ts
new file mode 100644
index 0000000..7405c3f
--- /dev/null
+++ b/scripts/rollback-okta-config.ts
@@ -0,0 +1,438 @@
+#!/usr/bin/env node
+import prompts from 'prompts';
+import chalk from 'chalk';
+import ora from 'ora';
+import * as fs from 'fs';
+import { OktaAPIClient } from './lib/okta-api.js';
+import { AgentIdentityAPIClient } from './lib/agent-identity-api.js';
+
+interface AgentConnectionInfo {
+ agentId: string;
+ connectionId: string;
+}
+
+interface RollbackState {
+ oktaDomain: string;
+ mcpAuthServerIds: string[];
+ agent0AppIds: string[];
+ todo0AppIds: string[];
+ agent0AppUserIds: string[];
+ todo0AppUserIds: string[];
+ agentIdentityIds: string[];
+ agentConnections: AgentConnectionInfo[];
+ agentOwnerSetupMethod?: 'standard' | 'developer';
+ mcpPolicyIds: string[];
+ mcpPolicyRuleIds: string[];
+ trustedOriginNames: string[];
+}
+
+/**
+ * Load rollback state from file
+ */
+function loadRollbackState(): RollbackState | null {
+ const statePath = '.okta-bootstrap-state.json';
+ if (!fs.existsSync(statePath)) {
+ return null;
+ }
+
+ try {
+ const content = fs.readFileSync(statePath, 'utf8');
+ return JSON.parse(content);
+ } catch (error) {
+ console.error(chalk.red('Failed to parse rollback state file'));
+ return null;
+ }
+}
+
+/**
+ * Delete rollback state file
+ */
+function deleteRollbackState(): void {
+ const statePath = '.okta-bootstrap-state.json';
+ if (fs.existsSync(statePath)) {
+ fs.unlinkSync(statePath);
+ console.log(chalk.gray(' Rollback state file deleted'));
+ }
+}
+
+/**
+ * Main rollback function
+ */
+async function rollback() {
+ console.log(chalk.bold.red('\n🗑️ Okta Configuration Rollback\n'));
+ console.log(chalk.yellow('⚠️ WARNING: This will delete resources from your Okta tenant!\n'));
+
+ // Load rollback state
+ const state = loadRollbackState();
+ if (!state) {
+ console.error(chalk.red('❌ No rollback state found'));
+ console.log(chalk.yellow('\n💡 Rollback state is created during bootstrap'));
+ console.log(chalk.yellow(' File: .okta-bootstrap-state.json\n'));
+ process.exit(1);
+ }
+
+ console.log('Resources to be deleted:');
+ if (state.mcpAuthServerIds?.length > 0) {
+ console.log(chalk.gray(` • MCP Authorization Servers (${state.mcpAuthServerIds.length})`));
+ }
+ if (state.agent0AppIds?.length > 0) {
+ console.log(chalk.gray(` • agent0 Applications (${state.agent0AppIds.length})`));
+ }
+ if (state.agent0AppUserIds?.length > 0) {
+ console.log(chalk.gray(` • agent0 Application User Assignments (${state.agent0AppUserIds.length})`));
+ }
+ if (state.todo0AppIds?.length > 0) {
+ console.log(chalk.gray(` • todo0 Applications (${state.todo0AppIds.length})`));
+ }
+ if (state.todo0AppUserIds?.length > 0) {
+ console.log(chalk.gray(` • todo0 Application User Assignments (${state.todo0AppUserIds.length})`));
+ }
+ if (state.mcpPolicyIds?.length > 0) {
+ console.log(chalk.gray(` • MCP Policies (${state.mcpPolicyIds.length})`));
+ }
+ if (state.agentConnections?.length > 0) {
+ console.log(chalk.gray(` • Agent Connections (${state.agentConnections.length})`));
+ }
+ if (state.trustedOriginNames?.length > 0) {
+ console.log(chalk.gray(` • Trusted Origins: ${state.trustedOriginNames.join(', ')}`));
+ }
+ console.log('');
+
+ // Prompt for API token
+ const answers = await prompts([
+ {
+ type: 'password',
+ name: 'oktaApiToken',
+ message: 'Enter your Okta API token to proceed:',
+ validate: (value) => (value ? true : 'API token is required'),
+ },
+ {
+ type: 'confirm',
+ name: 'confirm',
+ message: chalk.red('Are you sure you want to delete these resources?'),
+ initial: false,
+ },
+ {
+ type: (prev) => (prev ? 'confirm' : null),
+ name: 'doubleConfirm',
+ message: chalk.red('This action cannot be undone. Continue?'),
+ initial: false,
+ },
+ ]);
+
+ if (!answers.confirm || !answers.doubleConfirm) {
+ console.log(chalk.yellow('\n⚠️ Rollback cancelled\n'));
+ process.exit(0);
+ }
+
+ const oktaClient = new OktaAPIClient({
+ orgUrl: `https://${state.oktaDomain}`,
+ token: answers.oktaApiToken,
+ });
+
+ const agentClient = new AgentIdentityAPIClient({
+ oktaDomain: state.oktaDomain,
+ apiToken: answers.oktaApiToken,
+ });
+
+ let deletedCount = 0;
+ let errorCount = 0;
+
+ try {
+ // Delete in reverse dependency order: policies/rules → apps → auth servers → origins
+
+ // Step 1: Delete Policy Rules (must be deleted before policies)
+ if (state.mcpPolicyRuleIds && state.mcpPolicyRuleIds.length > 0) {
+ for (const ruleId of state.mcpPolicyRuleIds) {
+ const spinner = ora(`Deleting MCP policy rule ${ruleId}...`).start();
+ try {
+ const authServerId = state.mcpAuthServerIds?.[0];
+ const policyId = state.mcpPolicyIds?.[0];
+ if (authServerId && policyId) {
+ await oktaClient.deletePolicyRule(authServerId, policyId, ruleId);
+ spinner.succeed(`MCP policy rule deleted`);
+ deletedCount++;
+ } else {
+ spinner.warn('Skipped (missing auth server or policy ID)');
+ }
+ } catch (error: any) {
+ spinner.fail(`Failed: ${error.message}`);
+ errorCount++;
+ }
+ }
+ }
+
+ // Step 2: Delete Policies (must be deleted before auth servers)
+ if (state.mcpPolicyIds && state.mcpPolicyIds.length > 0) {
+ for (const policyId of state.mcpPolicyIds) {
+ const spinner = ora(`Deleting MCP policy ${policyId}...`).start();
+ try {
+ const authServerId = state.mcpAuthServerIds?.[0];
+ if (authServerId) {
+ await oktaClient.deletePolicy(authServerId, policyId);
+ spinner.succeed(`MCP policy deleted`);
+ deletedCount++;
+ } else {
+ spinner.warn('Skipped (missing auth server ID)');
+ }
+ } catch (error: any) {
+ spinner.fail(`Failed: ${error.message}`);
+ errorCount++;
+ }
+ }
+ }
+
+ // Step 2.5: Remove Agent Owners (must be before connections/agents)
+ if (state.agentOwnerSetupMethod && state.agentIdentityIds && state.agentIdentityIds.length > 0) {
+ const spinner = ora('Removing agent owners...').start();
+ try {
+ // Get org metadata for ORN construction
+ const orgMetadata = await agentClient.getOrgMetadata();
+
+ if (state.agentOwnerSetupMethod === 'developer') {
+ // Use developer API to remove owners
+ await agentClient.removeAgentOwnersDeveloper(orgMetadata.id);
+ spinner.succeed('Agent owners removed using Developer API');
+ } else {
+ // Use standard API - remove owners for each agent
+ for (const agentId of state.agentIdentityIds) {
+ spinner.text = `Removing owners for agent ${agentId}...`;
+ await agentClient.removeAgentOwnersStandard(agentId, orgMetadata.id);
+ }
+ spinner.succeed('Agent owners removed using Standard API');
+ }
+ deletedCount++;
+ } catch (error: any) {
+ spinner.warn(`Failed to remove owners: ${error.message}`);
+ // Don't increment errorCount - this is not critical for cleanup
+ }
+ }
+
+ // Step 3: Delete Agent Connections (must be before agents)
+ if (state.agentConnections && state.agentConnections.length > 0) {
+ for (const connection of state.agentConnections) {
+ const spinner = ora(`Deleting agent connection ${connection.connectionId}...`).start();
+ try {
+ await agentClient.deleteConnection(connection.agentId, connection.connectionId);
+ spinner.succeed(`Agent connection deleted`);
+ deletedCount++;
+ } catch (error: any) {
+ spinner.fail(`Failed: ${error.message}`);
+ errorCount++;
+ }
+ }
+ }
+
+ // Step 4: Delete Agent Identities (must be before applications)
+ if (state.agentIdentityIds && state.agentIdentityIds.length > 0) {
+ for (const agentId of state.agentIdentityIds) {
+ const spinner = ora(`Deleting agent identity ${agentId}...`).start();
+ try {
+ // Deactivate agent first
+ spinner.text = `Deactivating agent ${agentId}...`;
+ const deactivationUrl = await agentClient.deactivateAgent(agentId);
+ await agentClient.pollOperation(deactivationUrl);
+
+ // Then delete agent
+ spinner.text = `Deleting agent ${agentId}...`;
+ const deletionUrl = await agentClient.deleteAgent(agentId);
+ await agentClient.pollOperation(deletionUrl);
+
+ spinner.succeed(`Agent identity deleted`);
+ deletedCount++;
+ } catch (error: any) {
+ spinner.fail(`Failed: ${error.message}`);
+ errorCount++;
+ }
+ }
+ }
+
+ // Step 4.5: Unassign Users from Applications (must be before deleting applications)
+ if (state.agent0AppUserIds && state.agent0AppUserIds.length > 0 && state.agent0AppIds && state.agent0AppIds.length > 0) {
+ for (const userId of state.agent0AppUserIds) {
+ const spinner = ora(`Unassigning user ${userId} from agent0 application...`).start();
+ try {
+ await oktaClient.unassignUserFromApplication(state.agent0AppIds[0], userId);
+ spinner.succeed(`User unassigned from agent0 application`);
+ deletedCount++;
+ } catch (error: any) {
+ spinner.fail(`Failed: ${error.message}`);
+ errorCount++;
+ }
+ }
+ }
+
+ if (state.todo0AppUserIds && state.todo0AppUserIds.length > 0 && state.todo0AppIds && state.todo0AppIds.length > 0) {
+ for (const userId of state.todo0AppUserIds) {
+ const spinner = ora(`Unassigning user ${userId} from todo0 application...`).start();
+ try {
+ await oktaClient.unassignUserFromApplication(state.todo0AppIds[0], userId);
+ spinner.succeed(`User unassigned from todo0 application`);
+ deletedCount++;
+ } catch (error: any) {
+ spinner.fail(`Failed: ${error.message}`);
+ errorCount++;
+ }
+ }
+ }
+
+ // Step 5: Delete Applications
+ if (state.agent0AppIds && state.agent0AppIds.length > 0) {
+ for (const appId of state.agent0AppIds) {
+ const spinner = ora(`Deleting agent0 application ${appId}...`).start();
+ try {
+ await oktaClient.deleteApplication(appId);
+ spinner.succeed(`agent0 application deleted`);
+ deletedCount++;
+ } catch (error: any) {
+ spinner.fail(`Failed: ${error.message}`);
+ errorCount++;
+ }
+ }
+ }
+
+ if (state.todo0AppIds && state.todo0AppIds.length > 0) {
+ for (const appId of state.todo0AppIds) {
+ const spinner = ora(`Deleting todo0 application ${appId}...`).start();
+ try {
+ await oktaClient.deleteApplication(appId);
+ spinner.succeed(`todo0 application deleted`);
+ deletedCount++;
+ } catch (error: any) {
+ spinner.fail(`Failed: ${error.message}`);
+ errorCount++;
+ }
+ }
+ }
+
+ // Step 6: Delete Authorization Servers (scopes are auto-deleted with auth server)
+ if (state.mcpAuthServerIds && state.mcpAuthServerIds.length > 0) {
+ for (const authServerId of state.mcpAuthServerIds) {
+ const spinner = ora(`Deleting MCP Authorization Server ${authServerId}...`).start();
+ try {
+ await oktaClient.deleteAuthorizationServer(authServerId);
+ spinner.succeed(`MCP Authorization Server deleted`);
+ deletedCount++;
+ } catch (error: any) {
+ spinner.fail(`Failed: ${error.message}`);
+ errorCount++;
+ }
+ }
+ }
+
+ // Step 7: Delete Trusted Origins
+ if (state.trustedOriginNames && state.trustedOriginNames.length > 0) {
+ const spinner = ora('Deleting Trusted Origins...').start();
+ let originsDeleted = 0;
+
+ for (const originName of state.trustedOriginNames) {
+ try {
+ await oktaClient.deleteTrustedOriginByName(originName);
+ originsDeleted++;
+ } catch (error: any) {
+ console.log(chalk.yellow(` Warning: Could not delete origin "${originName}": ${error.message}`));
+ }
+ }
+
+ if (originsDeleted > 0) {
+ spinner.succeed(`Deleted ${originsDeleted} trusted origin(s)`);
+ deletedCount += originsDeleted;
+ } else {
+ spinner.warn('No trusted origins deleted');
+ }
+ }
+
+ // Optional: Clean up local files
+ const cleanupAnswers = await prompts([
+ {
+ type: 'confirm',
+ name: 'deleteEnvFiles',
+ message: 'Delete generated .env files?',
+ initial: false,
+ },
+ {
+ type: 'confirm',
+ name: 'deletePrivateKey',
+ message: 'Delete generated private key file?',
+ initial: false,
+ },
+ {
+ type: 'confirm',
+ name: 'deleteReport',
+ message: 'Delete configuration report?',
+ initial: false,
+ },
+ ]);
+
+ console.log('');
+
+ if (cleanupAnswers.deleteEnvFiles) {
+ const agent0AppEnv = 'packages/agent0/.env.app';
+ const agent0AgentEnv = 'packages/agent0/.env.agent';
+ const todo0AppEnv = 'packages/todo0/.env.app';
+ const todo0McpEnv = 'packages/todo0/.env.mcp';
+
+ if (fs.existsSync(agent0AppEnv)) {
+ fs.unlinkSync(agent0AppEnv);
+ console.log(chalk.gray(` Deleted: ${agent0AppEnv}`));
+ }
+ if (fs.existsSync(agent0AgentEnv)) {
+ fs.unlinkSync(agent0AgentEnv);
+ console.log(chalk.gray(` Deleted: ${agent0AgentEnv}`));
+ }
+ if (fs.existsSync(todo0AppEnv)) {
+ fs.unlinkSync(todo0AppEnv);
+ console.log(chalk.gray(` Deleted: ${todo0AppEnv}`));
+ }
+ if (fs.existsSync(todo0McpEnv)) {
+ fs.unlinkSync(todo0McpEnv);
+ console.log(chalk.gray(` Deleted: ${todo0McpEnv}`));
+ }
+ }
+
+ if (cleanupAnswers.deletePrivateKey) {
+ const keyPath = 'packages/agent0/agent0-private-key.pem';
+ if (fs.existsSync(keyPath)) {
+ fs.unlinkSync(keyPath);
+ console.log(chalk.gray(` Deleted: ${keyPath}`));
+ }
+ }
+
+ if (cleanupAnswers.deleteReport) {
+ const reportPath = 'okta-config-report.md';
+ if (fs.existsSync(reportPath)) {
+ fs.unlinkSync(reportPath);
+ console.log(chalk.gray(` Deleted: ${reportPath}`));
+ }
+ }
+
+ // Delete rollback state
+ deleteRollbackState();
+
+ // Summary
+ console.log(chalk.bold('\n📊 Rollback Summary\n'));
+ console.log(` ${chalk.green('Resources deleted:')} ${deletedCount}`);
+ if (errorCount > 0) {
+ console.log(` ${chalk.red('Errors encountered:')} ${errorCount}`);
+ }
+ console.log('');
+
+ if (errorCount === 0) {
+ console.log(chalk.bold.green('✅ Rollback completed successfully!\n'));
+ } else {
+ console.log(chalk.bold.yellow('⚠️ Rollback completed with some errors\n'));
+ console.log(chalk.yellow('💡 Check Okta Admin Console to verify all resources were removed\n'));
+ }
+ } catch (error: any) {
+ console.error(chalk.red('\n❌ Rollback failed:'), error.message);
+ console.error(chalk.yellow('\n⚠️ Some resources may remain in your Okta tenant.'));
+ console.error(chalk.yellow('Please check Okta Admin Console and delete manually if needed.\n'));
+ process.exit(1);
+ }
+}
+
+// Run rollback
+rollback().catch((error) => {
+ console.error(chalk.red('Fatal error:'), error);
+ process.exit(1);
+});
diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json
new file mode 100644
index 0000000..7da7f5c
--- /dev/null
+++ b/scripts/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "module": "ESNext",
+ "moduleResolution": "node",
+ "target": "ES2020",
+ "lib": ["ES2020"],
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "resolveJsonModule": true
+ },
+ "include": ["**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/scripts/validate-okta-config.ts b/scripts/validate-okta-config.ts
new file mode 100644
index 0000000..9e42037
--- /dev/null
+++ b/scripts/validate-okta-config.ts
@@ -0,0 +1,318 @@
+#!/usr/bin/env node
+import chalk from 'chalk';
+import ora from 'ora';
+import * as fs from 'fs';
+import * as path from 'path';
+import axios from 'axios';
+import * as jwt from 'jsonwebtoken';
+import * as jose from 'jose';
+
+interface ValidationResult {
+ passed: boolean;
+ message: string;
+ details?: any;
+}
+
+/**
+ * Load environment variables from .env file
+ */
+function loadEnvFile(filePath: string): Record {
+ const absolutePath = path.resolve(filePath);
+ if (!fs.existsSync(absolutePath)) {
+ throw new Error(`Environment file not found: ${absolutePath}`);
+ }
+
+ const content = fs.readFileSync(absolutePath, 'utf8');
+ const env: Record = {};
+
+ content.split('\n').forEach((line) => {
+ line = line.trim();
+ if (line && !line.startsWith('#')) {
+ const [key, ...valueParts] = line.split('=');
+ if (key && valueParts.length > 0) {
+ env[key.trim()] = valueParts.join('=').trim();
+ }
+ }
+ });
+
+ return env;
+}
+
+/**
+ * Create a signed JWT for private key JWT authentication
+ */
+function createClientAssertion(
+ clientId: string,
+ audience: string,
+ privateKeyPem: string,
+ kid: string
+): string {
+ const now = Math.floor(Date.now() / 1000);
+
+ const payload = {
+ iss: clientId,
+ sub: clientId,
+ aud: audience,
+ jti: Math.random().toString(36).substring(2),
+ exp: now + 300, // 5 minutes
+ iat: now,
+ };
+
+ return jwt.sign(payload, privateKeyPem, {
+ algorithm: 'RS256',
+ keyid: kid,
+ });
+}
+
+
+/**
+ * Test: Validate MCP Authorization Server is reachable
+ */
+async function validateMcpAS(env: Record): Promise {
+ try {
+ const issuer = env.MCP_OKTA_ISSUER;
+ if (!issuer) {
+ return { passed: false, message: 'MCP issuer not configured' };
+ }
+
+ const response = await axios.get(`${issuer}/.well-known/openid-configuration`);
+ const config = response.data;
+
+ return {
+ passed: true,
+ message: 'MCP AS is reachable',
+ details: {
+ issuer: config.issuer,
+ tokenEndpoint: config.token_endpoint,
+ },
+ };
+ } catch (error: any) {
+ return {
+ passed: false,
+ message: `Failed to reach MCP AS: ${error.message}`,
+ };
+ }
+}
+
+/**
+ * Test: Validate private key file exists and is readable
+ */
+async function validatePrivateKey(env: Record): Promise {
+ try {
+ const keyFile = env.AI_AGENT_PRIVATE_KEY_FILE;
+ if (!keyFile) {
+ return { passed: false, message: 'Private key file not configured (AI_AGENT_PRIVATE_KEY_FILE)' };
+ }
+
+ const keyPath = path.resolve('packages/agent0', keyFile);
+ if (!fs.existsSync(keyPath)) {
+ return { passed: false, message: `Private key file not found: ${keyPath}` };
+ }
+
+ const keyContent = fs.readFileSync(keyPath, 'utf8');
+
+ // Try to parse the key to ensure it's valid
+ try {
+ await jose.importPKCS8(keyContent, 'RS256');
+ } catch {
+ return { passed: false, message: 'Private key file is invalid or corrupted' };
+ }
+
+ // Check file permissions (should be 600)
+ const stats = fs.statSync(keyPath);
+ const mode = (stats.mode & parseInt('777', 8)).toString(8);
+
+ return {
+ passed: true,
+ message: 'Private key is valid',
+ details: {
+ path: keyPath,
+ permissions: mode,
+ warning: mode !== '600' ? 'Recommended permissions: 600' : null,
+ },
+ };
+ } catch (error: any) {
+ return {
+ passed: false,
+ message: `Failed to validate private key: ${error.message}`,
+ };
+ }
+}
+
+/**
+ * Test: Validate .env files exist and contain required variables
+ */
+async function validateEnvFiles(): Promise {
+ try {
+ const agent0AppEnvPath = 'packages/agent0/.env.app';
+ const agent0AgentEnvPath = 'packages/agent0/.env.agent';
+ const todo0AppEnvPath = 'packages/todo0/.env.app';
+ const todo0McpEnvPath = 'packages/todo0/.env.mcp';
+
+ const missing: string[] = [];
+ if (!fs.existsSync(agent0AppEnvPath)) missing.push(agent0AppEnvPath);
+ if (!fs.existsSync(agent0AgentEnvPath)) missing.push(agent0AgentEnvPath);
+ if (!fs.existsSync(todo0AppEnvPath)) missing.push(todo0AppEnvPath);
+ if (!fs.existsSync(todo0McpEnvPath)) missing.push(todo0McpEnvPath);
+
+ if (missing.length > 0) {
+ return {
+ passed: false,
+ message: 'Missing .env files',
+ details: { missing },
+ };
+ }
+
+ const agent0AppEnv = loadEnvFile(agent0AppEnvPath);
+ const agent0AgentEnv = loadEnvFile(agent0AgentEnvPath);
+ const todo0AppEnv = loadEnvFile(todo0AppEnvPath);
+ const todo0McpEnv = loadEnvFile(todo0McpEnvPath);
+
+ const requiredAgent0App = [
+ 'PORT',
+ 'SESSION_SECRET',
+ 'OKTA_DOMAIN',
+ 'OKTA_CLIENT_ID',
+ 'OKTA_CLIENT_SECRET',
+ 'OKTA_REDIRECT_URI',
+ ];
+
+ const requiredAgent0Agent = [
+ 'MCP_SERVER_URL',
+ 'OKTA_DOMAIN',
+ 'AI_AGENT_ID',
+ 'AI_AGENT_PRIVATE_KEY_FILE',
+ 'AI_AGENT_PRIVATE_KEY_KID',
+ 'AI_AGENT_TODO_MCP_SERVER_SCOPES_TO_REQUEST',
+ 'MCP_AUTHORIZATION_SERVER',
+ 'MCP_AUTHORIZATION_SERVER_TOKEN_ENDPOINT',
+ ];
+
+ const requiredTodo0App = [
+ 'PORT',
+ 'OKTA_ISSUER',
+ 'OKTA_CLIENT_ID',
+ 'OKTA_CLIENT_SECRET',
+ 'OKTA_REDIRECT_URI',
+ 'EXPECTED_AUDIENCE',
+ ];
+
+ const requiredTodo0Mcp = [
+ 'MCP_PORT',
+ 'MCP_OKTA_ISSUER',
+ 'MCP_EXPECTED_AUDIENCE',
+ ];
+
+ const missingVars: string[] = [];
+ requiredAgent0App.forEach((key) => {
+ if (!agent0AppEnv[key]) missingVars.push(`agent0 (.env.app): ${key}`);
+ });
+ requiredAgent0Agent.forEach((key) => {
+ if (!agent0AgentEnv[key]) missingVars.push(`agent0 (.env.agent): ${key}`);
+ });
+ requiredTodo0App.forEach((key) => {
+ if (!todo0AppEnv[key]) missingVars.push(`todo0 (.env.app): ${key}`);
+ });
+ requiredTodo0Mcp.forEach((key) => {
+ if (!todo0McpEnv[key]) missingVars.push(`todo0 (.env.mcp): ${key}`);
+ });
+
+ if (missingVars.length > 0) {
+ return {
+ passed: false,
+ message: 'Missing required environment variables',
+ details: { missingVars },
+ };
+ }
+
+ return {
+ passed: true,
+ message: 'All environment files are properly configured',
+ };
+ } catch (error: any) {
+ return {
+ passed: false,
+ message: `Failed to validate env files: ${error.message}`,
+ };
+ }
+}
+
+
+/**
+ * Main validation function
+ */
+async function validate() {
+ console.log(chalk.bold.blue('\n🔍 Validating Okta Configuration\n'));
+
+ // Load environment variables
+ let agent0AgentEnv: Record = {};
+ let todo0McpEnv: Record = {};
+
+ try {
+ agent0AgentEnv = loadEnvFile('packages/agent0/.env.agent');
+ todo0McpEnv = loadEnvFile('packages/todo0/.env.mcp');
+ } catch (error: any) {
+ console.error(chalk.red('❌ Failed to load environment files:'), error.message);
+ console.log(chalk.yellow('\n💡 Run `pnpm run bootstrap:okta` first\n'));
+ process.exit(1);
+ }
+
+ const tests: Array<{ name: string; fn: () => Promise }> = [
+ { name: 'Environment Files', fn: () => validateEnvFiles() },
+ { name: 'Private Key', fn: () => validatePrivateKey(agent0AgentEnv) },
+ { name: 'MCP Auth Server', fn: () => validateMcpAS(todo0McpEnv) },
+ ];
+
+ let passedCount = 0;
+ let failedCount = 0;
+
+ for (const test of tests) {
+ const spinner = ora(`Testing: ${test.name}`).start();
+
+ try {
+ const result = await test.fn();
+
+ if (result.passed) {
+ spinner.succeed(chalk.green(`${test.name}: ${result.message}`));
+ if (result.details) {
+ console.log(chalk.gray(' Details:'), result.details);
+ }
+ passedCount++;
+ } else {
+ spinner.fail(chalk.red(`${test.name}: ${result.message}`));
+ if (result.details) {
+ console.log(chalk.gray(' Details:'), result.details);
+ }
+ failedCount++;
+ }
+ } catch (error: any) {
+ spinner.fail(chalk.red(`${test.name}: Unexpected error - ${error.message}`));
+ failedCount++;
+ }
+ }
+
+ // Summary
+ console.log(chalk.bold('\n📊 Validation Summary\n'));
+ console.log(` ${chalk.green('Passed:')} ${passedCount}/${tests.length}`);
+ console.log(` ${chalk.red('Failed:')} ${failedCount}/${tests.length}\n`);
+
+ if (failedCount === 0) {
+ console.log(chalk.bold.green('✅ All validations passed!\n'));
+ console.log('Your Okta configuration is ready to use.\n');
+ process.exit(0);
+ } else {
+ console.log(chalk.bold.red('❌ Some validations failed\n'));
+ console.log(chalk.yellow('💡 Tips:'));
+ console.log(' • Check that all resources were created in Okta Admin Console');
+ console.log(' • Verify .env files have correct values');
+ console.log(' • Ensure private key file has correct permissions (600)');
+ console.log(' • Try re-running: pnpm run bootstrap:okta\n');
+ process.exit(1);
+ }
+}
+
+// Run validation
+validate().catch((error) => {
+ console.error(chalk.red('Fatal error:'), error);
+ process.exit(1);
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..7d5ac1b
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "commonjs",
+ "lib": ["ES2020"],
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "moduleResolution": "node",
+ "resolveJsonModule": true
+ }
+}