Skip to content

API Integration

Eric Fitzgerald edited this page Apr 6, 2026 · 5 revisions

API Integration

This guide covers integrating with the TMI REST API, WebSocket API, and building client applications. For the full endpoint listing, see REST-API-Reference. For the WebSocket protocol details, see WebSocket-API-Reference.

Table of Contents

REST API Integration

API Overview

TMI provides a RESTful API with an OpenAPI 3.0 specification. See API-Specifications for the full spec files.

Property Value
Base URL http://localhost:8080 (development) or https://api.tmi.dev (production)
API Specification /api-schema/tmi-openapi.json
Content Type application/json
Authentication Bearer token (JWT) -- see Setting-Up-Authentication

Quick Start

# Get server info (no auth required)
curl http://localhost:8080/

# Get threat models (requires auth)
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
  http://localhost:8080/threat_models

Core Endpoints

Threat Models

List Threat Models:

GET /threat_models
Authorization: Bearer {token}

Response:

[
  {
    "id": "uuid",
    "name": "Web Application Security",
    "description": "Security analysis for web app",
    "owner": {
      "principal_type": "user",
      "provider": "google",
      "provider_id": "[email protected]",
      "display_name": "Alice Johnson",
      "email": "[email protected]"
    },
    "authorization": [
      {
        "principal_type": "user",
        "provider": "google",
        "provider_id": "[email protected]",
        "display_name": "Alice Johnson",
        "email": "[email protected]",
        "role": "owner"
      }
    ],
    "threat_model_framework": "STRIDE",
    "created_at": "2025-01-15T10:00:00Z",
    "modified_at": "2025-01-15T15:30:00Z",
    "diagrams": [],
    "threats": [],
    "documents": []
  }
]

Create Threat Model:

POST /threat_models
Authorization: Bearer {token}
Content-Type: application/json

{
  "name": "My Threat Model",
  "description": "Security analysis for new feature"
}

Important: Do not include server-controlled fields in your request body:

Field Reason
id Server-generated
created_at, modified_at Server-set timestamps
created_by Derived from your JWT token
diagrams, threats, documents, repositories, notes Nested collections managed via sub-resource endpoints

Update Threat Model (PUT):

Important: PUT replaces the entire object. Any fields you omit may be cleared. Best practice is to either:

  1. Use PATCH (preferred) to change only specific fields, or
  2. Read the full object first with GET, modify the fields you need, then PUT the complete object back.
PUT /threat_models/{threat_model_id}
Authorization: Bearer {token}
Content-Type: application/json

{
  "name": "Updated Name",
  "description": "Updated description",
  "threat_model_framework": "STRIDE",
  "owner": {
    "principal_type": "user",
    "provider": "google",
    "provider_id": "[email protected]",
    "display_name": "Alice Johnson",
    "email": "[email protected]"
  },
  "authorization": [
    {
      "principal_type": "user",
      "provider": "google",
      "provider_id": "[email protected]",
      "email": "[email protected]",
      "role": "owner"
    }
  ]
}

Patch Threat Model (PATCH) — preferred for changing individual fields:

PATCH /threat_models/{threat_model_id}
Authorization: Bearer {token}
Content-Type: application/json-patch+json

[
  {
    "op": "replace",
    "path": "/name",
    "value": "New Name"
  }
]

Delete Threat Model:

DELETE /threat_models/{threat_model_id}
Authorization: Bearer {token}

Diagrams

Create Diagram:

POST /threat_models/{threat_model_id}/diagrams
Authorization: Bearer {token}
Content-Type: application/json

{
  "name": "System Architecture",
  "description": "High-level data flow diagram"
}

Get Diagram:

GET /threat_models/{threat_model_id}/diagrams/{diagram_id}
Authorization: Bearer {token}

Response:

{
  "id": "uuid",
  "threat_model_id": "uuid",
  "name": "System Architecture",
  "description": "High-level data flow diagram",
  "cells": [
    {
      "id": "cell-uuid",
      "shape": "process",
      "x": 100,
      "y": 200,
      "width": 120,
      "height": 80,
      "label": "Authentication Service"
    }
  ],
  "update_vector": 5
}

Patch a Cell (JSON Patch, RFC 6902):

PATCH /diagrams/{diagram_id}/cells/{cell_id}
Authorization: Bearer {token}
Content-Type: application/json-patch+json

[
  {
    "op": "replace",
    "path": "/x",
    "value": 150
  },
  {
    "op": "replace",
    "path": "/label",
    "value": "Updated Label"
  }
]

Batch Patch Cells:

POST /diagrams/{diagram_id}/cells/batch/patch
Authorization: Bearer {token}
Content-Type: application/json

{
  "operations": [
    {
      "cell_id": "cell-uuid",
      "patch": [
        { "op": "replace", "path": "/x", "value": 150 }
      ]
    }
  ]
}

Note: Position and size accept both formats. The API always returns the flat format.

Format Example
Flat (preferred) {x: 100, y: 200, width: 80, height: 60}
Nested (legacy) {position: {x: 100, y: 200}, size: {width: 80, height: 60}}

Threats

Create Threat:

POST /threat_models/{threat_model_id}/threats
Authorization: Bearer {token}
Content-Type: application/json

{
  "name": "SQL Injection",
  "description": "Database injection vulnerability",
  "threat_type": ["Tampering"],
  "severity": "high",
  "status": "open",
  "mitigation": "Use parameterized queries"
}

List Threats:

GET /threat_models/{threat_model_id}/threats
Authorization: Bearer {token}

Metadata

All entities support arbitrary key-value metadata:

Create Metadata:

POST /threat_models/{threat_model_id}/metadata
Authorization: Bearer {token}
Content-Type: application/json

{
  "key": "project_phase",
  "value": "design"
}

List Metadata:

GET /threat_models/{threat_model_id}/metadata
Authorization: Bearer {token}

