Skip to content

Identity Linking

Eric Fitzgerald edited this page Jun 12, 2026 · 1 revision

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_time within the last 5 minutes (step-up-fresh); otherwise 401.

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:

  1. Exchanges the code for IdP tokens (server-to-server, not exposed to the browser).
  2. Discards the IdP tokens; retains only (provider, provider_user_id, email, name) from the userinfo response.
  3. 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.
  4. Stages a pending link in Redis (5-min TTL, one-time token, bound to the user UUID from the OAuth state).
  5. Redirects to the allowlisted client_callback URL 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 → 403 or 404 if 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."

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_time within 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:

  1. Re-checks for 409 inside a transaction (race condition safety).
  2. Inserts a row into linked_identities.
  3. Writes a auth.identity_link_complete event to system_audit_entries.
  4. 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": "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"
    }
  ]
}

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 → 404 if 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:

  1. Handle the client_callback redirect, extracting the pending_token query parameter.
  2. GET /me/identities/link/pending/{token} to fetch both identities.
  3. Render a confirmation screen with both identity names visible and explicit "grants sign-in access" copy — do not skip this step.
  4. POST /me/identities/link/confirm with {"token": "..."}.
  5. Handle 401 (step-up required): prompt the user to re-authenticate freshly before retrying confirm.
  6. Handle 409: inform the user the identity is already bound elsewhere.

Related pages

Home

Releases


Getting Started

Deployment

Operation

Troubleshooting

Development

Integrations

Tools

API Reference

Reference

Clone this wiki locally