Skip to content

Authentication

Eric Fitzgerald edited this page Apr 5, 2026 · 1 revision

Authentication

This page explains how authentication works in TMI, covering the supported authentication flows, token management, session handling, and client credentials for automation. For provider-specific setup instructions (Google, GitHub, Microsoft), see Setting-Up-Authentication. For security hardening guidance, see Security-Best-Practices.

Overview

TMI supports two authentication protocols:

  • OAuth 2.0 with PKCE (RFC 7636) -- for browser-based and interactive authentication
  • SAML 2.0 -- for enterprise SSO integration

Both protocols result in TMI issuing JWT tokens that clients use for all subsequent API requests.

TMI also supports Client Credentials Grant (CCG) for non-interactive automation and service-to-service authentication.

Supported Identity Providers

TMI dynamically discovers OAuth providers via environment variables following the pattern OAUTH_PROVIDERS_{PROVIDER_ID}_{FIELD}. Any OAuth 2.0-compliant provider works, including:

  • Google (Google Workspace and consumer accounts)
  • GitHub (personal and organization accounts)
  • Microsoft (Azure AD / Entra ID -- personal, work, and school accounts)
  • Custom OAuth 2.0 / OIDC providers

SAML providers are configured separately via SAML_PROVIDERS_{PROVIDER_ID}_{FIELD} environment variables.

See Setting-Up-Authentication for provider configuration details, and Configuration-Reference for the full list of authentication settings.

Authentication Components

Component Role
TMI-UX (Web Application) Initiates OAuth/SAML flows, manages client-side session via HttpOnly cookies
TMI Server Manages PKCE challenges, handles OAuth/SAML callbacks, exchanges codes with providers, issues JWT tokens, validates client credentials
OAuth/SAML Provider Authenticates users and provides identity information (email, name, groups)
Redis Stores PKCE challenges, OAuth state, refresh tokens, token blacklist, and user lookup cache
Database Stores user records, client credential hashes, and group memberships

Authentication Endpoints

Method Path Purpose
GET /oauth2/authorize Initiate OAuth authorization flow
POST /oauth2/token Exchange authorization code or client credentials for tokens
GET /oauth2/providers List configured OAuth providers
POST /oauth2/refresh Refresh an expired access token
POST /oauth2/revoke Revoke a token
POST /oauth2/introspect Inspect token validity (RFC 7662)
GET /oauth2/userinfo Get current user information
GET /.well-known/openid-configuration OpenID Connect discovery
GET /.well-known/jwks.json JSON Web Key Set
GET /.well-known/oauth-authorization-server OAuth authorization server metadata
GET /saml/{provider}/login Initiate SAML login for a specific provider
POST /me/logout Log out and invalidate tokens

OAuth 2.0 with PKCE (Interactive Users)

PKCE (Proof Key for Code Exchange) is the primary authentication flow for interactive users. TMI-UX uses this flow, and it is also suitable for scripts or tools that can open a browser for user authentication.

How PKCE Works

  1. The client generates a random code_verifier (43-128 characters) and computes code_challenge = BASE64URL(SHA256(code_verifier)).
  2. The client redirects to TMI's /oauth2/authorize endpoint with the code_challenge.
  3. TMI stores the challenge in Redis and redirects to the identity provider.
  4. The user authenticates with the provider.
  5. The provider redirects back to TMI with an authorization code.
  6. TMI binds the PKCE challenge to the code and redirects to the client's callback URL.
  7. The client exchanges the authorization code plus the original code_verifier at /oauth2/token.
  8. TMI validates that SHA256(code_verifier) matches the stored challenge, exchanges the code with the provider, and issues JWT tokens.

PKCE Flow Diagram

