Sm0k367/Agent-auth

Agent OAuth Passport

β˜… 0Forks 0GitHub β†—Compare

README

πŸ›‚ AgentAuth

The Agent Passport β€” log in once, let your agents in forever.

A credential vault and identity broker built for the age of autonomous AI. Your agent authenticates once. After that, it can securely act as you β€” everywhere β€” without ever holding your raw secrets.

CI Node TypeScript Crypto Fail License

β–Ά See it: an agent logs into a web app, hits MFA, a human approves from their phone, the transcript downloads β€” no password or code ever logged.


The problem nobody solved

AI agents are getting terrifyingly capable β€” and they're stuck at the front door.

Every agent that wants to do something real hits the same wall: login. Today the "solutions" are all bad:

  • πŸ”“ Paste your password into a prompt. Now it's in a model's context, a log, a trace.
  • πŸ—οΈ Hand the agent a .env full of API keys. One leak and everything's gone.
  • 🀷 Re-authenticate on every run. Doesn't scale, breaks automation, drives you insane.

You shouldn't have to choose between capable agents and not getting owned.

The idea: a passport for your agent

You log in once, manually. AgentAuth seals those credentials into a passport. From then on, your agent presents its own key and gets exactly the access you granted β€” scoped, time-boxed, revocable, and logged β€” and the raw secret is unsealed only for the instant it's used.

It's a password manager, an OAuth broker, and an audit system β€” re-imagined as an identity layer for machines.

 You (once)                    AgentAuth                         Your Agent (forever)
 ──────────                    ─────────                         ────────────────────
 deposit creds  ───────────▢  πŸ”’ sealed in your passport
 mint agent key ───────────▢  πŸ€– bound to passport + scopes ──▢  aa_… (shown once)
                                                                  β”‚
                               "what can I use?"  ◀────────────────  discovers (scoped)
                               πŸ”“ unseal + audit  ◀────────────────  logs into anything
                               revoke / expire    βœ‹               β”‚

Why it's safe (the part that matters)

AgentAuth is a vault, so it's engineered like one. Security isn't a feature here β€” it's the whole product.

πŸ” Envelope encryption, per passport

A master key (KEK) wraps a unique data key for every passport; every credential is sealed with AES-256-GCM (random nonce, auth tag, and passport:target AAD binding). Crack one passport and you've learned nothing about any other. Keys are versioned and rotatable β€” old data stays readable while you roll forward.

βœ‹ Fail-closed, always

If the authorization store is unreachable, agents are denied (503) β€” never default-allowed. Revocation flips a flag checked on every single request. There is no "fail open" path. We tested it: pull the database, access stops.

🧾 A tamper-evident audit trail

Every issue, deposit, use, revoke, and denial is appended to an HMAC hash-chained log. Change one row and the chain breaks β€” detectably. Triggers block UPDATE/DELETE/TRUNCATE on the table via the normal SQL path (run the runtime under a least-privilege DB role so it can't disable them β€” see SECURITY.md). Secrets never touch the log.

🎯 Least authority by default

Agents are bound to one passport and gated by scopes (vault:read, vault:use) and target globs (target:github.com, target:*.internal). A narrowly-scoped agent can't even enumerate the credentials it isn't allowed to touch.

πŸš‡ Proxy mode β€” the secret never reaches the agent

use hands the unsealed secret back to the agent. Proxy mode never does. With POST /v1/vault/credentials/:id/proxy, AgentAuth makes the downstream call server-side, injects the credential into that request, and returns only the downstream response β€” with the secret redacted from the body. The agent chooses the method/path/query/headers/body; the host is pinned to the credential's target server-side, so the agent can't repoint the request to exfiltrate the secret.

# Agent presents its own key; AgentAuth calls github.com with the sealed token injected.
curl -s $BASE/v1/vault/credentials/$CRED/proxy -X POST \
  -H "authorization: Bearer $APIKEY" -H 'content-type: application/json' \
  -d '{"method":"GET","path":"/user","headers":{"accept":"application/vnd.github+json"}}'
# β†’ { "status": 200, "headers": {...}, "body": "{...}" }   the raw token is never in the response

This needs the vault:proxy scope. Injection is configured per credential at deposit time (injection: bearer Β· basic Β· cookie Β· header Β· query), so AgentAuth knows exactly where the secret goes. Because proxy mode returns no secret, you can issue proxy-only agents β€” grant vault:proxy without vault:use and the agent can act through credentials it can never read.

πŸ–₯️ Browser-login mode β€” drive a real browser as you

Some "logins" aren't an API call β€” they're a web app behind a cookie, a stored session, or a form. POST /v1/vault/credentials/:id/browser-login (agent key, scope vault:use) turns a credential into a concrete browser-login plan: a small set of instructions ("set these cookies", "fill this login form", "set this auth header", "seed this localStorage key") that an agent driving a real browser β€” Playwright, Puppeteer, computer-use β€” applies to a page to become authenticated.

Trust model, stated honestly. The returned plan carries secret material (the cookie value, auth header, or the password typed into the form). It is the same trust level as /use β€” secret material reaches the caller. The strong "the secret never reaches the agent" guarantee remains proxy mode (HTTP only). For browser use, the meaningful boundary is the SDK helper: it applies the plan to a page object and confines the secret to the SDK process's memory β€” it returns only a non-secret summary, never handing the values back up to the agent's reasoning/LLM layer. The server audits mode + target only; the plan and its secret are never logged.

The non-secret spec lives in metadata.browser on the credential (set at deposit time / in the admin UI). It describes where the secret goes without containing it, so it can travel in listing metadata and be edited safely. Four spec shapes:

mode Spec fields Default for type
cookie { cookies?, url? } β€” when cookies is omitted the secret is parsed as a name=value; name2=value2 string cookie
header { header?, prefix?, url? } β€” defaults to Authorization: Bearer <secret> api_key Β· oauth_token
localStorage { origin, key, url? } β€” sets localStorage[key] = secret on origin β€”
form { url, fields:[{selector, valueFrom:"secret"|"username"} | {selector, value}], submitSelector?, successUrlIncludes? } β€”

cookie, api_key, and oauth_token credentials get a sensible default plan with no spec at all. A password credential has no safe default β€” it requires an explicit form spec, else the call returns 422 no_browser_spec. The response is the matching plan shape: cookie (cookies to set), header (headers to set), localStorage (items to seed), or form (an ordered actions list of goto / fill / click).

import { AgentAuthClient } from '@agentauth/sdk';
import { chromium } from 'playwright';

const aa = new AgentAuthClient({ baseUrl, apiKey });           // scope vault:use
const browser = await chromium.launch();
const page = await browser.newPage();

// Fetch the plan, apply it to the page, and get back a NON-secret summary.
// The secret flows only into the browser β€” never into this return value or a log.
const summary = await aa.browserLogin(page, 'app.example.com');
console.log('logged in via', summary.mode);                   // e.g. "cookie"
await page.goto('https://app.example.com/dashboard');         // now authenticated

The SDK surface is browserLogin(page, target) β€” the safe path, needing only vault:use β€” and getBrowserLoginPlan(target) β€” the liability path (Python: browser_login / get_browser_login_plan).

getBrowserLoginPlan is the liability path. It returns the plan with the secret in plaintext to your process. It requires the vault:browser:raw scope, which is off by default and must be explicitly granted per agent (a checkbox on the mint-agent page) β€” without it the call is 403 missing_scope. If you enable it, treat the return value like a decrypted password: do not log it, do not pass it to an LLM, do not persist it. AgentAuth cannot enforce this once the plan leaves the server β€” the trust boundary moves to your process. Prefer browserLogin unless you have a concrete reason you can't.

Browser-login is intentionally an SDK feature, not an MCP tool: a stdio MCP bridge has no browser page to apply the plan to, so exposing it there would only surface secret values to the model. MCP agents authenticate with use_credential or proxy_request.

Browser host-pinning. A login plan is pinned to the credential's target host: the optional metadata.browser.allowedDomains allowlist (echoed into every plan) makes the SDK refuse any navigation, cookie/header injection, form fill, or MFA-code injection on an off-list host β€” checked before any secret touches the page, and re-checked at the moment the code is typed. Revoking the agent mid-flow forces a logout (clears cookies, navigates to about:blank) so an authenticated session can't outlive the revoked agent.

πŸ”‘ MFA handoff β€” a human approves, the agent never sees the code

Real logins hit MFA. AgentAuth turns that from a dead end into a human-in-the-loop handoff: the agent drives the browser to the challenge page, a human approves from their phone, and the one-time code is injected into the page β€” without the code ever reaching the agent's reasoning layer or any log.

When browserLogin lands on an MFA challenge (detected from the URL, page text, or a one-time-code input β€” tunable via metadata.browser.mfa), the summary comes back { authenticated: false, mfa: { kind, promptText, challengeId } }. promptText is non-secret page text (digit runs masked), safe to surface to an LLM. The agent then calls resolveMfa(page, target, challenge) (Python: resolve_mfa), which:

  1. opens an approval request (POST /v1/vault/credentials/:id/mfa/request),
  2. polls until a human approves it from the /mfa admin queue β€” the passport owner, or a per-credential metadata.delegateApproverId,
  3. injects the approved code straight into the page's DOM and submits.

The one-time code is sealed at rest under the passport DEK (AAD bound to the request's immutable row id), single-use (an atomic consume β†’ 410 on a second fetch), TTL-bounded (5 min), rate-limited per credential+agent, and never logged or returned to the agent β€” resolveMfa hands back only { resolved, status, by, at }. Revoking the agent (or narrowing its target scope) cancels any pending request and zeroes the sealed code. Every step emits an mfa.* audit event; the code value appears in none of them.

const summary = await aa.browserLogin(page, 'irs.gov');
if (summary.mfa) {
  // A human taps "approve" in the admin queue; the code lands in the page, not here.
  const r = await aa.resolveMfa(page, 'irs.gov', summary.mfa);
  // r === { resolved: true, status: 'approved', by: 'cpa@firm.com', at: '…' }
}

Like browser-login, MFA resolution is an SDK feature, not an MCP tool β€” it needs a live page to inject the code into. The human approval side is the admin web UI's /mfa queue (approve with the code, or deny).

πŸ›‘οΈ Hardened on every layer

Argon2id password & key hashing Β· constant-time login (no user enumeration) Β· per-route rate limiting & brute-force / argon2-DoS protection Β· session revocation (jti denylist) Β· Helmet security headers Β· CORS allowlist Β· strict body limits Β· Zod validation everywhere Β· request-id correlation Β· no stack-trace leakage Β· fail-fast secret validation (won't even boot insecure).


⚑ Quickstart (Docker only β€” no host toolchain)

cp .env.example .env
# Generate two 32-byte base64 secrets. With openssl (no Node needed):
echo "MASTER_KEY=$(openssl rand -base64 32)" >> .env
echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env
# …or, if you have neither openssl nor host Node, use the Docker image you're
# about to build:  docker run --rm node:22-alpine node -e \
#   "console.log(require('crypto').randomBytes(32).toString('base64'))"
# ↑ edit .env so each key appears once. Save .env as UTF-8 **without a BOM**.

docker compose up -d --build     # app + db + auto-migrate β†’ http://localhost:8080
docker compose exec app node dist/cli/bootstrap.js   # prints a ready-to-use AGENT API KEY

That's it: docker compose up brings up the database, applies migrations, and serves the API; the in-container bootstrap.js mints a principal, a passport, and an agent key you can hand straight to an agent (via the MCP server, an SDK, or raw HTTP). On a host with the toolchain installed (pnpm install) you can run pnpm agentauth:init instead.

Local dev (no Docker for the app)
pnpm install
pnpm db:up                       # Postgres in Docker (port 5433)
cp .env.example .env             # + MASTER_KEY / JWT_SECRET as above
pnpm db:generate && pnpm db:migrate
pnpm dev                         # http://localhost:8080  β€’  docs at /docs

Interactive OpenAPI docs are served at /docs. Liveness /healthz, readiness /readyz, Prometheus metrics /metrics.

🎬 The whole story in one script

BASE=http://localhost:8080

# 1) You β€” register & log in (once)
curl -s $BASE/v1/principals -H 'content-type: application/json' \
  -d '{"email":"[email protected]","password":"correct-horse-battery"}'
TOKEN=$(curl -s $BASE/v1/auth/login -H 'content-type: application/json' \
  -d '{"email":"[email protected]","password":"correct-horse-battery"}' | jq -r .token)

# 2) You β€” open a passport and deposit a credential (the manual login)
PASSPORT=$(curl -s $BASE/v1/passports -H "authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' -d '{"name":"work"}' | jq -r .id)
curl -s $BASE/v1/passports/$PASSPORT/credentials -H "authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  -d '{"target":"github.com","label":"GH token","type":"api_key","secret":"ghp_xxx"}'

# 3) You β€” mint a scoped agent key (shown exactly once)
APIKEY=$(curl -s $BASE/v1/agents -H "authorization: Bearer $TOKEN" \
  -H 'content-type: application/json' \
  -d "{\"passportId\":\"$PASSPORT\",\"name\":\"ci-bot\",\"scopes\":[\"vault:read\",\"vault:use\",\"target:github.com\"]}" \
  | jq -r .apiKey)

# 4) Your agent β€” logs into anything, no human in the loop
CRED=$(curl -s $BASE/v1/vault/credentials -H "authorization: Bearer $APIKEY" | jq -r '.items[0].id')
curl -s $BASE/v1/vault/credentials/$CRED/use -X POST -H "authorization: Bearer $APIKEY"
# β†’ the sealed secret, unsealed for use, fully audited

# 5) You β€” changed your mind? Revocation is instant and fail-closed.
curl -s $BASE/v1/agents/<agentId>/revoke -X POST -H "authorization: Bearer $TOKEN"

πŸ—ΊοΈ API at a glance

Area Endpoint Who
Identity POST /v1/principals, POST /v1/auth/login, POST /v1/auth/logout Human
Passports POST/GET /v1/passports Human
Deposit POST/GET /v1/passports/:id/credentials Human
Agents POST/GET /v1/agents, POST /v1/agents/:id/revoke Human
Vault GET /v1/vault/credentials, POST /v1/vault/credentials/:id/use Agent
Proxy POST /v1/vault/credentials/:id/proxy (secret-free; vault:proxy) Agent
Browser POST /v1/vault/credentials/:id/browser-login (login plan; vault:use, ?raw=true needs vault:browser:raw) Agent
MFA POST/GET /v1/vault/credentials/:id/mfa/request[/:reqId] (open + poll; vault:use) Agent
MFA GET /v1/mfa, POST /v1/mfa/:id/approve Β· /deny (approval queue) Human
Audit GET /v1/audit, GET /v1/audit/verify Human
Ops GET /healthz, /readyz, /metrics, /docs β€”

🧱 Architecture

src/
  env.ts            fail-fast config (refuses to boot without real secrets)
  server.ts         Fastify: helmet Β· cors Β· rate-limit Β· swagger Β· error envelope Β· request-id
  crypto/
    envelope.ts     AES-256-GCM envelope encryption Β· key versioning + rotation
    secrets.ts      argon2id hashing Β· agent key format Β· constant-time helpers
  auth/
    human.ts        session JWTs (jti) + fail-closed revocation
    agent.ts        fail-closed agent auth Β· scope + target enforcement
  lib/
    vault.ts        deposit / unseal (transient DEK handling, buffer scrubbing)
    browser.ts      browser-login plan builder Β· host-pinning Β· spec validation
    mfa.ts          MFA approval handoff Β· sealed single-use codes Β· owner/delegate authz
    audit.ts        HMAC hash-chained, tamper-evident audit log + verifier
    http.ts         one error envelope Β· pagination
    metrics.ts      Prometheus counters
  db/schema.ts      principals Β· passports Β· credentials Β· agents Β· revoked_sessions Β· audit_events Β· mfa_requests
  routes/           principals Β· passports Β· agents Β· vault Β· mfa Β· audit Β· guards

πŸ§ͺ Tested like a vault β€” a comprehensive unit + integration suite across server, SDKs, and web, all green

  • Crypto unit tests β€” round-trips, tamper rejection, AAD binding, wrong-key failure, format-version & algorithm checks, key-id tagging, rotation.
  • Integration tests (Fastify inject + ephemeral Postgres) β€” every route, plus the properties that actually matter: cross-tenant isolation (IDOR), scope & target enforcement, revocation fail-closed, database-down fail-closed, user-enumeration resistance, audit completeness, hash-chain tamper detection, and append-only enforcement.

Built under adversarial review: a fleet of independent agents audited the code (99 findings), every one was fixed, then a second fleet tried to disprove each fix line-by-line. The gaps they found were closed and re-verified.

pnpm test        # unit + integration (Vitest + Fastify inject + ephemeral Postgres)
pnpm typecheck   # strict TS, no any-escapes
pnpm lint        # eslint clean
pnpm build       # production build (no source maps)

CI runs typecheck β†’ lint β†’ migrate β†’ test β†’ build β†’ Docker image on every push.

πŸš€ Deploy

docker build -t agentauth .
docker run -p 8080:8080 --env-file .env agentauth   # runs as non-root, healthchecked

Production turns on Postgres TLS by default, locks CORS to your allowlist, enables CSP, and validates that every secret is present and well-formed before accepting a single request.

🧩 Ecosystem

  • MCP server β€” packages/mcp-server: drop-in Model Context Protocol server exposing list_credentials, use_credential, and proxy_request (secret-free proxy mode) tools. Point any MCP-capable agent (Claude Desktop, etc.) at it with AGENTAUTH_API_KEY and your agents get the vault as tools β€” zero code.
  • TypeScript SDK β€” packages/sdk-ts: new AgentAuthClient({ baseUrl, apiKey }) then await client.useCredential('github.com') β€” plus proxy, browserLogin, and resolveMfa for secret-free proxying, browser logins, and MFA handoff. A HumanClient covers the management API.
  • Python SDK β€” packages/sdk-py: the same surface (AgentAuthClient, HumanClient) over httpx.
  • Admin web UI β€” web/: a Next.js console for login, passports, credential deposit, agent issuance/revocation, the approvals queue, the MFA approval queue, OAuth connect, and the audit trail.
  • Runnable examples β€” examples/: copy-paste TS + Python agents that fetch and use a credential.

βœ… What ships today

  • πŸ” KMS-backed keys β€” KEY_PROVIDER=kms keeps the master key in AWS KMS; the in-process key never holds it. Local AES-GCM KEK is the default.
  • πŸ” Zero-downtime key rotation β€” KEK, JWT signing key, and audit HMAC key are all versioned and rotatable. See the rotation runbook; the re-wrap runs via pnpm db:rotate (local) / node dist/db/rotate-keys.js (in the shipped image).
  • πŸͺͺ OAuth credential capture β€” authorize a provider in the browser once (PKCE auth-code); AgentAuth seals the tokens and transparently refreshes them when an agent uses the credential. Proactive refresh requires the provider to return expires_in (so expiry is known); a provider that issues a refresh_token but omits expires_in is treated as freshness-unknown and is not proactively refreshed β€” configure such providers to return expires_in, or a server-side expiry surfaces as a downstream 401.
  • πŸ“œ Per-credential policies β€” max-uses, time windows, and human approval workflows (request β†’ approve β†’ single-use grant) gate sensitive credentials.
  • πŸ–₯️ Browser-login + MFA handoff β€” turn a credential into a browser-login plan an SDK confines to the page, and resolve MFA challenges through a human approval queue β€” the one-time code reaches the page, never the agent or a log.
  • 🌐 mTLS agent identity β€” agents can authenticate with a client certificate (native or proxy-terminated) instead of a bearer key.
  • πŸ”Œ TLS termination β€” native HTTPS (HTTPS_CERT/HTTPS_KEY) or front it with a proxy.

πŸ”­ Roadmap

  • ⏳ Scheduled credential-expiry sweeps & richer approval notifications
  • 🧭 OIDC discovery + more first-class OAuth providers out of the box
  • πŸ“Š Built-in dashboards on top of /metrics

πŸ“„ License

MIT. See SECURITY.md for the security policy and design guarantees.

AgentAuth β€” because your agents deserve a passport, not your password.

Contributors

RealDealCPA-VR

Issues