Update Metadata:

PUT /threat_models/{threat_model_id}/metadata/{key}
Authorization: Bearer {token}
Content-Type: application/json

{
  "value": "implementation"
}

Delete Metadata:

DELETE /threat_models/{threat_model_id}/metadata/{key}
Authorization: Bearer {token}

Authorization Management

You manage authorization as part of the threat model resource through the authorization array field. There are no separate /authorization endpoints. Use PUT or PATCH on the threat model to modify authorization entries.

Each authorization entry uses the Principal model with an added role field:

{
  "principal_type": "user",
  "provider": "google",
  "provider_id": "[email protected]",
  "display_name": "Bob Smith",
  "email": "[email protected]",
  "role": "writer"
}

Available roles:

Role Permissions
reader View threat models and diagrams
writer Edit threat models and diagrams
owner Full control, including authorization changes

Add Authorization via PATCH (JSON Patch):

PATCH /threat_models/{threat_model_id}
Authorization: Bearer {token}
Content-Type: application/json-patch+json

[
  {
    "op": "add",
    "path": "/authorization/-",
    "value": {
      "principal_type": "user",
      "provider": "google",
      "provider_id": "[email protected]",
      "display_name": "Bob Smith",
      "email": "[email protected]",
      "role": "writer"
    }
  }
]

Grant Group Read Access via PATCH:

PATCH /threat_models/{threat_model_id}
Authorization: Bearer {token}
Content-Type: application/json-patch+json

[
  {
    "op": "add",
    "path": "/authorization/-",
    "value": {
      "principal_type": "group",
      "provider": "*",
      "provider_id": "everyone",
      "role": "reader"
    }
  }
]

Update Authorization via PUT (replaces the entire threat model):

PUT /threat_models/{threat_model_id}
Authorization: Bearer {token}
Content-Type: application/json

{
  "name": "My Threat Model",
  "threat_model_framework": "STRIDE",
  "owner": { ... },
  "authorization": [
    {
      "principal_type": "user",
      "provider": "google",
      "provider_id": "[email protected]",
      "email": "[email protected]",
      "role": "owner"
    },
    {
      "principal_type": "user",
      "provider": "google",
      "provider_id": "[email protected]",
      "email": "[email protected]",
      "role": "writer"
    }
  ]
}

Response Codes

Code Meaning Description
200 OK Successful GET, PUT, PATCH
201 Created Resource created successfully
204 No Content Successful DELETE
400 Bad Request Invalid input data
401 Unauthorized Missing or invalid authentication
403 Forbidden Insufficient permissions
404 Not Found Resource not found
409 Conflict Duplicate resource or state conflict
422 Unprocessable Entity Validation error
429 Too Many Requests Rate limit exceeded (see Retry-After header)
500 Internal Server Error Server error

WebSocket API Integration

For the full WebSocket protocol reference, see WebSocket-API-Reference. For a hands-on testing tool, see WebSocket-Test-Harness.

Overview

TMI provides WebSocket-based real-time collaboration for simultaneous diagram editing. For a higher-level guide on collaborative workflows, see Collaborative-Threat-Modeling.

Connection Flow

1. Join a Collaboration Session (REST API)

CRITICAL: You must join a session through the REST API before opening a WebSocket connection.

Create Session (as host):

POST /threat_models/{tm_id}/diagrams/{diagram_id}/collaborate
Authorization: Bearer {token}

Response (201 Created):

{
  "session_id": "uuid",
  "host": {
    "principal_type": "user",
    "provider": "google",
    "provider_id": "[email protected]",
    "display_name": "Alice Johnson",
    "email": "[email protected]"
  },
  "threat_model_id": "uuid",
  "threat_model_name": "Web App Security",
  "diagram_id": "uuid",
  "diagram_name": "Architecture Diagram",
  "participants": [
    {
      "user": {
        "principal_type": "user",
        "provider": "google",
        "provider_id": "[email protected]",
        "display_name": "Alice Johnson",
        "email": "[email protected]"
      },
      "last_activity": "2025-01-15T10:00:00Z",
      "permissions": "writer"
    }
  ],
  "websocket_url": "ws://localhost:8080/threat_models/.../diagrams/.../ws"
}

Note: There is no separate "join" endpoint. POST returns 409 Conflict if a session already exists. Use GET to check session status and retrieve the websocket_url for an existing session.

Check Session Status:

GET /threat_models/{tm_id}/diagrams/{diagram_id}/collaborate
Authorization: Bearer {token}

2. Connect to WebSocket

Use the websocket_url from the session response:

const ws = new WebSocket(`${websocket_url}?token=${jwt_token}`);

ws.onopen = () => {
  console.log('Connected to collaboration session');
};

ws.onmessage = (event) => {
  const message = JSON.parse(event.data);
  handleMessage(message);
};

3. Handle Initial State Sync

CRITICAL: The first message you receive is always diagram_state_sync. You must process this message before sending any operations.

{
  "message_type": "diagram_state_sync",
  "diagram_id": "uuid",
  "update_vector": 42,
  "cells": [ /* current diagram state */ ]
}

Handle this message to prevent cell_already_exists errors:

function handleDiagramStateSync(message) {
  // Compare with locally cached diagram
  const localVector = cachedDiagram?.update_vector || 0;
  const serverVector = message.update_vector || 0;

  if (serverVector !== localVector) {
    console.warn('State mismatch - resyncing');
    // Update local state with server cells
    cachedDiagram.cells = message.cells;
    cachedDiagram.update_vector = message.update_vector;
    renderDiagram(message.cells);
  }

  isStateSynchronized = true;
}

WebSocket Message Types

Sending Operations

Diagram Operation (cell add/update/remove):

