Adjutant handles sensitive data including emails, calendar events, and authentication tokens. Security is built into the design from the ground up.
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) |
- 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.FixedTimeEqualsagainst 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
expiresUtcon, 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 theADJUTANT_MCP_KEYenvironment key never expire. - Enforced by default.
Adjutant:Mcp:RequireApiKeydefaults totrue. 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://ExternalBaseUrlstops 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.
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
401from the MCP endpoint carriesWWW-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/authorizerequires 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_idcan'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 inidentity.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 inidentity.db; token values are stored hashed by OpenIddict.
Like an API key, an OAuth token reaches every account the server is configured with.
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 profileand 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: falseis 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
OnTicketReceivedevent, 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.
- Cookie hardening tracks the transport. When
Adjutant:ExternalBaseUrldeclares an HTTPS origin, the session cookie is named__Host-Adjutant.AdminAuthand markedSecure. The__Host-prefix is browser-enforced: the cookie must have been set by that exact origin, over HTTPS, with noDomainattribute — so a sibling host on a shared suffix such asts.netcannot 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.
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.
- 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
- Short-lived (typically 1 hour)
- Never logged or exposed in telemetry
- Used only for authenticated API calls
- Automatically refreshed before expiration
- Long-lived (until explicitly revoked)
- Stored securely per-account
- Used to obtain new access tokens
- Independent per account (one compromise doesn't affect others)
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
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);
}
}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 consultsAccountCapabilities.IsAllowedbefore any provider call is made. A denied operation never reaches the provider. - Grants are intersected with provider capability.
IsAllowedreturns 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_accountsadvertises 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.
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
Microsoft Accounts:
- Each account has separate
IPublicClientApplicationinstance - Different tenant IDs prevent cross-tenant authentication
- Separate cache registration per instance
Google Accounts:
- Each account has separate
UserCredentialinstance - Different FileDataStore directories per account
- No shared authentication state
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 VaultThe 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 callingAddAdjutantCore) 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.jsonbut not the keystore). - ✅ Reduces the chance an accidental log dump, screenshot, or chat paste contains a usable password.
- ✅ Stops naive
grepfor 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.
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 onlyNever 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);Store in environment variables:
export ADJUTANT_Google_ClientSecret="GOCSPX-..."Reference in configuration:
{
"accounts": [{
"provider": "google",
"configuration": {
"clientSecret": "${ADJUTANT_Google_ClientSecret}"
}
}]
}Enabled by default in telemetry configuration:
{
"telemetry": {
"redaction": {
"enabled": true,
"redactEmailContent": true,
"redactTokens": true,
"redactPii": true
}
}
}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
// 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]"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();
}
}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
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");
}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");
}public void ValidateEmailAddress(string email)
{
if (!MailAddress.TryCreate(email, out _))
throw new ArgumentException($"Invalid email address: {email}");
}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");
}Enforced at every layer:
- MCP tool receives accountId parameter
- Router validates account exists and is enabled
- Provider service validates account exists
- Auth instance lookup by accountId
- 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 dataRequest 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
}]
}If account compromised:
-
Revoke via provider admin console:
- Microsoft: Azure AD → Users → Revoke sessions
- Google: Account settings → Security → Third-party access → Remove
-
Delete local token cache:
# Microsoft rm "%LOCALAPPDATA%/Adjutant/msal_cache_<account-id>.bin" # Google rm -rf ~/.credentials/adjutant/<account-id>/
-
Re-authenticate:
adjutant-setup refresh-account <account-id>
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)
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
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
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