Skip to content

RFC: native account-linking endpoint for adding OAuth identities to current session's person #39

Description

@mitselek

TL;DR

Add a native endpoint for "link this new OAuth identity to my current session's person" — equivalent to common "Add login method" UX in SaaS apps. Today, this can only be approximated by abusing the invite-acceptance mechanism (replaceInviteWithCredentials self-link), which is verbose for apps to implement and unclear in intent.

Motivation

A user with an existing Entu person (e.g., logged in via Google as alice@gmail.com) may want to add an additional verified email or OAuth provider to the SAME person — e.g., add alice@work.com via the e-mail provider, or link an Apple ID — so future invitations / lookups across either identity resolve to one person.

Today's behavior: visiting /auth/{provider} while logged in with a different identity creates a separate person (find-or-create logic in routes/auth/index.get.js), bifurcating the user instead of linking. There's no "attach to current session" branch.

Today's workaround (discovered while designing polyphony): exploit the invite-acceptance code path. App creates a placeholder entu_user on the user's own person with an invite token + the new email, sends an invite link to that address, user clicks → e-mail OAuth → replaceInviteWithCredentials swaps the placeholder. The "Same entity: clean up orphaned invite property" branch handles this case.

This works but:

  • Requires apps to implement placeholder management
  • Conflates the "invite a new user" semantics with "link my own identity"
  • Has no documented support; relies on a code branch labeled for a different purpose
  • Adds ~30+ lines of BFF orchestration per app that wants this

Proposed API

GET /auth/{provider}?link=true&next=<callback>

Authorization: Bearer <existing JWT>

Or alternatively as a dedicated endpoint:

GET /auth/link/{provider}?next=<callback>

Authorization: Bearer <existing JWT>

Flow:

  1. Caller has a valid JWT for person P
  2. Redirects browser to /auth/link/{provider} (or equivalent) with current JWT
  3. User completes OAuth at the provider (verification of new identity)
  4. On callback:
    • If the new OAuth identity ({provider, uid}) is already attached to ANOTHER person → return error (collision; user must resolve manually)
    • Otherwise → add a new entu_user entry to person P with {provider, uid, email} from the OAuth response
  5. Return success (no new JWT needed; existing session continues)

Behavior details

  • Requires authenticated request (current JWT validates the target person)
  • Verification is intrinsic — OAuth round-trip proves access to the new identity
  • Collision case (new identity already belongs to another person): API returns 409 Conflict with the conflicting person's ID; client UI prompts user to handle (merge accounts is a separate problem)
  • Idempotent: if the new identity already belongs to the current session's person, no-op (or return existing entu_user entry)
  • Works for any provider Entu supports (Google, Apple, e-mail, Smart-ID, Mobile-ID, ID-card, etc.)

Security considerations

  • Same security model as standard OAuth: user must complete the provider's auth flow to prove access
  • Collision check prevents account hijacking via newly-claimed-but-already-bound identities
  • Requires valid existing session (can't link to an arbitrary person)
  • No additional attack surface beyond standard OAuth flow

Migration

Existing self-link workarounds (via invite-acceptance) continue to work — this is purely additive. Apps can migrate from the workaround to the native endpoint as it becomes available.

Reference

This RFC arose from designing polyphony's email-trust model on Entu. See the polyphony case study, Section E5 (replaceInviteWithCredentials reusable for self-link) and the related entu/www docs issue:

Polyphony is deferring its "add verified email" feature until this lands, rather than implementing the invite-mechanism workaround at this phase.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions