-
Notifications
You must be signed in to change notification settings - Fork 1
Identity Linking
Identity linking lets a TMI user bind a second identity provider to their existing account so they can sign in with either one. This page covers the complete flow, security design, error cases, and login resolution behavior. For a summary, see Authentication#identity-linking-multi-idp-accounts.
TMI's primary identity resolution is exact (provider, provider_user_id) matching. A second-IdP login attempt with the same email but no linked identity is rejected with 409 Conflict — this is intentional (T1 account-takeover protection). The link flow is the only supported path to multi-IdP.
POST /me/identities/link/start?idp=github
Authorization: Bearer <step-up-fresh token>Requirements:
- Interactive JWT (service-account tokens →
403). -
auth_timewithin the last 5 minutes (step-up-fresh); otherwise401.
Response:
{
"authorize_url": "https://github.com/login/oauth/authorize?...&prompt=select_account&state=...",
"expires_at": "2026-06-11T14:40:00Z"
}The server stores an oauth_state:{state} key in Redis (10-min TTL) marked with identity_link: true and the caller's user UUID.
prompt=select_account (or prompt=consent where supported, classified per provider) is appended to force a visible IdP chooser. This prevents a silent redirect that would let an attacker swipe the existing IdP session.
The user follows authorize_url in a browser and authenticates with the second IdP. The IdP redirects back to TMI's /oauth2/callback with an authorization code.
On the callback, TMI:
- Exchanges the code for IdP tokens (server-to-server, not exposed to the browser).
- Discards the IdP tokens; retains only
(provider, provider_user_id, email, name)from the userinfo response. - Performs a preliminary 409 check: if the
(provider, provider_user_id)is already bound to any TMI account, returns an error to the confirmation page. - Stages a pending link in Redis (5-min TTL, one-time token, bound to the user UUID from the OAuth state).
- Redirects to the allowlisted
client_callbackURL with the pending token.
GET /me/identities/link/pending/{token}
Authorization: Bearer <any valid interactive JWT>Requirements:
- The JWT's user UUID must match the UUID stored in the pending link →
403or404if mismatched or expired. - Token is not consumed by this GET — only the confirm POST consumes it.
Response:
{
"token": "<one-time token>",
"expires_at": "2026-06-11T14:35:00Z",
"identity_a": {
"provider": "google",
"email": "alice@example.com",
"display_name": "Alice Example"
},
"identity_b": {
"provider": "github",
"provider_sub_suffix": "...abc123",
"email": "alice@users.noreply.github.com",
"display_name": "alicegithub"
}
}The client must render both identities to the user with explicit copy such as: "Linking GitHub [B] to your account [A]. After this, signing in with GitHub [B] will give you access to Alice Example's account."
POST /me/identities/link/confirm
Authorization: Bearer <step-up-fresh token>
Content-Type: application/json
{
"token": "<one-time token from step 3>"
}Requirements:
- Interactive JWT;
auth_timewithin the last 5 minutes (step-up-fresh). - JWT user UUID must match the pending link's UUID.
- Token is consumed one-time; a second POST with the same token →
400.
On success, TMI:
- Re-checks for 409 inside a transaction (race condition safety).
- Inserts a row into
linked_identities. - Writes a
auth.identity_link_completeevent tosystem_audit_entries. - Returns
204 No Content.
The user navigates to TMI's login page and selects the second IdP. Tier-1 resolution now also checks linked_identities — a match resolves to the owning user and mints a JWT carrying the owner's primary identity claims. The login identity used to authenticate is not exposed in the JWT; only the account owner's identity appears.
linked_identities.last_used_at is updated on each login via a linked identity.
GET /me/identities
Authorization: Bearer <any valid interactive JWT>Response:
{
"primary": {
"provider": "google",
"email": "alice@example.com",
"display_name": "Alice Example",
"linked_at": null
},
"linked": [
{
"id": "7fa85f64-5717-4562-b3fc-2c963f66afa6",
"provider": "github",
"email": "alice@users.noreply.github.com",
"display_name": "alicegithub",
"linked_at": "2026-06-11T14:35:00Z",
"last_used_at": "2026-06-11T18:00:00Z"
}
]
}DELETE /me/identities/{id}
Authorization: Bearer <step-up-fresh token>Requirements:
- Interactive JWT; step-up-fresh.
- The linked identity must be owned by the calling user →
404if not found or not owned. - The primary identity cannot be unlinked →
422.
On success, the (provider, provider_user_id) pair is freed and can be linked to another account.
| Status | Condition |
|---|---|
400 |
Invalid or already-consumed pending token |
401 |
Missing/expired JWT, or stale auth_time (step-up required) |
403 |
Service-account (CCG) token; or UUID mismatch on pending/confirm |
404 |
Pending token not found or expired; linked identity not found |
409 |
identity_already_bound — the (provider, provider_user_id) is already linked to a TMI account |
422 |
Attempted to unlink the primary identity |
An attacker could start a link flow on their own account, then trick a victim into completing the IdP round-trip with the victim's B credential. Defense:
- The callback does not commit any bind — it only stages a pending link.
- The pending token is delivered only to the allowlisted
client_callback(the legitimate tmi-ux). - The confirm endpoint requires a JWT whose UUID matches the pending link's user UUID.
- The attacker's UUID ≠ the victim's UUID →
403; the pending link expires unconsumed.
Email is used for display only — never for binding or resolving the link. Explicit consent ((provider, provider_user_id) control proof) replaces email heuristics.
Linking a second identity never merges two existing TMI accounts. If the second IdP identity is already bound to a different TMI account, the link is rejected with 409 identity_already_bound. There is no override.
| Redis key | TTL | Notes |
|---|---|---|
oauth_state:{state} |
10 minutes | Created at start; consumed at callback |
pending_link:{token} |
5 minutes | Created at callback; consumed at confirm |
| Event | Trigger |
|---|---|
auth.identity_link_complete |
Bind committed to linked_identities
|
auth.identity_link_failed |
Error during IdP exchange or staging |
auth.identity_link_rejected |
409 conflict, UUID mismatch, or expired token |
auth.identity_unlink |
Successful DELETE of a linked identity |
All events flow through system_audit_entries and therefore also fire the operator-pinned webhook alert if configured. Payloads include redacted (provider, sub-suffix) for both identities.
The linked_identities table:
| Column | Notes |
|---|---|
id |
UUID PK |
user_internal_uuid |
Indexed FK → users.internal_uuid
|
provider |
IdP name; unique index with provider_user_id
|
provider_user_id |
IdP-issued user ID; unique index with provider
|
email |
Display cache only — never used for access decisions |
name |
Display cache only |
linked_at |
autoCreateTime |
last_used_at |
Nullable; touched on login via this identity |
The (provider, provider_user_id) unique constraint ensures each upstream identity belongs to exactly one TMI account.
The confirmation screen and flow wiring are implemented in tmi-ux. If you are building a custom client:
- Handle the
client_callbackredirect, extracting thepending_tokenquery parameter. -
GET /me/identities/link/pending/{token}to fetch both identities. - Render a confirmation screen with both identity names visible and explicit "grants sign-in access" copy — do not skip this step.
-
POST /me/identities/link/confirmwith{"token": "..."}. - Handle 401 (step-up required): prompt the user to re-authenticate freshly before retrying confirm.
- Handle 409: inform the user the identity is already bound elsewhere.
- Authentication#identity-linking-multi-idp-accounts -- Overview and summary table
- Authentication#privilege-restrictions -- Why CCG tokens cannot perform this operation
-
Admin-Audit-API -- Query
auth.identity_link_*events in the system audit log - Webhook-Integration#operator-pinned-alert-sink -- Out-of-band alerting for identity events
-
Database-Schema-Reference --
linked_identitiestable schema
- Using TMI for Threat Modeling
- Accessing TMI
- Authentication
- Identity Linking
- 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
- Bootstrapping Production
- Component Integration
- Post-Deployment
- Branding and Customization
- Monitoring and Health
- Cloud Logging
- Configuring Local Development
- Managing Operational Settings
- Content Extractors - Limits and Overrides
- Database Operations
- Database Security Strategies
- Transaction Isolation
- Oracle Content Feedback FK Cleanup
- Security Operations
- Performance and Scaling
- Maintenance Tasks
- Getting Started with Development
- Local Development Cluster
- Architecture and Design
- API Integration
- Testing
- Contributing
- Extending TMI
- Dependency Upgrade Plans
- DFD Graphing Library Reference
- Migration Instructions
- Issue Tracker Integration
- Webhook Integration
- Addon System
- MCP Integration
- Delegated Content Providers
- Setting Up Google Content Providers
- API Clients
- API Client Maintenance
- Database Tool Reference
- TMI Terraform Analyzer
- TMI Promtail Logger
- WebSocket Test Harness