Skip to content

Security: MarimerLLC/adjutant

Security

docs/security.md

Security Considerations

Overview

Adjutant handles sensitive data including emails, calendar events, and authentication tokens. Security is built into the design from the ground up.

Transport Security (HTTP server)

The HTTP server exposes three surfaces with independent protection:

Surface Protection
/ (MCP protocol), /attachments/* MCP API key — Authorization: Bearer <key> or X-Api-Key: <key> — or an MCP OAuth access token issued by this server
/connect/*, /.well-known/oauth-* MCP OAuth authorization server; only offered while an admin sign-in provider is configured
/admin/* (REST API) Admin token — ADJUTANT_ADMIN_TOKEN
/admin/ui/* (Blazor console) Session cookie, issued after OIDC sign-in or admin-token login
/health, /health/ready Anonymous (Kubernetes probes)

MCP API Keys

  • Hashed at rest. Only a SHA-256 of each key is written to mcp-keys.json. The secret is displayed once at creation and cannot be recovered, so a leaked key file grants nothing.
  • Fixed-time comparison. Validation uses CryptographicOperations.FixedTimeEquals against every active key with no early exit, so response latency reveals neither how close a guess was nor which key matched.
  • Individually revocable. Each key carries a label and id. Revoking one leaves the others working, and revoked keys are retained for audit rather than deleted. Create and revoke keys from MCP Access in the admin console; revocation takes effect immediately.
  • Optional expiry. A new key can be given a lifetime (30, 90, 180 or 365 days; the console defaults to 90) or none. From its expiresUtc on, an expired key is refused like a revoked one, and a warning naming it is logged at most every five minutes. Keys created before expiry existed, the first-start key and the ADJUTANT_MCP_KEY environment key never expire.
  • Enforced by default. Adjutant:Mcp:RequireApiKey defaults to true. If no key exists at startup, one is generated and logged rather than leaving the endpoint open.
  • Refuses plaintext transport. With enforcement on, a non-loopback http:// ExternalBaseUrl stops the server from starting, since the key would cross the wire in clear text.

An API key authorizes access to every account the server is configured with. Per-account limits are a separate layer — see Per-Account Permissions below.

See Configuration for setup.

MCP OAuth

The server is its own OAuth authorization server for MCP clients (OpenIddict), following the MCP authorization spec. Google and Entra don't let MCP clients register themselves, so the server does the registration and federates only the sign-in to the admin console's providers.

  • Discovery. A 401 from the MCP endpoint carries WWW-Authenticate: Bearer resource_metadata=".../.well-known/oauth-protected-resource" (RFC 9728), which names this server as both the resource and the authorization server.
  • Approval is the owner's. /connect/authorize requires an admin console session that still passes the console's own checks (allow-list, bound provider subject). A break-glass admin-token session can't approve a client outside Development, since there is no verified identity behind it.
  • Consent on every authorization. The consent page names the client, says whether its name is verified (client ID metadata document) or self-asserted (dynamic registration), shows the host it redirects to, and warns about loopback redirects, which any program on the machine can claim. It refuses to be framed. Without it, anyone could register a client with their own redirect URI and send the owner a link that hands them a code.
  • Public clients, PKCE (S256) required. Redirect URIs must be HTTPS, or HTTP on a loopback host. Client ID metadata documents are fetched without following redirects and only from public IP addresses (checked on the resolved address at connect time), so a crafted client_id can't probe the server's own network.
  • Audience-bound tokens (RFC 8707). Tokens are issued only for this server's MCP endpoint and only accepted there. Access tokens last 1 hour; refresh tokens 14 days and rotate on use.
  • Revalidated on every request. An OAuth token's owner must still be on the allow-list, with the same bound provider subject, and OAuth must still be offered; otherwise the request gets 401. The token's own entry and the approval it came from must also still be valid in identity.db.
  • Disconnect. MCP Access in the admin console lists the connected apps. Disconnect revokes an app's approvals and tokens: its access token fails on the next request and its refresh token is refused, so it has to be approved on the consent page again.
  • Keys at rest. Token signing and encryption keys are generated on first start and stored in oauth-keys/, encrypted with the DataProtection keyring, unless supplied through configuration. Clients, approvals and refresh tokens are in identity.db; token values are stored hashed by OpenIddict.

Like an API key, an OAuth token reaches every account the server is configured with.

Admin Console Sign-In

The console accepts OIDC sign-in from Google or Microsoft, restricted to an allow-list of verified email addresses.

  • Identity only. Sign-in requests openid email profile and nothing else. Provider tokens are not stored (SaveTokens = false), so the console session cannot be used to reach the provider's APIs, and no refresh token is ever issued.
  • Verified addresses only. An explicit email_verified: false is disqualifying. An absent claim is not — Entra generally omits it, and refusing on absence would exclude it entirely.
  • Subject binding. The provider's subject is pinned on first sign-in and required to match afterwards. The allow-list is by email, and an email address can be reassigned to a different person; without binding, that reassignment would inherit console access.
  • No cookie for an unauthorized identity. The allow-list check runs inside the OIDC OnTicketReceived event, before any cookie is issued, so a refused sign-in leaves no session behind.
  • Claim-gated first run. While the allow-list is empty, the first sign-in must also present a one-time code from the startup log. This is what stops a publicly reachable server from being claimed by whoever finds it first. The code alone grants nothing; it is only accepted alongside a provider-verified identity.
  • Break-glass token login. The admin token can log in to the console while no provider is configured, and is hidden automatically once one is. It is compared in fixed time. Override with AdminAuth:AllowTokenLogin, or from Settings in the console.
  • Client secrets encrypted at rest. A secret entered through the console is protected with DataProtection before being written to appsettings.json, using the keyring in the data directory. Secrets set by hand or through environment variables remain plaintext and keep working, so both forms are accepted.

Anyone who signs in to the console has full administrative control of every configured account. The allow-list is the whole authorization model — there are no console roles.

Session Handling

  • Cookie hardening tracks the transport. When Adjutant:ExternalBaseUrl declares an HTTPS origin, the session cookie is named __Host-Adjutant.AdminAuth and marked Secure. The __Host- prefix is browser-enforced: the cookie must have been set by that exact origin, over HTTPS, with no Domain attribute — so a sibling host on a shared suffix such as ts.net cannot overwrite it. Over plain HTTP the cookie keeps its original name and follows the request, so local development still works.
  • Always HttpOnly, SameSite=Lax. Lax rather than Strict because the identity provider redirects back as a top-level navigation, which Strict would strip the cookie from.
  • Sliding 8-hour expiry. An administrator working in the console is not signed out mid-task; an abandoned session closes.
  • Live sessions are revalidated every minute. Removing someone from the allow-list, or turning off token login, ends their open console circuit rather than waiting out the cookie. Subject-binding changes end it too.

Changing ExternalBaseUrl from HTTP to HTTPS changes the cookie name, which signs everyone out once. That is expected.

Rate Limiting

Sign-in, the MCP endpoint and the MCP OAuth endpoints are rate limited; everything else is unlimited.

Surface Limit Partitioned by
/admin/ui/login, /admin/ui/claim, /admin/auth/* 10/minute client address
/, /sse, /message, /attachments/* 240/minute API key or OAuth token (hashed), or address when absent
/connect/register 10/hour client address
other /connect/*, /.well-known/* 60/minute client address

Rejections return 429 with Retry-After. The limiter runs before authentication, so credential guessing is throttled before it reaches any validation work.

The client address is whatever UseForwardedHeaders resolved. With the default ForwardLimit of 1, that is the entry appended by the nearest proxy rather than one a client can supply itself — but it does mean the limit is only as trustworthy as the proxy in front of the server.

See Configuration for setup.

Authentication & Authorization

OAuth 2.0 Flow

  • Standard OAuth 2.0: All providers use industry-standard OAuth 2.0
  • Authorization Code Flow: Desktop app flow with PKCE (Proof Key for Code Exchange)
  • Consent Required: Users explicitly grant permissions during initial setup
  • Scope Limitation: Request only necessary permissions per account

Token Management

Access Tokens

  • Short-lived (typically 1 hour)
  • Never logged or exposed in telemetry
  • Used only for authenticated API calls
  • Automatically refreshed before expiration

Refresh Tokens

  • Long-lived (until explicitly revoked)
  • Stored securely per-account
  • Used to obtain new access tokens
  • Independent per account (one compromise doesn't affect others)

Token Storage Security

Microsoft Accounts (M365 + Outlook.com)

Encrypted Token Cache:

  • Windows: DPAPI (Data Protection API) encryption
  • macOS: Keychain Services encryption
  • Linux: LibSecret / GNOME Keyring encryption
  • File Location: %LOCALAPPDATA%/Adjutant/msal_cache_{accountId}.bin
  • Permissions: Restricted to current user only (0600)

Implementation via MsalCacheHelper:

var storageProperties = new StorageCreationPropertiesBuilder(
    cacheFileName, 
    cacheDirectory)
    .Build();
    
var cacheHelper = await MsalCacheHelper.CreateAsync(storageProperties);
cacheHelper.RegisterCache(app.UserTokenCache);

Security Features:

  • ✅ Automatic OS-level encryption
  • ✅ Per-user file permissions
  • ✅ Per-account isolation
  • ✅ Protected from other processes

Google Accounts

FileDataStore with File Permissions:

  • Storage: JSON files in ~/.credentials/adjutant/{accountId}/
  • Current State: ⚠️ Plaintext JSON (access/refresh tokens)
  • Permissions: Restricted to current user (0600)
  • Isolation: Separate directory per account

Security Limitations:

  • ⚠️ Tokens stored in plaintext JSON
  • ⚠️ Anyone with file system access (as your user) can read tokens
  • ⚠️ No OS-level encryption by default

Future Enhancement:

// Encrypt tokens before writing to FileDataStore
public class EncryptedFileDataStore : IDataStore
{
    private readonly IDataStore _innerStore;
    private readonly IDataProtector _protector;
    
    public async Task StoreAsync<T>(string key, T value)
    {
        var json = JsonSerializer.Serialize(value);
        var encrypted = _protector.Protect(json);
        await _innerStore.StoreAsync(key, encrypted);
    }
}

Per-Account Permissions

Beyond OAuth scopes, each configured account carries a Permissions block that gates which MCP tools may touch it. This is enforced in the server, independently of what the provider token would allow, so an over-scoped OAuth grant can still be narrowed at the account level.

"Permissions": {
  "emailRead": true,
  "emailSend": false,
  "calendarRead": false,
  "calendarWrite": false,
  "contactsRead": false,
  "contactsWrite": false
}

Properties worth knowing for a threat model:

  • Grants are per account, not per provider type. Two accounts on the same provider are scoped independently.
  • Enforcement is central. Every tool resolves its account through ToolGuard, which consults AccountCapabilities.IsAllowed before any provider call is made. A denied operation never reaches the provider.
  • Grants are intersected with provider capability. IsAllowed returns false when the provider has no such capability (calendar on IMAP, email on DAV) or is read-only for it (writes on an ICS feed), no matter what the config says. list_accounts advertises this effective result, so an assistant is never told about access it doesn't have.
  • Defaults are permissive. An omitted block grants everything, preserving behaviour for configs written before the feature existed. Least-privilege setups must set the flags explicitly.
  • This is not a substitute for narrow OAuth scopes. Permissions stop this server from making the call; the underlying token may still be broadly scoped. For defence in depth, narrow the app registration's scopes as well — see M365 setup and Google setup.

Full flag reference: Account Permissions.

Multi-Tenant Isolation

Per-Account Token Caches

Critical Design Principle: Every account has its own token storage.

Why This Matters:

  • Prevents cross-tenant token leakage
  • Ensures M365 Tenant A cannot access Tenant B resources
  • Isolates personal accounts from work accounts
  • Limits blast radius if one account compromised

Authentication Instance Isolation

Microsoft Accounts:

  • Each account has separate IPublicClientApplication instance
  • Different tenant IDs prevent cross-tenant authentication
  • Separate cache registration per instance

Google Accounts:

  • Each account has separate UserCredential instance
  • Different FileDataStore directories per account
  • No shared authentication state

Configuration Security

Sensitive Data Storage

DO NOT store in appsettings.json:

  • ❌ API keys
  • ❌ Client secrets (Google)
  • ❌ Access tokens
  • ❌ Refresh tokens

Use environment variables instead:

# Secure approaches:
export ADJUTANT_Router__ApiKey="sk-..."
export ADJUTANT_Accounts__0__Configuration__ClientSecret="GOCSPX-..."

# Or use a secrets management service:
# - Azure Key Vault
# - AWS Secrets Manager
# - HashiCorp Vault

Password Encryption At Rest (IMAP, DAV and Exchange Accounts)

The IMAP/SMTP, CalDAV/CardDAV (dav) and on-premises Exchange (exchange) providers store their password field encrypted via ASP.NET DataProtection. Values written by the admin UI or any future CLI command land in appsettings.json as a string starting with the ENC: prefix followed by the protected blob.

Key persistence:

  • Keys are persisted to <data-dir>/keys/ (e.g. /app/data/keys/ in k8s, %LOCALAPPDATA%\Adjutant\keys\ locally).
  • The same data directory holds appsettings.json, so backup/restore of the data volume covers both — but lose the keystore and you lose the ability to decrypt previously-stored passwords.
  • All hosts (HttpServer, StdioServer, anything else calling AddAdjutantCore) share the same keystore and can encrypt or decrypt values written by another host.

Threat model:

  • ✅ Protects against config-volume-only leaks (someone reads appsettings.json but not the keystore).
  • ✅ Reduces the chance an accidental log dump, screenshot, or chat paste contains a usable password.
  • ✅ Stops naive grep for the password value.
  • ❌ Does not protect against full-data-directory compromise — the keystore is right next to the encrypted data. This is intentional: a separate KMS or HSM would be a different feature.

Compatibility:

  • A stored value without the ENC: prefix is treated as plaintext and returned as-is. This means manually-edited entries continue to work, and a future migration that re-saves them through the UI will encrypt them.

File Permissions

appsettings.json:

  • Contains account metadata (IDs, domains, priorities)
  • IMAP, DAV and Exchange passwords stored encrypted (with ENC: prefix) — see above
  • Other secrets and tokens are not stored here
  • Can be committed to source control (with secrets externalized)

Recommended permissions:

# Linux/macOS
chmod 644 appsettings.json  # Read by owner, readable by others (no secrets)

# Token caches
chmod 600 msal_cache_*.bin  # Read/write by owner only
chmod 700 ~/.credentials/adjutant/  # Directory access by owner only

API Key Protection

Router Backend API Keys

Never log API keys:

// BAD
_logger.LogInformation($"Using API key: {apiKey}");

// GOOD
_logger.LogInformation("Router backend initialized");

Redact in telemetry:

activity?.SetTag("router.backend", "openai");
// DON'T: activity?.SetTag("router.api_key", apiKey);

Client Secrets (Google)

Store in environment variables:

export ADJUTANT_Google_ClientSecret="GOCSPX-..."

Reference in configuration:

{
  "accounts": [{
    "provider": "google",
    "configuration": {
      "clientSecret": "${ADJUTANT_Google_ClientSecret}"
    }
  }]
}

Telemetry Data Privacy

Redaction Strategy

Enabled by default in telemetry configuration:

{
  "telemetry": {
    "redaction": {
      "enabled": true,
      "redactEmailContent": true,
      "redactTokens": true,
      "redactPii": true
    }
  }
}

What Gets Redacted

Always Redacted:

  • Access tokens
  • Refresh tokens
  • Client secrets
  • API keys

Redacted when redactEmailContent: true:

  • Email subject lines
  • Email body content
  • Email sender/recipient names (keeps domains)

Redacted when redactPii: true:

  • Full email addresses (keeps domain: ***@example.com)
  • Display names
  • Phone numbers
  • Physical addresses

Never Redacted (safe metadata):

  • Account IDs
  • Provider types
  • Domains (e.g., "example.com")
  • Message counts
  • Timestamps
  • Status codes
  • Performance metrics

Example Redaction

// Before redaction
"email.subject": "Q4 Budget Proposal from John Smith"
"email.from": "john.smith@example.com"
"email.body": "Here are the Q4 budget numbers..."

// After redaction
"email.subject": "[REDACTED]"
"email.from": "***@example.com"
"email.body": "[REDACTED]"

Rate Limiting & DoS Protection

Per-Account Rate Limiting

Implementation (per provider service):

private readonly Dictionary<string, SemaphoreSlim> _rateLimiters;

public async Task<T> ExecuteWithRateLimitAsync<T>(
    string accountId, 
    Func<Task<T>> operation)
{
    var limiter = _rateLimiters[accountId];
    await limiter.WaitAsync();
    try
    {
        return await operation();
    }
    finally
    {
        limiter.Release();
    }
}

Provider-Specific Limits

Microsoft Graph:

  • ~2,000 requests per minute per user
  • Implement exponential backoff on 429 responses
  • Use $batch for multiple operations

Google APIs:

  • Gmail: 250 quota units per second per user
  • Calendar: 1,000,000 queries per day
  • Implement exponential backoff on quota errors

Backoff Strategy

public async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> operation)
{
    var retries = 0;
    while (retries < 3)
    {
        try
        {
            return await operation();
        }
        catch (RateLimitException)
        {
            var delay = Math.Pow(2, retries) * 1000; // Exponential backoff
            await Task.Delay(TimeSpan.FromMilliseconds(delay));
            retries++;
        }
    }
    throw new Exception("Max retries exceeded");
}

Input Validation

Account ID Validation

public void ValidateAccountId(string accountId)
{
    if (string.IsNullOrWhiteSpace(accountId))
        throw new ArgumentException("Account ID cannot be empty");
        
    if (!_accountRegistry.ContainsKey(accountId))
        throw new InvalidOperationException($"Account '{accountId}' not found");
        
    // Prevent path traversal
    if (accountId.Contains("..") || accountId.Contains("/") || accountId.Contains("\\"))
        throw new ArgumentException("Invalid account ID format");
}

Email Address Validation

public void ValidateEmailAddress(string email)
{
    if (!MailAddress.TryCreate(email, out _))
        throw new ArgumentException($"Invalid email address: {email}");
}

Query Parameter Sanitization

public void ValidateSearchQuery(string query)
{
    // Prevent injection attacks
    var dangerous = new[] { "<script>", "javascript:", "onerror=" };
    if (dangerous.Any(d => query.Contains(d, StringComparison.OrdinalIgnoreCase)))
        throw new ArgumentException("Invalid search query");
}

Access Control

Account Isolation

Enforced at every layer:

  1. MCP tool receives accountId parameter
  2. Router validates account exists and is enabled
  3. Provider service validates account exists
  4. Auth instance lookup by accountId
  5. API call made with account-specific token

No cross-account access possible:

// This is enforced:
var emails = await _m365Provider.GetEmailsAsync("work-account", ...);
// Cannot accidentally use personal-account's token for work-account's data

Principle of Least Privilege

Request minimal scopes:

  • ✅ Mail.Read (not Mail.ReadWrite if only reading)
  • ✅ Calendars.ReadWrite (only if writing calendar events)
  • ✅ Contacts.ReadWrite (only if managing contacts)
  • ❌ Don't request User.Read.All if not needed

Per-account scopes:

{
  "accounts": [{
    "id": "readonly-account",
    "scopes": ["Mail.Read", "Calendars.Read", "Contacts.Read"]  // No write permissions
  }]
}

Incident Response

Token Revocation

If account compromised:

  1. Revoke via provider admin console:

    • Microsoft: Azure AD → Users → Revoke sessions
    • Google: Account settings → Security → Third-party access → Remove
  2. Delete local token cache:

    # Microsoft
    rm "%LOCALAPPDATA%/Adjutant/msal_cache_<account-id>.bin"
    
    # Google
    rm -rf ~/.credentials/adjutant/<account-id>/
  3. Re-authenticate:

    adjutant-setup refresh-account <account-id>

Audit Logging

Enable comprehensive telemetry for security auditing:

{
  "telemetry": {
    "enabled": true,
    "azureMonitor": {
      "enabled": true,
      "connectionString": "..."
    }
  }
}

Query for suspicious activity:

  • Unusual access patterns
  • Failed authentication attempts
  • Rate limit violations
  • Cross-account access attempts (should be impossible)

Compliance

GDPR Considerations

User Rights:

  • Right to access: Users can export their data via provider tools
  • Right to erasure: Remove account with adjutant-setup remove-account
  • Data minimization: Only request necessary scopes

Data Processing:

  • ✅ No data stored server-side (tokens local only)
  • ✅ No data sent to third parties (except router LLM if configured)
  • ✅ Telemetry redaction prevents PII leakage

Router Privacy Considerations

Local models (Ollama):

  • ✅ Data never leaves your machine
  • ✅ No cloud provider sees your queries

Cloud APIs (OpenAI, Anthropic):

  • ⚠️ Account metadata sent to LLM provider
  • ⚠️ Not email content (only account names, domains)
  • 💡 Consider data residency requirements
  • 💡 Review provider's data processing agreement

Security Checklist

Before deploying Adjutant:

  • All API keys stored in environment variables
  • Token cache files have correct permissions (0600)
  • Telemetry redaction enabled in production
  • Minimal scopes requested per account
  • Rate limiting configured
  • Input validation on all user inputs
  • OpenTelemetry configured for audit logging
  • Incident response plan documented
  • Users trained on security best practices
  • Regular security reviews scheduled

There aren't any published security advisories