{
  "message_type": "diagram_operation",
  "user_id": "[email protected]",
  "operation_id": "uuid",
  "operation": {
    "type": "patch",
    "cells": [
      {
        "id": "cell-uuid",
        "operation": "add",
        "data": {
          "shape": "process",
          "x": 100,
          "y": 200,
          "width": 120,
          "height": 80,
          "label": "New Process"
        }
      }
    ]
  }
}

Request Presenter Mode:

{
  "message_type": "presenter_request",
  "user_id": "[email protected]"
}

Send Cursor Position (only if presenter):

{
  "message_type": "presenter_cursor",
  "user_id": "[email protected]",
  "cursor_position": { "x": 150, "y": 300 }
}

Undo Request:

{
  "message_type": "undo_request",
  "user_id": "[email protected]"
}

Redo Request:

{
  "message_type": "redo_request",
  "user_id": "[email protected]"
}

Receiving Messages

Diagram Operation (from other users):

{
  "message_type": "diagram_operation",
  "user_id": "[email protected]",
  "operation_id": "uuid",
  "operation": {
    "type": "patch",
    "cells": [ /* changes */ ]
  }
}

Presenter Changed:

{
  "message_type": "current_presenter",
  "current_presenter": "[email protected]"
}

Presenter Cursor:

{
  "message_type": "presenter_cursor",
  "user_id": "[email protected]",
  "cursor_position": { "x": 150, "y": 300 }
}

User Joined:

{
  "event": "join",
  "user_id": "[email protected]",
  "timestamp": "2025-01-15T10:05:00Z"
}

User Left:

{
  "event": "leave",
  "user_id": "[email protected]",
  "timestamp": "2025-01-15T10:10:00Z"
}

Session Ended:

{
  "event": "session_ended",
  "user_id": "[email protected]",
  "message": "Session ended: host has left",
  "timestamp": "2025-01-15T10:15:00Z"
}

State Correction (conflict detected):

{
  "message_type": "state_correction",
  "update_vector": 45
}

Authorization Denied:

{
  "message_type": "authorization_denied",
  "original_operation_id": "uuid",
  "reason": "insufficient_permissions"
}

Echo Prevention

CRITICAL: Never send WebSocket messages when applying remote operations. Doing so creates infinite message loops.

class DiagramCollaborationManager {
  constructor(diagramEditor) {
    this.isApplyingRemoteChange = false;

    // Listen to local diagram changes
    this.diagramEditor.on('cellChanged', (change) => {
      if (this.isApplyingRemoteChange) {
        return; // DON'T send WebSocket message
      }
      this.sendOperation(change); // Only send for local changes
    });
  }

  handleDiagramOperation(message) {
    // Skip own operations
    if (message.user_id === this.currentUser.email) {
      return;
    }

    this.isApplyingRemoteChange = true;
    try {
      this.applyOperationToEditor(message.operation);
    } finally {
      this.isApplyingRemoteChange = false;
    }
  }
}

Client Integration Patterns

For pre-built client libraries, see API-Clients.

JavaScript OAuth Client

The following is a complete OAuth client implementation for web applications:

class TMIOAuth {
  constructor(tmiServerUrl = "http://localhost:8080") {
    this.tmiServerUrl = tmiServerUrl;
  }

  // Start OAuth login flow (PKCE required)
  async login(provider) {
    const state = this.generateState();
    sessionStorage.setItem("oauth_state", state);

    // Generate PKCE verifier and challenge
    const codeVerifier = PKCEHelper.generateCodeVerifier();
    const codeChallenge = await PKCEHelper.generateCodeChallenge(codeVerifier);
    sessionStorage.setItem("pkce_verifier", codeVerifier);

    const callbackUrl = `${window.location.origin}/oauth2/callback`;

    // TMI server mediates the OAuth flow -- redirect to /oauth2/authorize
    const params = new URLSearchParams({
      idp: provider,
      state: state,
      client_callback: callbackUrl,
      code_challenge: codeChallenge,
      code_challenge_method: "S256",
    });

    window.location.href = `${this.tmiServerUrl}/oauth2/authorize?${params}`;
  }

  // Handle OAuth callback (call this in your /oauth2/callback page)
  // Tokens are delivered via URL fragments (after #), not query parameters
  async handleCallback() {
    const hash = window.location.hash.substring(1); // Remove leading '#'
    const params = new URLSearchParams(hash);

    const accessToken = params.get("access_token");
    const refreshToken = params.get("refresh_token");
    const state = params.get("state");
    const error = params.get("error");

    if (error) throw new Error(`OAuth error: ${error}`);
    if (!accessToken) throw new Error("Missing access token in callback");

    // Verify state for CSRF protection
    const storedState = sessionStorage.getItem("oauth_state");
    if (state && state !== storedState) throw new Error("Invalid state parameter");

    sessionStorage.removeItem("oauth_state");
    sessionStorage.removeItem("pkce_verifier");

    // Store tokens
    const expiresIn = parseInt(params.get("expires_in") || "3600", 10);
    localStorage.setItem("tmi_access_token", accessToken);
    if (refreshToken) localStorage.setItem("tmi_refresh_token", refreshToken);
    localStorage.setItem("tmi_token_expires", Date.now() + expiresIn * 1000);

    // Clear fragment from URL to prevent token exposure
    window.history.replaceState({}, document.title, window.location.pathname);

    return { access_token: accessToken, refresh_token: refreshToken, expires_in: expiresIn };
  }

  // Make authenticated API calls
  async apiCall(endpoint, options = {}) {
    let token = localStorage.getItem("tmi_access_token");
    const expiresAt = localStorage.getItem("tmi_token_expires");

    if (expiresAt && Date.now() > parseInt(expiresAt) - 60000) {
      await this.refreshToken();
      token = localStorage.getItem("tmi_access_token");
    }

    return fetch(`${this.tmiServerUrl}${endpoint}`, {
      ...options,
      headers: {
        Authorization: `Bearer ${token}`,
        "Content-Type": "application/json",
        ...options.headers,
      },
    });
  }

