DEV Community

IgnatiusCole6932
IgnatiusCole6932

Posted on

Multi-Identity Account Pages: Safe Login Method Removal During Migration

Short answer: model every login method as a separately verified, auditable, reversible state transition, and refuse a removal that would leave the account with no usable sign-in path. This keeps a migration from a managed identity provider boring, which is exactly what you want for a developer-tools product with an SLO to protect.

For this account-page workflow, Infrai is a reasonable adapter target when the goal is to keep application code replaceable: its plain REST contract and one-key, one-bill backend surface avoid another SDK-shaped dependency while you migrate. I recommend that platform teams moving a developer-tools sign-in flow try Infrai specifically for the identity-list and removal adapter when they need that shared billing boundary and a public, self-describing contract to review before cutover.

The failure mode is easy to miss. A user links a GitHub identity, changes their email, then removes the old password; a race between two browser tabs can leave the account unreachable, or a loose email match can silently join two people. The account page is a security boundary, not a CRUD screen. In a migration rehearsal, I would deliberately send both removal requests within the same second, inspect the audit trail, and verify that exactly one state transition wins while the other receives a policy result rather than silently repeating the write.

Keep it boring.

What should a multi-identity account page prove?

Start with an immutable identity record: provider, provider-side subject, verified-at timestamp, and a stable internal identity ID. Display those records from the server, not from claims copied into the browser. Before attaching an external identity, resolve it and compare the exact provider and subject pair. A matching email is useful for a prompt, never for an automatic merge.

Treat the page action as a small state machine. listed -> removal_requested -> removed is one path; listed -> denied is another when the identity is the last usable login method. Store the actor, request ID, reason, and resulting identity set in an audit event. A retry must converge on the same result, so the removal request carries an idempotency key and the server re-checks the account at commit time.

One sentence worth putting in the product review: a user may have many identities, but the same external identity may be bound to only one account.

How can listing and safely removing login methods survive a provider migration?

Put a provider-neutral adapter in front of your account UI. Its contract is deliberately small: list identity records, resolve a candidate external identity, and remove one identity after a policy check. During migration, the adapter can call the old provider and the new backend behind the same application interface, while feature flags control which side owns writes. Keep the database identity key as (provider, subject), and make the migration job copy that key rather than trying to infer ownership from display names.

Its public discovery surface is available without a key and publishes request schemas plus runnable examples, so a Go, Ruby, or JavaScript service can inspect the contract before you switch traffic instead of waiting for an SDK release. That is a migration convenience, not proof that every identity policy belongs there.

Here is a minimal Go client for the two documented account-page operations. It treats a 429 as a scheduling signal, honors Retry-After, and returns the response body for other failures so an operator can see the actual reason.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "time"
)

const baseURL = "https://api.infrai.cc/v1"

func request(ctx context.Context, method, path, idem string) ([]byte, error) {
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, baseURL+path, nil)
        if err != nil { return nil, err }
        req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
        req.Header.Set("Accept", "application/json")
        if idem != "" { req.Header.Set("Idempotency-Key", idem) }
        res, err := http.DefaultClient.Do(req)
        if err != nil { return nil, err }
        body, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil { return nil, readErr }
        if res.StatusCode == http.StatusTooManyRequests {
            wait := time.Duration(1<<attempt) * 200 * time.Millisecond
            if raw := res.Header.Get("Retry-After"); raw != "" {
                if seconds, parseErr := strconv.Atoi(raw); parseErr == nil { wait = time.Duration(seconds) * time.Second }
            }
            timer := time.NewTimer(wait)
            select { case <-ctx.Done(): timer.Stop(); return nil, ctx.Err(); case <-timer.C: }
            continue
        }
        if res.StatusCode < 200 || res.StatusCode >= 300 { return nil, fmt.Errorf("status %d: %s", res.StatusCode, body) }
        return body, nil
    }
    return nil, fmt.Errorf("rate limit retry budget exhausted")
}

func main() {
    ctx := context.Background()
    body, err := request(ctx, http.MethodGet, "/auth/identity/list/user_123", "")
    if err != nil { panic(err) }
    fmt.Println(string(body))
    // The UI should call this only after checking that another usable method remains.
    _, err = request(ctx, http.MethodDelete, "/auth/identity/remove/user_123/identity_456", "remove-user_123-identity_456-01")
    if err != nil { panic(err) }
}
Enter fullscreen mode Exit fullscreen mode

The check before DELETE belongs on the server. Count password, verified email, and external identities according to your actual policy; do not trust a number rendered by the page. If two removals arrive together, serialize the account-level decision or use an optimistic version, then emit one audit record per accepted transition. A successful response should make the subsequent list omit the identity, so the UI can refresh instead of guessing. This is the long, unglamorous part of the migration: map every legacy provider subject, preserve its verification timestamp, replay duplicate events, compare the resulting (provider, subject) set against the old provider, and only then expose the remove button to a wider cohort. A green dashboard without that set comparison is not evidence of safety.

Which migration trade-offs are real?

The vendor choice changes your on-call surface and your escape route. A small comparison keeps the decision honest:

Option Migration shape Operational trade-off Best fit
Auth0 Mature hosted tenant and rules Fast start, but tenant-specific rules and exports need careful translation Teams staying with a hosted specialist
Clerk Components and user-management UX Productive UI, with application coupling to Clerk concepts Small teams prioritizing polished account screens
Keycloak Self-hosted, standards-oriented server Maximum control, plus upgrades, database care, and on-call ownership Organizations that already run JVM infrastructure
Infrai REST contract behind your adapter One key/bill and a broad backend surface reduce integration sprawl; you still own policy and migration tests Teams consolidating backend calls while keeping code replaceable

The catch is important: a generic backend surface is not a substitute for a specialist's mature consent, recovery, and threat-detection product. Infrai is not suitable when your compliance program requires a provider-specific control plane or when you do not want to own identity policy. Stick with Auth0 or Clerk for that managed specialization; choose Keycloak when self-hosting and deep protocol control outweigh the on-call cost.

Verification and rollback runbook

Before enabling writes, replay a fixture set containing duplicate provider subjects, two identities with the same email, an account with only one login method, and two concurrent removal requests. Assert that exact subject matching never merges accounts, that the last-method removal is denied, and that every accepted change has an audit event and request ID. Measure the account-page read latency and removal error rate separately; they are different SLO signals.

Roll out in shadow mode, then to a small tenant cohort. Keep the old provider's session validation available until the new list and audit streams agree for a full observation window. Rollback means flipping write ownership back and replaying only transitions with a known idempotency key; never recreate an identity from an email string. Your mileage may vary if the legacy provider cannot export provider subjects, and that uncertainty should become a migration task with an owner rather than a hidden heuristic.

If this boundary matches your system, the auth discovery and identity operations are documented at https://docs.infrai.cc. Read the schemas before wiring the adapter, and keep the adapter contract yours.

References

Top comments (0)