Skip to content
Merged
36 changes: 10 additions & 26 deletions app-modules/identity/src/Auth/Actions/AttachProviderToUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,23 @@
namespace He4rt\Identity\Auth\Actions;

use He4rt\Identity\Auth\DTOs\OAuthAccessDTO;
use He4rt\Identity\Auth\DTOs\OAuthConnectionDTO;
use He4rt\Identity\Auth\DTOs\OAuthUserDTO;
use He4rt\Identity\ExternalIdentity\Enums\CredentialsType;
use He4rt\Identity\ExternalIdentity\Events\ExternalIdentityConnected;
use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity;
use He4rt\Identity\User\Models\User;

final class AttachProviderToUser
final readonly class AttachProviderToUser
{
public function __construct(private PersistOAuthConnection $persistConnection) {}

public function execute(User $owner, OAuthUserDTO $oauthUser, OAuthAccessDTO $access): ExternalIdentity
{
/** @var ExternalIdentity $identity */
$identity = $owner->providers()->updateOrCreate(
[
'provider' => $oauthUser->provider,
'external_account_id' => $oauthUser->providerId,
],
[
'type' => $oauthUser->provider->getType(),
'credentials_type' => CredentialsType::OAuth2,
'credentials' => $access->toClientAccessManager(),
'metadata' => array_filter([
'email' => $oauthUser->email,
'avatar' => $oauthUser->avatarUrl,
'username' => $oauthUser->username,
]),
'connected_at' => now(),
'disconnected_at' => null,
'connected_by' => auth()->id(),
]
);
$authenticatedUserId = auth()->id();

event(new ExternalIdentityConnected($identity));

return $identity;
return $this->persistConnection->execute(
owner: $owner,
connection: OAuthConnectionDTO::fromOAuth($oauthUser, $access),
connectedBy: is_string($authenticatedUserId) ? $authenticatedUserId : null,
);
}
}
53 changes: 53 additions & 0 deletions app-modules/identity/src/Auth/Actions/ConfirmOAuthMerge.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace He4rt\Identity\Auth\Actions;

use He4rt\Identity\Auth\DTOs\PendingOAuthMergeDTO;
use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity;
use He4rt\Identity\User\Models\User;
use Illuminate\Support\Facades\DB;

final readonly class ConfirmOAuthMerge
{
public function __construct(
private MergeAccountsAction $mergeAccounts,
private PersistOAuthConnection $persistConnection,
) {}

public function execute(User $currentUser, PendingOAuthMergeDTO $pending): ?User
{
return DB::transaction(function () use ($currentUser, $pending): ?User {
$targetUser = User::query()
->lockForUpdate()
->find($pending->conflictingUserId);

if (!$targetUser instanceof User || $targetUser->is($currentUser)) {
return null;
}

$conflictingIdentity = ExternalIdentity::query()
->where('model_type', (new User)->getMorphClass())
->where('model_id', $targetUser->id)
->where('provider', $pending->connection->provider)
->where('external_account_id', $pending->connection->providerId)
->lockForUpdate()
->first();

if (!$conflictingIdentity instanceof ExternalIdentity) {
return null;
}

$this->mergeAccounts->execute($currentUser, $targetUser);

$this->persistConnection->execute(
owner: $targetUser,
connection: $pending->connection,
connectedBy: $targetUser->id,
);

return $targetUser->refresh();
});
}
}
37 changes: 37 additions & 0 deletions app-modules/identity/src/Auth/Actions/PersistOAuthConnection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace He4rt\Identity\Auth\Actions;

use He4rt\Identity\Auth\DTOs\OAuthConnectionDTO;
use He4rt\Identity\ExternalIdentity\Enums\CredentialsType;
use He4rt\Identity\ExternalIdentity\Events\ExternalIdentityConnected;
use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity;
use He4rt\Identity\User\Models\User;

