-
Notifications
You must be signed in to change notification settings - Fork 2
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.
- REST API Integration
- WebSocket API Integration
- Client Integration Patterns
- Authentication Integration
- Error Handling
- Best Practices
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 |
# 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_modelsList 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:
- Use PATCH (preferred) to change only specific fields, or
- 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}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}} |
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}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}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"
}
]
}| 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 |
For the full WebSocket protocol reference, see WebSocket-API-Reference. For a hands-on testing tool, see WebSocket-Test-Harness.
TMI provides WebSocket-based real-time collaboration for simultaneous diagram editing. For a higher-level guide on collaborative workflows, see Collaborative-Threat-Modeling.
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}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);
};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;
}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]"
}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"
}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;
}
}
}For pre-built client libraries, see API-Clients.
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();
}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();
}
}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)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:
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.
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.
GET /oauth2/authorize?idp=google&client_callback=https://app.example.com/callback&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&state=RANDOM_STATEThe 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.
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.
Include the token in the Authorization header for all API requests:
Authorization: Bearer eyJhbGc...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"
}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:
- Your client generates a random
code_verifier(43-128 characters). - Your client computes
code_challenge = BASE64URL(SHA256(code_verifier)). - Your client sends the
code_challengeto the authorization endpoint. - The server stores the
code_challengealongside the authorization code. - Your client exchanges the authorization code plus the
code_verifierfor tokens. - The server validates that
SHA256(code_verifier)matches the storedcode_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 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:
- An administrator or user creates client credentials in TMI-UX, receiving a
client_idandclient_secret. - Your application sends a POST request to the token endpoint with the credentials.
- TMI validates the credentials and returns an access token.
- 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_SECRETResponse:
{
"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",
})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).
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.
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"
}
}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);
}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}`);
}For security-specific guidance, see Security-Best-Practices. For performance tuning, see Performance-and-Scaling.
- 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.
- 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.
- 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_syncmessage 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.
- 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.
- 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_descriptionanddetails.suggestionfields. - Log without leaking -- log errors for debugging, but do not expose sensitive information to end users.
- 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.
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 |
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 |
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.
| 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 |
What changed: POST and PUT operations now use Input schemas that exclude server-generated fields.
Affected resources: Assets, Documents, Notes, Repositories
Migration steps:
- Remove
idfrom POST requests (now server-generated). - Remove
metadatafrom POST requests (use the metadata endpoints instead). - Remove
created_atandmodified_atfrom POST/PUT requests. - Use the
*Inputschemas 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"
}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)
}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 |
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;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"}
]If you use code generation tools, follow these steps:
- Download the new OpenAPI specification.
- Regenerate your client SDK using oapi-codegen, OpenAPI Generator, or Swagger Codegen.
- Update your code to use the new
*Inputtypes 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- API-Clients -- Pre-built client libraries
- REST-API-Reference -- Complete endpoint documentation
- API-Specifications -- OpenAPI and AsyncAPI specs
The following TypeScript type definitions cover collaboration sessions and WebSocket messages. Use these as a reference when building a typed client.
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;
}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;interface TMIClientConfig {
baseUrl: string;
jwtToken?: string;
}
interface TMICollaborativeClientConfig {
threatModelId: string;
diagramId: string;
jwtToken: string;
serverUrl?: string;
autoReconnect?: boolean;
maxReconnectAttempts?: number;
}- Testing -- Learn about testing strategies
- Extending-TMI -- Build addons and integrations
- Architecture-and-Design -- Understand the system architecture
- Debugging-Guide -- Troubleshoot integration issues
- Common-Issues -- Solutions to frequently encountered problems
- Using TMI for Threat Modeling
- Accessing TMI
- Authentication
- Creating Your First Threat Model
- Understanding the User Interface
- Working with Data Flow Diagrams
- Managing Threats
- Collaborative Threat Modeling
- Using Notes and Documentation
- Timmy AI Assistant
- Metadata and Extensions
- Planning Your Deployment
- Terraform Deployment (AWS, OCI, GCP, Azure)
- Deploying TMI Server
- OCI Container Deployment
- Certificate Automation
- Deploying TMI Web Application
- Setting Up Authentication
- Database Setup
- Component Integration
- Post-Deployment
- Branding and Customization
- Monitoring and Health
- Cloud Logging
- Database Operations
- Security Operations
- Performance and Scaling
- Maintenance Tasks
- Getting Started with Development
- Architecture and Design
- API Integration
- Testing
- Contributing
- Extending TMI
- Dependency Upgrade Plans
- DFD Graphing Library Reference
- Migration Instructions