Content
# 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)
MCP Config
Below is the configuration for this MCP Server. You can copy it directly to Cursor or other MCP clients.
mcp.json
Connection Info
You Might Also Like
No recommendations available