diff --git a/LICENSE b/LICENSE index a1fad7a..ce9d649 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Descope +Copyright (c) 2026 Descope Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/hooks/.gitignore b/hooks/.gitignore new file mode 100644 index 0000000..e84ebeb --- /dev/null +++ b/hooks/.gitignore @@ -0,0 +1,16 @@ +# Secrets β€” never commit real configs +**/descope-auth.config.json +!**/*.example.json + +# Runtime artifacts +**/.token-cache/ +**/descope-auth.log + +# Node +node_modules/ +dist/ +*.tgz + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/hooks/README.md b/hooks/README.md new file mode 100644 index 0000000..bbac783 --- /dev/null +++ b/hooks/README.md @@ -0,0 +1,372 @@ +# πŸ” Descope Agent Hooks + +**Secure your AI agent's MCP tool calls with scoped OAuth tokens β€” using native hook systems in Cursor and Claude Code.** + +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) +[![Cursor](https://img.shields.io/badge/Cursor-supported-green.svg)](#cursor) +[![Claude Code](https://img.shields.io/badge/Claude_Code-supported-green.svg)](#claude-code) + +--- + +## The Problem + +When AI agents call MCP servers, every request needs authentication. Without enforcement at the execution layer, agents operate with static tokens, overly broad credentials, or no auth at all β€” creating security blind spots that grow with every tool call. + +## The Solution + +**Agent Hooks** intercept MCP tool calls at the source β€” before they leave the agent β€” and acquire short-lived, scoped tokens from [Descope](https://descope.com). The agent never manages credentials. Auth is transparent, enforced, and automatic. + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” tool call β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Bearer token β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ AI Agent β”‚ ───────────►│ Descope Hook β”‚ ──────────────►│ MCP Server β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ Cursor or β”‚ β”‚ Acquire scoped β”‚ β”‚ GitHub β”‚ +β”‚ Claude Codeβ”‚ ◄───────────│ token from β”‚ β”‚ Salesforce β”‚ +β”‚ β”‚ allow + β”‚ Descope β”‚ β”‚ Google β”‚ +β”‚ β”‚ auth headerβ”‚ β”‚ β”‚ etc. β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**No SDK. No runtime. Just `jq` + `curl`.** + +--- + +## Quick Start + +### Cursor + +```bash +curl -fsSL https://agent-hooks.sh/install.sh | bash +``` + +Then edit `~/.cursor/hooks/descope-auth.config.json` with your Descope credentials and restart Cursor. + +### Claude Code + +```bash +curl -fsSL https://agent-hooks.sh/install-claude-code.sh | bash +``` + +Then edit `hooks/descope-auth.config.json` with your Descope credentials. Hooks activate on the next MCP tool call. + +--- + +## Four Auth Strategies + +Each strategy maps to a real-world deployment pattern. Pick the one that matches how your agent authenticates. + +### 1. Client Credentials + Token Exchange + +**The agent authenticates as itself**, then exchanges for a scoped MCP server token. Two HTTP calls, fully automated. + +``` +Agent ──client_credentials──► Descope /apps/token ──► agent_access_token +Agent ──token_exchange──────► Descope /apps/{pid}/token ──► scoped_mcp_token +``` + +```json +{ + "strategy": "client_credentials_exchange", + "projectId": "P2xxxxxxxxx", + "clientId": "DS_xxxxxxxx", + "clientSecret": "ds_xxxxxxxx", + "audience": "mcp-server-github", + "scopes": "repo:read issues:write" +} +``` + +**Best for:** M2M agents, CI/CD pipelines, scheduled tasks β€” anything with no user session. + +### 2. User Token Exchange ⭐ Recommended + +**Exchange the user's Descope access token** for a narrowly-scoped MCP server token. One HTTP call. Least privilege, most secure. + +``` +User access_token ──token_exchange──► Descope /apps/{pid}/token ──► scoped_mcp_token +``` + +```json +{ + "strategy": "user_token_exchange", + "projectId": "P2xxxxxxxxx", + "userAccessToken": "eyJhbGciOi...", + "audience": "mcp-server-salesforce", + "scopes": "contacts:read deals:write" +} +``` + +**Best for:** Interactive agents where the user is already authenticated. The recommended default. + +### 3. Connections API + +**Retrieve a third-party provider token** via Descope's Outbound Apps. + +``` +User access_token ──► Descope /v1/mgmt/outbound/app/user/token ──► provider_token +``` + +```json +{ + "strategy": "connections", + "projectId": "P2xxxxxxxxx", + "userAccessToken": "eyJhbGciOi...", + "appId": "google-contacts", + "userId": "U2xxxxxxxxx", + "scopes": ["https://www.googleapis.com/auth/contacts.readonly"] +} +``` + +> **⚠️ Security considerations:** +> +> Unlike token exchange, the Connections API returns the raw third-party provider token directly to the agent. Two things to be aware of: +> +> 1. **Trust boundary** β€” If the agent caches or leaks this token, it can be used to access the third-party service directly, outside the MCP server's control. With token exchange (strategies 1 & 2), external tokens stay server-side inside the MCP server. +> 2. **Token lifetime** β€” External tokens issued by providers like Google, HubSpot, etc. are **not controlled by Descope**. They may be long-lived (hours or permanent) unlike the short-lived ephemeral tokens Descope issues via its `/token` endpoint. + +**Best for:** When the agent needs to call a third-party API directly, with no intermediary MCP server. + +### 4. CIBA (Backchannel Authentication) + +**Request user consent out-of-band** β€” push notification, email, etc. β€” and poll for approval. No active browser session needed. + +``` +Agent ──bc-authorize──► Descope ──► User (consent prompt) +Agent ──poll──────────► Descope ◄── User approves +Agent ◄── scoped_mcp_token +``` + +```json +{ + "strategy": "ciba", + "projectId": "P2xxxxxxxxx", + "clientId": "DS_xxxxxxxx", + "clientSecret": "ds_xxxxxxxx", + "audience": "mcp-server-calendar", + "scopes": "events:read events:write", + "loginHint": "kevin@descope.com", + "bindingMessage": "Allow AI assistant to manage your calendar?" +} +``` + +**Best for:** Scheduled tasks, background agents, or when the user is on a different device. + +--- + +## Decision Matrix + +| | Client Creds (1) | Token Exchange (2) | Connections (3) | CIBA (4) | +| ------------------------------------ | ---------------- | ---------------------- | -------------------- | --------------------- | +| User session required | No | Yes | Yes | No | +| Token stays server-side | βœ… | βœ… | ❌ | βœ… | +| Token lifetime controlled by Descope | βœ… | βœ… | ❌ | βœ… | +| User consent | None | Implicit | Implicit | Explicit | +| Latency | ~2 calls | ~1 call | ~1 call | Seconds–minutes | +| **Best for** | **M2M agents** | **Interactive agents** | **Direct API calls** | **Background agents** | + +--- + +## How It Works + +### Cursor β€” Direct Header Injection + +Cursor's `beforeMCPExecution` hook can return headers that get injected into the outbound MCP request. One script, no proxy. + +``` +Cursor ──stdin──► descope-auth.sh ──► {"permission":"allow","headers":{"Authorization":"Bearer ..."}} +``` + +**Files:** + +``` +~/.cursor/ +β”œβ”€β”€ hooks.json # Registers the hook +└── hooks/ + β”œβ”€β”€ descope-auth.sh # Hook script + └── descope-auth.config.json # Server β†’ strategy mapping +``` + +### Claude Code β€” Hook + MCP Wrapper + +Claude Code's `PreToolUse` hooks can allow or block tool calls but **cannot inject headers**. So we use two components: + +1. **PreToolUse hook** β€” acquires the token, writes it to a shared cache +2. **MCP wrapper** β€” launched as the MCP server, reads the cache, proxies with `Authorization` header + +``` +Claude Code ──PreToolUse──► descope-auth-cc.sh ──► writes .token-cache/server.json +Claude Code ──tool call───► descope-mcp-wrapper.sh ──reads cache──► upstream MCP + Bearer token +``` + +**Files:** + +``` +your-project/ +β”œβ”€β”€ .claude/ +β”‚ └── settings.json # Hook + MCP server registration +└── hooks/ + β”œβ”€β”€ descope-auth-cc.sh # PreToolUse hook + β”œβ”€β”€ descope-mcp-wrapper.sh # MCP auth wrapper + └── descope-auth.config.json # Server β†’ strategy mapping +``` + +### Platform Comparison + +| | Cursor | Claude Code | +| ------------------ | ---------------------- | --------------------------- | +| Hook type | `beforeMCPExecution` | `PreToolUse` | +| Can inject headers | βœ… | ❌ (needs wrapper) | +| Tool name format | `github_create_issue` | `mcp__github__create_issue` | +| Files needed | 1 script | 2 scripts | +| Config location | `~/.cursor/hooks.json` | `.claude/settings.json` | + +--- + +## API Endpoints + +All Descope OAuth requests use **JSON bodies** (`Content-Type: application/json`). + +| Operation | Endpoint | +| ------------------ | ----------------------------------------- | +| Client Credentials | `POST /oauth2/v1/apps/token` | +| Token Exchange | `POST /oauth2/v1/apps/{project_id}/token` | +| CIBA Authorize | `POST /oauth2/v1/apps/bc-authorize` | +| CIBA Poll | `POST /oauth2/v1/apps/token` | +| Connections | `POST /v1/mgmt/outbound/app/user/token` | + +> The token exchange endpoint is project-scoped (`/apps/{project_id}/token`) while `client_credentials` uses the base path (`/apps/token`). + +--- + +## Configuration + +Both platforms use the same `descope-auth.config.json`: + +```jsonc +{ + "servers": { + "github": { + "strategy": "client_credentials_exchange", + "projectId": "P2xxxxxxxxx", + "clientId": "DS_xxxxxxxx", + "clientSecret": "ds_xxxxxxxx", + "audience": "mcp-server-github", + "scopes": "repo:read issues:write", + }, + "salesforce": { + "strategy": "user_token_exchange", + "projectId": "P2xxxxxxxxx", + "userAccessToken": "eyJhbGciOi...", + "audience": "mcp-server-salesforce", + "scopes": "contacts:read deals:write", + }, + }, +} +``` + +Keys in `servers` are matched against tool names. Cursor matches by prefix (`github` matches `github_create_issue`). Claude Code matches the server segment from `mcp__github__create_issue`. + +> **⚠️ Never commit secrets.** Use environment variables, a `.env` file (git-ignored), or a secrets manager in production. + +--- + +## Token Caching + +Both hooks include a file-based token cache (`hooks/.token-cache/`) that prevents redundant Descope calls: + +- Tokens are cached per-server with a **30-second expiry buffer** +- Cache files are JSON: `{"access_token": "...", "expires_at": "2025-02-12T..."}` +- Expired tokens are automatically refreshed on the next tool call +- The cache directory is created at runtime and should be git-ignored + +--- + +## Extending + +Chain multiple hooks for auth + audit, auth + policy enforcement, etc. + +**Cursor:** + +```json +{ + "hooks": { + "beforeMCPExecution": [ + { "command": "./hooks/descope-auth.sh" }, + { "command": "./hooks/audit-logger.sh" } + ] + } +} +``` + +**Claude Code:** + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "^mcp__", + "hooks": [ + { "type": "command", "command": "hooks/descope-auth-cc.sh" }, + { "type": "command", "command": "hooks/audit-logger.sh" } + ] + } + ] + } +} +``` + +--- + +## TypeScript Library + +For Node.js-based agents or custom integrations, a TypeScript library is also available with the same four strategies as a programmatic API: + +```typescript +import { preToolUseHook } from "@descope/agent-hooks"; + +const result = await preToolUseHook({ + type: "user_token_exchange", + config: { projectId: "P2xxx" }, + userAccessToken: session.token, + exchange: { + audience: "mcp-server-salesforce", + scopes: "contacts:read deals:write", + }, +}); + +headers["Authorization"] = `Bearer ${result.accessToken}`; +``` + +See [`typescript/src/descope-agent-hooks.ts`](typescript/src/descope-agent-hooks.ts) for the full API. + +--- + +## Testing + +A test agent validates the hooks without requiring live Descope credentials: + +```bash +node test-agent/test-agent.mjs +``` + +Use `--integration` to run full tests with valid credentials. See [test-agent/README.md](test-agent/README.md) for details. + +## Requirements + +- `jq` β€” JSON processing +- `curl` β€” HTTP requests +- A [Descope](https://descope.com) project with OAuth apps configured + +No Node.js, Python, or other runtimes required for the shell hooks (Node.js needed only for the test agent). + +--- + +## License + +MIT β€” see [LICENSE](LICENSE). + +--- + +

+ Built by Descope Β· agent-hooks.sh Β· GitHub +

diff --git a/hooks/claude-code/descope-auth-cc.sh b/hooks/claude-code/descope-auth-cc.sh new file mode 100644 index 0000000..1631eea --- /dev/null +++ b/hooks/claude-code/descope-auth-cc.sh @@ -0,0 +1,345 @@ + +#!/bin/bash +# ───────────────────────────────────────────────────────────── +# Descope Agent Auth Hook for Claude Code +# ───────────────────────────────────────────────────────────── +# +# A PreToolUse hook that acquires a scoped access token from +# Descope before every MCP tool call. +# +# KEY DIFFERENCE FROM CURSOR: +# Cursor hooks can inject headers directly via +# {"permission":"allow","headers":{...}}. Claude Code hooks +# can only allow/block β€” they cannot modify the request. +# +# So this hook writes the token to a shared cache file, and +# the companion MCP wrapper (descope-mcp-wrapper.sh) reads +# from the cache to inject the Authorization header. +# +# Claude Code stdin: +# { "tool_name": "mcp__github__create_issue", +# "tool_input": { ... }, "session_id": "abc123" } +# +# Response: +# Allow: exit 0, empty stdout +# Block: exit 0, stdout: {"reason": "..."} +# Error: exit non-zero +# +# ───────────────────────────────────────────────────────────── + +HOOKS_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG_FILE="${HOOKS_DIR}/descope-auth.config.json" +LOG_FILE="${HOOKS_DIR}/descope-auth.log" +TOKEN_DIR="${HOOKS_DIR}/.token-cache" + +# ─── Logging (stderr only, never stdout) ───────────────────── + +log() { + echo "[descope-auth] $(date -u +%Y-%m-%dT%H:%M:%SZ) $*" >> "$LOG_FILE" 2>&1 +} + +# ─── Dependency check ──────────────────────────────────────── + +for cmd in jq curl; do + if ! command -v "$cmd" >/dev/null 2>&1; then + log "WARNING: $cmd not found, bypassing auth" + exit 0 + fi +done + +# ─── Read hook input from Claude Code ──────────────────────── + +INPUT=$(cat) +TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty') +SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty') + +log "Hook fired: tool=$TOOL_NAME session=$SESSION_ID" + +# ─── Load config ───────────────────────────────────────────── + +if [ ! -f "$CONFIG_FILE" ]; then + log "WARNING: Config not found at $CONFIG_FILE, allowing without auth" + exit 0 +fi + +# MCP tool names in Claude Code: mcp____ +SERVER_NAME=$(echo "$TOOL_NAME" | sed -n 's/^mcp__\([^_]*\)__.*/\1/p') + +if [ -z "$SERVER_NAME" ]; then + log "Not an MCP tool (tool=$TOOL_NAME), allowing" + exit 0 +fi + +SERVER_CONFIG=$(jq -c --arg server "$SERVER_NAME" '.servers[$server] // empty' "$CONFIG_FILE" 2>/dev/null) + +if [ -z "$SERVER_CONFIG" ] || [ "$SERVER_CONFIG" = "null" ]; then + SERVER_CONFIG=$(jq -c --arg tool "$TOOL_NAME" ' + .servers | to_entries[] | + select(.key as $k | $tool | contains($k)) | + .value + ' "$CONFIG_FILE" 2>/dev/null | head -1) +fi + +if [ -z "$SERVER_CONFIG" ] || [ "$SERVER_CONFIG" = "null" ]; then + log "No auth config for server=$SERVER_NAME, allowing without auth" + exit 0 +fi + +STRATEGY=$(echo "$SERVER_CONFIG" | jq -r '.strategy') +BASE_URL=$(echo "$SERVER_CONFIG" | jq -r '.baseUrl // "https://api.descope.com"') + +log "Strategy: $STRATEGY for server=$SERVER_NAME" + +# ─── Token cache ───────────────────────────────────────────── + +mkdir -p "$TOKEN_DIR" + +get_cached_token() { + local cache_file="${TOKEN_DIR}/${1}.json" + if [ -f "$cache_file" ]; then + local expires_at=$(jq -r '.expires_at' "$cache_file" 2>/dev/null) + local now=$(date +%s) + local expires_epoch=$(date -d "$expires_at" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%S" "$expires_at" +%s 2>/dev/null || echo 0) + if [ $((expires_epoch - now)) -gt 30 ]; then + jq -r '.access_token' "$cache_file" + return 0 + fi + fi + return 1 +} + +set_cached_token() { + local cache_file="${TOKEN_DIR}/${1}.json" + echo "$2" > "$cache_file" +} + +# ─── OAuth flows ───────────────────────────────────────────── + +do_client_credentials_exchange() { + local client_id=$(echo "$SERVER_CONFIG" | jq -r '.clientId') + local client_secret=$(echo "$SERVER_CONFIG" | jq -r '.clientSecret') + local project_id=$(echo "$SERVER_CONFIG" | jq -r '.projectId') + local audience=$(echo "$SERVER_CONFIG" | jq -r '.audience') + local resource=$(echo "$SERVER_CONFIG" | jq -r '.resource // empty') + + local cached=$(get_cached_token "$SERVER_NAME") && { echo "$cached"; return 0; } + + local cc_response=$(curl -s -X POST "${BASE_URL}/oauth2/v1/apps/token" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg gt "client_credentials" \ + --arg ci "$client_id" \ + --arg cs "$client_secret" \ + '{grant_type: $gt, client_id: $ci, client_secret: $cs}' + )") + + local agent_token=$(echo "$cc_response" | jq -r '.access_token // empty') + if [ -z "$agent_token" ]; then + log "ERROR: client_credentials failed: $cc_response" + return 1 + fi + + local te_body=$(jq -n \ + --arg gt "urn:ietf:params:oauth:grant-type:token-exchange" \ + --arg ci "$client_id" \ + --arg cs "$client_secret" \ + --arg st "$agent_token" \ + --arg stt "urn:ietf:params:oauth:token-type:access_token" \ + --arg aud "$audience" \ + --arg res "$resource" \ + '{grant_type: $gt, client_id: $ci, client_secret: $cs, + subject_token: $st, subject_token_type: $stt, audience: $aud} + | if $res != "" then . + {resource: $res} else . end') + + local te_response=$(curl -s -X POST "${BASE_URL}/oauth2/v1/apps/${project_id}/token" \ + -H "Content-Type: application/json" \ + -d "$te_body") + + local token=$(echo "$te_response" | jq -r '.access_token // empty') + if [ -z "$token" ]; then + log "ERROR: token exchange failed: $te_response" + return 1 + fi + + local expires_in=$(echo "$te_response" | jq -r '.expires_in // 3600') + local expires_at=$(date -u -d "+${expires_in} seconds" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || \ + date -u -v+${expires_in}S +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) + + set_cached_token "$SERVER_NAME" "$(jq -n \ + --arg t "$token" --arg e "$expires_at" \ + '{access_token: $t, expires_at: $e}')" + + echo "$token" +} + +do_user_token_exchange() { + local project_id=$(echo "$SERVER_CONFIG" | jq -r '.projectId') + local user_token=$(echo "$SERVER_CONFIG" | jq -r '.userAccessToken') + local audience=$(echo "$SERVER_CONFIG" | jq -r '.audience') + local resource=$(echo "$SERVER_CONFIG" | jq -r '.resource // empty') + + local cached=$(get_cached_token "$SERVER_NAME") && { echo "$cached"; return 0; } + + local te_body=$(jq -n \ + --arg gt "urn:ietf:params:oauth:grant-type:token-exchange" \ + --arg st "$user_token" \ + --arg stt "urn:ietf:params:oauth:token-type:access_token" \ + --arg aud "$audience" \ + --arg res "$resource" \ + '{grant_type: $gt, subject_token: $st, subject_token_type: $stt, audience: $aud} + | if $res != "" then . + {resource: $res} else . end') + + local response=$(curl -s -X POST "${BASE_URL}/oauth2/v1/apps/${project_id}/token" \ + -H "Content-Type: application/json" \ + -d "$te_body") + + local token=$(echo "$response" | jq -r '.access_token // empty') + if [ -z "$token" ]; then + log "ERROR: user token exchange failed: $response" + return 1 + fi + + local expires_in=$(echo "$response" | jq -r '.expires_in // 3600') + local expires_at=$(date -u -d "+${expires_in} seconds" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || \ + date -u -v+${expires_in}S +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) + + set_cached_token "$SERVER_NAME" "$(jq -n \ + --arg t "$token" --arg e "$expires_at" \ + '{access_token: $t, expires_at: $e}')" + + echo "$token" +} + +do_connections() { + local project_id=$(echo "$SERVER_CONFIG" | jq -r '.projectId') + local user_token=$(echo "$SERVER_CONFIG" | jq -r '.userAccessToken') + local app_id=$(echo "$SERVER_CONFIG" | jq -r '.appId') + local user_id=$(echo "$SERVER_CONFIG" | jq -r '.userId') + local tenant_id=$(echo "$SERVER_CONFIG" | jq -r '.tenantId // empty') + local scopes=$(echo "$SERVER_CONFIG" | jq -c '.scopes // []') + + local cached=$(get_cached_token "$SERVER_NAME") && { echo "$cached"; return 0; } + + local body=$(jq -n \ + --arg ai "$app_id" --arg ui "$user_id" --arg ti "$tenant_id" \ + --argjson sc "$scopes" \ + '{appId: $ai, userId: $ui, scopes: $sc} + | if $ti != "" then . + {tenantId: $ti} else . end') + + local response=$(curl -s -X POST "${BASE_URL}/v1/mgmt/outbound/app/user/token" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${project_id}:${user_token}" \ + -d "$body") + + local token=$(echo "$response" | jq -r '.token // .accessToken // .access_token // empty') + if [ -z "$token" ]; then + log "ERROR: connections failed: $response" + return 1 + fi + + local expires_in=$(echo "$response" | jq -r '.expiresIn // .expires_in // 3600') + local expires_at=$(date -u -d "+${expires_in} seconds" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || \ + date -u -v+${expires_in}S +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) + + set_cached_token "$SERVER_NAME" "$(jq -n \ + --arg t "$token" --arg e "$expires_at" \ + '{access_token: $t, expires_at: $e}')" + + echo "$token" +} + +do_ciba() { + local client_id=$(echo "$SERVER_CONFIG" | jq -r '.clientId') + local client_secret=$(echo "$SERVER_CONFIG" | jq -r '.clientSecret') + local audience=$(echo "$SERVER_CONFIG" | jq -r '.audience') + local scopes=$(echo "$SERVER_CONFIG" | jq -r '.scopes // empty') + local login_hint=$(echo "$SERVER_CONFIG" | jq -r '.loginHint // empty') + local login_hint_token=$(echo "$SERVER_CONFIG" | jq -r '.loginHintToken // empty') + local binding_message=$(echo "$SERVER_CONFIG" | jq -r '.bindingMessage // empty') + local poll_interval=$(echo "$SERVER_CONFIG" | jq -r '.pollIntervalSeconds // 2') + local timeout=$(echo "$SERVER_CONFIG" | jq -r '.timeoutSeconds // 120') + + local auth_body=$(jq -n \ + --arg ci "$client_id" --arg cs "$client_secret" \ + --arg sc "$scopes" --arg aud "$audience" \ + --arg lh "$login_hint" --arg lht "$login_hint_token" \ + --arg bm "$binding_message" \ + '{client_id: $ci, client_secret: $cs, scope: $sc, audience: $aud} + | if $lh != "" then . + {login_hint: $lh} else . end + | if $lht != "" then . + {login_hint_token: $lht} else . end + | if $bm != "" then . + {binding_message: $bm} else . end') + + local auth_response=$(curl -s -X POST "${BASE_URL}/oauth2/v1/apps/bc-authorize" \ + -H "Content-Type: application/json" \ + -d "$auth_body") + + local auth_req_id=$(echo "$auth_response" | jq -r '.auth_req_id // empty') + if [ -z "$auth_req_id" ]; then + log "ERROR: CIBA authorize failed: $auth_response" + return 1 + fi + + local server_interval=$(echo "$auth_response" | jq -r '.interval // empty') + if [ -n "$server_interval" ] && [ "$server_interval" -gt "$poll_interval" ]; then + poll_interval=$server_interval + fi + + log "CIBA: waiting for user consent (polling every ${poll_interval}s)" + + local elapsed=0 + while [ $elapsed -lt $timeout ]; do + sleep "$poll_interval" + elapsed=$((elapsed + poll_interval)) + + local poll_response=$(curl -s -X POST "${BASE_URL}/oauth2/v1/apps/token" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg gt "urn:openid:params:grant-type:ciba" \ + --arg ar "$auth_req_id" \ + --arg ci "$client_id" --arg cs "$client_secret" \ + '{grant_type: $gt, auth_req_id: $ar, client_id: $ci, client_secret: $cs}' + )") + + local token=$(echo "$poll_response" | jq -r '.access_token // empty') + if [ -n "$token" ]; then + log "CIBA: user approved (${elapsed}s)" + echo "$token" + return 0 + fi + + local error=$(echo "$poll_response" | jq -r '.error // empty') + case "$error" in + authorization_pending) continue ;; + slow_down) sleep "$poll_interval"; elapsed=$((elapsed + poll_interval)); continue ;; + *) log "ERROR: CIBA poll failed: $poll_response"; return 1 ;; + esac + done + + log "ERROR: CIBA timed out after ${timeout}s" + return 1 +} + +# ─── Dispatch ──────────────────────────────────────────────── + +TOKEN="" +case "$STRATEGY" in + client_credentials_exchange) TOKEN=$(do_client_credentials_exchange) ;; + user_token_exchange) TOKEN=$(do_user_token_exchange) ;; + connections) TOKEN=$(do_connections) ;; + ciba) TOKEN=$(do_ciba) ;; + *) + log "ERROR: Unknown strategy: $STRATEGY" + exit 0 + ;; +esac + +# ─── Response to Claude Code ──────────────────────────────── + +if [ -z "$TOKEN" ]; then + log "ERROR: Failed to acquire token for server=$SERVER_NAME" + echo "{\"reason\": \"Descope auth failed: could not acquire token for $SERVER_NAME. Check hooks/descope-auth.log for details.\"}" + exit 0 +fi + +log "SUCCESS: Token cached for server=$SERVER_NAME" +exit 0 \ No newline at end of file diff --git a/hooks/claude-code/descope-auth.config.example.json b/hooks/claude-code/descope-auth.config.example.json new file mode 100644 index 0000000..c14c426 --- /dev/null +++ b/hooks/claude-code/descope-auth.config.example.json @@ -0,0 +1,47 @@ +{ + "servers": { + "github": { + "strategy": "client_credentials_exchange", + "baseUrl": "https://api.descope.com", + "projectId": "P2xxxxxxxxx", + "clientId": "DS_xxxxxxxx", + "clientSecret": "ds_xxxxxxxx", + "audience": "mcp-server-github", + "scopes": "repo:read issues:write" + }, + + "salesforce": { + "strategy": "user_token_exchange", + "baseUrl": "https://api.descope.com", + "projectId": "P2xxxxxxxxx", + "userAccessToken": "YOUR_USER_ACCESS_TOKEN", + "audience": "mcp-server-salesforce", + "scopes": "contacts:read deals:write" + }, + + "google-contacts": { + "strategy": "connections", + "baseUrl": "https://api.descope.com", + "projectId": "P2xxxxxxxxx", + "userAccessToken": "YOUR_USER_ACCESS_TOKEN", + "appId": "google-contacts", + "userId": "YOUR_DESCOPE_USER_ID", + "tenantId": "", + "scopes": ["https://www.googleapis.com/auth/contacts.readonly"] + }, + + "calendar": { + "strategy": "ciba", + "baseUrl": "https://api.descope.com", + "projectId": "P2xxxxxxxxx", + "clientId": "DS_xxxxxxxx", + "clientSecret": "ds_xxxxxxxx", + "audience": "mcp-server-calendar", + "scopes": "events:read events:write", + "loginHint": "user@example.com", + "bindingMessage": "Allow AI assistant to manage your calendar?", + "pollIntervalSeconds": 2, + "timeoutSeconds": 120 + } + } +} diff --git a/hooks/claude-code/descope-mcp-wrapper.sh b/hooks/claude-code/descope-mcp-wrapper.sh new file mode 100644 index 0000000..88db4d4 --- /dev/null +++ b/hooks/claude-code/descope-mcp-wrapper.sh @@ -0,0 +1,87 @@ + +#!/bin/bash +# ───────────────────────────────────────────────────────────── +# Descope MCP Server Wrapper for Claude Code +# ───────────────────────────────────────────────────────────── +# +# Claude Code's PreToolUse hooks cannot inject headers into MCP +# requests. This wrapper bridges that gap: +# +# 1. Claude Code launches this as a command-type MCP server +# 2. It reads JSON-RPC messages from stdin +# 3. Reads the latest token from the hook's cache +# 4. Forwards requests to upstream with Authorization header +# 5. Pipes responses back to Claude Code on stdout +# +# The PreToolUse hook (descope-auth-cc.sh) runs BEFORE this +# wrapper receives each tool call, ensuring the cache is fresh. +# +# Usage (in .claude/settings.json): +# "github": { +# "command": "hooks/descope-mcp-wrapper.sh", +# "args": ["https://mcp.github.com/sse"], +# "env": { "DESCOPE_SERVER_KEY": "github" } +# } +# +# ───────────────────────────────────────────────────────────── + +set -e + +HOOKS_DIR="$(cd "$(dirname "$0")" && pwd)" +TOKEN_DIR="${HOOKS_DIR}/.token-cache" +LOG_FILE="${HOOKS_DIR}/descope-auth.log" + +UPSTREAM_URL="${1:?Usage: descope-mcp-wrapper.sh }" +SERVER_KEY="${DESCOPE_SERVER_KEY:?Set DESCOPE_SERVER_KEY env var}" + +log() { + echo "[mcp-wrapper] $(date -u +%Y-%m-%dT%H:%M:%SZ) $*" >> "$LOG_FILE" 2>&1 +} + +get_token() { + local cache_file="${TOKEN_DIR}/${SERVER_KEY}.json" + if [ -f "$cache_file" ]; then + jq -r '.access_token // empty' "$cache_file" 2>/dev/null + fi +} + +log "Starting wrapper: ${SERVER_KEY} β†’ ${UPSTREAM_URL}" + +while IFS= read -r LINE; do + [ -z "$LINE" ] && continue + + if ! echo "$LINE" | jq empty 2>/dev/null; then + log "WARN: Non-JSON input: ${LINE:0:100}" + continue + fi + + TOKEN=$(get_token) + + if [ -z "$TOKEN" ]; then + MSG_ID=$(echo "$LINE" | jq -r '.id // null') + echo "{\"jsonrpc\":\"2.0\",\"id\":${MSG_ID},\"error\":{\"code\":-32000,\"message\":\"No Descope auth token available for ${SERVER_KEY}. Ensure the PreToolUse hook ran.\"}}" + log "ERROR: No token in cache for ${SERVER_KEY}" + continue + fi + + RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "$UPSTREAM_URL" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -H "Authorization: Bearer ${TOKEN}" \ + -d "$LINE" 2>/dev/null) + + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | sed '$d') + + if [ "$HTTP_CODE" -ge 200 ] && [ "$HTTP_CODE" -lt 300 ]; then + echo "$BODY" + else + MSG_ID=$(echo "$LINE" | jq -r '.id // null') + ESCAPED_BODY=$(echo "$BODY" | jq -Rs '.' 2>/dev/null || echo "\"upstream error\"") + echo "{\"jsonrpc\":\"2.0\",\"id\":${MSG_ID},\"error\":{\"code\":-32000,\"message\":\"Upstream MCP error ${HTTP_CODE}\",\"data\":${ESCAPED_BODY}}}" + log "ERROR: Upstream returned ${HTTP_CODE}: ${BODY:0:200}" + fi + +done + +log "Wrapper stdin closed, exiting" \ No newline at end of file diff --git a/hooks/claude-code/settings.json b/hooks/claude-code/settings.json new file mode 100644 index 0000000..e73364a --- /dev/null +++ b/hooks/claude-code/settings.json @@ -0,0 +1,67 @@ +// .claude/settings.json +// ───────────────────── +// Claude Code hook configuration for Descope agent auth. +// +// PreToolUse hooks run before every tool call. The matcher is a regex +// against tool_name β€” MCP tools follow the pattern "mcp____". +// +// The hook: +// 1. Acquires a scoped Descope token for the matched MCP server +// 2. Writes it to a shared token file the MCP wrapper reads +// 3. Blocks execution if token acquisition fails +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "^mcp__", + "hooks": [ + { + "type": "command", + "command": "hooks/descope-auth-cc.sh" + } + ] + } + ] + }, + + // MCP servers use the auth wrapper to inject tokens. + // The wrapper reads the token from the hook's cache and + // proxies to the upstream MCP server with Authorization headers. + "mcpServers": { + // Strategy 1: Client Credentials + Token Exchange + "github": { + "command": "hooks/descope-mcp-wrapper.sh", + "args": ["https://mcp.github.com/sse"], + "env": { + "DESCOPE_SERVER_KEY": "github" + } + }, + + // Strategy 2: User Token Exchange + "salesforce": { + "command": "hooks/descope-mcp-wrapper.sh", + "args": ["https://mcp.salesforce.com/sse"], + "env": { + "DESCOPE_SERVER_KEY": "salesforce" + } + }, + + // Strategy 3: Connections API + "google-contacts": { + "command": "hooks/descope-mcp-wrapper.sh", + "args": ["https://mcp.google.com/sse"], + "env": { + "DESCOPE_SERVER_KEY": "google-contacts" + } + }, + + // Strategy 4: CIBA + "calendar": { + "command": "hooks/descope-mcp-wrapper.sh", + "args": ["https://mcp.calendar.com/sse"], + "env": { + "DESCOPE_SERVER_KEY": "calendar" + } + } + } +} diff --git a/hooks/cursor/descope-auth.config.example.json b/hooks/cursor/descope-auth.config.example.json new file mode 100644 index 0000000..e69de29 diff --git a/hooks/cursor/descope-auth.sh b/hooks/cursor/descope-auth.sh new file mode 100644 index 0000000..cbfec7e --- /dev/null +++ b/hooks/cursor/descope-auth.sh @@ -0,0 +1,382 @@ + +#!/bin/bash +# ───────────────────────────────────────────────────────────── +# Descope Agent Auth Hook for Cursor +# ───────────────────────────────────────────────────────────── +# +# A beforeMCPExecution hook that acquires a scoped access token +# from Descope and injects it into the MCP tool call. +# +# Cursor calls this script before every MCP tool execution. +# It receives a JSON payload on stdin with: +# { email, tool_name, tool_input, model, conversation_id } +# +# The script: +# 1. Reads the tool call context from stdin +# 2. Looks up the auth strategy for this MCP server +# 3. Acquires a scoped token from Descope +# 4. Returns { "permission": "allow", "headers": { "Authorization": "..." } } +# or { "permission": "deny", "reason": "..." } +# +# ───────────────────────────────────────────────────────────── + +HOOKS_DIR="$(cd "$(dirname "$0")" && pwd)" +CONFIG_FILE="${HOOKS_DIR}/descope-auth.config.json" +LOG_FILE="${HOOKS_DIR}/descope-auth.log" + +# ─── Logging (stderr only, never stdout) ───────────────────── + +log() { + echo "[descope-auth] $(date -u +%Y-%m-%dT%H:%M:%SZ) $*" >> "$LOG_FILE" 2>&1 +} + +# ─── Dependency check ──────────────────────────────────────── + +if ! command -v jq >/dev/null 2>&1; then + echo '{"permission": "allow"}' + log "WARNING: jq not found, bypassing auth" + exit 0 +fi + +if ! command -v curl >/dev/null 2>&1; then + echo '{"permission": "allow"}' + log "WARNING: curl not found, bypassing auth" + exit 0 +fi + +# ─── Read hook input from Cursor ───────────────────────────── + +INPUT=$(cat) +TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty') +USER_EMAIL=$(echo "$INPUT" | jq -r '.email // empty') +CONVERSATION_ID=$(echo "$INPUT" | jq -r '.conversation_id // empty') + +log "Hook fired: tool=$TOOL_NAME user=$USER_EMAIL conversation=$CONVERSATION_ID" + +# ─── Load config ───────────────────────────────────────────── + +if [ ! -f "$CONFIG_FILE" ]; then + log "WARNING: Config file not found at $CONFIG_FILE, allowing without auth" + echo '{"permission": "allow"}' + exit 0 +fi + +SERVER_CONFIG=$(jq -c --arg tool "$TOOL_NAME" ' + .servers | to_entries[] | + select(.key as $k | $tool | startswith($k)) | + .value +' "$CONFIG_FILE" 2>/dev/null | head -1) + +if [ -z "$SERVER_CONFIG" ] || [ "$SERVER_CONFIG" = "null" ]; then + log "No auth config for tool=$TOOL_NAME, allowing without auth" + echo '{"permission": "allow"}' + exit 0 +fi + +STRATEGY=$(echo "$SERVER_CONFIG" | jq -r '.strategy') +BASE_URL=$(echo "$SERVER_CONFIG" | jq -r '.baseUrl // "https://api.descope.com"') + +log "Strategy: $STRATEGY for tool=$TOOL_NAME" + +# ─── Token cache (file-based, per-server) ──────────────────── + +CACHE_DIR="${HOOKS_DIR}/.token-cache" +mkdir -p "$CACHE_DIR" + +cache_key() { + echo "$1" | shasum -a 256 | cut -c1-16 +} + +get_cached_token() { + local key=$(cache_key "$1") + local cache_file="${CACHE_DIR}/${key}.json" + + if [ -f "$cache_file" ]; then + local expires_at=$(jq -r '.expires_at' "$cache_file" 2>/dev/null) + local now=$(date +%s) + local expires_epoch=$(date -d "$expires_at" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%S" "$expires_at" +%s 2>/dev/null || echo 0) + + if [ $((expires_epoch - now)) -gt 30 ]; then + jq -r '.access_token' "$cache_file" + return 0 + fi + fi + return 1 +} + +set_cached_token() { + local key=$(cache_key "$1") + local cache_file="${CACHE_DIR}/${key}.json" + echo "$2" > "$cache_file" +} + +# ─── OAuth flows ───────────────────────────────────────────── + +# Strategy 1: client_credentials β†’ token exchange +do_client_credentials_exchange() { + local client_id=$(echo "$SERVER_CONFIG" | jq -r '.clientId') + local client_secret=$(echo "$SERVER_CONFIG" | jq -r '.clientSecret') + local project_id=$(echo "$SERVER_CONFIG" | jq -r '.projectId') + local audience=$(echo "$SERVER_CONFIG" | jq -r '.audience') + local scopes=$(echo "$SERVER_CONFIG" | jq -r '.scopes // empty') + local resource=$(echo "$SERVER_CONFIG" | jq -r '.resource // empty') + + local cache_id="cc:${client_id}:${audience}:${scopes}" + local cached=$(get_cached_token "$cache_id") && { echo "$cached"; return 0; } + + # Step 1: client_credentials + local cc_response=$(curl -s -X POST "${BASE_URL}/oauth2/v1/apps/token" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg grantType "client_credentials" \ + --arg clientId "$client_id" \ + --arg clientSecret "$client_secret" \ + '{grant_type: $grantType, client_id: $clientId, client_secret: $clientSecret}' + )") + + local agent_token=$(echo "$cc_response" | jq -r '.access_token // empty') + if [ -z "$agent_token" ]; then + log "ERROR: client_credentials failed: $cc_response" + return 1 + fi + + # Step 2: token exchange + local te_body=$(jq -n \ + --arg grantType "urn:ietf:params:oauth:grant-type:token-exchange" \ + --arg clientId "$client_id" \ + --arg clientSecret "$client_secret" \ + --arg subjectToken "$agent_token" \ + --arg subjectTokenType "urn:ietf:params:oauth:token-type:access_token" \ + --arg audience "$audience" \ + --arg resource "$resource" \ + '{ + grant_type: $grantType, + client_id: $clientId, + client_secret: $clientSecret, + subject_token: $subjectToken, + subject_token_type: $subjectTokenType, + audience: $audience + } | if $resource != "" then . + {resource: $resource} else . end') + + local te_response=$(curl -s -X POST "${BASE_URL}/oauth2/v1/apps/${project_id}/token" \ + -H "Content-Type: application/json" \ + -d "$te_body") + + local token=$(echo "$te_response" | jq -r '.access_token // empty') + if [ -z "$token" ]; then + log "ERROR: token exchange failed: $te_response" + return 1 + fi + + local expires_in=$(echo "$te_response" | jq -r '.expires_in // 3600') + local expires_at=$(date -u -d "+${expires_in} seconds" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || \ + date -u -v+${expires_in}S +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) + + set_cached_token "$cache_id" "$(jq -n \ + --arg token "$token" --arg expires "$expires_at" \ + '{access_token: $token, expires_at: $expires}')" + + echo "$token" +} + +# Strategy 2: user token β†’ token exchange +do_user_token_exchange() { + local project_id=$(echo "$SERVER_CONFIG" | jq -r '.projectId') + local user_token=$(echo "$SERVER_CONFIG" | jq -r '.userAccessToken') + local audience=$(echo "$SERVER_CONFIG" | jq -r '.audience') + local scopes=$(echo "$SERVER_CONFIG" | jq -r '.scopes // empty') + local resource=$(echo "$SERVER_CONFIG" | jq -r '.resource // empty') + + local cache_id="ute:${audience}:${scopes}:${user_token: -8}" + local cached=$(get_cached_token "$cache_id") && { echo "$cached"; return 0; } + + local te_body=$(jq -n \ + --arg grantType "urn:ietf:params:oauth:grant-type:token-exchange" \ + --arg subjectToken "$user_token" \ + --arg subjectTokenType "urn:ietf:params:oauth:token-type:access_token" \ + --arg audience "$audience" \ + --arg resource "$resource" \ + '{ + grant_type: $grantType, + subject_token: $subjectToken, + subject_token_type: $subjectTokenType, + audience: $audience + } | if $resource != "" then . + {resource: $resource} else . end') + + local response=$(curl -s -X POST "${BASE_URL}/oauth2/v1/apps/${project_id}/token" \ + -H "Content-Type: application/json" \ + -d "$te_body") + + local token=$(echo "$response" | jq -r '.access_token // empty') + if [ -z "$token" ]; then + log "ERROR: user token exchange failed: $response" + return 1 + fi + + local expires_in=$(echo "$response" | jq -r '.expires_in // 3600') + local expires_at=$(date -u -d "+${expires_in} seconds" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || \ + date -u -v+${expires_in}S +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) + + set_cached_token "$cache_id" "$(jq -n \ + --arg token "$token" --arg expires "$expires_at" \ + '{access_token: $token, expires_at: $expires}')" + + echo "$token" +} + +# Strategy 3: connections API +do_connections() { + local project_id=$(echo "$SERVER_CONFIG" | jq -r '.projectId') + local user_token=$(echo "$SERVER_CONFIG" | jq -r '.userAccessToken') + local app_id=$(echo "$SERVER_CONFIG" | jq -r '.appId') + local user_id=$(echo "$SERVER_CONFIG" | jq -r '.userId') + local tenant_id=$(echo "$SERVER_CONFIG" | jq -r '.tenantId // empty') + local scopes=$(echo "$SERVER_CONFIG" | jq -c '.scopes // []') + + local cache_id="conn:${app_id}:${user_id}:${user_token: -8}" + local cached=$(get_cached_token "$cache_id") && { echo "$cached"; return 0; } + + local body=$(jq -n \ + --arg appId "$app_id" \ + --arg userId "$user_id" \ + --arg tenantId "$tenant_id" \ + --argjson scopes "$scopes" \ + '{appId: $appId, userId: $userId, scopes: $scopes} + | if $tenantId != "" then . + {tenantId: $tenantId} else . end') + + local response=$(curl -s -X POST "${BASE_URL}/v1/mgmt/outbound/app/user/token" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${project_id}:${user_token}" \ + -d "$body") + + local token=$(echo "$response" | jq -r '.token // .accessToken // .access_token // empty') + if [ -z "$token" ]; then + log "ERROR: connections failed: $response" + return 1 + fi + + local expires_in=$(echo "$response" | jq -r '.expiresIn // .expires_in // 3600') + local expires_at=$(date -u -d "+${expires_in} seconds" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || \ + date -u -v+${expires_in}S +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) + + set_cached_token "$cache_id" "$(jq -n \ + --arg token "$token" --arg expires "$expires_at" \ + '{access_token: $token, expires_at: $expires}')" + + echo "$token" +} + +# Strategy 4: CIBA +do_ciba() { + local client_id=$(echo "$SERVER_CONFIG" | jq -r '.clientId') + local client_secret=$(echo "$SERVER_CONFIG" | jq -r '.clientSecret') + local audience=$(echo "$SERVER_CONFIG" | jq -r '.audience') + local scopes=$(echo "$SERVER_CONFIG" | jq -r '.scopes // empty') + local login_hint=$(echo "$SERVER_CONFIG" | jq -r '.loginHint // empty') + local login_hint_token=$(echo "$SERVER_CONFIG" | jq -r '.loginHintToken // empty') + local binding_message=$(echo "$SERVER_CONFIG" | jq -r '.bindingMessage // empty') + local poll_interval=$(echo "$SERVER_CONFIG" | jq -r '.pollIntervalSeconds // 2') + local timeout=$(echo "$SERVER_CONFIG" | jq -r '.timeoutSeconds // 120') + + # Step 1: backchannel authorize + local auth_body=$(jq -n \ + --arg clientId "$client_id" \ + --arg clientSecret "$client_secret" \ + --arg scope "$scopes" \ + --arg audience "$audience" \ + --arg loginHint "$login_hint" \ + --arg loginHintToken "$login_hint_token" \ + --arg bindingMessage "$binding_message" \ + '{client_id: $clientId, client_secret: $clientSecret, scope: $scope, audience: $audience} + | if $loginHint != "" then . + {login_hint: $loginHint} else . end + | if $loginHintToken != "" then . + {login_hint_token: $loginHintToken} else . end + | if $bindingMessage != "" then . + {binding_message: $bindingMessage} else . end') + + local auth_response=$(curl -s -X POST "${BASE_URL}/oauth2/v1/apps/bc-authorize" \ + -H "Content-Type: application/json" \ + -d "$auth_body") + + local auth_req_id=$(echo "$auth_response" | jq -r '.auth_req_id // empty') + if [ -z "$auth_req_id" ]; then + log "ERROR: CIBA authorize failed: $auth_response" + return 1 + fi + + local server_interval=$(echo "$auth_response" | jq -r '.interval // empty') + if [ -n "$server_interval" ] && [ "$server_interval" -gt "$poll_interval" ]; then + poll_interval=$server_interval + fi + + log "CIBA: waiting for user consent (auth_req_id=$auth_req_id, polling every ${poll_interval}s)" + + # Step 2: poll for token + local elapsed=0 + while [ $elapsed -lt $timeout ]; do + sleep "$poll_interval" + elapsed=$((elapsed + poll_interval)) + + local poll_response=$(curl -s -X POST "${BASE_URL}/oauth2/v1/apps/token" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg grantType "urn:openid:params:grant-type:ciba" \ + --arg authReqId "$auth_req_id" \ + --arg clientId "$client_id" \ + --arg clientSecret "$client_secret" \ + '{grant_type: $grantType, auth_req_id: $authReqId, client_id: $clientId, client_secret: $clientSecret}' + )") + + local token=$(echo "$poll_response" | jq -r '.access_token // empty') + if [ -n "$token" ]; then + log "CIBA: user approved (${elapsed}s elapsed)" + echo "$token" + return 0 + fi + + local error=$(echo "$poll_response" | jq -r '.error // empty') + case "$error" in + authorization_pending) continue ;; + slow_down) sleep "$poll_interval"; elapsed=$((elapsed + poll_interval)); continue ;; + *) + log "ERROR: CIBA poll failed: $poll_response" + return 1 + ;; + esac + done + + log "ERROR: CIBA timed out after ${timeout}s" + return 1 +} + +# ─── Dispatch ──────────────────────────────────────────────── + +TOKEN="" +case "$STRATEGY" in + client_credentials_exchange) TOKEN=$(do_client_credentials_exchange) ;; + user_token_exchange) TOKEN=$(do_user_token_exchange) ;; + connections) TOKEN=$(do_connections) ;; + ciba) TOKEN=$(do_ciba) ;; + *) + log "ERROR: Unknown strategy: $STRATEGY" + echo '{"permission": "allow"}' + exit 0 + ;; +esac + +# ─── Response to Cursor ───────────────────────────────────── + +if [ -z "$TOKEN" ]; then + log "ERROR: Failed to acquire token for tool=$TOOL_NAME" + echo '{"permission": "deny", "reason": "Failed to acquire Descope auth token"}' + exit 0 +fi + +log "SUCCESS: Token acquired for tool=$TOOL_NAME" + +jq -n \ + --arg token "$TOKEN" \ + '{ + "permission": "allow", + "headers": { + "Authorization": ("Bearer " + $token) + } + }' \ No newline at end of file diff --git a/hooks/cursor/hooks.json b/hooks/cursor/hooks.json new file mode 100644 index 0000000..fa5776c --- /dev/null +++ b/hooks/cursor/hooks.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "hooks": { + "beforeMCPExecution": [{ "command": "./hooks/descope-auth.sh" }] + } +} diff --git a/hooks/docs/security.md b/hooks/docs/security.md new file mode 100644 index 0000000..8f396cf --- /dev/null +++ b/hooks/docs/security.md @@ -0,0 +1,78 @@ +# Security Considerations + +## Token Trust Boundaries + +When choosing an auth strategy, the key security question is: +**where does the external provider token live?** + +### Token Exchange (Strategies 1, 2, 4) + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Agent β”‚ ◄──────► β”‚ Descope β”‚ ◄──────► β”‚ MCP Server β”‚ +β”‚ β”‚ Descope β”‚ β”‚ Descope β”‚ β”‚ +β”‚ β”‚ token β”‚ β”‚ token β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ (short) β”‚ β”‚ β”‚ β”‚Externalβ”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚Token β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚(stays β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚here) β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +The agent only holds a **Descope-issued token** that is: + +- Short-lived (minutes, controlled by Descope) +- Scoped to specific MCP server capabilities +- Revocable by Descope + +The external provider token (Google, HubSpot, etc.) stays inside +the MCP server and is never exposed to the agent. + +### Connections API (Strategy 3) + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Agent β”‚ ◄──────► β”‚ Descope β”‚ +β”‚ β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β” β”‚ External β”‚ β”‚ +β”‚ β”‚Googleβ”‚ β”‚ token β”‚ β”‚ +β”‚ β”‚Token β”‚ β”‚ (sent to β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ agent) β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +The agent holds the **raw third-party token** which is: + +- Potentially long-lived (set by the provider, not Descope) +- Scoped to the full connection (not narrowed to MCP server needs) +- Not revocable by Descope (only the provider can revoke it) + +## Token Lifetime Comparison + +| Token source | Lifetime | Controlled by | +| ------------------------- | ----------------------------------------------- | ------------- | +| Descope `/token` endpoint | Minutes (configurable) | Descope | +| Google OAuth | 1 hour (access), months (refresh) | Google | +| HubSpot OAuth | 30 minutes (access), 6 months (refresh) | HubSpot | +| GitHub OAuth | No expiry (classic PAT), 8 hours (fine-grained) | GitHub | +| Salesforce OAuth | Session-based, potentially hours | Salesforce | + +## Recommendations + +1. **Use token exchange (strategies 1 or 2) whenever possible.** + The agent never sees external tokens, and Descope controls lifetime. + +2. **Use connections (strategy 3) only when the agent needs direct + third-party API access** without an intermediary MCP server. + +3. **Never log tokens.** The hook scripts write to `descope-auth.log` + but never include token values β€” only metadata. + +4. **Git-ignore the token cache.** The `.token-cache/` directory + contains live tokens and must never be committed. + +5. **Rotate client secrets regularly.** The `clientId` / `clientSecret` + in the config file are long-lived credentials β€” treat them like + any other secret. diff --git a/hooks/docs/strategies.md b/hooks/docs/strategies.md new file mode 100644 index 0000000..d5603dc --- /dev/null +++ b/hooks/docs/strategies.md @@ -0,0 +1,146 @@ +# Auth Strategies β€” Deep Dive + +For the full guide, see the [README](../README.md). This document covers +implementation details and edge cases for each strategy. + +## Descope API Endpoints + +All requests use JSON bodies (`Content-Type: application/json`). + +| Operation | Endpoint | +| ------------------ | ----------------------------------------- | +| Client Credentials | `POST /oauth2/v1/apps/token` | +| Token Exchange | `POST /oauth2/v1/apps/{project_id}/token` | +| CIBA Authorize | `POST /oauth2/v1/apps/bc-authorize` | +| CIBA Poll | `POST /oauth2/v1/apps/token` | +| Connections | `POST /v1/mgmt/outbound/app/user/token` | + +The token exchange endpoint is project-scoped (`/apps/{project_id}/token`) +while `client_credentials` uses the base path (`/apps/token`). + +## Strategy 1: Client Credentials + Token Exchange + +**Grant types used:** + +1. `client_credentials` β€” agent authenticates as itself +2. `urn:ietf:params:oauth:grant-type:token-exchange` β€” narrow to MCP server scope + +**Step 1 β€” Client Credentials:** + +```bash +curl -X POST 'https://api.descope.com/oauth2/v1/apps/token' \ + -H 'Content-Type: application/json' \ + -d '{ + "grant_type": "client_credentials", + "client_id": "", + "client_secret": "" + }' +``` + +**Step 2 β€” Token Exchange:** + +```bash +curl -X POST 'https://api.descope.com/oauth2/v1/apps/{project_id}/token' \ + -H 'Content-Type: application/json' \ + -d '{ + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "client_id": "", + "client_secret": "", + "subject_token": "", + "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", + "audience": "", + "resource": "" + }' +``` + +## Strategy 2: User Token Exchange + +**Grant type:** `urn:ietf:params:oauth:grant-type:token-exchange` + +Same as Step 2 above, but `subject_token` is the user's Descope access token +instead of a client_credentials token. + +```bash +curl -X POST 'https://api.descope.com/oauth2/v1/apps/{project_id}/token' \ + -H 'Content-Type: application/json' \ + -d '{ + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token": "", + "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", + "audience": "" + }' +``` + +## Strategy 3: Connections API + +**Endpoint:** `POST /v1/mgmt/outbound/app/user/token` + +**Auth header:** `Bearer :` + +```bash +curl -X POST 'https://api.descope.com/v1/mgmt/outbound/app/user/token' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer :' \ + -d '{ + "appId": "google-contacts", + "userId": "xxxxx", + "tenantId": "optional-tenant-id", + "scopes": [ + "https://www.googleapis.com/auth/contacts.readonly" + ], + "options": { + "withRefreshToken": false, + "forceRefresh": false + } + }' +``` + +### Security Considerations + +- The raw third-party provider token is returned directly to the agent +- External tokens (Google, HubSpot, etc.) are NOT controlled by Descope + and may be long-lived (hours or permanent) +- With token exchange (strategies 1 & 2), external tokens stay server-side + inside the MCP server and the agent only holds short-lived Descope tokens + +## Strategy 4: CIBA + +**Grant type:** `urn:openid:params:grant-type:ciba` + +**Step 1 β€” Backchannel Authorize:** + +```bash +curl -X POST 'https://api.descope.com/oauth2/v1/apps/bc-authorize' \ + -H 'Content-Type: application/json' \ + -d '{ + "client_id": "", + "client_secret": "", + "scope": "events:read events:write", + "audience": "mcp-server-calendar", + "login_hint": "user@example.com", + "binding_message": "Allow AI assistant to manage your calendar?" + }' +``` + +Returns `auth_req_id` and optional `interval`. + +**Step 2 β€” Poll for Token:** + +```bash +curl -X POST 'https://api.descope.com/oauth2/v1/apps/token' \ + -H 'Content-Type: application/json' \ + -d '{ + "grant_type": "urn:openid:params:grant-type:ciba", + "auth_req_id": "", + "client_id": "", + "client_secret": "" + }' +``` + +**Poll responses:** + +- `authorization_pending` β€” keep polling +- `slow_down` β€” increase poll interval, keep polling +- `expired_token` β€” request expired, start over +- `access_denied` β€” user rejected +- Success β€” returns `access_token` diff --git a/hooks/install-claude-code.sh b/hooks/install-claude-code.sh new file mode 100644 index 0000000..5675fa8 --- /dev/null +++ b/hooks/install-claude-code.sh @@ -0,0 +1,133 @@ + +#!/bin/bash +# ───────────────────────────────────────────────────────────── +# Descope Agent Hooks Installer β€” Claude Code +# ───────────────────────────────────────────────────────────── +# +# Usage: +# curl -fsSL https://agent-hooks.sh/install-claude-code.sh | bash +# +# Or from the cloned repo: +# ./install-claude-code.sh +# +# Installs into the CURRENT PROJECT directory by default. +# Pass --global to install to ~/.claude/ instead. +# +# ───────────────────────────────────────────────────────────── + +set -e + +GLOBAL=false +if [ "$1" = "--global" ]; then + GLOBAL=true +fi + +if [ "$GLOBAL" = true ]; then + SETTINGS_DIR="${HOME}/.claude" + HOOKS_DIR="${HOME}/.claude/hooks" +else + SETTINGS_DIR=".claude" + HOOKS_DIR="hooks" +fi + +SETTINGS_FILE="${SETTINGS_DIR}/settings.json" +REPO_BASE="https://raw.githubusercontent.com/descope/agent-hooks/main" + +echo "πŸ” Descope Agent Hooks Installer (Claude Code)" +echo "────────────────────────────────────────────────" +if [ "$GLOBAL" = true ]; then + echo "Mode: Global (~/.claude)" +else + echo "Mode: Project (.claude)" +fi + +# ─── Check dependencies ───────────────────────────────────── + +for cmd in jq curl; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "❌ Required: $cmd" + exit 1 + fi +done +echo "βœ“ Dependencies: jq, curl" + +# ─── Create directories ───────────────────────────────────── + +mkdir -p "$SETTINGS_DIR" +mkdir -p "$HOOKS_DIR" +echo "βœ“ Directories created" + +# ─── Helper: install a file from repo or local clone ──────── + +install_file() { + local repo_path="$1" dest="$2" + if [ -f "./$repo_path" ]; then + cp "./$repo_path" "$dest" + else + curl -fsSL "${REPO_BASE}/${repo_path}" -o "$dest" + fi +} + +# ─── Install hook scripts ─────────────────────────────────── + +install_file "claude-code/descope-auth-cc.sh" "${HOOKS_DIR}/descope-auth-cc.sh" +install_file "claude-code/descope-mcp-wrapper.sh" "${HOOKS_DIR}/descope-mcp-wrapper.sh" +chmod +x "${HOOKS_DIR}/descope-auth-cc.sh" +chmod +x "${HOOKS_DIR}/descope-mcp-wrapper.sh" +echo "βœ“ Hook scripts installed" + +# ─── Install config ───────────────────────────────────────── + +CONFIG_FILE="${HOOKS_DIR}/descope-auth.config.json" +if [ -f "$CONFIG_FILE" ]; then + echo "⚠ Config already exists β€” skipping" +else + install_file "claude-code/descope-auth.config.example.json" "$CONFIG_FILE" + echo "βœ“ Example config installed" +fi + +# ─── Update settings.json ─────────────────────────────────── + +if [ -f "$SETTINGS_FILE" ]; then + if jq -e '.hooks.PreToolUse[]?.hooks[]? | select(.command == "hooks/descope-auth-cc.sh")' "$SETTINGS_FILE" >/dev/null 2>&1; then + echo "βœ“ Hook already registered in settings.json" + else + UPDATED=$(jq ' + .hooks = (.hooks // {}) | + .hooks.PreToolUse = (.hooks.PreToolUse // []) + [{ + "matcher": "^mcp__", + "hooks": [{"type": "command", "command": "hooks/descope-auth-cc.sh"}] + }] + ' "$SETTINGS_FILE") + echo "$UPDATED" > "$SETTINGS_FILE" + echo "βœ“ Hook added to existing settings.json" + fi +else + install_file "claude-code/settings.json" "$SETTINGS_FILE" + echo "βœ“ Created settings.json" +fi + +# ─── Done ──────────────────────────────────────────────────── + +echo "" +echo "────────────────────────────────────────────────" +echo "βœ… Descope Agent Hooks installed for Claude Code!" +echo "" +echo "Next steps:" +echo " 1. Edit ${CONFIG_FILE}" +echo " with your Descope credentials and MCP server config." +echo "" +echo " 2. Add MCP servers to ${SETTINGS_FILE}:" +echo " \"mcpServers\": {" +echo " \"github\": {" +echo " \"command\": \"hooks/descope-mcp-wrapper.sh\"," +echo " \"args\": [\"https://mcp.github.com/sse\"]," +echo " \"env\": { \"DESCOPE_SERVER_KEY\": \"github\" }" +echo " }" +echo " }" +echo "" +echo " 3. Run: claude" +echo " Hooks activate automatically on MCP tool calls." +echo "" +echo "Docs: https://agent-hooks.sh" +echo "────────────────────────────────────────────────" diff --git a/hooks/install.sh b/hooks/install.sh new file mode 100644 index 0000000..dede58b --- /dev/null +++ b/hooks/install.sh @@ -0,0 +1,99 @@ + +#!/bin/bash +# ───────────────────────────────────────────────────────────── +# Descope Agent Hooks Installer β€” Cursor +# ───────────────────────────────────────────────────────────── +# +# Usage: +# curl -fsSL https://agent-hooks.sh/install.sh | bash +# +# Or from the cloned repo: +# ./install.sh +# +# ───────────────────────────────────────────────────────────── + +set -e + +HOOKS_DIR="${HOME}/.cursor/hooks" +HOOKS_JSON="${HOME}/.cursor/hooks.json" +REPO_BASE="https://raw.githubusercontent.com/descope/agent-hooks/main" + +echo "πŸ” Descope Agent Hooks Installer (Cursor)" +echo "───────────────────────────────────────────" + +# ─── Check dependencies ───────────────────────────────────── + +for cmd in jq curl; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "❌ Required dependency missing: $cmd" + echo " Install it and re-run this script." + exit 1 + fi +done +echo "βœ“ Dependencies: jq, curl" + +# ─── Create hooks directory ───────────────────────────────── + +mkdir -p "$HOOKS_DIR" +echo "βœ“ Hooks directory: $HOOKS_DIR" + +# ─── Helper: install a file from repo or local clone ──────── + +install_file() { + local repo_path="$1" dest="$2" + if [ -f "./$repo_path" ]; then + cp "./$repo_path" "$dest" + else + curl -fsSL "${REPO_BASE}/${repo_path}" -o "$dest" + fi +} + +# ─── Install hook script ──────────────────────────────────── + +install_file "cursor/descope-auth.sh" "$HOOKS_DIR/descope-auth.sh" +chmod +x "$HOOKS_DIR/descope-auth.sh" +echo "βœ“ Hook script installed" + +# ─── Install example config ───────────────────────────────── + +CONFIG_FILE="$HOOKS_DIR/descope-auth.config.json" +if [ -f "$CONFIG_FILE" ]; then + echo "⚠ Config already exists at $CONFIG_FILE β€” skipping" +else + install_file "cursor/descope-auth.config.example.json" "$CONFIG_FILE" + echo "βœ“ Example config installed (edit with your Descope credentials)" +fi + +# ─── Register hook in hooks.json ───────────────────────────── + +if [ -f "$HOOKS_JSON" ]; then + if jq -e '.hooks.beforeMCPExecution[]? | select(.command == "./hooks/descope-auth.sh")' "$HOOKS_JSON" >/dev/null 2>&1; then + echo "βœ“ Hook already registered in hooks.json" + else + UPDATED=$(jq '.hooks.beforeMCPExecution = (.hooks.beforeMCPExecution // []) + [{"command": "./hooks/descope-auth.sh"}]' "$HOOKS_JSON") + echo "$UPDATED" > "$HOOKS_JSON" + echo "βœ“ Hook added to existing hooks.json" + fi +else + install_file "cursor/hooks.json" "$HOOKS_JSON" + echo "βœ“ Created hooks.json" +fi + +# ─── Done ──────────────────────────────────────────────────── + +echo "" +echo "───────────────────────────────────────────" +echo "βœ… Descope Agent Hooks installed for Cursor!" +echo "" +echo "Next steps:" +echo " 1. Edit $CONFIG_FILE" +echo " with your Descope project ID, client credentials," +echo " and MCP server audience values." +echo "" +echo " 2. Restart Cursor to activate the hooks." +echo "" +echo " 3. MCP tool calls will now automatically acquire" +echo " scoped tokens from Descope before execution." +echo "" +echo "Docs: https://agent-hooks.sh" +echo "───────────────────────────────────────────" diff --git a/hooks/test-agent/README.md b/hooks/test-agent/README.md new file mode 100644 index 0000000..abd2090 --- /dev/null +++ b/hooks/test-agent/README.md @@ -0,0 +1,95 @@ +# Descope Agent Hooks β€” Test Agent + +Automated tests for the Cursor hook, Claude Code hook, MCP wrapper, and TypeScript library. + +## Requirements + +- Node.js 18+ +- `jq` and `curl` (required by the hooks themselves) + +## Usage + +From the `hooks` directory: + +```bash +node test-agent/test-agent.mjs +``` + +Or from the test-agent directory: + +```bash +node test-agent.mjs +``` + +### Options + +| Option | Description | +|---------------|-----------------------------------------------------------------------| +| `--integration` | Run integration tests (requires `descope-auth.config.json` with valid credentials) | +| `--verbose` | Log detailed output | + +### Environment + +| Variable | Description | +|------------|-----------------------| +| `VERBOSE=1` | Same as `--verbose` | + +## What It Tests + +### Cursor Hook (`cursor/descope-auth.sh`) + +- Hook exists and is executable +- Returns `{"permission": "allow"}` for non-matching tools or when no config exists +- Output is valid JSON with a `permission` field +- With matching config: returns allow+headers or deny+reason + +### Claude Code Hook (`claude-code/descope-auth-cc.sh`) + +- Hook exists and is executable +- Exits 0 for non-MCP tools (tool names not starting with `mcp__`) +- Exits 0 for unknown MCP servers (no config) +- Handles Descope failures gracefully (exit 0 with block reason, or success with cached token) + +### MCP Wrapper (`claude-code/descope-mcp-wrapper.sh`) + +- Wrapper exists and is executable +- Requires `DESCOPE_SERVER_KEY` environment variable + +### TypeScript Library (`typescript/src/descope-auth-hooks.ts`) + +- Library file exists +- Exports `clientCredentialsTokenExchange`, `userTokenExchange`, `preToolUseHook`, etc. + +### Config + +- Validates `descope-auth.config.json` structure when present +- Checks for example configs as fallback + +## Integration Tests + +To run with real Descope credentials: + +1. Copy the example config and fill in your credentials: + + ```bash + cp hooks/claude-code/descope-auth.config.example.json hooks/claude-code/descope-auth.config.json + # Edit with your projectId, clientId, clientSecret, etc. + ``` + +2. Run with `--integration`: + + ```bash + node test-agent/test-agent.mjs --integration + ``` + +## Adding to package.json + +If the hooks repo has a root `package.json`, add: + +```json +{ + "scripts": { + "test:hooks": "node test-agent/test-agent.mjs" + } +} +``` diff --git a/hooks/test-agent/test-agent.mjs b/hooks/test-agent/test-agent.mjs new file mode 100644 index 0000000..81078cb --- /dev/null +++ b/hooks/test-agent/test-agent.mjs @@ -0,0 +1,371 @@ +#!/usr/bin/env node +/** + * Descope Agent Hooks β€” Test Agent + * ───────────────────────────────── + * Tests the Cursor hook, Claude Code hook, MCP wrapper, and TypeScript library. + * + * Usage: + * node test-agent.mjs [--integration] [--verbose] + * + * Options: + * --integration Run integration tests (requires descope-auth.config.json with valid credentials) + * --verbose Log detailed output + */ + +import { spawn } from "child_process"; +import { readFileSync, existsSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const HOOKS_ROOT = join(__dirname, ".."); +const CURSOR_HOOK = join(HOOKS_ROOT, "cursor", "descope-auth.sh"); +const CLAUDE_HOOK = join(HOOKS_ROOT, "claude-code", "descope-auth-cc.sh"); +const MCP_WRAPPER = join(HOOKS_ROOT, "claude-code", "descope-mcp-wrapper.sh"); +const CURSOR_CONFIG = join(HOOKS_ROOT, "cursor", "descope-auth.config.json"); +const CLAUDE_CONFIG = join(HOOKS_ROOT, "claude-code", "descope-auth.config.json"); + +const INTEGRATION = process.argv.includes("--integration"); +const VERBOSE = process.argv.includes("--verbose") || process.env.VERBOSE === "1"; + +let passed = 0; +let failed = 0; + +function log(...args) { + if (VERBOSE) console.log(...args); +} + +function ok(name) { + passed++; + console.log(` βœ“ ${name}`); +} + +function fail(name, detail) { + failed++; + console.log(` βœ— ${name}`); + if (detail) console.log(` ${detail}`); +} + +// ─── Run shell script with stdin, return stdout + exit code ───────────────── + +async function runHook(script, stdin, opts = {}) { + return new Promise((resolve) => { + const proc = spawn("bash", [script], { + cwd: opts.cwd || dirname(script), + env: { ...process.env, ...opts.env }, + stdio: ["pipe", "pipe", opts.stderr ? "pipe" : "inherit"], + }); + + let stdout = ""; + let stderr = ""; + if (proc.stdout) proc.stdout.on("data", (d) => (stdout += d.toString())); + if (proc.stderr) proc.stderr?.on("data", (d) => (stderr += d.toString())); + + proc.stdin.write(stdin); + proc.stdin.end(); + + proc.on("close", (code) => { + resolve({ stdout: stdout.trim(), stderr, code: code ?? -1 }); + }); + }); +} + +// ─── Cursor Hook Tests ───────────────────────────────────────────────────── + +async function testCursorHook() { + console.log("\n[Cursor Hook] descope-auth.sh"); + + if (!existsSync(CURSOR_HOOK)) { + fail("Cursor hook exists", `Not found: ${CURSOR_HOOK}`); + return; + } + ok("Cursor hook file exists"); + + // Test 1: Non-matching tool or no config β†’ allow + const input1 = JSON.stringify({ + tool_name: "random_tool_xyz", + tool_input: {}, + email: "test@example.com", + conversation_id: "test-1", + }); + + const result1 = await runHook(CURSOR_HOOK, input1); + + if (result1.code !== 0) { + fail("Cursor hook exits 0 for non-matching tool", `exit ${result1.code}`); + } else { + ok("Cursor hook exits 0 for non-matching tool"); + } + + let out1; + try { + out1 = JSON.parse(result1.stdout); + } catch { + fail("Cursor hook returns valid JSON", result1.stdout?.slice(0, 80)); + return; + } + + if (out1.permission === "allow") { + ok("Cursor hook allows non-matching tool"); + } else { + fail("Cursor hook allows non-matching tool", `got permission=${out1.permission}`); + } + + if (typeof out1.permission === "string") { + ok("Cursor hook output has permission field"); + } else { + fail("Cursor hook output has permission field"); + } + + // Test 2: Matching tool but with placeholder config β†’ may deny or fail gracefully + const input2 = JSON.stringify({ + tool_name: "github_create_issue", + tool_input: { repo: "test" }, + email: "test@example.com", + }); + + const result2 = await runHook(CURSOR_HOOK, input2); + + if (result2.code !== 0 && result2.code !== -1) { + log(" (Cursor hook with placeholder config: non-zero exit is expected)"); + } + + try { + const out2 = JSON.parse(result2.stdout); + if (out2.permission === "allow" && out2.headers?.Authorization) { + ok("Cursor hook returns allow + Authorization header (integration)"); + } else if (out2.permission === "deny" || out2.permission === "allow") { + ok("Cursor hook returns valid permission (allow or deny)"); + } else { + fail("Cursor hook output format", `unexpected: ${JSON.stringify(out2)}`); + } + } catch { + // If Descope fails, hook may output nothing or malformed + if (result2.stdout.includes("permission")) { + ok("Cursor hook outputs permission-related response"); + } else { + fail("Cursor hook returns parseable JSON", result2.stdout?.slice(0, 100) || "(empty)"); + } + } +} + +// ─── Claude Code Hook Tests ──────────────────────────────────────────────── + +async function testClaudeHook() { + console.log("\n[Claude Code Hook] descope-auth-cc.sh"); + + if (!existsSync(CLAUDE_HOOK)) { + fail("Claude hook exists", `Not found: ${CLAUDE_HOOK}`); + return; + } + ok("Claude hook file exists"); + + // Test 1: Non-MCP tool β†’ allow, exit 0 + const input1 = JSON.stringify({ + tool_name: "some_other_tool", + tool_input: {}, + session_id: "test-session", + }); + + const result1 = await runHook(CLAUDE_HOOK, input1); + + if (result1.code === 0) { + ok("Claude hook exits 0 for non-MCP tool"); + } else { + fail("Claude hook exits 0 for non-MCP tool", `exit ${result1.code}`); + } + + // Test 2: MCP tool with no matching server config β†’ allow + const input2 = JSON.stringify({ + tool_name: "mcp__unknown_server__some_tool", + tool_input: {}, + session_id: "test-session", + }); + + // Need config without "unknown_server" - the example has github, salesforce, etc. + const result2 = await runHook(CLAUDE_HOOK, input2); + + if (result2.code === 0) { + ok("Claude hook exits 0 for unknown MCP server"); + } else { + fail("Claude hook exits 0 for unknown MCP server", `exit ${result2.code}`); + } + + // Test 3: MCP tool with matching config (github) β†’ will try Descope + const input3 = JSON.stringify({ + tool_name: "mcp__github__create_issue", + tool_input: { title: "test" }, + session_id: "test-session", + }); + + const result3 = await runHook(CLAUDE_HOOK, input3); + + if (result3.code === 0) { + if (result3.stdout && result3.stdout.includes("reason")) { + ok("Claude hook returns block reason when Descope fails (placeholder creds)"); + } else { + ok("Claude hook succeeds (valid credentials or cached token)"); + } + } else { + ok("Claude hook handles Descope failure (non-zero exit or block)"); + } + + // Test 4: Token cache written when successful (integration only) + const tokenCacheDir = join(HOOKS_ROOT, "claude-code", ".token-cache"); + if (existsSync(tokenCacheDir) && INTEGRATION) { + ok("Token cache directory exists"); + } +} + +// ─── MCP Wrapper Tests ───────────────────────────────────────────────────── + +async function testMcpWrapper() { + console.log("\n[MCP Wrapper] descope-mcp-wrapper.sh"); + + if (!existsSync(MCP_WRAPPER)) { + fail("MCP wrapper exists", `Not found: ${MCP_WRAPPER}`); + return; + } + ok("MCP wrapper file exists"); + + // Test: Without DESCOPE_SERVER_KEY β†’ script exits with error (${var:?msg}) + const env = { ...process.env }; + delete env.DESCOPE_SERVER_KEY; + const proc = spawn("bash", [MCP_WRAPPER, "https://example.com/mcp"], { + cwd: dirname(MCP_WRAPPER), + env, + stdio: ["pipe", "pipe", "pipe"], + }); + + proc.stdin.write('{"jsonrpc":"2.0","id":1,"method":"test"}\n'); + proc.stdin.end(); + + const [stdout, stderr, code] = await new Promise((resolve) => { + let out = ""; + let err = ""; + proc.stdout.on("data", (d) => (out += d.toString())); + proc.stderr.on("data", (d) => (err += d.toString())); + proc.on("close", (c) => resolve([out, err, c])); + }); + + if (code !== 0 && (stdout.includes("DESCOPE_SERVER_KEY") || stderr.includes("DESCOPE_SERVER_KEY"))) { + ok("MCP wrapper requires DESCOPE_SERVER_KEY"); + } else { + ok("MCP wrapper executable"); + } +} + +// ─── TypeScript Library Tests ────────────────────────────────────────────── + +async function testTypescriptLibrary() { + console.log("\n[TypeScript Library] descope-auth-hooks.ts"); + + const tsPath = join(HOOKS_ROOT, "typescript", "src", "descope-auth-hooks.ts"); + if (!existsSync(tsPath)) { + fail("TypeScript library exists", `Not found: ${tsPath}`); + return; + } + ok("TypeScript library file exists"); + + try { + // Dynamic import of the TS file - Node may not resolve .ts by default + const pkgPath = join(HOOKS_ROOT, "typescript"); + const pkg = JSON.parse( + readFileSync(join(pkgPath, "package.json"), "utf8").replace(/\/\*.*?\*\//gs, "") + ); + // Try to import - the package might use "exports" + const mod = await import(tsPath.replace(/\.ts$/, ".js")).catch(() => + import(tsPath).catch(() => null) + ); + if (!mod) { + // Fallback: run tsc and test, or just verify exports exist + const content = readFileSync(tsPath, "utf8"); + if ( + content.includes("clientCredentialsTokenExchange") && + content.includes("userTokenExchange") && + content.includes("preToolUseHook") + ) { + ok("TypeScript library exports all strategies"); + } else { + fail("TypeScript library exports", "Could not verify exports"); + } + return; + } + + const { + clientCredentialsTokenExchange, + userTokenExchange, + preToolUseHook, + } = mod; + + if (typeof clientCredentialsTokenExchange === "function") ok("clientCredentialsTokenExchange"); + if (typeof userTokenExchange === "function") ok("userTokenExchange"); + if (typeof preToolUseHook === "function") ok("preToolUseHook"); + } catch (err) { + // Library may not be built - check source structure + const content = readFileSync(tsPath, "utf8"); + if ( + content.includes("export async function clientCredentialsTokenExchange") && + content.includes("export async function userTokenExchange") && + content.includes("export async function preToolUseHook") + ) { + ok("TypeScript library defines all strategy functions"); + } else { + fail("TypeScript library", err.message); + } + } +} + +// ─── Config Validation ───────────────────────────────────────────────────── + +async function testConfig() { + console.log("\n[Config] descope-auth.config.json"); + + for (const [name, configPath, examplePath] of [ + ["Cursor", CURSOR_CONFIG, join(HOOKS_ROOT, "cursor", "descope-auth.config.example.json")], + ["Claude Code", CLAUDE_CONFIG, join(HOOKS_ROOT, "claude-code", "descope-auth.config.example.json")], + ]) { + if (existsSync(configPath)) { + try { + const cfg = JSON.parse(readFileSync(configPath, "utf8")); + if (cfg.servers && typeof cfg.servers === "object") { + ok(`${name} config valid with ${Object.keys(cfg.servers).length} server(s)`); + } else { + fail(`${name} config structure`, "missing servers object"); + } + } catch (e) { + fail(`${name} config valid JSON`, e.message); + } + } else if (existsSync(examplePath)) { + ok(`${name} example config exists (copy to config.json for integration)`); + } else { + ok(`${name} config path checked`); + } + } +} + +// ─── Main ────────────────────────────────────────────────────────────────── + +async function main() { + console.log("πŸ” Descope Agent Hooks β€” Test Agent"); + console.log("────────────────────────────────────"); + + if (!INTEGRATION) { + console.log("(Unit tests only. Use --integration for full tests with Descope.)"); + } + + await testConfig(); + await testCursorHook(); + await testClaudeHook(); + await testMcpWrapper(); + await testTypescriptLibrary(); + + console.log("\n────────────────────────────────────"); + console.log(`Results: ${passed} passed, ${failed} failed`); + process.exit(failed > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/hooks/typescript/src/descope-auth-hooks.ts b/hooks/typescript/src/descope-auth-hooks.ts new file mode 100644 index 0000000..ea2501e --- /dev/null +++ b/hooks/typescript/src/descope-auth-hooks.ts @@ -0,0 +1,482 @@ +/** + * Descope Agent OAuth Hooks + * ───────────────────────── + * Standardized pre-tool-use hooks for AI agents (Cursor, Claude Code, etc.) + * that need to acquire scoped MCP server access tokens via Descope. + * + * Four strategies: + * + * 1. clientCredentialsTokenExchange + * β†’ client_credentials grant β†’ token exchange for MCP server token + * + * 2. userTokenExchange + * β†’ user access_token β†’ token exchange for MCP server token + * + * 3. userConnectionsToken + * β†’ user access_token β†’ Descope Connections API β†’ connection token + * ⚠️ Security: returns raw third-party token to the agent + * + * 4. cibaFlow + * β†’ access_token or login_hint β†’ CIBA backchannel auth β†’ MCP server token + */ + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface DescopeOAuthConfig { + /** Descope Project ID (used as the OAuth issuer namespace) */ + projectId: string; + /** Base URL β€” override for dedicated / EU deployments */ + baseUrl?: string; +} + +export interface ClientCredentialsConfig extends DescopeOAuthConfig { + clientId: string; + clientSecret: string; +} + +export interface TokenExchangeParams { + /** The target MCP server audience (resource indicator / audience URI) */ + audience: string; + /** Space-separated scopes to request on the MCP server token */ + scopes: string; + /** Optional `resource` parameter (RFC 8707) */ + resource?: string; +} + +export interface CIBAParams { + /** Target MCP server audience */ + audience: string; + /** Scopes to request */ + scopes: string; + /** Human-readable binding message shown to the user during consent */ + bindingMessage?: string; + /** Polling interval in ms (default 2 000) */ + pollIntervalMs?: number; + /** Max time to wait for user consent in ms (default 120 000) */ + timeoutMs?: number; +} + +export interface ConnectionsParams { + /** The Descope Outbound App ID (e.g., "google-contacts", "github") */ + appId: string; + /** The Descope user ID to retrieve the connection token for */ + userId: string; + /** Optional tenant ID if the user belongs to a specific tenant */ + tenantId?: string; + /** Scopes to request on the connection token */ + scopes?: string[]; + /** Additional options */ + options?: { + withRefreshToken?: boolean; + forceRefresh?: boolean; + }; +} + +export interface TokenResult { + accessToken: string; + tokenType: string; + expiresAt: Date; + scope?: string; + raw: Record; +} + +// ─── Constants ─────────────────────────────────────────────────────────────── + +const DEFAULT_BASE_URL = "https://api.descope.com"; +const TOKEN_PATH = "/oauth2/v1/apps/token"; +const TOKEN_EXCHANGE_PATH = (projectId: string) => + `/oauth2/v1/apps/${projectId}/token`; +const CIBA_AUTH_PATH = "/oauth2/v1/apps/bc-authorize"; +const CONNECTIONS_PATH = "/v1/mgmt/outbound/app/user/token"; + +const GRANT_TYPE = { + CLIENT_CREDENTIALS: "client_credentials", + TOKEN_EXCHANGE: "urn:ietf:params:oauth:grant-type:token-exchange", + CIBA: "urn:openid:params:grant-type:ciba", +} as const; + +const TOKEN_TYPE = { + ACCESS_TOKEN: "urn:ietf:params:oauth:token-type:access_token", +} as const; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function tokenUrl(cfg: DescopeOAuthConfig): string { + return `${cfg.baseUrl ?? DEFAULT_BASE_URL}${TOKEN_PATH}`; +} + +function tokenExchangeUrl(cfg: DescopeOAuthConfig): string { + return `${cfg.baseUrl ?? DEFAULT_BASE_URL}${TOKEN_EXCHANGE_PATH(cfg.projectId)}`; +} + +function cibaAuthUrl(cfg: DescopeOAuthConfig): string { + return `${cfg.baseUrl ?? DEFAULT_BASE_URL}${CIBA_AUTH_PATH}`; +} + +function connectionsUrl(cfg: DescopeOAuthConfig): string { + return `${cfg.baseUrl ?? DEFAULT_BASE_URL}${CONNECTIONS_PATH}`; +} + +function toJsonBody(params: Record): string { + return JSON.stringify( + Object.fromEntries(Object.entries(params).filter(([, v]) => v != null)), + ); +} + +function parseTokenResponse(json: Record): TokenResult { + const expiresIn = (json.expires_in as number) ?? 3600; + return { + accessToken: json.access_token as string, + tokenType: (json.token_type as string) ?? "Bearer", + expiresAt: new Date(Date.now() + expiresIn * 1000), + scope: json.scope as string | undefined, + raw: json, + }; +} + +async function postJson( + url: string, + body: Record, + headers?: Record, +): Promise> { + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: toJsonBody(body), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Descope OAuth error ${res.status}: ${text}`); + } + return res.json() as Promise>; +} + +async function postJsonRaw( + url: string, + body: Record, + bearerToken: string, +): Promise> { + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${bearerToken}`, + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Descope API error ${res.status}: ${text}`); + } + return res.json() as Promise>; +} + +// ─── Simple In-Memory Token Cache ──────────────────────────────────────────── + +const cache = new Map(); + +function cacheKey(...parts: string[]): string { + return parts.join("|"); +} + +function getCached(key: string): TokenResult | null { + const entry = cache.get(key); + if (!entry) return null; + if (entry.expiresAt.getTime() - Date.now() > 30_000) return entry; + cache.delete(key); + return null; +} + +function setCache(key: string, result: TokenResult): TokenResult { + cache.set(key, result); + return result; +} + +// ─── Hook 1: Client Credentials β†’ Token Exchange ───────────────────────────── + +export async function clientCredentialsTokenExchange( + cfg: ClientCredentialsConfig, + exchange: TokenExchangeParams, +): Promise { + const key = cacheKey( + "cc-exchange", + cfg.clientId, + exchange.audience, + exchange.scopes, + ); + const cached = getCached(key); + if (cached) return cached; + + // Step 1 β€” Client Credentials + const ccJson = await postJson(tokenUrl(cfg), { + grant_type: GRANT_TYPE.CLIENT_CREDENTIALS, + client_id: cfg.clientId, + client_secret: cfg.clientSecret, + }); + + const agentToken = ccJson.access_token as string; + + // Step 2 β€” Token Exchange for MCP server token (project-scoped endpoint) + const teJson = await postJson(tokenExchangeUrl(cfg), { + grant_type: GRANT_TYPE.TOKEN_EXCHANGE, + client_id: cfg.clientId, + client_secret: cfg.clientSecret, + subject_token: agentToken, + subject_token_type: TOKEN_TYPE.ACCESS_TOKEN, + audience: exchange.audience, + resource: exchange.resource, + }); + + return setCache(key, parseTokenResponse(teJson)); +} + +// ─── Hook 2: User Access Token β†’ Token Exchange ────────────────────────────── + +export async function userTokenExchange( + cfg: DescopeOAuthConfig, + userAccessToken: string, + exchange: TokenExchangeParams, +): Promise { + const key = cacheKey( + "user-exchange", + exchange.audience, + exchange.scopes, + userAccessToken.slice(-8), + ); + const cached = getCached(key); + if (cached) return cached; + + const json = await postJson(tokenExchangeUrl(cfg), { + grant_type: GRANT_TYPE.TOKEN_EXCHANGE, + subject_token: userAccessToken, + subject_token_type: TOKEN_TYPE.ACCESS_TOKEN, + audience: exchange.audience, + resource: exchange.resource, + }); + + return setCache(key, parseTokenResponse(json)); +} + +// ─── Hook 3: User Access Token β†’ Connections API ───────────────────────────── +// +// ⚠️ SECURITY CONSIDERATIONS: +// 1. Unlike token exchange (Hooks 1 & 2), the Connections API returns the +// raw third-party provider token directly to the agent. If the agent +// caches or leaks this token, it can be used to access the third-party +// service directly β€” outside the MCP server's control. +// +// 2. External tokens issued by providers like Google, HubSpot, etc. are +// NOT controlled by Descope. They may be long-lived (hours or even +// permanent) unlike the short-lived ephemeral tokens Descope issues +// via its /token endpoint. A leaked external token could remain valid +// far longer than expected. +// +// With token exchange, external tokens stay server-side inside the MCP +// server and are never exposed to the agent. Prefer Hooks 1 or 2 when +// the MCP server can handle token resolution internally. + +export async function userConnectionsToken( + cfg: DescopeOAuthConfig, + userAccessToken: string, + params: ConnectionsParams, +): Promise { + const key = cacheKey( + "connections", + params.appId, + params.userId, + userAccessToken.slice(-8), + ); + const cached = getCached(key); + if (cached) return cached; + + const body: Record = { + appId: params.appId, + userId: params.userId, + }; + if (params.tenantId) body.tenantId = params.tenantId; + if (params.scopes) body.scopes = params.scopes; + if (params.options) body.options = params.options; + + const json = await postJsonRaw( + connectionsUrl(cfg), + body, + `${cfg.projectId}:${userAccessToken}`, + ); + + const token = + (json as any).token ?? + (json as any).accessToken ?? + (json as any).access_token; + const expiresIn = (json as any).expiresIn ?? (json as any).expires_in ?? 3600; + + const result: TokenResult = { + accessToken: token as string, + tokenType: "Bearer", + expiresAt: new Date(Date.now() + (expiresIn as number) * 1000), + raw: json, + }; + + return setCache(key, result); +} + +// ─── Hook 4: CIBA ──────────────────────────────────────────────────────────── + +export async function cibaFlow( + cfg: ClientCredentialsConfig, + userIdentifier: { accessToken: string } | { loginHint: string }, + params: CIBAParams, +): Promise { + const pollInterval = params.pollIntervalMs ?? 2_000; + const timeout = params.timeoutMs ?? 120_000; + + // Step 1 β€” Initiate backchannel authorization + const authBody: Record = { + client_id: cfg.clientId, + client_secret: cfg.clientSecret, + scope: params.scopes, + audience: params.audience, + login_hint: undefined, + login_hint_token: undefined, + binding_message: params.bindingMessage, + }; + + if ("accessToken" in userIdentifier) { + authBody.login_hint_token = userIdentifier.accessToken; + } else { + authBody.login_hint = userIdentifier.loginHint; + } + + const authJson = await postJson(cibaAuthUrl(cfg), authBody); + const authReqId = authJson.auth_req_id as string; + const serverInterval = + (authJson.interval as number) ?? Math.ceil(pollInterval / 1000); + const effectivePollMs = Math.max(pollInterval, serverInterval * 1000); + + // Step 2 β€” Poll for token + const deadline = Date.now() + timeout; + + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, effectivePollMs)); + + try { + const json = await postJson(tokenUrl(cfg), { + grant_type: GRANT_TYPE.CIBA, + auth_req_id: authReqId, + client_id: cfg.clientId, + client_secret: cfg.clientSecret, + }); + return parseTokenResponse(json); + } catch (err: any) { + const msg = err?.message ?? ""; + if (msg.includes("authorization_pending")) continue; + if (msg.includes("slow_down")) { + await new Promise((r) => setTimeout(r, effectivePollMs)); + continue; + } + throw err; + } + } + + throw new Error("CIBA flow timed out waiting for user consent"); +} + +// ─── Unified Pre-Tool-Use Hook ─────────────────────────────────────────────── + +export type HookStrategy = + | { + type: "client_credentials_exchange"; + config: ClientCredentialsConfig; + exchange: TokenExchangeParams; + } + | { + type: "user_token_exchange"; + config: DescopeOAuthConfig; + userAccessToken: string; + exchange: TokenExchangeParams; + } + | { + type: "connections"; + config: DescopeOAuthConfig; + userAccessToken: string; + connection: ConnectionsParams; + } + | { + type: "ciba"; + config: ClientCredentialsConfig; + userIdentifier: { accessToken: string } | { loginHint: string }; + ciba: CIBAParams; + }; + +/** + * Universal pre-tool-use hook. + * + * @example + * ```ts + * const token = await preToolUseHook({ + * type: "user_token_exchange", + * config: { projectId: "P2abc..." }, + * userAccessToken: session.token, + * exchange: { + * audience: "mcp-github-server", + * scopes: "repo:read repo:write", + * }, + * }); + * + * headers["Authorization"] = `Bearer ${token.accessToken}`; + * ``` + */ +export async function preToolUseHook( + strategy: HookStrategy, +): Promise { + switch (strategy.type) { + case "client_credentials_exchange": + return clientCredentialsTokenExchange(strategy.config, strategy.exchange); + + case "user_token_exchange": + return userTokenExchange( + strategy.config, + strategy.userAccessToken, + strategy.exchange, + ); + + case "connections": + return userConnectionsToken( + strategy.config, + strategy.userAccessToken, + strategy.connection, + ); + + case "ciba": + return cibaFlow(strategy.config, strategy.userIdentifier, strategy.ciba); + } +} + +/** + * Creates a bound hook you can call repeatedly with no arguments. + * + * @example + * ```ts + * const getToken = createPreToolHook({ + * type: "client_credentials_exchange", + * config: { + * projectId: "P2abc...", + * clientId: "DS_...", + * clientSecret: "ds_...", + * }, + * exchange: { + * audience: "mcp-server-github", + * scopes: "repo:read issues:write", + * }, + * }); + * + * const { accessToken } = await getToken(); + * ``` + */ +export function createPreToolHook( + strategy: HookStrategy, +): () => Promise { + return () => preToolUseHook(strategy); +} diff --git a/hooks/typescript/src/package.json b/hooks/typescript/src/package.json new file mode 100644 index 0000000..1a1839b --- /dev/null +++ b/hooks/typescript/src/package.json @@ -0,0 +1,36 @@ +{ + "name": "@descope/agent-hooks", + "version": "1.0.0", + "description": "Descope OAuth hooks for AI agent MCP server authentication", + "type": "module", + "main": "dist/descope-auth-hooks.js", + "types": "dist/descope-auth-hooks.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "descope", + "mcp", + "oauth", + "agent", + "hooks", + "cursor", + "claude-code", + "token-exchange", + "ciba" + ], + "author": "Descope", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/descope/agent-hooks.git", + "directory": "hooks/typescript" + }, + "devDependencies": { + "typescript": "^5.4.0" + } +} diff --git a/hooks/typescript/tsconfig.json b/hooks/typescript/tsconfig.json new file mode 100644 index 0000000..9431974 --- /dev/null +++ b/hooks/typescript/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["ES2022"] + }, + "include": ["src"] +}