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

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.

ScopeDescriptionPermissions
`read`Read-only accessList/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 accessAll permissions + API key management
`mcp`MCP transport/session accessMCP-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 CodeDescriptionCommon Cause
`invalid_request`Missing or malformed parameterMissing required field
`invalid_client`Client authentication failedWrong client_id or client_secret
`invalid_grant`Authorization code/refresh token badExpired or already used code
`unauthorized_client`Client not authorized for grant typeRequesting unsupported grant type
`unsupported_grant_type`Grant type not supportedTypo in grant_type parameter
`invalid_scope`Requested scope invalidNon-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:

VariableDefaultDescription
`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

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.