Authentication
API authentication and access control.
Authentication Guide
Fortémi supports two authentication mechanisms: API Keys (simple, token-based) and OAuth2 (full authorization flow with PKCE). Choose the method that best fits your use case.
The public Community Edition profile uses Fortemi's self-hosted OAuth/API-key compatibility layer. Internal hosted deployments use a separate `hosted-auth` build profile: external OIDC bearer tokens are verified against the configured issuer and audience, a canonical tenant claim is required, and hosted startup fails closed when identity configuration or tenant lookup is unavailable. The hosted routes and requirements described below are not mounted by the public Community Edition image.
Table of Contents
- Quick Start
- API Key Authentication
- OAuth2 Authentication
- Scopes and Permissions
- Rate Limiting
- Error Handling
- Security Best Practices
Quick Start
For Simple Scripts and CLI Tools
Use API keys for straightforward authentication:
# Create an API key
curl -X POST http://localhost:3000/api/v1/api-keys \
-H "Content-Type: application/json" \
-d '{
"name": "My Script",
"description": "Script for daily note imports",
"scope": "read write",
"expires_in_days": 90
}'
# Response (save the api_key value - shown only once!)
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"api_key": "<API_KEY>",
"key_prefix": "<API_KEY_PREFIX>",
"name": "My Script",
"scope": "read write",
"expires_at": "2024-04-15T10:00:00Z",
"created_at": "2024-01-15T10:00:00Z"
}
For Applications with User Context
Use OAuth2 for applications that need user-scoped access:
# Register your application
curl -X POST http://localhost:3000/oauth/register \
-H "Content-Type: application/json" \
-d '{
"client_name": "My App",
"redirect_uris": ["http://localhost:3000/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"scope": "read write"
}'
Internal hosted OIDC profile
The hosted profile requires all of the following at startup:
FORTEMI_MULTI_TENANT=true
REQUIRE_AUTH=true
ISSUER_URL=https://identity.example.com/
FORTEMI_AUTH_AUDIENCE=<DEPLOYMENT_AUDIENCE>
FORTEMI_AUTH_TENANT_CLAIM=fortemi:tenant_id
`FORTEMI_AUTH_CLOCK_SKEW_SECONDS` defaults to 60 and is bounded to `0..60`; `FORTEMI_AUTH_JWKS_CACHE_CAPACITY` defaults to 128 and is bounded to `1..4096`; `FORTEMI_AUTH_HTTP_TIMEOUT_SECONDS` defaults to 5 and is bounded to `1..30`. The issuer and audience are required and cannot be blank. Values and identity-provider credentials must come from the hosted configuration/secret authority, not committed examples. This profile is distinct from Fortemi's self-hosted `/oauth/*` authorization-code and client-credentials flows.
Hosted note and event qualification
The migrated hosted routes include ordinary `POST /api/v1/notes`, note list, owner detail/delete, and `GET /api/v1/events`. Creation resolves referenced collections, document types, tags and queue writes through the same tenant transaction. Missing tenant-visible configuration fails the request and rolls back its writes. Use `"pipeline": []` for store-only qualification; enqueueing NLP jobs does not establish tenant-worker execution readiness. Apply the September 8 tenant tag migrations with the migration identity before upgrading the API. They qualify default scheme notation and flat tag identity by tenant; existing data and tenant-qualified foreign-key guards are preserved. Provision any required default SKOS scheme or document types under the intended tenant, not by sharing the personal tenant's seed rows.
Hosted stale-running job recovery now enumerates active tenants through the service control plane and updates each tenant in a short scoped transaction. Startup performs one bounded page; periodic passes continue the cursor. Partial failures and pass deadlines are reported as incomplete recovery, not empty success. Recovery does not by itself establish tenant-worker execution readiness.
The candidate also includes a service-owned bounded claim dispatcher with an explicit handler allowlist and post-commit, attempt-fenced settlement capability. Cycle90 connects it to an explicit hosted-only registry and claim drain. The document-type handler is registered; legacy personal handlers never receive hosted claims. Global/per-archive pause is checked before claims and shutdown retains attempt-fenced settlement. Public hosted readiness remains false.
HostedJobHandler/HostedJobContext expose bounded claim-bound content work and fenced async progress. A pool-free document-type handler now implements scoped detection, assignment/access/provenance and durable job-bound replay, including native tenant/archive-addressed membership follow-ups. Its committed progress and terminal events use tenant/archive context without raw bridge lookups/writes. Cycle91 adds typed temporary-database retries and a bounded read-only note.updated snapshot in fenced completion. Both success events follow acknowledged commit; failures publish neither and leave settlement to recovery. Other handlers, follow-up embedding execution, index events, scoped queue summaries and complete live SSE lifecycle remain unqualified. Global queue summaries are emitted only in personal mode. Content and terminal settlement commit separately; no exactly-once external-effect guarantee is implied.
Hosted SSE requires a bearer header with the canonical tenant and `mcp` scope. Archive names are authorized first; resolved schemas filter live/replay events. Missing tenant/memory attribution is rejected on hosted streams. Consumer request names and canonical event schemas must be resolved separately; a test transport adapter is not application acceptance. Memory access also requires `read` scope. The default subscription is the caller's default memory, never a cross-tenant monitoring stream. `memory=default` and `memory=public` select tenant-protected public tables when no tenant archive exists. Other archive names must resolve under that tenant's RLS transaction. Hosted archive selection does not share the personal profile's process-wide default cache or attempt runtime migrations.
The request releases its database transaction before streaming. Live and replay frames require an explicit matching envelope tenant; unattributed global events are withheld. Note-created/deleted events publish only after commit. A hosted stream closes at verified token expiry: obtain a replacement token, close the old connection, reopen with its bearer header, and refresh the note list. Query stream tokens remain a personal-profile feature and cannot replace hosted canonical identity. Existing TLS, JWT, RLS and durable authorization-audit checks remain enforced. These route gates do not enable `hosted_multi_tenant_ready` or qualify unrelated routes or worker execution. The suite audit remains NO-GO.
Custom OIDC certificate trust
For a Keycloak or other supported issuer using a private CA, set `FORTEMI_AUTH_CA_BUNDLE` to a PEM certificate file readable by the API process. The hosted OIDC verifier adds every certificate in the bundle to its default trust roots for both discovery and JWKS requests. HTTPS, hostname validation, certificate validation, issuer/audience validation, and the redirect prohibition remain enforced. The bundle does not change inbound server TLS or trust for unrelated HTTP clients. No private keys belong in this file.
The variable is optional. When explicitly set, a blank path, unreadable or empty file, malformed PEM/DER, or non-certificate PEM block prevents startup with a `FORTEMI_AUTH_CA_BUNDLE` diagnostic. There is no fallback on configuration errors. The file is read once during initialization; restart the API after changing it. For CA rotation, deploy a bundle containing both old and new CA certificates, restart, rotate the issuer certificate, then remove the retired CA and restart.
For a standalone process, use an absolute path:
export FORTEMI_AUTH_CA_BUNDLE=/etc/fortemi/trust/oidc-ca.pem
matric-api
For a container running the standalone API, add this to its Compose service (alongside its existing hosted configuration):
services:
api:
environment:
FORTEMI_AUTH_CA_BUNDLE: /etc/fortemi/trust/oidc-ca.pem
volumes:
- ./trust/oidc-ca.pem:/etc/fortemi/trust/oidc-ca.pem:ro
For the bundle image, put the same environment variable and mount on the bundle service when configuring a deployment that satisfies hosted admission:
services:
fortemi:
environment:
FORTEMI_AUTH_CA_BUNDLE: /etc/fortemi/trust/oidc-ca.pem
volumes:
- ./trust/oidc-ca.pem:/etc/fortemi/trust/oidc-ca.pem:ro
The host file must exist before starting either container and be readable by the API runtime user. Recreate/restart the service after replacing the mounted file. The standard API and bundle Dockerfiles compile the public `hosted-auth` capability by default through `ARG FORTEMI_API_FEATURES=hosted-auth`. This does not activate hosted mode or satisfy all hosted deployment prerequisites. `FORTEMI_MULTI_TENANT` remains opt-in; hardened database roles, audit, quotas, scanning, and key custody retain their existing admission checks. In particular, these default images do not compile a KMS backend. Internal image builders select `--build-arg FORTEMI_API_FEATURES=hosted-auth,kms-vault` for OpenBao or `hosted-auth,kms-aws` for AWS. Runtime `FORTEMI_KEY_PROVIDER` selects the backend; its credentials/configuration and every other hosted prerequisite remain required. The Integro Labs environment requires OpenBao Transit. A bare Cargo build continues to require explicit `--features hosted-auth` for this verifier.
Issuer URL validation also remains unchanged. Keycloak realm paths currently require the existing `FORTEMI_ALLOW_LOCAL_ISSUER=true` override. This override relaxes the server's local/private/path restriction, but the hosted provider still independently requires HTTPS and verifies TLS certificates and hostnames; it does not make HTTP or an untrusted certificate acceptable. Configure only the intended issuer and trust roots for the qualification deployment.
API Key Authentication
API keys are ideal for:
- Server-to-server integrations
- CLI tools and scripts
- Personal automation
- MCP server (stdio mode)
Creating an API Key
Endpoint: `POST /api/v1/api-keys`
Request:
{
"name": "Production Integration",
"description": "API access for production app",
"scope": "read write",
"expires_in_days": 365
}
Response:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"api_key": "<API_KEY>",
"key_prefix": "<API_KEY_PREFIX>",
"name": "Production Integration",
"scope": "read write",
"expires_at": "2025-01-15T10:00:00Z",
"created_at": "2024-01-15T10:00:00Z"
}
Important: The `api_key` field is only returned once. Store it securely immediately.
Using an API Key
Include the key in the `Authorization` header with the `Bearer` scheme:
curl http://localhost:3000/api/v1/notes \
-H "Authorization: Bearer <API_KEY>"
Python Example:
import requests
API_BASE = "http://localhost:3000"
API_KEY = "<API_KEY>"
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json"
}
# Create a note
response = requests.post(
f"{API_BASE}/api/v1/notes",
headers=headers,
json={
"content": "My new note",
"tags": ["important"]
}
)
print(response.json())
JavaScript Example:
const API_BASE = "http://localhost:3000";
const API_KEY = "<API_KEY>";
async function createNote(content, tags = []) {
const response = await fetch(`${API_BASE}/api/v1/notes`, {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({ content, tags })
});
return response.json();
}
const result = await createNote("My new note", ["important"]);
console.log(result);
Managing API Keys
List All Keys (shows prefix only):
GET /api/v1/api-keys
Response:
{
"api_keys": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"key_prefix": "<API_KEY_PREFIX>",
"name": "Production Integration",
"description": "API access for production app",
"scope": "read write",
"rate_limit_per_minute": 60,
"rate_limit_per_hour": 1000,
"last_used_at": "2024-01-15T14:30:00Z",
"use_count": 1247,
"is_active": true,
"expires_at": "2025-01-15T10:00:00Z",
"created_at": "2024-01-15T10:00:00Z"
}
]
}
Revoke a Key:
DELETE /api/v1/api-keys/{id}
API Key Format
- Format: `mm_key_{32_random_chars}`
- Prefix: First 12 characters (e.g., `<API_KEY_PREFIX>`) shown in listings
- Storage: SHA256 hash stored in database
- Expiration: Optional, defaults to no expiration
OAuth2 Authentication
OAuth2 is ideal for:
- Web applications with user authentication
- Mobile applications
- Third-party integrations requiring user consent
- MCP server (HTTP mode)
Fortémi's current self-hosted operator compatibility profile implements OAuth 2.0 with:
- Open Dynamic Client Registration (RFC 7591 compatibility)
- Authorization Code Flow with PKCE (RFC 7636)
- Client Credentials Grant
- Refresh Tokens (30-day expiration)
- Token Introspection (RFC 7662)
- Token Revocation (RFC 7009)
This is not a hosted-strict launch profile. Hosted-strict OAuth remains unavailable until its registration, client-type, authorization, and resource-binding owners land. In particular, current open registration must not be exposed as a hosted-safe default.
Discovery Endpoint
OAuth2 server metadata is available at:
GET /.well-known/oauth-authorization-server
Response:
{
"issuer": "http://localhost:3000",
"authorization_endpoint": "http://localhost:3000/oauth/authorize",
"token_endpoint": "http://localhost:3000/oauth/token",
"registration_endpoint": "http://localhost:3000/oauth/register",
"introspection_endpoint": "http://localhost:3000/oauth/introspect",
"revocation_endpoint": "http://localhost:3000/oauth/revoke",
"response_types_supported": ["code"],
"grant_types_supported": [
"authorization_code",
"client_credentials",
"refresh_token"
],
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post"
],
"scopes_supported": ["read", "write", "admin", "mcp"],
"code_challenge_methods_supported": ["S256"]
}
1. Register Your Application
Endpoint: `POST /oauth/register`
Request:
{
"client_name": "My Application",
"client_uri": "https://myapp.example.com",
"redirect_uris": ["https://myapp.example.com/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "read write",
"contacts": ["[email protected]"]
}
Response:
{
"client_id": "mm_AbCdEfGh12345678901234",
"client_secret": "<MCP_CLIENT_SECRET>",
"client_id_issued_at": 1705320000,
"client_secret_expires_at": 0,
"client_name": "My Application",
"redirect_uris": ["https://myapp.example.com/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "read write",
"token_endpoint_auth_method": "client_secret_basic",
"registration_access_token": "rEgToKeN_123...",
"registration_client_uri": "http://localhost:3000/oauth/register/mm_AbCdEfGh12345678901234"
}
The compatibility registration response currently includes `registration_access_token` and `registration_client_uri`, but Fortémi does not implement RFC 7592 client management routes. Do not treat either field as a working read, update, or delete API. Hosted-strict discovery and documentation must not advertise registration management unless those routes and their authorization policy are implemented.
Important: Save `client_id` and `client_secret` securely. The secret is only shown once.
2. Authorization Code Flow (with PKCE)
Step 1: Generate PKCE Parameters
import secrets
import hashlib
import base64
# Generate code verifier (random 43-128 chars)
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8').rstrip('=')
# Generate code challenge (SHA256 hash)
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
).decode('utf-8').rstrip('=')
print(f"Verifier: {code_verifier}")
print(f"Challenge: {code_challenge}")
Step 2: Redirect User to Authorization Endpoint
GET /oauth/authorize?
response_type=code&
client_id=mm_AbCdEfGh12345678901234&
redirect_uri=https://myapp.example.com/callback&
scope=read write&
state=random_state_value&
code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&
code_challenge_method=S256
The user will see a consent page and can approve or deny access.
Step 3: Handle Redirect with Authorization Code
After approval, the user is redirected to:
https://myapp.example.com/callback?code=AUTH_CODE_HERE&state=random_state_value
Step 4: Exchange Code for Tokens
Endpoint: `POST /oauth/token`
Request (using client_secret_basic):
curl -X POST http://localhost:3000/oauth/token \
-H "Authorization: Basic $(echo -n 'client_id:client_secret' | base64)" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "code=AUTH_CODE_HERE" \
-d "redirect_uri=https://myapp.example.com/callback" \
-d "code_verifier=VERIFIER_FROM_STEP1"
Response:
{
"access_token": "<ACCESS_TOKEN>",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "<REFRESH_TOKEN>",
"scope": "read write"
}
Python Example:
import requests
import base64
client_id = "mm_AbCdEfGh12345678901234"
client_secret = "<MCP_CLIENT_SECRET>"
auth_code = "AUTH_CODE_FROM_REDIRECT"
redirect_uri = "https://myapp.example.com/callback"
code_verifier = "VERIFIER_FROM_STEP1"
# Basic auth header
credentials = f"{client_id}:{client_secret}"
auth_header = base64.b64encode(credentials.encode()).decode()
response = requests.post(
"http://localhost:3000/oauth/token",
headers={
"Authorization": f"Basic {auth_header}",
"Content-Type": "application/x-www-form-urlencoded"
},
data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier
}
)
tokens = response.json()
access_token = tokens["access_token"]
refresh_token = tokens["refresh_token"]
3. Using Access Tokens
Include the access token in the `Authorization` header:
curl http://localhost:3000/api/v1/notes \
-H "Authorization: Bearer <ACCESS_TOKEN>"
4. Refreshing Tokens
Access tokens expire after 1 hour by default (configurable via `OAUTH_TOKEN_LIFETIME_SECS`). Use refresh tokens to obtain new access tokens without user interaction.
Endpoint: `POST /oauth/token`
Request:
curl -X POST http://localhost:3000/oauth/token \
-H "Authorization: Basic $(echo -n 'client_id:client_secret' | base64)" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "refresh_token=<REFRESH_TOKEN>"
Response:
{
"access_token": "<ACCESS_TOKEN>",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "<REFRESH_TOKEN>",
"scope": "read write"
}
Note: Refresh tokens are single-use. Each refresh returns a new access token AND a new refresh token.
5. Client Credentials Grant
For machine-to-machine authentication without user context:
Request:
curl -X POST http://localhost:3000/oauth/token \
-H "Authorization: Basic $(echo -n 'client_id:client_secret' | base64)" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "scope=read write"
Response:
{
"access_token": "<ACCESS_TOKEN>",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read write"
}
6. Token Introspection
Check if a token is active and retrieve its metadata (requires client authentication).
Endpoint: `POST /oauth/introspect`
Request:
curl -X POST http://localhost:3000/oauth/introspect \
-H "Authorization: Basic $(echo -n 'client_id:client_secret' | base64)" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "token=<ACCESS_TOKEN>"
Response (active token):
{
"active": true,
"scope": "read write",
"client_id": "mm_AbCdEfGh12345678901234",
"token_type": "Bearer",
"exp": 1705323600,
"iat": 1705320000,
"iss": "http://localhost:3000"
}
Response (inactive token):
{
"active": false
}
7. Token Revocation
Revoke access or refresh tokens when they're no longer needed.
Endpoint: `POST /oauth/revoke`
Request:
curl -X POST http://localhost:3000/oauth/revoke \
-H "Authorization: Basic $(echo -n 'client_id:client_secret' | base64)" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "token=<ACCESS_TOKEN>" \
-d "token_type_hint=access_token"
Response: `200 OK` (always returns success per RFC 7009, even if token doesn't exist)
Scopes and Permissions
Fortémi uses OAuth2 scopes to control access levels.
| Scope | Description | Permissions |
|---|---|---|
| `read` | Read-only access | List/get notes, search, view tags/collections |
| `write` | Create and update resources | `read` + create/update notes, tags |
| `delete` | Delete resources | `read` `write` + delete notes, purge |
| `admin` | Full administrative access | All permissions + API key management |
| `mcp` | MCP transport/session access | MCP-specific operations only |
Scope Hierarchy
- `admin` includes all other scopes
- MCP transport scope is separate from REST `read`/`write`; grant explicit resource scopes for operations that read or mutate data.
- `delete` typically requires `write`
- Scopes can be combined with spaces: `"read write delete"`
Checking Scopes in Code
# The API validates scopes automatically.
# If your token lacks the required scope, you'll receive a 403 Forbidden response.
# Example: Creating a note requires 'write' scope
response = requests.post(
"http://localhost:3000/api/v1/notes",
headers={"Authorization": f"Bearer {token}"},
json={"content": "New note"}
)
if response.status_code == 403:
print("Insufficient permissions. 'write' scope required.")
Rate Limiting
The CE limiter is process-wide. API-key-specific limit metadata is not enforced by this limiter and must not be treated as tenant or principal isolation. Hosted multi-tenant mode has a separate Redis-backed preview gate for authenticated non-exempt API routes. It uses an opaque key over tenant, principal, client, route class, and policy dimensions; it does not expose those identifiers in response headers.
Default Limits
- `RATE_LIMIT_ENABLED=true`
- `RATE_LIMIT_REQUESTS=100`
- `RATE_LIMIT_PERIOD_SECS=60`
Rate Limit Headers
The CE global rate limiter does not emit quota-capacity headers. There are no `X-RateLimit-Limit`, `X-RateLimit-Remaining`, or `X-RateLimit-Reset` headers, or draft `RateLimit` / `RateLimit-Policy` fields. Its 429 response includes only `Retry-After` with a whole-number delay in seconds. Clients cannot read a remaining-quota value; wait at least the indicated delay before retrying.
The hosted preview emits bounded combined `RateLimit` and `RateLimit-Policy` draft fields on admitted and denied authenticated routes. A hosted denial also includes `Retry-After`; an unavailable Redis store or missing trusted request context returns a non-cacheable 503 without capacity fields. These fields are draft hints, not evidence of a billing balance or a complete tenant plan. The remaining quota dimensions and policy work are tracked by ADR-098 and #714.
Handling Rate Limits
HTTP 429 Response:
{
"type": "https://fortemi.com/problems/rate-limit-exceeded",
"title": "Too Many Requests",
"status": 429,
"detail": "Rate limit or quota boundary reached.",
"request_id": "018fd1a0-example"
}
Best Practices:
- Honor `Retry-After` and retain a bounded exponential-backoff policy
- Cache frequently accessed data
- Batch operations when possible (e.g., `bulk_create_notes`)
- In CE, back off on HTTP `429`; there is no remaining-quota header to monitor
- In hosted preview mode, treat `RateLimit` fields as bounded hints and honor `Retry-After`
Python Example:
import time
import requests
def api_call_with_retry(url, headers, max_retries=3):
for attempt in range(max_retries):
response = requests.get(url, headers=headers)
if response.status_code == 429:
retry_after = int(response.headers.get("Retry-After", "1"))
backoff = max(retry_after, 2 ** attempt)
print(f"Rate limited. Waiting {backoff}s...")
time.sleep(backoff)
continue
return response
raise Exception("Max retries exceeded")
Error Handling
Authentication Errors
401 Unauthorized
Cause: Missing, invalid, or expired token
{
"type": "https://fortemi.com/problems/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "Missing, malformed, expired, or invalid credentials.",
"request_id": "018fd1a0-example"
}
Resolution:
- Verify token is included in `Authorization: Bearer {token}` header
- Check token hasn't expired (access tokens expire after 1 hour)
- Refresh token if expired (OAuth2) or generate new API key
403 Forbidden
Cause: Valid token but insufficient permissions
{
"type": "https://fortemi.com/problems/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "Authenticated request denied by authorization policy or admin gate.",
"request_id": "018fd1a0-example"
}
Resolution:
- Check token has required scope
- Request new token with broader scope
- For API keys, create new key with appropriate scope
OAuth2 Errors
Token, introspection, and revocation endpoint errors use the Fortemi RFC 9457 problem contract. Authorization redirect denials still use RFC 6749 query parameters on the registered redirect URI.
{
"type": "https://fortemi.com/problems/validation-error",
"title": "Bad Request",
"status": 400,
"detail": "OAuth grant is invalid or expired.",
"request_id": "018fd1a0-example"
}
The RFC 6749 error codes below are used internally to select the problem `type`; they are not emitted as a body field. The response body carries `type`, `title`, `status`, `detail`, and `request_id` — read those, not an `error` field.
| RFC 6749 Code | Description | Common Cause |
|---|---|---|
| `invalid_request` | Missing or malformed parameter | Missing required field |
| `invalid_client` | Client authentication failed | Wrong client_id or client_secret |
| `invalid_grant` | Authorization code/refresh token bad | Expired or already used code |
| `unauthorized_client` | Client not authorized for grant type | Requesting unsupported grant type |
| `unsupported_grant_type` | Grant type not supported | Typo in grant_type parameter |
| `invalid_scope` | Requested scope invalid | Non-existent or unauthorized scope |
Error Handling Example:
def exchange_code_for_token(auth_code):
try:
response = requests.post(
"http://localhost:3000/oauth/token",
headers={"Authorization": f"Basic {auth_header}"},
data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier
}
)
if response.status_code >= 400:
# Errors are RFC 9457 problem+json: read type/status/detail.
problem = response.json()
problem_type = problem.get("type", "")
if problem_type.endswith("/validation-error"):
print(f"Bad OAuth request: {problem.get('detail')}")
elif problem_type.endswith("/unauthorized"):
print("Client credentials invalid. Check client_id/secret.")
else:
print(f"OAuth error {problem.get('status')}: {problem.get('detail')}")
response.raise_for_status()
return response.json()
except requests.exceptions.HTTPError as e:
print(f"Token exchange failed: {e}")
return None
Security Best Practices
Token Storage
DO:
- Store API keys and client secrets in environment variables or secure vaults
- Use encrypted storage for tokens on client devices
- Implement token rotation for long-lived applications
- Clear tokens on logout
DON'T:
- Commit tokens to version control
- Store tokens in localStorage for sensitive apps (use httpOnly cookies instead)
- Share tokens between users
- Log tokens in application logs
PKCE for Public Clients
Always use PKCE (Proof Key for Code Exchange) for:
- Single-page applications (SPAs)
- Mobile applications
- Desktop applications
- Any client that cannot securely store secrets
Secure Communication
- Always use HTTPS in production
- Validate SSL/TLS certificates
- Use certificate pinning for mobile apps
Token Lifecycle
- Access tokens: 1 hour default expiration (use refresh tokens)
- MCP access tokens: 4 hour default expiration (longer to support interactive AI sessions)
- Refresh tokens: 30 days expiration (require re-authentication after)
- API keys: Optional expiration (recommend 90-365 days for rotation)
- Authorization codes: 10 minutes expiration (single-use)
Configurable Token Lifetimes
Token lifetimes can be tuned via environment variables:
| Variable | Default | Description |
|---|---|---|
| `OAUTH_TOKEN_LIFETIME_SECS` | `3600` (1 hour) | Standard access token lifetime |
| `OAUTH_MCP_TOKEN_LIFETIME_SECS` | `14400` (4 hours) | MCP access token lifetime |
Tradeoffs:
- Shorter tokens improve security posture but require more frequent re-authentication
- Longer MCP tokens reduce mid-session disconnects for interactive AI workflows
- Recommend not exceeding 24 hours for standard tokens or 48 hours for MCP tokens
Scope Minimization
Request only the scopes you need:
# Good: Minimal scope
scope = "read"
# Bad: Over-privileged
scope = "read write delete admin"
Revocation
Revoke tokens immediately when:
- User logs out
- Security incident detected
- Token potentially compromised
- User revokes application access
Environment-Specific Configuration
Development:
# .env.development
MATRIC_MEMORY_URL=http://localhost:3000
MATRIC_MEMORY_API_KEY=<API_KEY>
Production:
# .env.production (use secrets manager)
MATRIC_MEMORY_URL=http://localhost:3000
MATRIC_MEMORY_API_KEY=${VAULT_API_KEY} # Loaded from vault
MCP Server Authentication
The MCP server supports both authentication modes:
Stdio Mode (API Keys)
# Set environment variable
export MATRIC_MEMORY_API_KEY="<API_KEY>"
# Run MCP server
cd mcp-server
node index.js
HTTP Mode (OAuth2)
# Set transport mode
export MCP_TRANSPORT=http
export MCP_PORT=3001
# Run MCP server
cd mcp-server
node index.js
The MCP server will: 1. Use token introspection to validate OAuth2 access tokens 2. Store tokens per-session using AsyncLocalStorage 3. Automatically include tokens in API requests
Complete Examples
Python OAuth2 Client
import requests
import secrets
import hashlib
import base64
from urllib.parse import urlencode
class MatricMemoryClient:
def __init__(self, client_id, client_secret, redirect_uri):
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.base_url = "http://localhost:3000"
self.access_token = None
self.refresh_token = None
def get_authorization_url(self):
"""Generate authorization URL with PKCE"""
# Generate PKCE parameters
self.code_verifier = base64.urlsafe_b64encode(
secrets.token_bytes(32)
).decode('utf-8').rstrip('=')
code_challenge = base64.urlsafe_b64encode(
hashlib.sha256(self.code_verifier.encode()).digest()
).decode('utf-8').rstrip('=')
self.state = secrets.token_urlsafe(32)
params = {
"response_type": "code",
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"scope": "read write",
"state": self.state,
"code_challenge": code_challenge,
"code_challenge_method": "S256"
}
return f"{self.base_url}/oauth/authorize?{urlencode(params)}"
def exchange_code(self, code):
"""Exchange authorization code for tokens"""
credentials = f"{self.client_id}:{self.client_secret}"
auth_header = base64.b64encode(credentials.encode()).decode()
response = requests.post(
f"{self.base_url}/oauth/token",
headers={
"Authorization": f"Basic {auth_header}",
"Content-Type": "application/x-www-form-urlencoded"
},
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": self.redirect_uri,
"code_verifier": self.code_verifier
}
)
response.raise_for_status()
tokens = response.json()
self.access_token = tokens["access_token"]
self.refresh_token = tokens["refresh_token"]
return tokens
def refresh(self):
"""Refresh access token"""
credentials = f"{self.client_id}:{self.client_secret}"
auth_header = base64.b64encode(credentials.encode()).decode()
response = requests.post(
f"{self.base_url}/oauth/token",
headers={
"Authorization": f"Basic {auth_header}",
"Content-Type": "application/x-www-form-urlencoded"
},
data={
"grant_type": "refresh_token",
"refresh_token": self.refresh_token
}
)
response.raise_for_status()
tokens = response.json()
self.access_token = tokens["access_token"]
self.refresh_token = tokens["refresh_token"]
return tokens
def request(self, method, path, **kwargs):
"""Make authenticated API request"""
headers = kwargs.get("headers", {})
headers["Authorization"] = f"Bearer {self.access_token}"
kwargs["headers"] = headers
response = requests.request(method, f"{self.base_url}{path}", **kwargs)
# Auto-refresh on 401
if response.status_code == 401 and self.refresh_token:
self.refresh()
headers["Authorization"] = f"Bearer {self.access_token}"
response = requests.request(method, f"{self.base_url}{path}", **kwargs)
response.raise_for_status()
return response.json() if response.content else None
def create_note(self, content, tags=None):
"""Create a new note"""
return self.request(
"POST",
"/api/v1/notes",
json={"content": content, "tags": tags or []}
)
def search_notes(self, query, limit=20):
"""Search notes"""
return self.request(
"GET",
f"/api/v1/search?q={query}&limit={limit}"
)
# Usage
if __name__ == "__main__":
client = MatricMemoryClient(
client_id="mm_AbCdEfGh12345678901234",
client_secret="<MCP_CLIENT_SECRET>",
redirect_uri="http://localhost:3000/callback"
)
# Step 1: Get authorization URL
auth_url = client.get_authorization_url()
print(f"Visit: {auth_url}")
# Step 2: After redirect, exchange code
code = input("Enter code from redirect: ")
client.exchange_code(code)
# Step 3: Use API
result = client.create_note("Hello from OAuth2!", ["test"])
print(f"Created note: {result}")
Simple API Key Script
#!/usr/bin/env python3
import os
import requests
API_BASE = "http://localhost:3000"
API_KEY = os.environ.get("MATRIC_MEMORY_API_KEY")
if not API_KEY:
print("Error: MATRIC_MEMORY_API_KEY environment variable not set")
exit(1)
headers = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json"
}
def create_note(content, tags=None):
response = requests.post(
f"{API_BASE}/api/v1/notes",
headers=headers,
json={"content": content, "tags": tags or []}
)
response.raise_for_status()
return response.json()
def search_notes(query):
response = requests.get(
f"{API_BASE}/api/v1/search",
headers=headers,
params={"q": query}
)
response.raise_for_status()
return response.json()
if __name__ == "__main__":
# Create a note
note = create_note("Daily standup notes", ["work", "meetings"])
print(f"Created: {note}")
# Search notes
results = search_notes("standup")
print(f"Found {results['total']} results")
Additional Resources
- Consumer API Reference: API Reference
- Operator Swagger UI: `/api/v1/operator/docs` (admin bearer required; request execution disabled)
- Operator OpenAPI Spec: `/api/v1/operator/openapi.yaml` (admin bearer required)
- OAuth2 RFC 6749: https://datatracker.ietf.org/doc/html/rfc6749
- PKCE RFC 7636: https://datatracker.ietf.org/doc/html/rfc7636
- Token Introspection RFC 7662: https://datatracker.ietf.org/doc/html/rfc7662
For questions or issues, please contact support or open an issue on the project repository.
Selected memory context (producer candidate)
`GET /api/v1/memory/context` uses the authenticated hosted tenant transaction and existing `read` scope. It returns only the currently authorized canonical `name` and `schema_name`, with `Cache-Control: no-store`. The normal `X-Fortemi-Memory` header selects a visible memory; an absent header resolves the tenant default or public fallback. Later requests are independently authorized; this response is not a continuing access grant.
Archive inventory remains admin-only and unmigrated. The context endpoint does not expose global storage statistics or add management privileges. Missing transaction/context and lookup failures fail closed. This Cycle93 producer candidate is not yet consumed by HotM or qualified as a released hosted flow; see `.aiwg/architecture/impact/selected-memory-context.md` and the suite receipt.
Hosted OpenBao key custody
On-prem hosted deployments use the separately compiled OpenBao Transit provider. See OpenBao KMS for provider selection, independent TLS trust, scoped token-file delivery, runtime policy, startup checks and rotation. OIDC CA support alone does not supply the KMS backend.