Identity Linking - ericfitz/tmi GitHub Wiki
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.
Background
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.
Flow reference
Step 1: Start the link
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.
Step 2: Complete the IdP OAuth round-trip
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.
Step 3: Fetch pending link details
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": "[email protected]",
"display_name": "Alice Example"
},
"identity_b": {
"provider": "github",
"provider_sub_suffix": "...abc123",
"email": "[email protected]",
"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."
Step 4: Confirm the bind
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.
Step 5: Sign in with the linked identity
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.
Listing and unlinking
List identities
GET /me/identities
Authorization: Bearer <any valid interactive JWT>
Response:
{
"primary": {
"provider": "google",
"email": "[email protected]",
"display_name": "Alice Example",
"linked_at": null
},
"linked": [
{
"id": "7fa85f64-5717-4562-b3fc-2c963f66afa6",
"provider": "github",
"email": "[email protected]",
"display_name": "alicegithub",
"linked_at": "2026-06-11T14:35:00Z",
"last_used_at": "2026-06-11T18:00:00Z"
}
]
}
Unlink
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.
Error reference
| 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 |
Security design
Anti link-CSRF (swapped-direction attack)
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.
No email matching
Email is used for display only — never for binding or resolving the link. Explicit consent ((provider, provider_user_id) control proof) replaces email heuristics.
No account merge
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.
Token lifetime
| 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 |
Audit events
| 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.
Database schema
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.
Client implementation notes
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.
Related pages
- 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