Skip to content

API Integration

Eric Fitzgerald edited this page Jan 24, 2026 · 2 revisions

API Integration

This guide provides comprehensive information for integrating with TMI's REST API, WebSocket API, and building client applications.

Table of Contents

REST API Integration

API Overview

TMI provides a RESTful API with OpenAPI 3.0 specification.

Base URL: http://localhost:8080 (development) or https://api.tmi.dev (production)

API Specification: /docs/reference/apis/tmi-openapi.json

Content Type: application/json

Authentication: Bearer token (JWT)

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": "alice@example.com",
    "created_at": "2025-01-15T10:00:00Z",
    "modified_at": "2025-01-15T15:30:00Z",
    "diagram_count": 3,
    "threat_count": 12,
    "document_count": 2
  }
]

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 calculated or server-controlled fields:

  • id (server-generated)
  • created_at, modified_at (server-set)
  • owner, created_by (from JWT token)
  • diagram_count, threat_count, document_count (calculated)

Update Threat Model:

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

{
  "name": "Updated Name",
  "description": "Updated description"
}

Patch Threat Model:

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
}

Update Diagram Cells:

PUT /diagrams/{diagram_id}/cells
Authorization: Bearer {token}
Content-Type: application/json

{
  "cells": [
    {
      "id": "cell-uuid",
      "shape": "process",
      "x": 150,
      "y": 250,
      "width": 120,
      "height": 80,
      "label": "Updated Label"
    }
  ]
}

Note: Position and size accept both formats:

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

API always returns flat format.

Threats

Create Threat:

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

{
  "name": "SQL Injection",
  "description": "Database injection vulnerability",
  "stride": "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

Add Authorization Entry:

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

{
  "subject": "bob@example.com",
  "subject_type": "user",
  "role": "writer"
}

Grant Public Read Access:

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

{
  "subject": "everyone",
  "subject_type": "group",
  "role": "reader"
}

Update User Role:

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

{
  "role": "owner"
}

Remove Authorization:

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

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
500 Internal Server Error Server error

WebSocket API Integration

Overview

TMI provides WebSocket-based real-time collaboration for simultaneous diagram editing.

Connection Flow

1. Join Collaboration Session (REST API)

CRITICAL: Must join via REST API before 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": "alice@example.com",
  "threat_model_id": "uuid",
  "threat_model_name": "Web App Security",
  "diagram_id": "uuid",
  "diagram_name": "Architecture Diagram",
  "participants": [
    {
      "user_id": "alice@example.com",
      "joined_at": "2025-01-15T10:00:00Z",
      "permissions": "writer"
    }
  ],
  "websocket_url": "ws://localhost:8080/threat_models/.../diagrams/.../ws"
}

Join Existing Session:

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

Response (200 OK): Same structure as create

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: First message received is diagram_state_sync:

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

Always 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": "alice@example.com",
  "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": "alice@example.com"
}

Send Cursor Position (only if presenter):

{
  "message_type": "presenter_cursor",
  "user_id": "alice@example.com",
  "cursor_position": { "x": 150, "y": 300 }
}

Undo Request:

{
  "message_type": "undo_request",
  "user_id": "alice@example.com"
}

Redo Request:

{
  "message_type": "redo_request",
  "user_id": "alice@example.com"
}

Receiving Messages

Diagram Operation (from other users):

{
  "message_type": "diagram_operation",
  "user_id": "bob@example.com",
  "operation_id": "uuid",
  "operation": {
    "type": "patch",
    "cells": [ /* changes */ ]
  }
}

Presenter Changed:

{
  "message_type": "current_presenter",
  "current_presenter": "alice@example.com"
}

Presenter Cursor:

{
  "message_type": "presenter_cursor",
  "user_id": "alice@example.com",
  "cursor_position": { "x": 150, "y": 300 }
}

User Joined:

{
  "event": "join",
  "user_id": "charlie@example.com",
  "timestamp": "2025-01-15T10:05:00Z"
}