sequenceDiagram
autonumber

    box rgb(220,235,250) Client
        participant UX as TMI-UX (SPA)
        participant Browser
    end

    box rgb(250,235,220) TMI Server
        participant TMI as TMI API Server
        participant Redis
        participant DB as PostgreSQL
    end

    box rgb(235,250,220) Identity Provider
        participant Google as Google (IdP)
    end

    Note over UX, Google: ——— PKCE Authorization Code (Browser User) ———

    UX ->> UX: Generate code_verifier + code_challenge (S256)
    UX ->> Browser: Redirect to TMI /oauth2/authorize
    Browser ->> TMI: GET /oauth2/authorize?idp=google<br/>&code_challenge=...&client_callback=https://tmi-ux/callback
    TMI ->> Redis: Store oauth_state:{state} (provider, callback, PKCE challenge) [10min TTL]
    TMI ->> Browser: 302 Redirect to Google consent screen

    Browser ->> Google: GET /authorize?client_id=...&state=...
    Google ->> Browser: Show login/consent UI
    Browser ->> Google: User authenticates & consents
    Google ->> Browser: 302 Redirect to TMI /oauth2/callback?code=...&state=...

    Browser ->> TMI: GET /oauth2/callback?code={google_code}&state=...
    TMI ->> Redis: Consume oauth_state:{state}
    TMI ->> Redis: Store pkce:{google_code} (challenge) [10min TTL]
    TMI ->> Browser: 302 Redirect to tmi-ux client_callback?code=...&state=...

    Browser ->> UX: GET /callback?code={google_code}&state=...
    UX ->> TMI: POST /oauth2/token<br/>grant_type=authorization_code<br/>code={google_code}&code_verifier=...

    TMI ->> Redis: Retrieve & delete pkce:{google_code}
    TMI ->> TMI: Validate code_verifier against challenge (constant-time)
    TMI ->> Google: Exchange code for Google access token
    TMI ->> Google: Fetch userinfo (email, name, groups)
    TMI ->> DB: Find or create user
    TMI ->> TMI: Sign TMI JWT (HS256/RS256/ES256)
    TMI ->> Redis: Store refresh_token:{token} [7 day TTL]

    TMI ->> UX: 200 JSON {access_token, refresh_token, expires_in}<br/>+ Set-Cookie: tmi_access_token (HttpOnly, SameSite=Lax)<br/>+ Set-Cookie: tmi_refresh_token (HttpOnly, SameSite=Strict, Path=/oauth2)

    Note over UX, TMI: Subsequent API requests use HttpOnly cookies automatically<br/>SPA never touches the JWT directly

    UX ->> TMI: GET /api/threat_models<br/>Cookie: tmi_access_token=...
    TMI ->> TMI: Validate JWT from cookie
    TMI ->> UX: 200 OK (data)
Loading

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)

PKCE Helper Functions

JavaScript:

class PKCEHelper {
  static generateCodeVerifier() {
    const array = new Uint8Array(32);
    crypto.getRandomValues(array);
    return this.base64URLEncode(array);
  }

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

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

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('=')

Client Credentials Grant (Automation)

Client Credentials Grant (CCG) is the preferred authentication method for scripts, CI/CD pipelines, webhooks, and service-to-service integrations. CCG does not require an interactive browser login.

How CCG Works

  1. A user or administrator creates client credentials in TMI-UX, receiving a client_id and client_secret.
  2. The automation sends a POST request to /oauth2/token with grant_type=client_credentials.
  3. TMI validates the credentials (bcrypt comparison) and issues an access token.
  4. The token carries the same identity and permissions as the user who owns the credentials.

Creating Client Credentials

Users create credentials for their own account in TMI-UX via User Preferences > Client Credentials, or via the API:

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

{
  "name": "CI/CD Pipeline",
  "description": "Used by GitHub Actions for automated threat model updates"
}

Administrators can create credentials for automation accounts:

POST /admin/users/{internal_uuid}/client_credentials
Authorization: Bearer {admin_token}
Content-Type: application/json

{
  "name": "Nightly STRIDE Analyzer",
  "description": "Service account for automated STRIDE analysis"
}

Important: The client_secret is displayed only once at creation time. Store it securely (e.g., in a secrets manager or environment variable). It cannot be retrieved later.

CCG Flow Diagram

sequenceDiagram
autonumber

