-
Notifications
You must be signed in to change notification settings - Fork 30
feat(identity): OAuth flow refactor with account merge strategy #288
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
cd19d84
fix(identity): remove references to deleted Information and Address m…
danielhe4rt d35e217
chore(identity): remove 3Pontos tenant and set default domain in Base…
danielhe4rt 7900307
feat(identity): refactor OAuth flow with intent-based routing
danielhe4rt c8c20e6
feat(panel-app): add Discord and GitHub social login buttons
danielhe4rt 2dd429e
fix(integration): use APP_URL for OAuth callback URIs
danielhe4rt c87a9d1
fix(panel-admin): ConnectionHub polymorphic owner for tenant providers
danielhe4rt 98e4382
feat(identity): convert tenant_id from bigint to UUID
danielhe4rt 6aad7d3
feat(integration-github): GitHub OAuth client with Saloon
danielhe4rt 00160ee
fix(identity): compact ConnectionHub layout for narrow sidebar
danielhe4rt 379394b
fix(identity): handle username collision with sequential suffix on OA…
danielhe4rt 7c23a00
feat(identity): add first_login_at and enrich user on first OAuth login
danielhe4rt aa0e7a1
refactor(identity): use match expression and expressive variable in F…
danielhe4rt d75a825
feat(identity): detect merge conflict on Link flow and store in session
danielhe4rt e2e3d16
feat(panel-app): merge confirmation modal in ConnectionHub with Merge…
danielhe4rt 9d5741d
fix(panel-app): use panel URL instead of request()->url() for merge r…
danielhe4rt 3ed0b40
feat(panel-app): OAuth login page with Discord/GitHub buttons and pro…
danielhe4rt c9604b7
fix(identity): resolve PHPStan errors — nullable email PHPDoc and ten…
danielhe4rt 1f6d352
fix(tests): resolve CI failures from tenant_id UUID migration
danielhe4rt ad134f7
wip
danielhe4rt 1682268
fix(tests): resolve remaining CI failures from UUID migration
danielhe4rt File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
17 changes: 17 additions & 0 deletions
17
...ules/identity/database/migrations/2026_05_26_001934_add_first_login_at_to_users_table.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| use Illuminate\Database\Migrations\Migration; | ||
| use Illuminate\Database\Schema\Blueprint; | ||
| use Illuminate\Support\Facades\Schema; | ||
|
|
||
| return new class extends Migration | ||
| { | ||
| public function up(): void | ||
| { | ||
| Schema::table('users', function (Blueprint $table): void { | ||
| $table->timestamp('first_login_at')->nullable()->after('banned_at'); | ||
| }); | ||
| } | ||
| }; | ||
128 changes: 128 additions & 0 deletions
128
app-modules/identity/docs/adr/0001-oauth-user-resolution-and-merge-strategy.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| # ADR-0001: OAuth User Resolution and Account Merge Strategy | ||
|
|
||
| **Status:** Accepted | ||
| **Date:** 2026-05-25 | ||
| **Deciders:** danielhe4rt | ||
|
|
||
| ## Context | ||
|
|
||
| The platform has multiple user creation paths that lead to duplicate User records for the same physical person: | ||
|
|
||
| 1. **Discord ETL** — imports guild members as Users with ExternalIdentity (`model_type=user`). These users have no email and a username derived from Discord. | ||
| 2. **OAuth Web Login** — creates Users when authenticating via GitHub/Discord/Twitch. Uses `FindOrCreateUserByProvider` which looks up by `(provider, external_account_id, tenant_id)` then by email. | ||
| 3. **Admin Panel (tenant connections)** — creates ExternalIdentity with `model_type=tenant` for infrastructure (bot credentials, channel access). | ||
|
|
||
| The collision scenarios: | ||
|
|
||
| - **Unique violation**: ETL creates `danielhe4rt` (no email). Same person authenticates via GitHub (username=`danielhe4rt`) → INSERT fails on `users_username_unique`. | ||
| - **Duplicate users**: ETL creates User A (Discord). Same person authenticates via GitHub → lookup fails (different provider, no email match) → creates User B. Now two Users exist for the same person. | ||
| - **Cross-provider blindness**: `FindOrCreateUserByProvider` only searches by the current provider's `external_account_id`. It cannot correlate Discord identity with GitHub identity. | ||
|
|
||
| ### Domain distinction: model_type semantics | ||
|
|
||
| - `model_type = user` → personal identity (the person's account in the app) | ||
| - `model_type = tenant` → infrastructure (Discord server credentials, Twitch channel OAuth, GitHub App tokens) | ||
|
|
||
| These are fundamentally different domains sharing the same table. A tenant-owned ExternalIdentity does NOT represent a person's identity — it represents organizational infrastructure. | ||
|
|
||
| ## Decision | ||
|
|
||
| ### 1. User Resolution on Login (intent=Login) | ||
|
|
||
| `FindOrCreateUserByProvider` resolves users with this priority: | ||
|
|
||
| 1. **ExternalIdentity match** — `(provider, external_account_id, model_type=user)` without tenant filter (cross-tenant) | ||
| 2. **Email match** — `User.email = oauth.email` (when email is not null) | ||
| 3. **Create new user** — if username collides, append sequential suffix (`-2`, `-3`, ...) | ||
|
|
||
| ### 2. First Login Enrichment | ||
|
|
||
| New column `first_login_at` (timestamp, nullable) on `users`. When null, the user was created by ETL and never authenticated directly. | ||
|
|
||
| On first real login (`first_login_at IS NULL`): | ||
|
|
||
| - Update `email` from OAuth provider | ||
| - Update `name` from OAuth provider | ||
| - Attempt `username` update — skip silently if unique constraint would violate | ||
| - Set `first_login_at = now()` | ||
|
|
||
| Subsequent logins: no User field updates. | ||
|
|
||
| ### 3. Account Merge on Link (intent=Link, ConnectionHub) | ||
|
|
||
| When a logged-in user connects a provider via ConnectionHub and the `external_account_id` already belongs to a **different** User (`model_type=user`, cross-tenant lookup): | ||
|
|
||
| | Step | Action | | ||
| | -------- | -------------------------------------------------------------------------------------- | | ||
| | Detect | OAuth callback finds conflicting ExternalIdentity owned by another User | | ||
| | Store | Save conflict state in session (OAuth credentials, conflicting user ID, provider data) | | ||
| | Confirm | ConnectionHub displays modal: old user's username, `created_at`, message count | | ||
| | Execute | Synchronous `DB::transaction` on user confirmation | | ||
| | Re-login | `Auth::login($oldUser)` | | ||
|
|
||
| #### Merge transaction (confirmed by user): | ||
|
|
||
| 1. **Move ExternalIdentities** — update `model_id` on new user's identities → old user | ||
| 2. **Sync tenant memberships** — `$oldUser->tenants()->syncWithoutDetaching($newUser->tenants)` | ||
| 3. **Update old user info** — if `first_login_at` is null: update email, name, attempt username | ||
| 4. **Set `first_login_at`** — on old user if null | ||
| 5. **Delete new user** — hard delete (new user has no heavy relations) | ||
| 6. **Re-login** — authenticate as old user | ||
|
|
||
| #### Merge direction rationale: | ||
|
|
||
| Always keep the **old** user (the one that owns the conflicting `external_account_id`). The old user carries heavy relations (100k+ messages, Character, activity history). The new user is lightweight (just created via OAuth). Transferring 2-3 records from new→old is trivial; transferring 100k messages would be catastrophic overhead. | ||
|
|
||
| ### 4. Conflict detection scope | ||
|
|
||
| - **Cross-tenant**: `external_account_id` is globally unique per provider (Discord user ID, GitHub user ID). Conflict detection queries without `tenant_id` filter. | ||
| - **Only `model_type=user`**: tenant-owned identities are infrastructure, not personal identity. They are never considered for merge. | ||
|
|
||
| ### 5. Username suffix generation | ||
|
|
||
| When creating a new User and `username` collides: | ||
|
|
||
| ```sql | ||
| SELECT username FROM users WHERE username LIKE 'danielhe4rt-%' ORDER BY username DESC LIMIT 1 | ||
| ``` | ||
|
|
||
| Extract the numeric suffix, increment. If none exists, use `-2`. | ||
|
|
||
| ## Alternatives Considered | ||
|
|
||
| ### A — Match by username across providers | ||
|
|
||
| Use username collision as proof of identity (same username = same person). **Rejected**: security risk — anyone could register `torvalds` on GitHub and impersonate a user imported from Discord ETL. | ||
|
|
||
| ### B — Single ExternalIdentity per (provider, external_account_id) with ownership pivot | ||
|
|
||
| Redesign ExternalIdentity to be unique per physical account with a separate ownership table. **Rejected**: over-engineering for the current problem. The `model_type` split (user vs tenant) serves a valid domain distinction and the merge strategy handles conflicts without schema redesign. | ||
|
|
||
| ### C — Transfer old user's data to new user (merge direction: old→new) | ||
|
|
||
| Keep the new user, transfer all history from old. **Rejected**: prohibitively expensive. Users can have 100k+ messages, years of activity. Moving 2-3 lightweight records (identities + tenant pivot) from new→old is orders of magnitude cheaper. | ||
|
|
||
| ### D — Automatic merge without confirmation | ||
|
|
||
| Merge silently when conflict is detected. **Rejected**: user should understand what's happening. They might have connected the wrong provider account accidentally. Confirmation modal shows the target account's details (username, creation date, message count) so the user can make an informed decision. | ||
|
|
||
| ## Consequences | ||
|
|
||
| ### Positive | ||
|
|
||
| - Eliminates duplicate Users for the same physical person | ||
| - ETL-created users seamlessly transition to full accounts on first login | ||
| - No data loss — heavy history stays in place, lightweight data moves to it | ||
| - User has explicit control over merge (confirmation modal) | ||
| - Cross-tenant detection prevents duplicates across multi-server setups | ||
|
|
||
| ### Negative | ||
|
|
||
| - Session-stored merge state can expire if user doesn't confirm promptly | ||
| - Sequential username suffix (`-2`) creates temporary "ugly" usernames until merge happens | ||
| - `first_login_at` adds a column that's only relevant during the ETL→OAuth transition period | ||
|
|
||
| ### Risks | ||
|
|
||
| - Race condition: two concurrent OAuth callbacks for the same `external_account_id` could both pass the lookup and try to create. Mitigated by: `UniqueConstraintViolationException` catch with retry/fallback to existing record. | ||
| - Orphaned merge state in session if user navigates away. Acceptable: state expires naturally, no side effects. |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.