final class PersistOAuthConnection
{
public function execute(User $owner, OAuthConnectionDTO $connection, ?string $connectedBy): ExternalIdentity
{
/** @var ExternalIdentity $identity */
$identity = $owner->providers()->firstOrNew([
'provider' => $connection->provider,
'external_account_id' => $connection->providerId,
]);

$identity->forceFill([
'type' => $connection->provider->getType(),
'credentials_type' => CredentialsType::OAuth2,
'credentials' => $connection->credentials,
'metadata' => array_replace($identity->metadata ?? [], $connection->metadata),
'connected_at' => now(),
'disconnected_at' => null,
'connected_by' => $connectedBy,
])->save();

event(new ExternalIdentityConnected($identity));

return $identity;
}
}
21 changes: 9 additions & 12 deletions app-modules/identity/src/Auth/DTOs/MergeConflictDTO.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,19 @@ public function __construct(
public OAuthUserDTO $oauthUser,
) {}

public function toPending(): PendingOAuthMergeDTO
{
return new PendingOAuthMergeDTO(
conflictingUserId: $this->conflictingUserId,
connection: OAuthConnectionDTO::fromOAuth($this->oauthUser, $this->credentials),
);
}

/**
* @return array<string, mixed>
*/
public function toSession(): array
{
return [
'conflicting_user_id' => $this->conflictingUserId,
'provider' => $this->provider->value,
'credentials' => $this->credentials->toDatabase(),
'oauth_user' => [
'provider_id' => $this->oauthUser->providerId,
'username' => $this->oauthUser->username,
'name' => $this->oauthUser->name,
'email' => $this->oauthUser->email,
'avatar_url' => $this->oauthUser->avatarUrl,
],
];
return $this->toPending()->toSession();
}
}
31 changes: 31 additions & 0 deletions app-modules/identity/src/Auth/DTOs/OAuthConnectionDTO.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace He4rt\Identity\Auth\DTOs;

use He4rt\Identity\ExternalIdentity\Data\ClientAccessManager;
use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider;

final readonly class OAuthConnectionDTO
{
/**
* @param array<string, mixed> $metadata
*/
public function __construct(
public IdentityProvider $provider,
public string $providerId,
public ClientAccessManager $credentials,
public array $metadata,
) {}

public static function fromOAuth(OAuthUserDTO $oauthUser, OAuthAccessDTO $access): self
{
return new self(
provider: $oauthUser->provider,
providerId: $oauthUser->providerId,
credentials: $access->toClientAccessManager(),
metadata: $oauthUser->toMetadata(),
);
}
}
10 changes: 10 additions & 0 deletions app-modules/identity/src/Auth/DTOs/OAuthUserDTO.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ public function __construct(
*/
abstract public static function make(OAuthAccessDTO $credentials, array $payload): self;

/** @return array<string, mixed> */
public function toMetadata(): array
{
return [
...($this->email !== null ? ['email' => $this->email] : []),
...($this->avatarUrl !== null ? ['avatar' => $this->avatarUrl] : []),
'username' => $this->username,
];
}
Comment thread
henrique-leme marked this conversation as resolved.

/**
* @return array<string, mixed>
*/
Expand Down
94 changes: 94 additions & 0 deletions app-modules/identity/src/Auth/DTOs/PendingOAuthMergeDTO.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<?php

declare(strict_types=1);

namespace He4rt\Identity\Auth\DTOs;

use He4rt\Identity\ExternalIdentity\Data\ClientAccessManager;
use He4rt\Identity\ExternalIdentity\Enums\CredentialsType;
use He4rt\Identity\ExternalIdentity\Enums\IdentityProvider;

final readonly class PendingOAuthMergeDTO
{
public function __construct(
public string $conflictingUserId,
public OAuthConnectionDTO $connection,
) {}

public static function fromSession(mixed $payload): ?self
{
if (!is_array($payload)) {
return null;
}

$conflictingUserId = $payload['conflicting_user_id'] ?? null;
$providerValue = $payload['provider'] ?? null;
$providerId = $payload['provider_id'] ?? null;
$credentials = $payload['credentials'] ?? null;
$metadata = $payload['metadata'] ?? null;

if (
!is_string($conflictingUserId)
|| !is_string($providerValue)
|| !is_string($providerId)
|| !is_array($credentials)
|| !is_array($metadata)
) {
return null;
}

$accessToken = $credentials['access_token'] ?? null;
$refreshToken = $credentials['refresh_token'] ?? null;
$expiresIn = $credentials['expires_in'] ?? null;
$provider = IdentityProvider::tryFrom($providerValue);

$hasValidTokens = is_string($accessToken)
&& is_string($refreshToken)
&& (is_string($expiresIn) || $expiresIn === null);
$isOAuthProvider = $provider instanceof IdentityProvider
&& $provider->getCredentialsType() === CredentialsType::OAuth2;

if (!$hasValidTokens || !$isOAuthProvider) {
return null;
}

/** @var array<string, mixed> $metadata */
return new self(
conflictingUserId: $conflictingUserId,
connection: new OAuthConnectionDTO(
provider: $provider,
providerId: $providerId,
credentials: ClientAccessManager::make(
accessToken: $accessToken,
refreshToken: $refreshToken,
expiresIn: $expiresIn,
),
metadata: $metadata,
),
);
}

/**
* @return array{
* conflicting_user_id: string,
* provider: string,
* provider_id: string,
* credentials: array{access_token: string|null, refresh_token: string|null, expires_in: int|string|null},
* metadata: array<string, mixed>,
* }
*/
public function toSession(): array
{
return [
'conflicting_user_id' => $this->conflictingUserId,
'provider' => $this->connection->provider->value,
'provider_id' => $this->connection->providerId,
'credentials' => [
'access_token' => $this->connection->credentials->accessToken,
'refresh_token' => $this->connection->credentials->refreshToken,
'expires_in' => $this->connection->credentials->expiresIn,
],
'metadata' => $this->connection->metadata,
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ public function handle(ResolveUserProviderDTO $dto): ExternalIdentity
'email' => $dto->email,
'avatar' => $dto->avatar,
]),
'connected_at' => now(),
]
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,9 @@ protected static function newFactory(): ExternalIdentityFactory

/**
* Identidade que uma pessoa realmente conectou, e não um registro criado por
* ingestão. As datas sozinhas não separam os dois: a ETL também preenche
* connected_at. O que só existe no fluxo OAuth é a credencial.
* ingestão. As datas sozinhas não separam os dois porque registros históricos
* da ETL também podem ter connected_at. O que só existe no fluxo de autenticação
* é a credencial.
*
* @param Builder<self> $query
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,42 @@ public static function make(OAuthAccessDTO $credentials, array $payload): self

Event::assertDispatched(fn (ExternalIdentityConnected $event): bool => $event->identity->id === $second->id);
});

test('reattaching preserves metadata owned by other ingestion flows', function (): void {
$user = User::factory()->create();

$existing = ExternalIdentity::factory()->morphFor()->create([
'model_id' => $user->id,
'provider' => IdentityProvider::GitHub,
'external_account_id' => '999',
'metadata' => [
'email' => 'preserved@example.com',
'username' => 'old-name',
'avatar' => 'old-avatar',
'profile' => ['bio' => 'Imported profile'],
'badges' => [['id' => 'contributor']],
],
]);

$identity = resolve(AttachProviderToUser::class)->execute(
$user,
attachOAuthUser(
providerId: '999',
provider: IdentityProvider::GitHub,
username: 'new-name',
email: null,
),
(attachOAuthUser())->credentials,
);

expect($identity->id)->toBe($existing->id)
->and($identity->metadata)->toMatchArray([
'email' => 'preserved@example.com',
'username' => 'new-name',
'avatar' => 'old-avatar',
'profile' => ['bio' => 'Imported profile'],
'badges' => [['id' => 'contributor']],
])
->and($identity->credentials->getAccessToken())->toBe('token')
->and((string) $identity->model_id)->toBe((string) $user->id);
});
Loading
Loading