    box rgb(220,235,250) Automation
        participant Automation as Client or Webhook
    end

    box rgb(250,235,220) TMI Server
        participant TMI as TMI API Server
        participant DB as PostgreSQL
    end

    Note over Automation, TMI: One-time setup: human user creates credentials

    Automation ->> TMI: POST /me/client_credentials {name, description}<br/>or POST /admin/users/{internal_uuid}/client_credentials<br/>Cookie: tmi_access_token=...
    TMI ->> DB: Store client_id + bcrypt(secret) + owner_uuid
    TMI ->> Automation: 200 {client_id, client_secret} (secret shown ONCE)

    Note over Automation, DB: Automation uses credentials (no browser needed)

    Automation ->> TMI: POST /oauth2/token<br/>grant_type=client_credentials<br/>client_id=tmi_cc_...&client_secret=...
    TMI ->> DB: Lookup client_id
    TMI ->> TMI: bcrypt.Compare(secret, hash)
    TMI ->> DB: Check active & not expired
    TMI ->> DB: Load owner user
    TMI ->> TMI: Sign TMI JWT<br/>sub=sa:{cred_id}:{owner_id}
    TMI ->> DB: Update last_used_at
    TMI ->> Automation: 200 {access_token, expires_in: 3600}<br/>(no refresh token, no cookies)