  // Refresh access token
  async refreshToken() {
    const refreshToken = localStorage.getItem("tmi_refresh_token");
    if (!refreshToken) throw new Error("No refresh token available");

    const response = await fetch(`${this.tmiServerUrl}/oauth2/refresh`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ refresh_token: refreshToken }),
    });

    if (!response.ok) {
      this.logout();
      throw new Error("Token refresh failed - please login again");
    }

    const tokens = await response.json();
    localStorage.setItem("tmi_access_token", tokens.access_token);
    localStorage.setItem("tmi_refresh_token", tokens.refresh_token);
    localStorage.setItem("tmi_token_expires", Date.now() + tokens.expires_in * 1000);
    return tokens;
  }

  // Logout user
  async logout() {
    try {
      await fetch(`${this.tmiServerUrl}/me/logout`, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${localStorage.getItem("tmi_access_token")}`,
        },
      });
    } catch (error) {
      console.warn("Logout request failed:", error);
    }
    localStorage.removeItem("tmi_access_token");
    localStorage.removeItem("tmi_refresh_token");
    localStorage.removeItem("tmi_token_expires");
  }

  generateState() {
    return btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(32))));
  }

  isLoggedIn() {
    const token = localStorage.getItem("tmi_access_token");
    const expiresAt = localStorage.getItem("tmi_token_expires");
    return token && expiresAt && Date.now() < parseInt(expiresAt);
  }
}

Usage Example:

const tmiAuth = new TMIOAuth("http://localhost:8080");

// Login with Google (login is async because it generates PKCE challenge)
document.getElementById("google-login").onclick = () => tmiAuth.login("google").catch(console.error);

// Handle callback (in your /oauth2/callback page, tokens arrive via URL fragment)
if (window.location.pathname === "/oauth2/callback" && window.location.hash) {
  tmiAuth.handleCallback()
    .then(() => window.location.href = "/dashboard")
    .catch(error => {
      console.error("Login failed:", error);
      window.location.href = "/login?error=" + encodeURIComponent(error.message);
    });
}

// Make API calls
async function loadThreatModels() {
  const response = await tmiAuth.apiCall("/threat_models");
  return response.json();
}

TypeScript/JavaScript Client

class TMIClient {
  private apiUrl: string;
  private token: string;

  constructor(apiUrl: string, token: string) {
    this.apiUrl = apiUrl;
    this.token = token;
  }

  // REST API Methods
  async getThreatModels(): Promise<ThreatModel[]> {
    const response = await fetch(`${this.apiUrl}/threat_models`, {
      headers: { Authorization: `Bearer ${this.token}` }
    });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  }

  async createThreatModel(data: CreateThreatModelRequest): Promise<ThreatModel> {
    const response = await fetch(`${this.apiUrl}/threat_models`, {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${this.token}`,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(data)
    });
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  }

  // WebSocket Collaboration
  async startCollaboration(tmId: string, diagramId: string): Promise<WebSocket> {
    // 1. Join session via REST API
    const session = await this.joinCollaborationSession(tmId, diagramId);

    // 2. Connect to WebSocket
    const ws = new WebSocket(`${session.websocket_url}?token=${this.token}`);

    // 3. Set up handlers
    ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      this.handleWebSocketMessage(message);
    };

    return ws;
  }

  private async joinCollaborationSession(tmId: string, diagramId: string) {
    const response = await fetch(
      `${this.apiUrl}/threat_models/${tmId}/diagrams/${diagramId}/collaborate`,
      {
        method: 'POST',
        headers: { Authorization: `Bearer ${this.token}` }
      }
    );

    if (response.status === 409) {
      // Session exists, join instead
      return this.joinExistingSession(tmId, diagramId);
    }

    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.json();
  }
}

Python Client

import requests
import websocket
import json
import time

class TMIClient:
    """TMI API client using Client Credentials Grant for authentication."""

    def __init__(self, api_url, client_id, client_secret):
        self.api_url = api_url
        self.client_id = client_id
        self.client_secret = client_secret
        self.session = requests.Session()
        self._token_expires_at = 0
        self._authenticate()

    def _authenticate(self):
        """Obtain an access token using client credentials."""
        response = requests.post(
            f'{self.api_url}/oauth2/token',
            data={
                'grant_type': 'client_credentials',
                'client_id': self.client_id,
                'client_secret': self.client_secret,
            },
        )
        response.raise_for_status()
        data = response.json()
        self.token = data['access_token']
        self._token_expires_at = time.time() + data['expires_in']
        self.session.headers.update({
            'Authorization': f'Bearer {self.token}'
        })

    def _ensure_token(self):
        """Refresh the token if it is about to expire."""
        if time.time() > self._token_expires_at - 60:
            self._authenticate()

    def get_threat_models(self):
        self._ensure_token()
        response = self.session.get(f'{self.api_url}/threat_models')
        response.raise_for_status()
        return response.json()

    def create_threat_model(self, name, description):
        self._ensure_token()
        response = self.session.post(
            f'{self.api_url}/threat_models',
            json={'name': name, 'description': description}
        )
        response.raise_for_status()
        return response.json()

    def start_collaboration(self, tm_id, diagram_id):
        self._ensure_token()
        # Join session
        session = self.join_collaboration_session(tm_id, diagram_id)

        # Connect to WebSocket
        ws = websocket.WebSocketApp(
            f"{session['websocket_url']}?token={self.token}",
            on_message=self.on_websocket_message,
            on_open=self.on_websocket_open
        )

        return ws

    def on_websocket_message(self, ws, message):
        data = json.loads(message)
        if data['message_type'] == 'diagram_state_sync':
            self.handle_state_sync(data)
        elif data['message_type'] == 'diagram_operation':
            self.handle_diagram_operation(data)

Authentication Integration

For a comprehensive guide to how authentication works in TMI, see Authentication. For provider setup instructions, see Setting-Up-Authentication.

TMI uses OAuth 2.0 authentication in two modes:

1. PKCE (Interactive Users)

PKCE (Proof Key for Code Exchange) is designed for cases where a user is logging in interactively. This is the flow used by TMI-UX and is also suitable for scripts that prompt a user to authenticate — for example, a Python script that opens a browser window for the user to sign in, then continues running in the shell with the authenticated token.

2. Client Credentials Grant (Automation — Preferred)

Client Credentials Grant (CCG) is the preferred method for integrating code with TMI. Instead of requiring an interactive login, CCG uses a pre-created client_id and client_secret to obtain a token directly.

  • Security reviewers and users can create client credentials in the TMI-UX user preferences dialog to build and test automation. When these credentials are used to authenticate, TMI treats the session as if it were the user associated with the credentials — same access level, same object permissions.
  • Administrators can create dedicated automation accounts with their own client credentials, separate from individual users.

Recommendation: Use CCG for scripts, CI/CD pipelines, and service-to-service integrations. Use PKCE only when an interactive user login is required.

OAuth Flow Overview (PKCE)

1. Initiate OAuth (PKCE Required)

GET /oauth2/authorize?idp=google&client_callback=https://app.example.com/callback&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&state=RANDOM_STATE

The server redirects to Google OAuth, handles the IdP callback, and then redirects the user to your client_callback URL with tokens delivered via URL fragments.

2. Handle Callback

TMI redirects the user's browser to your client_callback with tokens in the URL fragment:

https://app.example.com/callback#access_token=eyJhbGc...&refresh_token=abc123&token_type=Bearer&expires_in=3600&state=RANDOM_STATE

Extract tokens from window.location.hash (not window.location.search). See OAuth Token Delivery via URL Fragments for implementation details.

3. Use Token

Include the token in the Authorization header for all API requests:

Authorization: Bearer eyJhbGc...

Token Management

Get User Info:

GET /oauth2/userinfo
Authorization: Bearer {token}

Response:

{
  "sub": "google:123456789",
  "email": "[email protected]",
  "name": "Alice Smith",
  "idp": "google"
}

Logout:

POST /me/logout
Authorization: Bearer {token}

Revoke Token (RFC 7009):

POST /oauth2/revoke
Content-Type: application/json

{
  "token": "your_access_or_refresh_token"
}

PKCE Implementation (RFC 7636)

TMI requires PKCE (Proof Key for Code Exchange) for all OAuth flows. PKCE prevents authorization code interception attacks and is essential for public clients (SPAs, mobile apps, and desktop apps).

How PKCE works:

  1. Your client generates a random code_verifier (43-128 characters).
  2. Your client computes code_challenge = BASE64URL(SHA256(code_verifier)).
  3. Your client sends the code_challenge to the authorization endpoint.
  4. The server stores the code_challenge alongside the authorization code.
  5. Your client exchanges the authorization code plus the code_verifier for tokens.
  6. The server validates that SHA256(code_verifier) matches the stored code_challenge.

PKCE Helper Functions (JavaScript):

class PKCEHelper {
  // Generate cryptographically secure random code verifier
  static generateCodeVerifier() {
    const array = new Uint8Array(32); // 32 bytes = 256 bits
    crypto.getRandomValues(array);
    return this.base64URLEncode(array);
  }

  // Compute S256 challenge from verifier
  static async generateCodeChallenge(verifier) {
    const encoder = new TextEncoder();
    const data = encoder.encode(verifier);
    const digest = await crypto.subtle.digest("SHA-256", data);
    return this.base64URLEncode(new Uint8Array(digest));
  }

  // Base64URL encoding (without padding)
  static base64URLEncode(buffer) {
    const base64 = btoa(String.fromCharCode(...buffer));
    return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
  }
}

PKCE Helper Functions (Python):

import secrets
import hashlib
import base64

class PKCEHelper:
    @staticmethod
    def generate_code_verifier():
        """Generate 43-character base64url-encoded string (32 random bytes)."""
        verifier_bytes = secrets.token_bytes(32)
        return base64.urlsafe_b64encode(verifier_bytes).decode('utf-8').rstrip('=')

    @staticmethod
    def generate_code_challenge(verifier):
        """Generate S256 code challenge: base64url(SHA256(verifier))."""
        digest = hashlib.sha256(verifier.encode('utf-8')).digest()
        return base64.urlsafe_b64encode(digest).decode('utf-8').rstrip('=')

PKCE parameter requirements (per RFC 7636):

Parameter Requirements
code_verifier 43-128 characters: [A-Z] / [a-z] / [0-9] / - / . / _ / ~
code_challenge Base64URL-encoded SHA-256 hash (43 characters)
code_challenge_method Must be S256 (TMI does not support plain)

Authorization Request with PKCE:

const codeVerifier = PKCEHelper.generateCodeVerifier();
const codeChallenge = await PKCEHelper.generateCodeChallenge(codeVerifier);

// Store verifier for token exchange
sessionStorage.setItem('pkce_verifier', codeVerifier);

// Build authorization URL
const authUrl = `http://localhost:8080/oauth2/authorize?idp=google` +
  `&state=${generateRandomState()}` +
  `&client_callback=${encodeURIComponent(callbackUrl)}` +
  `&code_challenge=${encodeURIComponent(codeChallenge)}` +
  `&code_challenge_method=S256`;

window.location.href = authUrl;

Token Exchange with PKCE Verifier:

const codeVerifier = sessionStorage.getItem('pkce_verifier');

const response = await fetch(`http://localhost:8080/oauth2/token?idp=google`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    grant_type: 'authorization_code',
    code: authorizationCode,
    code_verifier: codeVerifier,
    redirect_uri: callbackUrl
  })
});

sessionStorage.removeItem('pkce_verifier');

Client Credentials Grant Implementation

Client Credentials Grant allows automation to obtain tokens without interactive user login. Credentials are created in TMI-UX (User Preferences > Client Credentials) or by administrators for automation accounts.

How CCG works:

  1. An administrator or user creates client credentials in TMI-UX, receiving a client_id and client_secret.
  2. Your application sends a POST request to the token endpoint with the credentials.
  3. TMI validates the credentials and returns an access token.
  4. The token carries the same identity and permissions as the user who created the credentials.

Token Request:

POST /oauth2/token
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET

Response:

{
  "access_token": "eyJhbGc...",
  "token_type": "Bearer",
  "expires_in": 3600
}

CCG Helper Functions (JavaScript):

class CCGHelper {
  constructor(tmiServerUrl, clientId, clientSecret) {
    this.tmiServerUrl = tmiServerUrl;
    this.clientId = clientId;
    this.clientSecret = clientSecret;
    this.accessToken = null;
    this.tokenExpiresAt = 0;
  }

  async getToken() {
    // Return cached token if still valid (with 60s buffer)
    if (this.accessToken && Date.now() < this.tokenExpiresAt - 60000) {
      return this.accessToken;
    }

    const response = await fetch(`${this.tmiServerUrl}/oauth2/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'client_credentials',
        client_id: this.clientId,
        client_secret: this.clientSecret,
      }),
    });

    if (!response.ok) {
      throw new Error(`Token request failed: ${response.status}`);
    }

    const data = await response.json();
    this.accessToken = data.access_token;
    this.tokenExpiresAt = Date.now() + data.expires_in * 1000;
    return this.accessToken;
  }

  async apiCall(endpoint, options = {}) {
    const token = await this.getToken();
    return fetch(`${this.tmiServerUrl}${endpoint}`, {
      ...options,
      headers: {
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json',
        ...options.headers,
      },
    });
  }
}

CCG Helper Functions (Python):

import requests
import time

class CCGHelper:
    def __init__(self, tmi_server_url, client_id, client_secret):
        self.tmi_server_url = tmi_server_url
        self.client_id = client_id
        self.client_secret = client_secret
        self.access_token = None
        self.token_expires_at = 0
        self.session = requests.Session()

    def get_token(self):
        """Obtain or refresh an access token using client credentials."""
        # Return cached token if still valid (with 60s buffer)
        if self.access_token and time.time() < self.token_expires_at - 60:
            return self.access_token

        response = requests.post(
            f"{self.tmi_server_url}/oauth2/token",
            data={
                "grant_type": "client_credentials",
                "client_id": self.client_id,
                "client_secret": self.client_secret,
            },
        )
        response.raise_for_status()

        data = response.json()
        self.access_token = data["access_token"]
        self.token_expires_at = time.time() + data["expires_in"]
        self.session.headers.update({"Authorization": f"Bearer {self.access_token}"})
        return self.access_token

    def api_call(self, method, endpoint, **kwargs):
        """Make an authenticated API call, refreshing the token if needed."""
        self.get_token()
        response = self.session.request(method, f"{self.tmi_server_url}{endpoint}", **kwargs)
        response.raise_for_status()
        return response.json()

Usage Example (Python):

# Create client with credentials from TMI-UX User Preferences
client = CCGHelper(
    tmi_server_url="https://api.tmi.dev",
    client_id="your-client-id",
    client_secret="your-client-secret",
)

# List threat models
threat_models = client.api_call("GET", "/threat_models")
for tm in threat_models:
    print(f"{tm['name']} ({tm['id']})")

# Create a threat model
new_tm = client.api_call("POST", "/threat_models", json={
    "name": "Automated Security Review",
    "description": "Created by CI/CD pipeline",
})

TMI Provider (Development Only)

For local development and testing, you can use the built-in TMI OAuth provider:

# Random test user
curl "http://localhost:8080/oauth2/authorize?idp=tmi"

# Specific test user with login_hint
curl "http://localhost:8080/oauth2/authorize?idp=tmi&login_hint=alice"

Note: The login_hint parameter must be 3-20 characters, alphanumeric plus hyphens (e.g., alice, qa-user).

OAuth Token Delivery via URL Fragments

Both OAuth and SAML authentication flows deliver JWT tokens via URL fragments (the portion after #) rather than query parameters (the portion after ?).

Token Delivery Format:

https://your-app.com/callback#access_token=eyJhbGc...&refresh_token=abc123&token_type=Bearer&expires_in=3600&state=xyz

Why URL fragments?

URL fragments are never sent to the server, which prevents tokens from appearing in server access logs, reverse proxy logs, browser history (on most browsers), and Referrer headers when navigating away. This approach follows the OAuth 2.0 implicit flow specification (RFC 6749) and provides a consistent token delivery method for both OAuth and SAML flows.

Client-Side Token Extraction:

// Extract tokens from URL fragment
ngOnInit() {
  const hash = window.location.hash.substring(1); // Remove leading '#'
  const params = new URLSearchParams(hash);

  const accessToken = params.get('access_token');
  const refreshToken = params.get('refresh_token');
  const tokenType = params.get('token_type');
  const expiresIn = params.get('expires_in');
  const state = params.get('state'); // For CSRF validation

  if (accessToken) {
    // Store tokens securely
    this.authService.setTokens({
      accessToken,
      refreshToken,
      tokenType,
      expiresIn: parseInt(expiresIn || '3600', 10)
    });

    // Clear fragment from URL to prevent token exposure
    window.history.replaceState({}, document.title, window.location.pathname);

    // Redirect to intended page
    this.router.navigate(['/']);
  }
}

React Example:

import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from './hooks/useAuth';

export function OAuthCallback() {
  const navigate = useNavigate();
  const { setTokens } = useAuth();

  useEffect(() => {
    // Extract tokens from URL fragment
    const hash = window.location.hash.substring(1);
    const params = new URLSearchParams(hash);

    const accessToken = params.get('access_token');
    const refreshToken = params.get('refresh_token');

    if (accessToken && refreshToken) {
      // Store tokens
      setTokens({
        accessToken,
        refreshToken,
        tokenType: params.get('token_type') || 'Bearer',
        expiresIn: parseInt(params.get('expires_in') || '3600', 10)
      });

      // Clear fragment
      window.history.replaceState({}, document.title, window.location.pathname);

      // Redirect
      navigate('/');
    } else {
      navigate('/login');
    }
  }, [navigate, setTokens]);

  return <div>Authenticating...</div>;
}

Important: Always read tokens from window.location.hash, not window.location.search.

Error Handling

REST API Errors

All errors return JSON with a consistent structure:

{
  "error": "invalid_input",
  "error_description": "The provided data does not meet validation requirements",
  "details": {
    "code": "VALIDATION_ERROR",
    "context": {
      "field": "name",
      "error": "must not be empty"
    },
    "suggestion": "Ensure all required fields are provided and meet validation constraints"
  }
}

WebSocket Errors

Authorization Denied:

{
  "message_type": "authorization_denied",
  "original_operation_id": "uuid",
  "reason": "insufficient_permissions"
}

State Correction:

{
  "message_type": "state_correction",
  "update_vector": 45
}

When you receive a state correction, re-fetch the diagram through the REST API:

async function handleStateCorrection(message) {
  console.warn('State correction received, resyncing...');

  const diagram = await fetch(
    `/threat_models/${tmId}/diagrams/${diagramId}`,
    { headers: { Authorization: `Bearer ${token}` } }
  ).then(r => r.json());

  cachedDiagram = diagram;
  renderDiagram(diagram.cells);
}

Retry Logic

async function apiCallWithRetry(url, options, maxRetries = 3) {
  let lastError;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);

      if (response.ok) return response;

      // Don't retry client errors (4xx except 429)
      if (response.status >= 400 && response.status < 500 && response.status !== 429) {
        throw new Error(`HTTP ${response.status}`);
      }

      // Retry server errors and rate limits
      if (attempt < maxRetries) {
        const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    } catch (error) {
      lastError = error;
      if (attempt < maxRetries) {
        const delay = Math.min(1000 * Math.pow(2, attempt - 1), 5000);
        await new Promise(resolve => setTimeout(resolve, delay));
      }
    }
  }

  throw new Error(`Failed after ${maxRetries} attempts: ${lastError.message}`);
}

Best Practices

For security-specific guidance, see Security-Best-Practices. For performance tuning, see Performance-and-Scaling.

Authentication

  • Prefer Client Credentials Grant for automation -- use CCG for scripts, pipelines, and integrations; reserve PKCE for interactive user logins.
  • Store tokens and credentials securely -- use httpOnly cookies, secure storage, or environment variables. Never commit client secrets to source control.
  • Refresh tokens proactively -- implement token refresh before expiration.
  • Handle 401 responses -- redirect users to the login page on authentication failure, or re-authenticate with CCG.
  • Log out properly -- call the logout endpoint to invalidate tokens server-side.

REST API

  • Use appropriate HTTP methods -- GET for reads, POST for creates, PUT for full replacement, PATCH for partial updates. Prefer PATCH when changing individual fields; if using PUT, read the full object first to avoid clearing fields.
  • Handle errors consistently -- check response status codes and parse error messages.
  • Validate input client-side -- but never trust client-side validation alone.
  • Paginate large lists -- use pagination parameters when available.
  • Omit server-controlled fields -- let the server compute IDs, counts, and timestamps.

WebSocket Collaboration

  • Join via REST first -- always create or retrieve a collaboration session through the REST API before opening a WebSocket connection.
  • Handle state sync -- process the initial diagram_state_sync message before sending operations.
  • Prevent echo loops -- do not send WebSocket messages when applying remote changes.
  • Reconnect gracefully -- implement exponential backoff for reconnection attempts.
  • Send progress updates -- keep presenter mode participants informed.
  • Handle disconnection -- save local state before disconnecting.

Performance

  • Throttle high-frequency events -- throttle cursor updates (100 ms) and debounce selection changes (250 ms).
  • Batch operations -- use batch endpoints when creating or updating multiple items.
  • Cache responses -- cache GET responses with an appropriate TTL.
  • Reserve WebSockets for collaboration -- use the REST API for non-real-time operations.

Error Handling

  • Retry transient errors -- implement exponential backoff for 500 and 503 responses.
  • Do not retry 4xx errors -- client errors indicate a problem with the request itself.
  • Show user-friendly messages -- parse the error_description and details.suggestion fields.
  • Log without leaking -- log errors for debugging, but do not expose sensitive information to end users.

Security

  • Validate on both sides -- never rely on client-side validation alone.
  • Sanitize user input -- prevent XSS and injection attacks.
  • Use HTTPS in production -- always use TLS outside of local development.
  • Check permissions -- verify that the user has the required role before performing operations.

Code Examples

The TMI repository contains complete integration examples:

Location Description
/docs/developer/integration/client-oauth-integration.md OAuth 2.0 client patterns with PKCE support
/docs/developer/integration/client-websocket-integration-guide.md WebSocket collaborative editing guide
/docs/migrated/developer/integration/README.md Integration patterns overview and quick start

API Reference

For complete API documentation, see the following resources:

Resource Location
OpenAPI Spec /api-schema/tmi-openapi.json
WebSocket Spec /api-schema/tmi-asyncapi.yml
Server Endpoint http://localhost:8080/ (server info and version)
REST API Reference REST-API-Reference
WebSocket API Reference WebSocket-API-Reference
API Specifications API-Specifications

API v1.0.0 Migration Guide

If you are migrating from a previous API version (v0.x) to v1.0.0, this section covers the breaking changes and required migration steps.

Breaking Changes Summary

Category Impact Action Required
Request Schemas HIGH Update POST/PUT request bodies
Response Schemas HIGH Handle new timestamp fields
Batch Endpoints MEDIUM Migrate to bulk endpoints
List Responses MEDIUM Handle Note summaries
Bulk Operations LOW Optional - use new capabilities
PATCH Support LOW Optional - use new endpoints

1. Request Schema Changes

What changed: POST and PUT operations now use Input schemas that exclude server-generated fields.

Affected resources: Assets, Documents, Notes, Repositories

Migration steps:

  • Remove id from POST requests (now server-generated).
  • Remove metadata from POST requests (use the metadata endpoints instead).
  • Remove created_at and modified_at from POST/PUT requests.
  • Use the *Input schemas for requests (AssetInput, DocumentInput, NoteInput, RepositoryInput).

Example:

// BEFORE (v0.x) - Don't do this
POST /threat_models/{id}/assets
{
  "id": "6ba7b810-9dad-11d1-beef-00c04fd430c8",
  "name": "Customer Database",
  "type": "software",
  "metadata": []
}

// AFTER (v1.0.0) - Do this
POST /threat_models/{id}/assets
{
  "name": "Customer Database",
  "type": "software"
}

2. Response Schema Changes

What changed: All resources now include created_at and modified_at timestamps in responses.

Migration: Update your response type definitions to include the new timestamp fields:

interface Asset {
  id: string;
  name: string;
  type: string;
  description?: string;
  metadata?: Metadata[];
  created_at: string;  // RFC3339 timestamp (NEW)
  modified_at: string; // RFC3339 timestamp (NEW)
}

3. Batch to Bulk Endpoint Migration

What changed: The /batch endpoints for threats have been removed. Use the /bulk endpoints instead.

Operation Old (v0.x) New (v1.0.0)
Bulk Create POST /threats/bulk POST /threats/bulk (unchanged)
Bulk Upsert PUT /threats/bulk PUT /threats/bulk (unchanged)
Bulk Partial Update PATCH /threats/batch/patch PATCH /threats/bulk
Bulk Delete DELETE /threats/batch DELETE /threats/bulk

4. List Response Changes

What changed: Note list endpoints now return summary schemas without the content field.

Migration: Fetch individual notes to retrieve content:

// List notes (summary only)
const notes = await GET(`/threat_models/${tmId}/notes`);

// Get full note with content
const fullNote = await GET(`/threat_models/${tmId}/notes/${notes[0].id}`);
const content = fullNote.content;

5. New PATCH Support

All resources now support JSON Patch (RFC 6902) for partial updates:

PATCH /threat_models/{id}/assets/{asset_id}
Content-Type: application/json-patch+json

[
  {"op": "replace", "path": "/name", "value": "Updated Name"},
  {"op": "add", "path": "/description", "value": "New description"}
]

Code Generation

If you use code generation tools, follow these steps:

  1. Download the new OpenAPI specification.
  2. Regenerate your client SDK using oapi-codegen, OpenAPI Generator, or Swagger Codegen.
  3. Update your code to use the new *Input types for requests.
# Example with oapi-codegen
oapi-codegen -package tmiclient tmi-openapi-v1.json > tmiclient.go

# Example with OpenAPI Generator
openapi-generator generate -i tmi-openapi-v1.json -g typescript-fetch -o ./src/client

Additional Resources

TypeScript Type Definitions

The following TypeScript type definitions cover collaboration sessions and WebSocket messages. Use these as a reference when building a typed client.

Collaboration Session Types

interface CollaborationSession {
  session_id?: string;
  host?: User;
  threat_model_id: string;
  threat_model_name: string;
  diagram_id: string;
  diagram_name: string;
  presenter?: User;
  participants: Participant[];
  websocket_url: string;
}

interface Participant {
  user: User;
  last_activity: string; // ISO 8601 timestamp
  permissions: 'reader' | 'writer' | 'owner';
}

interface User {
  principal_type: string;
  provider: string;
  provider_id: string;
  display_name?: string;
  email?: string;
}

WebSocket Message Types

interface DiagramOperationMessage {
  message_type: 'diagram_operation';
  user_id: string;
  operation_id: string;
  sequence_number?: number;
  operation: CellPatchOperation;
}

interface CellPatchOperation {
  type: 'patch';
  cells: CellOperation[];
}

interface CellOperation {
  id: string;
  operation: 'add' | 'update' | 'remove';
  data?: Cell;
}

interface Cell {
  id: string;
  shape: 'actor' | 'process' | 'store' | 'security-boundary' | 'text-box';
  x: number;
  y: number;
  width: number;
  height: number;
  label: string;
  [key: string]: any;
}

interface DiagramStateSyncMessage {
  message_type: 'diagram_state_sync';
  diagram_id: string;
  update_vector: number | null;
  cells: Cell[];
}

interface CurrentPresenterMessage {
  message_type: 'current_presenter';
  current_presenter: string;
}

interface PresenterCursorMessage {
  message_type: 'presenter_cursor';
  user_id: string;
  cursor_position: { x: number; y: number };
}

interface AuthorizationDeniedMessage {
  message_type: 'authorization_denied';
  original_operation_id: string;
  reason: string;
}

interface StateCorrectionMessage {
  message_type: 'state_correction';
  update_vector: number;
}

type WebSocketMessage =
  | DiagramOperationMessage
  | DiagramStateSyncMessage
  | CurrentPresenterMessage
  | PresenterCursorMessage
  | AuthorizationDeniedMessage
  | StateCorrectionMessage;

Client Configuration Types

interface TMIClientConfig {
  baseUrl: string;
  jwtToken?: string;
}

interface TMICollaborativeClientConfig {
  threatModelId: string;
  diagramId: string;
  jwtToken: string;
  serverUrl?: string;
  autoReconnect?: boolean;
  maxReconnectAttempts?: number;
}

Next Steps

Clone this wiki locally