User Left:

{
  "event": "leave",
  "user_id": "bob@example.com",
  "timestamp": "2025-01-15T10:10:00Z"
}

Session Ended:

{
  "event": "session_ended",
  "user_id": "alice@example.com",
  "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.

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

JavaScript OAuth Client

A complete OAuth client implementation for web applications:

class TMIOAuth {
  constructor(tmiServerUrl = "http://localhost:8080") {
    this.tmiServerUrl = tmiServerUrl;
    this.providerConfig = {
      google: {
        authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
        clientId: "your-google-client-id",
        scopes: "openid profile email",
      },
      github: {
        authUrl: "https://github.com/login/oauth/authorize",
        clientId: "your-github-client-id",
        scopes: "user:email",
      },
      microsoft: {
        authUrl: "https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize",
        clientId: "your-microsoft-client-id",
        scopes: "openid profile email User.Read",
      },
    };
  }

  // Start OAuth login flow
  login(provider) {
    const config = this.providerConfig[provider];
    if (!config) throw new Error(`Unsupported provider: ${provider}`);

    const state = this.generateState();
    localStorage.setItem("oauth_state", state);
    localStorage.setItem("oauth_provider", provider);

    const params = new URLSearchParams({
      client_id: config.clientId,
      redirect_uri: `${window.location.origin}/oauth2/callback`,
      response_type: "code",
      scope: config.scopes,
      state: state,
    });

    window.location.href = `${config.authUrl}?${params}`;
  }

  // Handle OAuth callback (call this in your /oauth2/callback page)
  async handleCallback() {
    const urlParams = new URLSearchParams(window.location.search);
    const code = urlParams.get("code");
    const state = urlParams.get("state");
    const error = urlParams.get("error");

    if (error) throw new Error(`OAuth error: ${error}`);
    if (!code || !state) throw new Error("Missing authorization code or state");

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

    localStorage.removeItem("oauth_state");
    localStorage.removeItem("oauth_provider");

    // Exchange code with TMI server
    const response = await fetch(`${this.tmiServerUrl}/oauth2/token?idp=${provider}`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        grant_type: "authorization_code",
        code,
        state,
        redirect_uri: `${window.location.origin}/oauth2/callback`,
      }),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(`OAuth exchange failed: ${error.error}`);
    }

    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;
  }

  // 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
document.getElementById("google-login").onclick = () => tmiAuth.login("google");

// Handle callback (in your /oauth2/callback page)
if (window.location.pathname === "/oauth2/callback") {
  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

class TMIClient:
    def __init__(self, api_url, token):
        self.api_url = api_url
        self.token = token
        self.session = requests.Session()
        self.session.headers.update({
            'Authorization': f'Bearer {token}'
        })

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

    def create_threat_model(self, name, description):
        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):
        # 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

OAuth Flow Integration

1. Initiate OAuth

GET /oauth2/authorize?idp=google

Server redirects to Google OAuth, then back to TMI with authorization code.

2. Handle Callback

TMI processes the callback and returns tokens:

GET /oauth2/callback?code=AUTH_CODE&state=STATE

Response:

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

3. Use Token

Include in 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": "alice@example.com",
  "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, desktop apps).

How PKCE Works:

  1. Client generates a random code_verifier (43-128 characters)
  2. Client computes code_challenge = BASE64URL(SHA256(code_verifier))
  3. Client sends code_challenge to authorization endpoint
  4. Server stores code_challenge with authorization code
  5. Client exchanges code + code_verifier for tokens
  6. Server validates: SHA256(code_verifier) == 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):

  • 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');

TMI Provider (Development Only)

For development, use the 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 part after #) rather than query parameters (the part 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?

  1. Security: URL fragments are never sent to the server, preventing tokens from appearing in:

    • Server access logs
    • Reverse proxy logs
    • Browser history (on most browsers)
    • Referrer headers when navigating away
  2. Standards Compliance: Follows OAuth 2.0 implicit flow specification (RFC 6749)

  3. Consistency: Both OAuth and SAML use the same token delivery method

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 consistent structure:

{
  "error": "validation_error",
  "message": "Invalid input data",
  "details": {
    "field": "name",
    "reason": "Field is required"
  }
}

WebSocket Errors

Authorization Denied:

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

State Correction:

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

Handle by re-fetching diagram via 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

1. Authentication

  • Store tokens securely: Use httpOnly cookies or secure storage
  • Refresh tokens: Implement token refresh before expiration
  • Handle 401: Redirect to login on authentication failure
  • Logout properly: Call logout endpoint to invalidate tokens

2. REST API

  • Use appropriate methods: GET for reads, POST for creates, PUT/PATCH for updates
  • Handle errors: Check response status and parse error messages
  • Validate input: Client-side validation before sending to API
  • Paginate large lists: Use pagination parameters when available
  • Don't send calculated fields: Let server compute counts and timestamps

3. WebSocket Collaboration

  • Join via REST first: Always join collaboration session via REST API before WebSocket
  • Handle state sync: Process initial diagram_state_sync message
  • Prevent echo: Don't send WebSocket messages for remote changes
  • Graceful reconnection: Implement exponential backoff for reconnection
  • Update progress: Send progress updates for presenter mode
  • Handle disconnection: Save state before disconnecting

4. Performance

  • Throttle high-frequency events: Throttle cursor updates (100ms), debounce selection (250ms)
  • Batch operations: Use batch endpoints when creating multiple items
  • Cache responses: Cache GET responses with appropriate TTL
  • Use WebSockets wisely: Only for real-time collaboration, not for all updates

5. Error Handling

  • Retry transient errors: Implement exponential backoff for 500/503 errors
  • Don't retry 4xx: Client errors should not be retried
  • Show user-friendly messages: Parse error details and show helpful messages
  • Log errors: Log errors for debugging but don't expose sensitive info to users

6. Security

  • Validate on client and server: Never trust client-side validation alone
  • Sanitize user input: Prevent XSS and injection attacks
  • Use HTTPS: Always use TLS in production
  • Check permissions: Verify user has required role before operations

Code Examples

See the TMI repository for complete code examples:

Current Integration Guides (/docs/developer/integration/):

  • client-oauth-integration.md - OAuth 2.0 client patterns with PKCE support
  • client-websocket-integration-guide.md - Comprehensive WebSocket collaborative editing

Migrated Documentation (/docs/migrated/developer/integration/):

  • README.md - Integration patterns overview and quick start guide

API Reference

For complete API documentation:

  • OpenAPI Spec: /docs/reference/apis/tmi-openapi.json
  • WebSocket Spec: /docs/reference/apis/tmi-asyncapi.yml
  • Server Endpoint: http://localhost:8080/ (server info and version)

API v1.0.0 Migration Guide

If you're migrating from a previous API version (v0.x) to v1.0.0, this section covers the key breaking changes and 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:

  • Remove id from POST requests (server-generated)
  • Remove metadata from POST requests (use metadata endpoints instead)
  • Remove created_at and modified_at from POST/PUT requests
  • Use *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 response type definitions to include 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: Removed /batch endpoints for threats. Use /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 get 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 using code generation tools:

  1. Download the new OpenAPI specification
  2. Regenerate client SDK using oapi-codegen, OpenAPI Generator, or Swagger Codegen
  3. Update code to use 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

For TypeScript integration, here are the complete type definitions for collaboration sessions and WebSocket messages:

Collaboration Session Types

interface CollaborationSession {
  session_id: string;
  host: string;
  threat_model_id: string;
  threat_model_name: string;
  diagram_id: string;
  diagram_name: string;
  participants: SessionParticipant[];
  websocket_url: string;
}

interface SessionParticipant {
  user_id: string;
  joined_at: string; // ISO 8601 timestamp
  permissions: 'reader' | 'writer' | 'owner';
}

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