    Automation ->> TMI: GET /api/threat_models<br/>Authorization: Bearer {access_token}
    TMI ->> TMI: Validate JWT from header
    TMI ->> Automation: 200 OK (data)
Loading

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

Note: CCG tokens do not include a refresh token. When the token expires, request a new one using the same client credentials.

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() {
    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,
      },
    });
  }
}

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."""
        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()

Choosing Between PKCE and CCG

Scenario Recommended Flow
TMI-UX web application PKCE (built-in)
Script with interactive user prompt PKCE
CI/CD pipeline CCG
Webhook / addon service CCG
Scheduled automation CCG
Service-to-service integration CCG
Development / testing scripts CCG (or TMI test provider)

JWT Tokens and Session Management

Both PKCE and CCG flows result in JWT tokens, but the way clients receive, store, and present those tokens differs significantly between browser-based applications and automation clients.

How JWT Is Used: Two Models

TMI supports two token delivery models, chosen based on the client type:

Model 1: HttpOnly Cookies (TMI-UX and Browser Applications)

This is the primary authentication model for TMI-UX and any browser-based SPA. It is designed so that JavaScript never touches the JWT directly.

When a PKCE flow completes, the TMI server returns the tokens in the JSON response body and sets two HttpOnly cookies:

Cookie Attributes Purpose
tmi_access_token HttpOnly, SameSite=Lax, Path=/ Carries the JWT for all API requests
tmi_refresh_token HttpOnly, SameSite=Strict, Path=/oauth2 Used only for token refresh requests

Why this matters:

  • HttpOnly prevents JavaScript from reading the cookie via document.cookie. This is TMI's primary defense against XSS token theft -- even if an attacker injects a script into the page, the script cannot extract the JWT.
  • SameSite=Lax on the access token means the cookie is sent on same-site requests and top-level navigations (e.g., following a link to the TMI server), but is not sent on cross-site sub-requests (e.g., <img> or <iframe> from a different origin). This provides baseline CSRF protection.
  • SameSite=Strict on the refresh token is more restrictive -- the cookie is only sent on requests originating from the same site. Combined with Path=/oauth2, this ensures the long-lived refresh token is never sent to API endpoints; it is only included in requests to the /oauth2/refresh endpoint.
  • Secure (set automatically when TLS is enabled) ensures cookies are only sent over HTTPS, preventing interception on the wire.

From the SPA's perspective, authentication is invisible after the initial PKCE flow. The browser automatically attaches the tmi_access_token cookie to every request to the TMI server -- the SPA uses fetch() with credentials: 'include' and does not manage tokens in localStorage, sessionStorage, or JavaScript variables at all.

Token refresh in the cookie model: TMI-UX periodically calls POST /oauth2/refresh. The browser automatically includes the tmi_refresh_token cookie (because the path matches /oauth2). The server validates the refresh token, issues new tokens, and sets updated cookies in the response. The SPA never sees the token values.

Logout: When the user logs out, the SPA calls POST /me/logout. The server invalidates the tokens server-side (adds them to the Redis blacklist) and sends Set-Cookie headers that expire both cookies, clearing them from the browser.

Model 2: Bearer Token (CCG and Non-Browser Clients)

Automation clients -- scripts, CI/CD pipelines, webhook services, and other server-side integrations -- use the Authorization: Bearer header. There are no cookies involved.

GET /threat_models
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

In this model:

  • The client receives the JWT as a JSON field (access_token) from the /oauth2/token endpoint.
  • The client stores the token in memory (or in a secrets manager for longer-lived processes).
  • The client attaches the token to each request via the Authorization header.
  • When the token expires, CCG clients simply request a new token using the same client credentials. There is no refresh token flow.
  • PKCE-based non-browser clients (e.g., a Python script that opened a browser for login) can use the refresh token via POST /oauth2/refresh with the refresh token in the request body.

Why no cookies for CCG? Cookies are a browser mechanism. Server-side HTTP clients (Python requests, Go http.Client, curl) do not benefit from HttpOnly or SameSite protections -- there is no DOM to protect against XSS. The Bearer header is simpler, explicit, and standard for machine-to-machine communication.

Server-Side Token Resolution

The TMI server accepts both delivery mechanisms and resolves them in a defined order:

  1. Check for Authorization: Bearer header.
  2. If no header, check for tmi_access_token cookie.
  3. If neither is present, return 401 Unauthorized.

This means a request that includes both a Bearer header and a cookie will authenticate using the Bearer token. In practice this does not happen -- browser clients use cookies and automation clients use headers.

Token Structure

{
  "sub": "provider-user-id",
  "email": "user@example.com",
  "email_verified": true,
  "name": "User Name",
  "idp": "github",
  "groups": ["team-security", "team-engineering"],
  "tmi_is_administrator": false,
  "tmi_is_security_reviewer": false,
  "iss": "http://localhost:8080/oauth2/callback",
  "aud": ["http://localhost:8080/oauth2/callback"],
  "exp": 1642185600,
  "iat": 1642099200,
  "nbf": 1642099200,
  "jti": "unique-token-id"
}

Token Claims

Claim Description
sub Provider user ID (from the identity provider, not the internal TMI UUID). For CCG tokens: sa:{cred_id}:{owner_id}
email User email address
email_verified Whether the email has been verified by the provider
name User display name
idp Identity provider identifier (e.g., github, google)
groups User group memberships from the identity provider
tmi_is_administrator Whether the user is a TMI administrator
tmi_is_security_reviewer Whether the user is a TMI security reviewer
iss Token issuer
aud Token audience
exp Expiration timestamp
iat Issued at timestamp
nbf Not before timestamp
jti Unique token identifier (UUID)

Token Lifetime

Token Default Lifetime Configuration
Access token 1 hour (3600s) TMI_JWT_EXPIRATION_SECONDS
Refresh token 7 days TMI_REFRESH_TOKEN_DAYS
Absolute session 7 days TMI_SESSION_ABSOLUTE_LIFETIME_HOURS

JWT Signing Methods

TMI supports three signing algorithms, configured via TMI_JWT_SIGNING_METHOD:

Method Type Key Configuration
HS256 Symmetric (HMAC) TMI_JWT_SECRET (shared secret)
RS256 Asymmetric (RSA) TMI_JWT_PRIVATE_KEY_FILE / TMI_JWT_PUBLIC_KEY_FILE
ES256 Asymmetric (ECDSA) TMI_JWT_PRIVATE_KEY_FILE / TMI_JWT_PUBLIC_KEY_FILE

Important: Change TMI_JWT_SECRET from the default value in production. The JWKS endpoint (/.well-known/jwks.json) publishes the public key for RS256/ES256, enabling external services to validate tokens.

Token Refresh

Access tokens expire after 1 hour by default.

Browser clients (cookies): TMI-UX calls POST /oauth2/refresh. The browser sends the tmi_refresh_token cookie automatically. The server sets updated cookies in the response.

Non-browser PKCE clients: Send the refresh token in the request body:

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

{
  "refresh_token": "your-refresh-token"
}

CCG clients: Do not use refresh tokens. Request a new access token with client credentials when the current token expires.

Note: Refresh tokens are rotated on each use -- the old refresh token is invalidated.

Token Revocation

Revoke a token when it is no longer needed (e.g., on logout):

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

{
  "token": "your_access_or_refresh_token"
}

Browser Security and CORS

Browser-based clients (TMI-UX and custom SPAs) are subject to browser security policies that directly affect authentication. This section explains the key policies and what must be configured correctly for PKCE authentication to work.

CORS (Cross-Origin Resource Sharing)

CORS controls whether JavaScript running on one origin (e.g., https://tmi-ux.example.com) can make requests to a different origin (e.g., https://api.tmi.example.com). This is relevant whenever the TMI-UX application and the TMI API server are on different origins.

Same origin means the scheme, host, and port all match. https://tmi.example.com and https://tmi.example.com:8080 are different origins. https://tmi.example.com and https://api.tmi.example.com are different origins.

When CORS Matters

Deployment CORS Required? Why
TMI-UX and TMI server on the same origin No Same-origin requests are not subject to CORS
TMI-UX on https://app.tmi.dev, server on https://api.tmi.dev Yes Different origins; browser blocks cross-origin requests without CORS headers
Local development: UX on localhost:4200, server on localhost:8080 Yes Different ports = different origins
WebSocket connections Separate WebSocket upgrades are not subject to CORS preflight, but TMI performs server-side origin checking via WEBSOCKET_ALLOWED_ORIGINS (see WebSocket Origin Checking)
CCG / automation clients No CORS is a browser-only policy; curl, Python, Go clients are unaffected

TMI CORS Configuration

Set the allowed origins via TMI_CORS_ALLOWED_ORIGINS:

# Production: explicit list of allowed origins
TMI_CORS_ALLOWED_ORIGINS=https://app.tmi.dev,https://admin.tmi.dev

# Development: TMI allows any origin when TMI_BUILD_MODE=dev
TMI_BUILD_MODE=dev

Note: WebSocket connections have a separate origin-checking setting, WEBSOCKET_ALLOWED_ORIGINS, which is not part of CORS but serves a similar purpose. In production, both should typically be set to the same values. See WebSocket Origin Checking for details.

The TMI server responds to CORS preflight requests (OPTIONS) and includes the following headers on responses:

Header Value
Access-Control-Allow-Origin The requesting origin (if in the allowed list)
Access-Control-Allow-Credentials true (required for cookie-based auth)
Access-Control-Allow-Methods GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers Authorization, Content-Type, X-Webhook-Signature, ...

CORS and Cookies

For HttpOnly cookies to be sent on cross-origin requests, all three of the following must be true:

  1. Server sets Access-Control-Allow-Credentials: true in the response.
  2. Server sets Access-Control-Allow-Origin to the specific requesting origin (not *). When credentials are involved, the wildcard * is rejected by browsers.
  3. Client sets credentials: 'include' on fetch requests (or withCredentials: true on XMLHttpRequest).

If any of these are missing, the browser will not send cookies on cross-origin requests, and API calls will fail with 401 Unauthorized.

// TMI-UX fetch example (cross-origin with cookies)
const response = await fetch("https://api.tmi.dev/threat_models", {
  credentials: "include", // Required for cross-origin cookies
  headers: {
    "Content-Type": "application/json",
  },
});

CORS and the PKCE Flow

The PKCE authorization flow involves browser redirects (302 responses), which are not subject to CORS -- the browser follows redirects natively regardless of origin. This means:

  • The redirect to /oauth2/authorize works without CORS.
  • The redirect to the identity provider works without CORS.
  • The redirect back to the client callback works without CORS.

However, the token exchange step (POST /oauth2/token) is a JavaScript-initiated request and is subject to CORS if the SPA and TMI server are on different origins. The TMI server must include the SPA's origin in TMI_CORS_ALLOWED_ORIGINS for this step to succeed.

CORS and CCG

CCG is not affected by CORS because it is used by server-side clients, not browsers. If you build a browser-based application that uses CCG (which is unusual -- CCG is intended for automation), the same CORS rules apply as for any cross-origin JavaScript request.

Cookie Domain and Secure Flag

For cookies to work correctly across your deployment:

Setting Requirement
TMI_COOKIE_DOMAIN Must cover both the TMI server and the TMI-UX origin. For example, if the server is api.tmi.dev and UX is app.tmi.dev, set the domain to .tmi.dev. If both are on the same host, this can be left unset (auto-inferred).
TMI_COOKIE_SECURE Must be true in production (auto-derived from TLS configuration). When set, cookies are only sent over HTTPS.
HTTPS Required in production. OAuth providers (Google, GitHub, Microsoft) require HTTPS callback URLs. Browsers increasingly restrict cookies on non-HTTPS origins.

SameSite and Third-Party Cookie Restrictions

Modern browsers are tightening restrictions on third-party cookies (cookies set by a domain other than the one in the browser's address bar). This affects deployments where TMI-UX and the TMI API are on different registrable domains (e.g., tmi-app.com and tmi-api.io).

Recommended: Deploy TMI-UX and the TMI server on the same registrable domain (e.g., app.tmi.dev and api.tmi.dev). This ensures cookies are treated as "same-site" by the browser, avoiding third-party cookie restrictions.

If you must deploy on different registrable domains, be aware that:

  • SameSite=Lax cookies will not be sent on cross-site sub-requests (AJAX/fetch), breaking cookie-based authentication.
  • You may need to use Bearer token authentication instead of cookies, which requires the SPA to handle token storage in JavaScript (losing XSS protection from HttpOnly).

Content Security Policy (CSP)

If your deployment includes a Content Security Policy, ensure it allows:

  • connect-src includes the TMI API server origin (for fetch/XHR requests).
  • default-src or script-src does not block inline scripts needed for token extraction from URL fragments on the callback page.
  • frame-ancestors is set appropriately if TMI-UX may be embedded in an iframe.

Summary: Security Configuration Checklist

Requirement PKCE (browser) CCG (automation) WebSocket
HTTPS in production Required Recommended Required (wss://)
TMI_CORS_ALLOWED_ORIGINS Required (if cross-origin) Not needed Not used (separate setting)
WEBSOCKET_ALLOWED_ORIGINS Not used Not relevant Required in production
TMI_COOKIE_ENABLED true (default) Not relevant Not used (ticket-based)
TMI_COOKIE_DOMAIN Must cover UX and API origins Not relevant Not used
TMI_COOKIE_SECURE true in production Not relevant Not used
credentials: 'include' in fetch Required (if cross-origin) Not relevant Not applicable
Same registrable domain Strongly recommended Not relevant Recommended
OAuth provider callback URL Must match TMI_OAUTH_CALLBACK_URL Not relevant Not relevant
Proxy Upgrade/Connection headers Not relevant Not relevant Required
Session affinity (multi-instance) Not relevant Not relevant Recommended

See Configuration-Reference for the full list of configuration variables.

User Identity

TMI uses a provider-scoped identity model. Users from different providers are treated as separate users even if they share the same email address (e.g., alice@google and alice@github are distinct).

Identity Fields

Field Purpose Visibility
internal_uuid Database primary key, foreign keys, rate limiting Internal only (never in API responses)
provider OAuth provider name (google, github, microsoft, etc.) Internal and API
provider_user_id Provider's user identifier (in JWT sub claim) In API id field
email User's email address API responses
name Display name API responses

Role-Based Access Control

TMI implements three permission levels per threat model:

Role Permissions
owner Full control -- read, write, delete, and manage authorization
writer Read and write -- cannot delete or change authorization
reader Read-only access

Authorization rules:

  1. The owner field takes absolute precedence.
  2. Highest role wins when a user appears multiple times in the authorization list.
  3. The special everyone pseudo-group (provider *, provider_id everyone) grants access to all authenticated users.
  4. Only owners can change authorization or ownership.

WebSocket Authentication

TMI's real-time collaboration features use WebSocket connections for diagram editing. WebSocket authentication works differently from REST API authentication and has its own interactions with browser security policies.

Why Ticket-Based Authentication?

WebSocket connections are initiated via an HTTP Upgrade request. The browser's WebSocket constructor does not support custom headers (no Authorization: Bearer), and while cookies are sent on the upgrade request, TMI deliberately does not accept cookies or JWTs on the WebSocket endpoint. Instead, TMI uses short-lived, single-use tickets.

The reasons for this design:

  • No custom headers: The new WebSocket(url) API does not allow setting Authorization headers. The only way to pass credentials is via query parameters or cookies.
  • Query parameter risk: Putting a long-lived JWT in a query parameter (?token=eyJ...) exposes it in server access logs, proxy logs, browser history, and the Referer header. Tickets mitigate this by being opaque, short-lived (30 seconds), and single-use.
  • Cookie complications: While cookies are sent on the WebSocket upgrade request, relying on them creates coupling to cookie domain/path configuration and makes it harder for non-browser clients to connect.

Authentication Flow

sequenceDiagram

    box rgb(220,235,250) Browser
    participant Client as TMI-UX / Client
    end

    box rgb(250,235,220) TMI Server
    participant API as TMI Server (REST)
    participant WS as TMI Server (WebSocket)
    participant Redis
    end

    Note over Client, Redis: Step 1: Create or join a collaboration session (REST)
    Client ->> API: POST /threat_models/{id}/diagrams/{id}/collaborate<br/>Cookie: tmi_access_token=... (or Authorization: Bearer ...)
    API ->> API: Validate JWT, check authorization
    API ->> Client: 201 {session_id, websocket_url, ...}

    Note over Client, Redis: Step 2: Obtain a short-lived ticket (REST)
    Client ->> API: GET /ws/ticket?session_id={session_id}<br/>Cookie: tmi_access_token=...
    API ->> Redis: Store ticket:{ticket} → {user, session_id} [30s TTL]
    API ->> Client: 200 {ticket: "opaque-token"}

    Note over Client, Redis: Step 3: Connect to WebSocket with the ticket
    Client ->> WS: GET /threat_models/.../ws?ticket={ticket}&session_id={session_id}<br/>Upgrade: websocket
    WS ->> Redis: Consume ticket:{ticket} (single-use)
    WS ->> WS: Validate ticket matches session_id
    WS ->> Client: 101 Switching Protocols
    WS ->> Client: diagram_state (initial sync)
Loading

Key properties of tickets:

Property Value
Format Opaque string (not a JWT)
Lifetime 30 seconds
Usage Single-use (consumed on first connection attempt)
Scope Bound to the session_id it was issued for
Storage Redis with TTL-based expiry

WebSocket Origin Checking

WebSocket connections are not subject to CORS in the traditional sense. The browser does not perform a CORS preflight (OPTIONS) request before a WebSocket upgrade. However, the browser does send an Origin header on the WebSocket upgrade request, and TMI validates it server-side.

TMI has a separate origin-checking setting for WebSocket connections:

Setting Purpose
TMI_CORS_ALLOWED_ORIGINS Controls CORS headers for REST API requests (HTTP)
WEBSOCKET_ALLOWED_ORIGINS Controls which origins may establish WebSocket connections

These are independent settings. In production, both should typically be set to the same values, but they are checked at different points:

  • TMI_CORS_ALLOWED_ORIGINS is checked by CORS middleware on every HTTP request.
  • WEBSOCKET_ALLOWED_ORIGINS is checked during the WebSocket upgrade handshake (the CheckOrigin function).
# Production: set both to the same value(s)
TMI_CORS_ALLOWED_ORIGINS=https://app.tmi.dev,https://admin.tmi.dev
WEBSOCKET_ALLOWED_ORIGINS=https://app.tmi.dev,https://admin.tmi.dev

In development mode (TMI_BUILD_MODE=dev), all WebSocket origins are accepted. In production mode, the origin must match one of:

  • The request host
  • The TLS subject name
  • A value in WEBSOCKET_ALLOWED_ORIGINS

If the origin check fails, the server rejects the upgrade request with a 403 Forbidden response before the WebSocket connection is established.

Proxy and Load Balancer Requirements

WebSocket connections require special handling at reverse proxies and load balancers. The HTTP Upgrade mechanism must be forwarded correctly:

# Nginx configuration for WebSocket proxying
location /threat_models/ {
    proxy_pass http://tmi-backend;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}

Common issues:

  • Missing Upgrade/Connection headers: The proxy strips them, causing the WebSocket handshake to fail. Ensure your proxy forwards these headers.
  • Timeout: Proxies may close idle WebSocket connections. Configure appropriate read/send timeouts (TMI defaults to 300 seconds of inactivity before server-side timeout).
  • Session affinity: In multi-instance deployments, WebSocket reconnections must reach the same server instance that holds the collaboration session. Use session affinity (sticky sessions) at the load balancer, or configure a shared Redis-backed session store.

WebSocket Authentication and TLS

When TLS is enabled, clients must use wss:// (WebSocket Secure) instead of ws://:

// Development
const ws = new WebSocket(
  `ws://localhost:8080/threat_models/${tmId}/diagrams/${diagramId}/ws?ticket=${ticket}`,
);

// Production (TLS)
const ws = new WebSocket(
  `wss://api.tmi.dev/threat_models/${tmId}/diagrams/${diagramId}/ws?ticket=${ticket}`,
);

Reconnection

When a WebSocket connection drops, the client must obtain a new ticket before reconnecting -- old tickets cannot be reused. The reconnection flow is:

  1. Detect disconnection.
  2. Call GET /ws/ticket?session_id={session_id} to get a fresh ticket (this REST call uses the existing JWT/cookie authentication).
  3. Reconnect to the WebSocket with the new ticket.
  4. Handle the diagram_state sync message to reconcile any changes that occurred during disconnection.

Use exponential backoff for reconnection attempts to avoid overwhelming the server.

See WebSocket-API-Reference for the complete protocol reference, including message types, operation handling, and client implementation patterns.

TMI Test Provider (Development Only)

For local development and testing, TMI includes a built-in test provider that bypasses external OAuth:

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

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

The login_hint parameter must be 3-20 characters, alphanumeric plus hyphens. This provider is only available when explicitly enabled and should never be used in production.

Token Delivery via URL Fragments

Both OAuth and SAML authentication flows deliver tokens via URL fragments (the portion after #) rather than query parameters. This prevents tokens from appearing in server logs, proxy logs, browser history, and Referrer headers.

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

Clients must extract tokens from window.location.hash, not window.location.search. See API-Integration#oauth-token-delivery-via-url-fragments for implementation examples.

Related Pages

Clone this wiki locally