-
Notifications
You must be signed in to change notification settings - Fork 2
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.
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.
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.
| 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 |
| 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 |
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.
- The client generates a random
code_verifier(43-128 characters) and computescode_challenge = BASE64URL(SHA256(code_verifier)). - The client redirects to TMI's
/oauth2/authorizeendpoint with thecode_challenge. - TMI stores the challenge in Redis and redirects to the identity provider.
- The user authenticates with the provider.
- The provider redirects back to TMI with an authorization code.
- TMI binds the PKCE challenge to the code and redirects to the client's callback URL.
- The client exchanges the authorization code plus the original
code_verifierat/oauth2/token. - TMI validates that
SHA256(code_verifier)matches the stored challenge, exchanges the code with the provider, and issues JWT tokens.
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)
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) |
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 (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.
- A user or administrator creates client credentials in TMI-UX, receiving a
client_idandclient_secret. - The automation sends a POST request to
/oauth2/tokenwithgrant_type=client_credentials. - TMI validates the credentials (bcrypt comparison) and issues an access token.
- The token carries the same identity and permissions as the user who owns the 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_secretis displayed only once at creation time. Store it securely (e.g., in a secrets manager or environment variable). It cannot be retrieved later.
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)
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
}Note: CCG tokens do not include a refresh token. When the token expires, request a new one using the same client credentials.
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()| 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) |
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.
TMI supports two token delivery models, chosen based on the client type:
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:
-
HttpOnlyprevents JavaScript from reading the cookie viadocument.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=Laxon 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=Stricton the refresh token is more restrictive -- the cookie is only sent on requests originating from the same site. Combined withPath=/oauth2, this ensures the long-lived refresh token is never sent to API endpoints; it is only included in requests to the/oauth2/refreshendpoint. -
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.
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/tokenendpoint. - 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
Authorizationheader. - 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/refreshwith 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.
The TMI server accepts both delivery mechanisms and resolves them in a defined order:
- Check for
Authorization: Bearerheader. - If no header, check for
tmi_access_tokencookie. - 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.
{
"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"
}| 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 | 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 |
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_SECRETfrom 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.
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.
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-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 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.comandhttps://tmi.example.com:8080are different origins.https://tmi.example.comandhttps://api.tmi.example.comare different origins.
| 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 |
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=devNote: 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, ... |
For HttpOnly cookies to be sent on cross-origin requests, all three of the following must be true:
-
Server sets
Access-Control-Allow-Credentials: truein the response. -
Server sets
Access-Control-Allow-Originto the specific requesting origin (not*). When credentials are involved, the wildcard*is rejected by browsers. -
Client sets
credentials: 'include'on fetch requests (orwithCredentials: trueon 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",
},
});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/authorizeworks 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.
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.
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. |
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=Laxcookies 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).
If your deployment includes a Content Security Policy, ensure it allows:
-
connect-srcincludes the TMI API server origin (for fetch/XHR requests). -
default-srcorscript-srcdoes not block inline scripts needed for token extraction from URL fragments on the callback page. -
frame-ancestorsis set appropriately if TMI-UX may be embedded in an iframe.
| 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.
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).
| 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 |
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:
- The
ownerfield takes absolute precedence. - Highest role wins when a user appears multiple times in the authorization list.
- The special
everyonepseudo-group (provider*, provider_ideveryone) grants access to all authenticated users. - Only owners can change authorization or ownership.
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.
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 settingAuthorizationheaders. 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 theRefererheader. 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.
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)
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 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_ORIGINSis checked by CORS middleware on every HTTP request. -
WEBSOCKET_ALLOWED_ORIGINSis checked during the WebSocket upgrade handshake (theCheckOriginfunction).
# 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.devIn 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.
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/Connectionheaders: 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.
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}`,
);When a WebSocket connection drops, the client must obtain a new ticket before reconnecting -- old tickets cannot be reused. The reconnection flow is:
- Detect disconnection.
- Call
GET /ws/ticket?session_id={session_id}to get a fresh ticket (this REST call uses the existing JWT/cookie authentication). - Reconnect to the WebSocket with the new ticket.
- Handle the
diagram_statesync 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.
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.
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.
- Setting-Up-Authentication -- Provider configuration (Google, GitHub, Microsoft)
- Configuration-Reference -- All authentication settings and environment variables
- API-Integration -- OAuth client implementations and integration patterns
- Security-Best-Practices -- Authentication hardening and session security
- Architecture-and-Design -- Authentication architecture and user identity design
- REST-API-Reference -- Complete endpoint documentation
- API-Overview -- Authentication endpoints and JWT token reference
- 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