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)
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues