Skip to main content
Glama
appclawhq

appium-mcp-auth

by appclawhq
README.md
# appium-mcp-auth

> 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).

When you expose appium-mcp over SSE, anyone who can reach the port gets a full
Appium session and can drive real devices. `appium-mcp-auth` adds a security
layer as a drop-in plugin:

- πŸ”‘ **Bearer API keys** (`ak_<id>_<secret>`) β€” hashed at rest, constant-time compared. For machines / CI.
- πŸͺͺ **OAuth JWT** access tokens β€” validated against the issuer JWKS (`iss`/`aud`/`exp`). For humans / IDE clients.
- 🎫 **Session tokens** β€” exchange an API key for a short-lived token via `auth_login`.
- πŸ›‚ **Scope-based authorization** β€” per-tool required scopes, admin-role bypass.
- πŸ§‘β€πŸ€β€πŸ§‘ **Per-caller session ownership** β€” callers only see/drive the Appium sessions they created (multi-tenant isolation).
- 🚦 **Rate limiting & session quotas** β€” per subject.

---

## How it works (read this first)

A plugin runs inside appium-mcp's `beforeCall` / `afterCall` hooks and **cannot
read HTTP headers** β€” so the credential is passed as a **tool argument**
(default `authToken`). Authentication *and* authorization both happen in
`beforeCall`; a denied call is short-circuited before the tool runs.

```
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   tool call { …args, authToken }   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ MCP client β”‚ ─────────────────────────────────► β”‚ appium-mcp  + auth plugin β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
      β–²                                                         β”‚ beforeCall
      β”‚                                     β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
      β”‚                                     β”‚ β‘  authenticate  authToken β†’ Identity β”‚
      β”‚                                     β”‚    (ak_… key Β· st_… token Β· JWT)     β”‚
      β”‚                                     β”‚ β‘‘ rate-limit    per subject          β”‚
      β”‚                                     β”‚ β‘’ authorize     scopes vs tool       β”‚
      β”‚                                     β”‚    (admin role bypasses)             β”‚
      β”‚                                     β”‚ β‘£ ownership     sessionId must       β”‚
      β”‚                                     β”‚    belong to the caller              β”‚
      β”‚                                     β”‚ β‘€ quota         per-subject session  β”‚
      β”‚                                     β”‚    cap on session-creating tools     β”‚
      β”‚                                     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
      β”‚                                          deny β”‚                 β”‚ allow
      │◄──────── error result β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                 β–Ό
      β”‚          (tool never runs)                                  tool runs
      β”‚                                                                 β”‚
      β”‚                                                     afterCall   β–Ό
      │◄──────── tool result ─────────────────  bind created session to caller Β·
                                                release deleted sessions
```

**Implications (by design):**
- Terminate **TLS** in front of the server β€” the credential travels in the request body.
- 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.
- Calls that omit `sessionId` hit Appium's global active session β€” in multi-tenant mode, require an owned `sessionId` on every call.

---

## Install

```bash
npm install @appclaw/appium-mcp-auth
```

That's it β€” `appium-mcp` (which brings `fastmcp` and `zod`) and `jose` for JWT
validation come along automatically as regular dependencies. The plugin
resolves the exact `fastmcp`/`zod` copies that `appium-mcp` uses at runtime, so
there is no peer-dependency juggling.

Requires Node.js **β‰₯ 20**.

---

## Implement it in your project

There are two ways to use it. Pick one.

### Option 1 β€” Turnkey CLI (fastest)

Run this package's binary *instead of* `appium-mcp`'s. It **is** the full
appium-mcp SSE server with the auth plugin composed in β€” one process, same
`/sse` endpoint.

```bash
# 1. Generate a credential β€” prints the client key (ak_…) AND the server record
npx @appclaw/appium-mcp-auth keygen --id=ci --subject=ci-bot --scopes=appium:use

# 2. Register the printed record with the server (it stores the hash, never the secret)
export APPIUM_MCP_AUTH_API_KEYS='[{"id":"ci","hash":"c65c…","subject":"ci-bot","kind":"service","scopes":["appium:use"]}]'

# 3. Start the auth-protected SSE server
npx @appclaw/appium-mcp-auth --httpStream --port=8080 --endpoint=/sse
# β†’ SSE listening on http://localhost:8080/sse
```

Clients authenticate with the `ak_…` key from step 1 β€” or exchange it for a
short-lived `st_…` token via the `auth_login` tool. Full `keygen` flags and
key anatomy: [Create an API key](#create-an-api-key-built-in-command).

### Option 2 β€” Compose the plugin into your own server (most control)

If you already build a custom appium-mcp server, just add the plugin. **No core
changes required** β€” `createAppiumMcpServer` already accepts plugins.

```ts
import { createAppiumMcpServer } from 'appium-mcp/core';
import { createAuthPluginFromEnv } from '@appclaw/appium-mcp-auth';

const server = await createAppiumMcpServer({
  plugins: [createAuthPluginFromEnv()], // reads APPIUM_MCP_AUTH_* env vars
});

await server.start({
  transportType: 'httpStream',
  httpStream: { endpoint: '/sse', port: 8080 },
});
```

Prefer explicit config over environment variables? Build the config yourself:

```ts
import { createAppiumMcpServer } from 'appium-mcp/core';
import { createAuthPlugin, sha256Hex, type AuthConfig } from '@appclaw/appium-mcp-auth';

const config: AuthConfig = {
  credentialArg: 'authToken',
  publicTools: ['auth_login'],
  apiKeys: [
    {
      id: 'ci',
      hash: sha256Hex('CHANGE-ME'),   // store the hash, not the secret
      subject: 'ci-bot',
      kind: 'service',
      scopes: ['appium:use'],
    },
  ],
  toolScopes: { mobile_clear_app: ['appium:admin'] },
  defaultScopes: ['appium:use'],
  adminRole: 'admin',
  sessionTokenTtlMs: 3_600_000,
  rateLimit: { limit: 120, windowMs: 60_000 },
  maxSessionsPerSubject: 3,
  enforceOwnership: true,
  sessionIdArgs: ['sessionId'],
  sessionCreatingTools: ['appium_session_management'],
  audit: true,
};

const server = await createAppiumMcpServer({
  plugins: [createAuthPlugin(config)],
});
```

---

## How clients authenticate

Whatever the MCP client, the credential is supplied as the **`authToken`
argument** on tool calls.

1. **Exchange an API key for a session token** (the `auth_login` tool is public):

   ```json
   { "tool": "auth_login", "arguments": { "apiKey": "ak_ci_CHANGE-ME" } }
   β†’ { "sessionToken": "st_…", "expiresAt": "…" }
   ```

2. **Pass the token** (or the API key, or an OAuth JWT) as `authToken` on every
   subsequent call:

   ```json
   { "tool": "appium_session_management", "arguments": { "action": "create", "authToken": "st_…" } }
   { "tool": "appium_gesture", "arguments": { "sessionId": "…", "authToken": "st_…" } }
   ```

3. `auth_whoami` echoes the caller identity; `auth_logout` revokes a session token.

### Will my MCP client work?

| Client behavior | Works? | How |
|---|---|---|
| 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. |
| Forwards agent-chosen tool arguments verbatim (e.g. AppClaw) | βœ… | Pass the token as the `authToken` argument (shown above). |
| Injects a fixed argument on every tool call | βœ… | Set `authToken` deterministically instead of relying on the LLM. |

> Tip: for LLM-driven clients, prefer a **session token** (`auth_login`) over
> the raw API key so the long-lived secret isn't repeated in every prompt/trace.

---

## Header auth for Cursor / Claude Desktop (gateway mode)

A plugin can't read HTTP headers, so header-based clients are served by a
built-in **credential-injecting reverse proxy**. It reads the `Authorization`
header, rewrites each `tools/call` to add the `authToken` argument, and forwards
to the appium-mcp server on loopback. **Nothing in appium-mcp core changes.**

```
Cursor ──(Authorization: Bearer ak_…)──►  gateway (public :8080)  ──►  appium-mcp + plugin (127.0.0.1:8790)
                                            reads header,               beforeCall sees authToken,
                                            injects authToken arg        authorizes exactly as normal
```

Start it:

```bash
export APPIUM_MCP_AUTH_API_KEYS='[{"id":"dev","secret":"CHANGE-ME","subject":"dev","kind":"user","roles":["admin"],"scopes":["appium:use","appium:admin"]}]'
npx @appclaw/appium-mcp-auth --gateway --port=8080 --endpoint=/sse
# gateway (header auth) on http://localhost:8080/sse
# upstream on http://127.0.0.1:8790/sse (loopback β€” firewall this port)
```

Point Cursor at it β€” `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global):

```json
{
  "mcpServers": {
    "appium-auth": {
      "url": "http://localhost:8080/sse",
      "headers": {
        "Authorization": "Bearer ak_dev_CHANGE-ME"
      }
    }
  }
}
```

Claude Desktop connects via the `mcp-remote` bridge:

```json
{
  "mcpServers": {
    "appium-auth": {
      "command": "npx",
      "args": [
        "-y", "mcp-remote", "http://localhost:8080/sse",
        "--header", "Authorization: Bearer ak_dev_CHANGE-ME"
      ]
    }
  }
}
```

The `Bearer` value is any accepted credential: an API key (`ak_…`), a session
token from `auth_login` (`st_…`), or an OAuth JWT. Requests with no/invalid
credential get `401`. `GET /health` is always allowed for probes.

Gateway options: `--port` (public), `--upstream-port` (loopback inner server),
`--endpoint`; `APPIUM_MCP_AUTH_GATEWAY_HEADER` changes which header is read
(default `authorization`).

> **Security:** put TLS in front (the token is in a header) and **firewall the
> upstream port** β€” the inner server trusts the injected `authToken`, so it must
> only be reachable through the gateway.

---

## Configuration

All settings are environment variables (used by `createAuthPluginFromEnv` and the
CLI). Full examples in [`.env.example`](./.env.example).

| Variable | Purpose | Default |
|---|---|---|
| `APPIUM_MCP_AUTH_API_KEYS` | JSON array of API-key records (`id`, `hash` **or** `secret`, `subject`, `kind`, `scopes`, `roles?`, `expiresAt?`) | β€” |
| `APPIUM_MCP_AUTH_OAUTH` | JSON OAuth JWT validation config (`issuer`, `audience`, `jwksUri?`, `scopeClaim?`, `rolesClaim?`) | β€” |
| `APPIUM_MCP_AUTH_ARG` | Tool-argument name carrying the credential | `authToken` |
| `APPIUM_MCP_AUTH_PUBLIC_TOOLS` | Comma list of tools that skip auth | `auth_login` |
| `APPIUM_MCP_AUTH_TOOL_SCOPES` | JSON map `tool β†’ scope | scope[]` | `{}` |
| `APPIUM_MCP_AUTH_DEFAULT_SCOPES` | Scopes required for unlisted tools | `appium:use` |
| `APPIUM_MCP_AUTH_ADMIN_ROLE` | Role that bypasses scope checks | `admin` |
| `APPIUM_MCP_AUTH_SESSION_TTL_MS` | Session-token lifetime | `3600000` |
| `APPIUM_MCP_AUTH_RATE_LIMIT` | `"<limit>/<windowMs>"` per subject | disabled |
| `APPIUM_MCP_AUTH_MAX_SESSIONS` | Per-subject Appium session cap (0 = off) | `0` |
| `APPIUM_MCP_AUTH_ENFORCE_OWNERSHIP` | Enforce session ownership | `true` |
| `APPIUM_MCP_AUTH_SESSION_ID_ARGS` | Arg names carrying a session id | `sessionId` |
| `APPIUM_MCP_AUTH_SESSION_TOOLS` | Tools that create sessions | `appium_session_management` |
| `APPIUM_MCP_AUTH_AUDIT` | Emit JSON audit lines to stderr | `true` |

> If neither API keys nor OAuth are configured, **every protected call is
> denied** and the server logs a warning at startup.

### Create an API key (built-in command)

Use the `keygen` command β€” it prints the **client bearer token** and the
**server config record** (which stores the hash, never the secret):

```bash
npx @appclaw/appium-mcp-auth keygen --id=ci --subject=ci-bot --scopes=appium:use
```

```
Give this to the CLIENT (Authorization header) β€” shown once, store it securely:
  Authorization: Bearer ak_ci_Tgaz5NSbOTqgz3A4s_CdPeV7FePHLExS

Add this record to APPIUM_MCP_AUTH_API_KEYS on the SERVER (stores the hash, not the secret):
  {"id":"ci","hash":"c65c…","subject":"ci-bot","kind":"service","scopes":["appium:use"]}
```

Flags: `--id` `--subject` `[--scopes=a,b]` `[--kind=service|user]` `[--roles=admin]`
`[--name="…"]` `[--expires-in=30d]` `[--secret=…]` `[--json]`.

The three strings are linked: the client presents `ak_<id>_<secret>`; the server
stores `hash = SHA256(secret)`; on each call it checks
`SHA256(presented secret) === stored hash` (constant-time). The plaintext secret
never leaves the client, and the `--json` form is handy for scripting/rotation.

---

## Authorization model

- **Scopes** β€” each tool requires a scope set (`APPIUM_MCP_AUTH_TOOL_SCOPES`),
  falling back to `APPIUM_MCP_AUTH_DEFAULT_SCOPES`. A caller needs **all** of them.
- **Admin role** β€” a caller with the `admin` role bypasses scope checks.
- **Ownership** β€” sessions a caller creates are bound to its `subject`; a call
  referencing someone else's tracked `sessionId` is denied (`not_session_owner`).
  Untracked ids (pre-existing / attach flows) pass through.
- **Quota / rate limit** β€” per subject, via `MAX_SESSIONS` and `RATE_LIMIT`.

## Public API

```ts
import {
  AppiumAuthPlugin,          // the plugin class
  createAuthPlugin,          // build from an AuthConfig
  createAuthPluginFromEnv,   // build from environment
  buildAuthenticatedServer,  // full server (used by the CLI)
  loadConfig, sha256Hex,     // config helpers
  // building blocks: Authenticator, Authorizer, KeyStore, OAuthValidator,
  // OwnershipRegistry, RateLimiter, AuditLog
} from '@appclaw/appium-mcp-auth';
```

## Security notes

- Store API-key **hashes**, never plaintext secrets, in committed config.
- Give services and humans **distinct scopes** so a leaked CI key can't act as a human.
- Pin OAuth `issuer` and `audience` so tokens minted for other apps are rejected.
- Rate limiter and ownership map are **process-local** β€” front with a shared
  store (e.g. Redis) if you run multiple SSE replicas.
- Credentials are never written to audit logs.

## Development

```bash
npm install
npm run typecheck   # tsc --noEmit
npm test            # node:test via tsx (35 tests)
npm run build       # emit dist/
```

## License

[Apache-2.0](./LICENSE)