{"_id":"@appclaw/appium-mcp-auth","name":"@appclaw/appium-mcp-auth","dist-tags":{"latest":"1.0.0"},"versions":{"1.0.0":{"name":"@appclaw/appium-mcp-auth","version":"1.0.0","description":"Authentication & authorization for appium-mcp when hosted over SSE / HTTP Stream. Bearer API keys + OAuth JWT, scope-based authorization, per-caller session ownership — built entirely on the appium-mcp Plugin API (no core changes).","type":"module","main":"dist/index.js","types":"dist/index.d.ts","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"}},"bin":{"appium-mcp-auth":"dist/cli.js"},"publishConfig":{"access":"public"},"engines":{"node":">=20"},"scripts":{"build":"rimraf dist && tsc -p tsconfig.json","typecheck":"tsc -p tsconfig.json --noEmit","start":"node dist/cli.js --httpStream","dev":"node --import tsx src/cli.ts --httpStream","test":"node --import tsx --test test/*.test.ts","lint":"tsc -p tsconfig.json --noEmit","prepublishOnly":"npm run build && npm test","release":"semantic-release"},"keywords":["appium","mcp","model-context-protocol","authentication","authorization","oauth","api-key","sse","plugin","mobile-automation"],"author":{"name":"saikrishna321","email":"krishnacodes321@gmail.com"},"license":"Apache-2.0","repository":{"type":"git","url":"git+https://github.com/appclawhq/appium-mcp-auth.git"},"homepage":"https://github.com/appclawhq/appium-mcp-auth#readme","bugs":{"url":"https://github.com/appclawhq/appium-mcp-auth/issues"},"dependencies":{"appium-mcp":"^1.80.0","jose":"^5.9.6","zod":"^4.3.6"},"devDependencies":{"@semantic-release/changelog":"^6.0.3","@semantic-release/git":"^10.0.1","@types/node":"^22.20.1","rimraf":"^6.0.1","semantic-release":"^25.0.7","tsx":"^4.19.2","typescript":"^5.7.2"},"gitHead":"4dd9c8b37870a49d886c9edbaa4c0057af5fd9d6","_id":"@appclaw/appium-mcp-auth@1.0.0","_nodeVersion":"22.23.1","_npmVersion":"11.18.0","dist":{"integrity":"sha512-4YSZKU76sJZxlDD6zKVDcq0E7n2AEX4KCpWU+4/ki3WhnncZBhCr3Q8SUAUpntYGzV7HabmaELtiKW6DU3WybA==","shasum":"a5b2543dee1a254976814d576e7b12ddb9c58097","tarball":"https://registry.npmjs.org/@appclaw/appium-mcp-auth/-/appium-mcp-auth-1.0.0.tgz","fileCount":80,"unpackedSize":198786,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEQCIE+TmkxWRKcxFOvOnyn2FGxZZQ4zAmLFpFKiOT3n+MUCAiAJfXm/4yYnll0sCun/E12qqgAaaY4qddb/Jcq018FILQ=="}]},"_npmUser":{"name":"saikris","email":"saikrishna321@yahoo.com"},"directories":{},"maintainers":[{"name":"saikris","email":"saikrishna321@yahoo.com"},{"name":"srinivasansekar","email":"srinivasan.sekar1990@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/appium-mcp-auth_1.0.0_1784043492287_0.3404178375429938"},"_hasShrinkwrap":false}},"time":{"created":"2026-07-14T15:38:12.128Z","1.0.0":"2026-07-14T15:38:12.420Z","modified":"2026-07-14T15:38:12.654Z"},"maintainers":[{"name":"saikris","email":"saikrishna321@yahoo.com"},{"name":"srinivasansekar","email":"srinivasan.sekar1990@gmail.com"}],"description":"Authentication & authorization for appium-mcp when hosted over SSE / HTTP Stream. Bearer API keys + OAuth JWT, scope-based authorization, per-caller session ownership — built entirely on the appium-mcp Plugin API (no core changes).","homepage":"https://github.com/appclawhq/appium-mcp-auth#readme","keywords":["appium","mcp","model-context-protocol","authentication","authorization","oauth","api-key","sse","plugin","mobile-automation"],"repository":{"type":"git","url":"git+https://github.com/appclawhq/appium-mcp-auth.git"},"author":{"name":"saikrishna321","email":"krishnacodes321@gmail.com"},"bugs":{"url":"https://github.com/appclawhq/appium-mcp-auth/issues"},"license":"Apache-2.0","readme":"# appium-mcp-auth\n\n> Authentication & authorization for [appium-mcp](https://github.com/appium/appium-mcp) when hosted over **SSE / HTTP Stream** — built entirely on the appium-mcp **Plugin API** (no core changes).\n\nWhen you expose appium-mcp over SSE, anyone who can reach the port gets a full\nAppium session and can drive real devices. `appium-mcp-auth` adds a security\nlayer as a drop-in plugin:\n\n- 🔑 **Bearer API keys** (`ak_<id>_<secret>`) — hashed at rest, constant-time compared. For machines / CI.\n- 🪪 **OAuth JWT** access tokens — validated against the issuer JWKS (`iss`/`aud`/`exp`). For humans / IDE clients.\n- 🎫 **Session tokens** — exchange an API key for a short-lived token via `auth_login`.\n- 🛂 **Scope-based authorization** — per-tool required scopes, admin-role bypass.\n- 🧑‍🤝‍🧑 **Per-caller session ownership** — callers only see/drive the Appium sessions they created (multi-tenant isolation).\n- 🚦 **Rate limiting & session quotas** — per subject.\n\n---\n\n## How it works (read this first)\n\nA plugin runs inside appium-mcp's `beforeCall` / `afterCall` hooks and **cannot\nread HTTP headers** — so the credential is passed as a **tool argument**\n(default `authToken`). Authentication *and* authorization both happen in\n`beforeCall`; a denied call is short-circuited before the tool runs.\n\n```\ntool call ──► beforeCall(ctx)\n                1. authenticate ctx.args.authToken → Identity   (apiKey | sessionToken | oauth)\n                2. rate-limit per subject\n                3. authorize tool vs Identity.scopes  (admin role bypasses)\n                4. ownership: any sessionId arg must be owned by the caller\n                5. session-creating tool? enforce per-subject session quota\n              allow → tool runs   |   deny → error result (tool never runs)\n            ──► afterCall: bind new sessions to caller, release deleted ones\n```\n\n**Implications (by design):**\n- Terminate **TLS** in front of the server — the credential travels in the request body.\n- No OAuth `.well-known` discovery endpoints (a plugin can't serve them); front with a gateway if your MCP client needs auto-negotiation. This package still *validates* JWTs.\n- Calls that omit `sessionId` hit Appium's global active session — in multi-tenant mode, require an owned `sessionId` on every call.\n\n---\n\n## Install\n\n```bash\nnpm install @appclaw/appium-mcp-auth\n```\n\nThat's it — `appium-mcp` (which brings `fastmcp` and `zod`) and `jose` for JWT\nvalidation come along automatically as regular dependencies. The plugin\nresolves the exact `fastmcp`/`zod` copies that `appium-mcp` uses at runtime, so\nthere is no peer-dependency juggling.\n\nRequires Node.js **≥ 20**.\n\n---\n\n## Implement it in your project\n\nThere are two ways to use it. Pick one.\n\n### Option 1 — Turnkey CLI (fastest)\n\nRun this package's binary *instead of* `appium-mcp`'s. It **is** the full\nappium-mcp SSE server with the auth plugin composed in — one process, same\n`/sse` endpoint.\n\n```bash\n# 1. Configure at least one credential (see \"Configuration\" below)\nexport APPIUM_MCP_AUTH_API_KEYS='[{\"id\":\"ci\",\"secret\":\"CHANGE-ME\",\"subject\":\"ci-bot\",\"scopes\":[\"appium:use\"]}]'\n\n# 2. Start the auth-protected SSE server\nnpx @appclaw/appium-mcp-auth --httpStream --port=8080 --endpoint=/sse\n# → SSE listening on http://localhost:8080/sse\n```\n\n### Option 2 — Compose the plugin into your own server (most control)\n\nIf you already build a custom appium-mcp server, just add the plugin. **No core\nchanges required** — `createAppiumMcpServer` already accepts plugins.\n\n```ts\nimport { createAppiumMcpServer } from 'appium-mcp/core';\nimport { createAuthPluginFromEnv } from '@appclaw/appium-mcp-auth';\n\nconst server = await createAppiumMcpServer({\n  plugins: [createAuthPluginFromEnv()], // reads APPIUM_MCP_AUTH_* env vars\n});\n\nawait server.start({\n  transportType: 'httpStream',\n  httpStream: { endpoint: '/sse', port: 8080 },\n});\n```\n\nPrefer explicit config over environment variables? Build the config yourself:\n\n```ts\nimport { createAppiumMcpServer } from 'appium-mcp/core';\nimport { createAuthPlugin, sha256Hex, type AuthConfig } from '@appclaw/appium-mcp-auth';\n\nconst config: AuthConfig = {\n  credentialArg: 'authToken',\n  publicTools: ['auth_login'],\n  apiKeys: [\n    {\n      id: 'ci',\n      hash: sha256Hex('CHANGE-ME'),   // store the hash, not the secret\n      subject: 'ci-bot',\n      kind: 'service',\n      scopes: ['appium:use'],\n    },\n  ],\n  toolScopes: { mobile_clear_app: ['appium:admin'] },\n  defaultScopes: ['appium:use'],\n  adminRole: 'admin',\n  sessionTokenTtlMs: 3_600_000,\n  rateLimit: { limit: 120, windowMs: 60_000 },\n  maxSessionsPerSubject: 3,\n  enforceOwnership: true,\n  sessionIdArgs: ['sessionId'],\n  sessionCreatingTools: ['appium_session_management'],\n  audit: true,\n};\n\nconst server = await createAppiumMcpServer({\n  plugins: [createAuthPlugin(config)],\n});\n```\n\n---\n\n## How clients authenticate\n\nWhatever the MCP client, the credential is supplied as the **`authToken`\nargument** on tool calls.\n\n1. **Exchange an API key for a session token** (the `auth_login` tool is public):\n\n   ```json\n   { \"tool\": \"auth_login\", \"arguments\": { \"apiKey\": \"ak_ci_CHANGE-ME\" } }\n   → { \"sessionToken\": \"st_…\", \"expiresAt\": \"…\" }\n   ```\n\n2. **Pass the token** (or the API key, or an OAuth JWT) as `authToken` on every\n   subsequent call:\n\n   ```json\n   { \"tool\": \"appium_session_management\", \"arguments\": { \"action\": \"create\", \"authToken\": \"st_…\" } }\n   { \"tool\": \"appium_gesture\", \"arguments\": { \"sessionId\": \"…\", \"authToken\": \"st_…\" } }\n   ```\n\n3. `auth_whoami` echoes the caller identity; `auth_logout` revokes a session token.\n\n### Will my MCP client work?\n\n| Client behavior | Works? | How |\n|---|---|---|\n| Sends an `Authorization: Bearer` header (Cursor, Claude Desktop, most SSE clients) | ✅ | Run **gateway mode** (below) — it reads the header and injects the credential. No per-call argument needed. |\n| Forwards agent-chosen tool arguments verbatim (e.g. AppClaw) | ✅ | Pass the token as the `authToken` argument (shown above). |\n| Injects a fixed argument on every tool call | ✅ | Set `authToken` deterministically instead of relying on the LLM. |\n\n> Tip: for LLM-driven clients, prefer a **session token** (`auth_login`) over\n> the raw API key so the long-lived secret isn't repeated in every prompt/trace.\n\n---\n\n## Header auth for Cursor / Claude Desktop (gateway mode)\n\nA plugin can't read HTTP headers, so header-based clients are served by a\nbuilt-in **credential-injecting reverse proxy**. It reads the `Authorization`\nheader, rewrites each `tools/call` to add the `authToken` argument, and forwards\nto the appium-mcp server on loopback. **Nothing in appium-mcp core changes.**\n\n```\nCursor ──(Authorization: Bearer ak_…)──►  gateway (public :8080)  ──►  appium-mcp + plugin (127.0.0.1:8790)\n                                            reads header,               beforeCall sees authToken,\n                                            injects authToken arg        authorizes exactly as normal\n```\n\nStart it:\n\n```bash\nexport APPIUM_MCP_AUTH_API_KEYS='[{\"id\":\"dev\",\"secret\":\"CHANGE-ME\",\"subject\":\"dev\",\"kind\":\"user\",\"roles\":[\"admin\"],\"scopes\":[\"appium:use\",\"appium:admin\"]}]'\nnpx @appclaw/appium-mcp-auth --gateway --port=8080 --endpoint=/sse\n# gateway (header auth) on http://localhost:8080/sse\n# upstream on http://127.0.0.1:8790/sse (loopback — firewall this port)\n```\n\nPoint Cursor at it — `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global):\n\n```json\n{\n  \"mcpServers\": {\n    \"appium-auth\": {\n      \"url\": \"http://localhost:8080/sse\",\n      \"headers\": {\n        \"Authorization\": \"Bearer ak_dev_CHANGE-ME\"\n      }\n    }\n  }\n}\n```\n\nClaude Desktop connects via the `mcp-remote` bridge:\n\n```json\n{\n  \"mcpServers\": {\n    \"appium-auth\": {\n      \"command\": \"npx\",\n      \"args\": [\n        \"-y\", \"mcp-remote\", \"http://localhost:8080/sse\",\n        \"--header\", \"Authorization: Bearer ak_dev_CHANGE-ME\"\n      ]\n    }\n  }\n}\n```\n\nThe `Bearer` value is any accepted credential: an API key (`ak_…`), a session\ntoken from `auth_login` (`st_…`), or an OAuth JWT. Requests with no/invalid\ncredential get `401`. `GET /health` is always allowed for probes.\n\nGateway options: `--port` (public), `--upstream-port` (loopback inner server),\n`--endpoint`; `APPIUM_MCP_AUTH_GATEWAY_HEADER` changes which header is read\n(default `authorization`).\n\n> **Security:** put TLS in front (the token is in a header) and **firewall the\n> upstream port** — the inner server trusts the injected `authToken`, so it must\n> only be reachable through the gateway.\n\n---\n\n## Configuration\n\nAll settings are environment variables (used by `createAuthPluginFromEnv` and the\nCLI). Full examples in [`.env.example`](./.env.example).\n\n| Variable | Purpose | Default |\n|---|---|---|\n| `APPIUM_MCP_AUTH_API_KEYS` | JSON array of API-key records (`id`, `hash` **or** `secret`, `subject`, `kind`, `scopes`, `roles?`, `expiresAt?`) | — |\n| `APPIUM_MCP_AUTH_OAUTH` | JSON OAuth JWT validation config (`issuer`, `audience`, `jwksUri?`, `scopeClaim?`, `rolesClaim?`) | — |\n| `APPIUM_MCP_AUTH_ARG` | Tool-argument name carrying the credential | `authToken` |\n| `APPIUM_MCP_AUTH_PUBLIC_TOOLS` | Comma list of tools that skip auth | `auth_login` |\n| `APPIUM_MCP_AUTH_TOOL_SCOPES` | JSON map `tool → scope | scope[]` | `{}` |\n| `APPIUM_MCP_AUTH_DEFAULT_SCOPES` | Scopes required for unlisted tools | `appium:use` |\n| `APPIUM_MCP_AUTH_ADMIN_ROLE` | Role that bypasses scope checks | `admin` |\n| `APPIUM_MCP_AUTH_SESSION_TTL_MS` | Session-token lifetime | `3600000` |\n| `APPIUM_MCP_AUTH_RATE_LIMIT` | `\"<limit>/<windowMs>\"` per subject | disabled |\n| `APPIUM_MCP_AUTH_MAX_SESSIONS` | Per-subject Appium session cap (0 = off) | `0` |\n| `APPIUM_MCP_AUTH_ENFORCE_OWNERSHIP` | Enforce session ownership | `true` |\n| `APPIUM_MCP_AUTH_SESSION_ID_ARGS` | Arg names carrying a session id | `sessionId` |\n| `APPIUM_MCP_AUTH_SESSION_TOOLS` | Tools that create sessions | `appium_session_management` |\n| `APPIUM_MCP_AUTH_AUDIT` | Emit JSON audit lines to stderr | `true` |\n\n> If neither API keys nor OAuth are configured, **every protected call is\n> denied** and the server logs a warning at startup.\n\n### Create an API key (built-in command)\n\nUse the `keygen` command — it prints the **client bearer token** and the\n**server config record** (which stores the hash, never the secret):\n\n```bash\nnpx @appclaw/appium-mcp-auth keygen --id=ci --subject=ci-bot --scopes=appium:use\n```\n\n```\nGive this to the CLIENT (Authorization header) — shown once, store it securely:\n  Authorization: Bearer ak_ci_Tgaz5NSbOTqgz3A4s_CdPeV7FePHLExS\n\nAdd this record to APPIUM_MCP_AUTH_API_KEYS on the SERVER (stores the hash, not the secret):\n  {\"id\":\"ci\",\"hash\":\"c65c…\",\"subject\":\"ci-bot\",\"kind\":\"service\",\"scopes\":[\"appium:use\"]}\n```\n\nFlags: `--id` `--subject` `[--scopes=a,b]` `[--kind=service|user]` `[--roles=admin]`\n`[--name=\"…\"]` `[--expires-in=30d]` `[--secret=…]` `[--json]`.\n\nThe three strings are linked: the client presents `ak_<id>_<secret>`; the server\nstores `hash = SHA256(secret)`; on each call it checks\n`SHA256(presented secret) === stored hash` (constant-time). The plaintext secret\nnever leaves the client, and the `--json` form is handy for scripting/rotation.\n\n---\n\n## Authorization model\n\n- **Scopes** — each tool requires a scope set (`APPIUM_MCP_AUTH_TOOL_SCOPES`),\n  falling back to `APPIUM_MCP_AUTH_DEFAULT_SCOPES`. A caller needs **all** of them.\n- **Admin role** — a caller with the `admin` role bypasses scope checks.\n- **Ownership** — sessions a caller creates are bound to its `subject`; a call\n  referencing someone else's tracked `sessionId` is denied (`not_session_owner`).\n  Untracked ids (pre-existing / attach flows) pass through.\n- **Quota / rate limit** — per subject, via `MAX_SESSIONS` and `RATE_LIMIT`.\n\n## Public API\n\n```ts\nimport {\n  AppiumAuthPlugin,          // the plugin class\n  createAuthPlugin,          // build from an AuthConfig\n  createAuthPluginFromEnv,   // build from environment\n  buildAuthenticatedServer,  // full server (used by the CLI)\n  loadConfig, sha256Hex,     // config helpers\n  // building blocks: Authenticator, Authorizer, KeyStore, OAuthValidator,\n  // OwnershipRegistry, RateLimiter, AuditLog\n} from '@appclaw/appium-mcp-auth';\n```\n\n## Security notes\n\n- Store API-key **hashes**, never plaintext secrets, in committed config.\n- Give services and humans **distinct scopes** so a leaked CI key can't act as a human.\n- Pin OAuth `issuer` and `audience` so tokens minted for other apps are rejected.\n- Rate limiter and ownership map are **process-local** — front with a shared\n  store (e.g. Redis) if you run multiple SSE replicas.\n- Credentials are never written to audit logs.\n\n## Development\n\n```bash\nnpm install\nnpm run typecheck   # tsc --noEmit\nnpm test            # node:test via tsx (35 tests)\nnpm run build       # emit dist/\n```\n\n## License\n\n[Apache-2.0](./LICENSE)\n","readmeFilename":"README.md","_rev":"1-1e54b0e89b8f4cfe71947e5212e3769b"}