Skip to content

Instantly share code, notes, and snippets.

@Shahinyanm
Last active April 14, 2026 14:47
Show Gist options
  • Select an option

  • Save Shahinyanm/47b2b813f28c839c6828a9ed0ff7d7d2 to your computer and use it in GitHub Desktop.

Select an option

Save Shahinyanm/47b2b813f28c839c6828a9ed0ff7d7d2 to your computer and use it in GitHub Desktop.
AuthProfile Module
<?php
namespace Modules\OAuth\Grants;
use DateInterval;
use Ramsey\Uuid\Uuid;
use Modules\OAuth\Utils\RequestMeta;
use League\OAuth2\Server\RequestEvent;
use Modules\OAuth\Dto\OAuthVerifyOtpDto;
use Modules\OAuth\Services\OAuthService;
use Illuminate\Support\Facades\Validator;
use Psr\Http\Message\ServerRequestInterface;
use Illuminate\Validation\ValidationException;
use Modules\OAuth\Exceptions\InvalidOtpException;
use Modules\OAuth\Exceptions\OAuthServerException;
use League\OAuth2\Server\Entities\UserEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface;
use Modules\OAuth\Exceptions\InvalidOtpRecoveryCodeException;
use League\OAuth2\Server\Repositories\UserRepositoryInterface;
use League\OAuth2\Server\Grant\AbstractGrant as LeagueAbstractGrant;
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
abstract class AbstractGrant extends LeagueAbstractGrant
{
public function __construct(
UserRepositoryInterface $userRepository,
RefreshTokenRepositoryInterface $refreshTokenRepository,
protected readonly OAuthService $service,
) {
$this->setUserRepository($userRepository);
$this->setRefreshTokenRepository($refreshTokenRepository);
$this->refreshTokenTTL = new DateInterval('P1M');
}
abstract protected function getServerRequestValidationRules(ServerRequestInterface $request): array;
protected function getServerRequestValidationMessages(ServerRequestInterface $request): array
{
return [
//
];
}
abstract protected function validateUser(ServerRequestInterface $request, ClientEntityInterface $client): UserEntityInterface;
abstract protected function shouldValidateOtp(): bool;
/**
* {@inheritDoc}
*
* @codeCoverageIgnore
* @see \League\OAuth2\Server\Grant\PasswordGrant
*/
public function respondToAccessTokenRequest(
ServerRequestInterface $request,
ResponseTypeInterface $responseType,
DateInterval $accessTokenTTL
) {
$client = $this->validateClient($request);
$scopes = $this->validateScopes($this->getRequestParameter('scope', $request, $this->defaultScope));
$user = $this->validateUser($request, $client);
if ($this->shouldValidateOtp()) {
$this->validateOtp($request, $user);
}
$finalizedScopes = $this->scopeRepository->finalizeScopes(
$scopes,
$this->getIdentifier(),
$client,
$user->getIdentifier()
);
$accessToken = $this->issueAccessToken(
$accessTokenTTL,
$client,
$user->getIdentifier(),
$finalizedScopes,
);
$this->getEmitter()->emit(new RequestEvent(RequestEvent::ACCESS_TOKEN_ISSUED, $request));
$responseType->setAccessToken($accessToken);
$refreshToken = $this->issueRefreshToken($accessToken);
if ($refreshToken !== null) {
$this->getEmitter()->emit(new RequestEvent(RequestEvent::REFRESH_TOKEN_ISSUED, $request));
$responseType->setRefreshToken($refreshToken);
}
return $responseType;
}
protected function validateClient(ServerRequestInterface $request)
{
$clientId = $this->getRequestParameter('client_id', $request);
if (!is_string($clientId) || !Uuid::isValid($clientId)) {
throw OAuthServerException::invalidClient($request);
}
return parent::validateClient($request);
}
protected function validateRequest(ServerRequestInterface $request): array
{
try {
$rules = collect($this->getServerRequestValidationRules($request));
if ($this->shouldValidateOtp()) {
$rules->merge($this->getServerRequestOTPValidationRules());
}
$data = $rules
->keys()
->mapWithKeys(fn ($key) => [$key => $this->getRequestParameter($key, $request)])
->toArray();
$validator = Validator::make(
$data,
$rules->all(),
$this->getServerRequestValidationMessages($request),
);
return $validator->validate();
} catch (ValidationException $e) {
$errors = $e->errors();
$parameter = array_key_first($errors);
$hint = reset($errors)[0];
throw OAuthServerException::invalidRequest($parameter, $hint);
}
}
private function getServerRequestOTPValidationRules(): array
{
return [
'otp' => [
'nullable',
'string',
'size:6',
],
'otp_recovery_code' => [
'nullable',
'string',
'max:255',
],
'trusted' => [
'nullable',
'bool',
],
];
}
private function validateOtp(ServerRequestInterface $request, UserEntityInterface $user): void
{
try {
$this->service->verifyOtp(new OAuthVerifyOtpDto(
$user->getIdentifier(),
$this->getRequestParameter('otp', $request),
$this->getRequestParameter('otp_recovery_code', $request),
RequestMeta::getRemoteAddr($request),
RequestMeta::getUserAgent($request),
(bool) $this->getRequestParameter('trusted', $request),
));
} catch (InvalidOtpException) {
throw OAuthServerException::invalidOtp();
} catch (InvalidOtpRecoveryCodeException) {
throw OAuthServerException::invalidOtpRecoveryCode();
}
}
}
<?php
namespace Modules\AuthProfile\Dto;
final class AuthProfileChangeEmailDto
{
public function __construct(
public readonly int $userId,
public readonly string $newEmail,
public readonly string $currentPassword,
) {
}
}
<?php
namespace Modules\AuthProfile\Dto;
final class AuthProfileChangePasswordDto
{
public function __construct(
public readonly int $userId,
public readonly string $currentPassword,
public readonly string $newPassword,
) {
}
}
<?php
namespace Modules\AuthProfile\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Infrastructure\Eloquent\Models\User;
use Modules\AuthProfile\Dto\AuthProfileUpdateDto;
use Modules\AuthProfile\Dto\AuthProfileChangeEmailDto;
use Modules\AuthProfile\Dto\AuthProfileChangePasswordDto;
use Modules\AuthProfile\Events\ProfileUpdatedEvent;
use Modules\AuthProfile\Events\EmailChangeRequestedEvent;
use Modules\AuthProfile\Events\PasswordChangedEvent;
use Modules\AuthProfile\Exceptions\InvalidCurrentPasswordException;
use Modules\AuthProfile\Exceptions\EmailAlreadyTakenException;
use Modules\AuthProfile\Exceptions\EmailChangeThrottledException;
use Infrastructure\Eloquent\Models\EmailChangeRequest;
use Carbon\CarbonImmutable;
final class AuthProfileCommandService
{
private const EMAIL_CHANGE_THROTTLE_MINUTES = 5;
private const EMAIL_CHANGE_TOKEN_TTL_HOURS = 24;
public function __construct(
private readonly AuthProfileQueryService $queryService,
) {
}
public function update(AuthProfileUpdateDto $dto): void
{
$user = $this->queryService->findOrFail($dto->userId);
$user->update([
'first_name' => $dto->firstName,
'last_name' => $dto->lastName,
]);
event(new ProfileUpdatedEvent($user));
}
/**
* Initiates email change flow with verification.
*
* Uses SELECT FOR UPDATE to prevent race conditions when the same user
* submits multiple email change requests concurrently. Without the lock,
* two concurrent requests could both pass the throttle check and create
* duplicate pending requests.
*
* The uniqueness of the new email is checked within the same transaction
* to avoid TOCTOU — between the check and the insert, another user could
* claim the same email.
*/
public function requestEmailChange(AuthProfileChangeEmailDto $dto): void
{
DB::transaction(function () use ($dto) {
/** @var User $user */
$user = User::query()
->lockForUpdate()
->findOrFail($dto->userId);
if (!Hash::check($dto->currentPassword, $user->password)) {
throw new InvalidCurrentPasswordException();
}
$emailTaken = User::query()
->where('email', $dto->newEmail)
->where('id', '!=', $user->id)
->exists();
if ($emailTaken) {
throw new EmailAlreadyTakenException();
}
$this->assertEmailChangeNotThrottled($user->id);
$pendingRequest = EmailChangeRequest::create([
'user_id' => $user->id,
'new_email' => $dto->newEmail,
'token' => bin2hex(random_bytes(32)),
'expires_at' => CarbonImmutable::now()->addHours(self::EMAIL_CHANGE_TOKEN_TTL_HOURS),
]);
event(new EmailChangeRequestedEvent($user, $pendingRequest));
});
}
public function changePassword(AuthProfileChangePasswordDto $dto): void
{
DB::transaction(function () use ($dto) {
/** @var User $user */
$user = User::query()
->lockForUpdate()
->findOrFail($dto->userId);
if (!Hash::check($dto->currentPassword, $user->password)) {
throw new InvalidCurrentPasswordException();
}
$user->update([
'password' => Hash::make($dto->newPassword),
'password_changed_at' => CarbonImmutable::now(),
]);
event(new PasswordChangedEvent($user));
});
}
/**
* Confirms email change using the token from the verification email.
*
* Uses UPDATE ... WHERE with token + expires_at as a CAS guard:
* only one request can successfully claim the token. If two concurrent
* requests arrive with the same token, only the first UPDATE will match
* a row — the second will see affected_rows = 0 and fail gracefully.
*/
public function confirmEmailChange(string $token): void
{
DB::transaction(function () use ($token) {
$affected = EmailChangeRequest::query()
->where('token', $token)
->where('expires_at', '>', CarbonImmutable::now())
->whereNull('confirmed_at')
->update(['confirmed_at' => CarbonImmutable::now()]);
if ($affected === 0) {
throw new \DomainException('Invalid or expired email change token.');
}
$request = EmailChangeRequest::query()
->where('token', $token)
->firstOrFail();
$emailStillAvailable = !User::query()
->where('email', $request->new_email)
->where('id', '!=', $request->user_id)
->exists();
if (!$emailStillAvailable) {
throw new EmailAlreadyTakenException();
}
User::query()
->where('id', $request->user_id)
->update([
'email' => $request->new_email,
'email_verified_at' => CarbonImmutable::now(),
]);
});
}
private function assertEmailChangeNotThrottled(int $userId): void
{
$recentRequest = EmailChangeRequest::query()
->where('user_id', $userId)
->where('created_at', '>', CarbonImmutable::now()->subMinutes(self::EMAIL_CHANGE_THROTTLE_MINUTES))
->exists();
if ($recentRequest) {
throw new EmailChangeThrottledException(self::EMAIL_CHANGE_THROTTLE_MINUTES);
}
}
}
<?php
namespace Modules\AuthProfile\Http\Resources;
use Infrastructure\Eloquent\Models\User;
use Infrastructure\Http\Resources\JsonResource;
use Modules\AuthProfile\Http\Schemas\AuthProfileSchema;
use Infrastructure\Http\Resources\Traits\ConvertsSchemaToArray;
/**
* @property User $resource
*/
final class AuthProfileResource extends JsonResource
{
use ConvertsSchemaToArray;
public function toSchema($request): AuthProfileSchema
{
return new AuthProfileSchema(
$this->resource->id,
$this->resource->first_name,
$this->resource->last_name,
$this->resource->email,
(bool) $this->resource->password,
(bool) $this->resource->google_id,
(bool) $this->resource->google_2fa_secret,
$this->resource->google_2fa_enabled,
(bool) $this->resource->facebook_id,
$this->resource->email_verified_at?->toRfc3339String(),
$this->resource->password_changed_at?->toRfc3339String(),
$this->resource->registered_at->toRfc3339String(),
);
}
}
<?php
namespace Modules\AuthProfile\Http\Schemas;
use Infrastructure\Http\Schemas\AbstractSchema;
/**
* @OA\Schema(schema="AuthProfileSchema", type="object")
*/
final class AuthProfileSchema extends AbstractSchema
{
public function __construct(
/** @OA\Property(minimum=1) */
public int $id,
/** @OA\Property() */
public ?string $firstName,
/** @OA\Property() */
public ?string $lastName,
/** @OA\Property(format="email") */
public ?string $email,
/** @OA\Property() */
public bool $hasPassword,
/** @OA\Property() */
public bool $hasGoogle,
/** @OA\Property() */
public bool $hasGoogle2FA,
/** @OA\Property() */
public bool $hasGoogle2FAEnabled,
/** @OA\Property() */
public bool $hasFacebook,
/** @OA\Property(format="date-time") */
public ?string $emailVerifiedAt,
/** @OA\Property(format="date-time") */
public ?string $passwordChangedAt,
/** @OA\Property(format="date-time") */
public string $registeredAt,
) {
//
}
}
<?php
namespace Modules\AuthProfile\Http\Actions;
use Illuminate\Http\JsonResponse;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Modules\AuthProfile\Services\AuthProfileCommandService;
use Modules\AuthProfile\Http\Requests\AuthProfileUpdateRequest;
final class AuthProfileUpdateAction
{
use AuthorizesRequests;
/**
* @OA\Put(
* path="/authProfile",
* tags={"AuthProfile"},
* description="Update current authenticated user profile",
* security={
* {"passport": {}},
* },
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* type="object",
* @OA\Property(property="firstName", type="string", nullable=true),
* @OA\Property(property="lastName", type="string", nullable=true),
* ),
* ),
* @OA\Response(
* response=200,
* description="Successful",
* @OA\JsonContent(
* type="object",
* ref="#/components/schemas/MessageSchema",
* ),
* ),
* @OA\Response(
* response=401,
* description="Unauthenticated",
* @OA\JsonContent(
* type="object",
* ref="#/components/schemas/ErrorMessageSchema",
* ),
* ),
* )
*/
public function __invoke(AuthProfileUpdateRequest $request, AuthProfileCommandService $service): JsonResponse
{
$dto = $request->toDto();
$this->authorize('auth:profile@update', [$dto]);
$service->update($dto);
return response()->message(trans('messages.auth_profile.updated'));
}
}
<?php
namespace Modules\AuthProfile\Dto;
final class AuthProfileUpdateDto
{
public function __construct(
public readonly int $userId,
public readonly string $firstName,
public readonly string $lastName,
) {
//
}
}
<?php
namespace Modules\AuthProfile\Http\Requests;
use Illuminate\Support\Facades\Auth;
use Illuminate\Foundation\Http\FormRequest;
use Modules\AuthProfile\Dto\AuthProfileUpdateDto;
final class AuthProfileUpdateRequest extends FormRequest
{
public function rules(): array
{
return [
'firstName' => [
'required',
'string',
'max:255',
],
'lastName' => [
'required',
'string',
'max:255',
],
];
}
public function toDto(): AuthProfileUpdateDto
{
return new AuthProfileUpdateDto(
Auth::id(),
$this->input('firstName'),
$this->input('lastName'),
);
}
}
<?php
namespace Modules\AuthProfile\Http\Actions;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Modules\AuthProfile\Services\AuthProfileQueryService;
use Modules\AuthProfile\Http\Resources\AuthProfileResource;
use Modules\AuthProfile\Http\Requests\AuthProfileViewRequest;
final class AuthProfileViewAction
{
use AuthorizesRequests;
/**
* @OA\Get(
* path="/authProfile",
* tags={"AuthProfile"},
* description="Get current authenticated user profile",
* security={
* {"passport": {}},
* },
* @OA\Response(
* response=200,
* description="Successful",
* @OA\JsonContent(
* type="object",
* @OA\Property(property="data", ref="#/components/schemas/AuthProfileSchema"),
* ),
* ),
* @OA\Response(
* response=401,
* description="Unauthenticated",
* @OA\JsonContent(
* type="object",
* ref="#/components/schemas/ErrorMessageSchema",
* ),
* ),
* )
*/
public function __invoke(AuthProfileViewRequest $request, AuthProfileQueryService $service): JsonResource
{
$dto = $request->toDto();
$this->authorize('auth:profile@view', [$dto]);
$me = $service->view($dto);
return new AuthProfileResource($me);
}
}
<?php
namespace Modules\AuthProfile\Dto;
final class AuthProfileViewDto
{
public function __construct(
public readonly int $userId,
) {
//
}
}
<?php
namespace Modules\OAuth\Grants;
use League\OAuth2\Server\RequestEvent;
use Modules\OAuth\Dto\OAuthFacebookDto;
use Psr\Http\Message\ServerRequestInterface;
use Modules\OAuth\Exceptions\OAuthServerException;
use League\OAuth2\Server\Entities\UserEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use Infrastructure\Validation\Rules\FacebookAccessTokenRule;
final class FacebookGrant extends AbstractGrant
{
public function getIdentifier(): string
{
return 'facebook';
}
protected function shouldValidateOtp(): bool
{
return false;
}
protected function getServerRequestValidationRules(ServerRequestInterface $request): array
{
return [
'token' => [
'required',
'string',
'max:255',
new FacebookAccessTokenRule(),
],
];
}
protected function validateUser(ServerRequestInterface $request, ClientEntityInterface $client): UserEntityInterface
{
$data = $this->validateRequest($request);
$user = $this->service->facebook(new OAuthFacebookDto(
$data['token'],
));
if ($user instanceof UserEntityInterface === false) {
$this->getEmitter()->emit(new RequestEvent(RequestEvent::USER_AUTHENTICATION_FAILED, $request));
throw OAuthServerException::invalidCredentials();
}
return $user;
}
}
<?php
namespace Modules\OAuth\Grants;
use Modules\OAuth\Dto\OAuthGoogleDto;
use League\OAuth2\Server\RequestEvent;
use Psr\Http\Message\ServerRequestInterface;
use Modules\OAuth\Exceptions\OAuthServerException;
use League\OAuth2\Server\Entities\UserEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use Infrastructure\Validation\Rules\GoogleAccessTokenRule;
final class GoogleGrant extends AbstractGrant
{
public function getIdentifier(): string
{
return 'google';
}
protected function shouldValidateOtp(): bool
{
return false;
}
protected function getServerRequestValidationRules(ServerRequestInterface $request): array
{
return [
'token' => [
'required',
'string',
'max:255',
new GoogleAccessTokenRule(),
],
];
}
protected function validateUser(ServerRequestInterface $request, ClientEntityInterface $client): UserEntityInterface
{
$data = $this->validateRequest($request);
$user = $this->service->google(new OAuthGoogleDto(
$data['token'],
));
if ($user instanceof UserEntityInterface === false) {
$this->getEmitter()->emit(new RequestEvent(RequestEvent::USER_AUTHENTICATION_FAILED, $request));
throw OAuthServerException::invalidCredentials();
}
return $user;
}
}
<?php
namespace Modules\OAuth\Grants;
use League\OAuth2\Server\RequestEvent;
use Modules\OAuth\Dto\OAuthPasswordDto;
use Psr\Http\Message\ServerRequestInterface;
use Modules\OAuth\Exceptions\OAuthServerException;
use League\OAuth2\Server\Entities\UserEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
final class PasswordGrant extends AbstractGrant
{
public function getIdentifier(): string
{
return 'password';
}
protected function shouldValidateOtp(): bool
{
return true;
}
protected function getServerRequestValidationRules(ServerRequestInterface $request): array
{
return [
'username' => [
'required',
'string',
'max:255',
],
'password' => [
'required',
'string',
'max:255',
],
];
}
protected function validateUser(ServerRequestInterface $request, ClientEntityInterface $client): UserEntityInterface
{
$data = $this->validateRequest($request);
$user = $this->service->password(new OAuthPasswordDto(
$data['username'],
$data['password'],
));
if ($user instanceof UserEntityInterface === false) {
$this->getEmitter()->emit(new RequestEvent(RequestEvent::USER_AUTHENTICATION_FAILED, $request));
throw OAuthServerException::invalidCredentials();
}
return $user;
}
}
<?php
namespace Tests\Unit\Modules\OAuth\Grants;
use DateInterval;
use Mockery;
use Mockery\MockInterface;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
use League\OAuth2\Server\CryptKey;
use League\OAuth2\Server\RequestEvent;
use League\OAuth2\Server\Entities\UserEntityInterface;
use League\OAuth2\Server\Entities\ScopeEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
use League\OAuth2\Server\Entities\AccessTokenEntityInterface;
use League\OAuth2\Server\Entities\RefreshTokenEntityInterface;
use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
use League\OAuth2\Server\Repositories\UserRepositoryInterface;
use League\OAuth2\Server\Repositories\ClientRepositoryInterface;
use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface;
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface;
use League\Event\EmitterInterface;
use Psr\Http\Message\ServerRequestInterface;
use Modules\OAuth\Grants\PasswordGrant;
use Modules\OAuth\Services\OAuthService;
use Modules\OAuth\Dto\OAuthPasswordDto;
use Modules\OAuth\Dto\OAuthVerifyOtpDto;
use Modules\OAuth\Exceptions\OAuthServerException;
use Modules\OAuth\Exceptions\InvalidOtpException;
final class PasswordGrantTest extends TestCase
{
private PasswordGrant $grant;
private MockInterface&OAuthService $oauthService;
private MockInterface&UserRepositoryInterface $userRepository;
private MockInterface&RefreshTokenRepositoryInterface $refreshTokenRepository;
private MockInterface&ClientRepositoryInterface $clientRepository;
private MockInterface&ScopeRepositoryInterface $scopeRepository;
private MockInterface&AccessTokenRepositoryInterface $accessTokenRepository;
protected function setUp(): void
{
parent::setUp();
$this->oauthService = Mockery::mock(OAuthService::class);
$this->userRepository = Mockery::mock(UserRepositoryInterface::class);
$this->refreshTokenRepository = Mockery::mock(RefreshTokenRepositoryInterface::class);
$this->clientRepository = Mockery::mock(ClientRepositoryInterface::class);
$this->scopeRepository = Mockery::mock(ScopeRepositoryInterface::class);
$this->accessTokenRepository = Mockery::mock(AccessTokenRepositoryInterface::class);
$this->grant = new PasswordGrant(
$this->userRepository,
$this->refreshTokenRepository,
$this->oauthService,
);
$this->grant->setClientRepository($this->clientRepository);
$this->grant->setScopeRepository($this->scopeRepository);
$this->grant->setAccessTokenRepository($this->accessTokenRepository);
$this->grant->setDefaultScope('');
$this->grant->setPrivateKey(new CryptKey('file://' . __DIR__ . '/stubs/private.key', null, false));
$this->grant->setEmitter(Mockery::mock(EmitterInterface::class)->shouldIgnoreMissing());
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
#[Test]
public function it_issues_tokens_for_valid_credentials(): void
{
$request = $this->createRequest([
'client_id' => 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
'client_secret' => 'secret',
'username' => 'user@example.com',
'password' => 'correct-password',
'otp' => '123456',
]);
$client = $this->mockValidClient();
$user = $this->mockValidUser(42);
$this->oauthService
->shouldReceive('password')
->once()
->with(Mockery::on(fn (OAuthPasswordDto $dto) =>
$dto->username === 'user@example.com'
&& $dto->password === 'correct-password'
))
->andReturn($user);
$this->oauthService
->shouldReceive('verifyOtp')
->once()
->with(Mockery::type(OAuthVerifyOtpDto::class));
$this->mockScopeFinalization($client, $user);
$this->mockTokenIssuance($client, $user);
$responseType = Mockery::mock(ResponseTypeInterface::class);
$responseType->shouldReceive('setAccessToken')->once();
$responseType->shouldReceive('setRefreshToken')->once();
$result = $this->grant->respondToAccessTokenRequest(
$request,
$responseType,
new DateInterval('PT1H'),
);
$this->assertSame($responseType, $result);
}
#[Test]
public function it_rejects_invalid_credentials(): void
{
$request = $this->createRequest([
'client_id' => 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
'client_secret' => 'secret',
'username' => 'user@example.com',
'password' => 'wrong-password',
]);
$this->mockValidClient();
$this->oauthService
->shouldReceive('password')
->once()
->andReturnNull();
$this->expectException(OAuthServerException::class);
$this->grant->respondToAccessTokenRequest(
$request,
Mockery::mock(ResponseTypeInterface::class),
new DateInterval('PT1H'),
);
}
#[Test]
public function it_rejects_invalid_otp(): void
{
$request = $this->createRequest([
'client_id' => 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
'client_secret' => 'secret',
'username' => 'user@example.com',
'password' => 'correct-password',
'otp' => '000000',
]);
$this->mockValidClient();
$user = $this->mockValidUser(42);
$this->oauthService
->shouldReceive('password')
->once()
->andReturn($user);
$this->oauthService
->shouldReceive('verifyOtp')
->once()
->andThrow(new InvalidOtpException());
$this->expectException(OAuthServerException::class);
$this->grant->respondToAccessTokenRequest(
$request,
Mockery::mock(ResponseTypeInterface::class),
new DateInterval('PT1H'),
);
}
#[Test]
#[DataProvider('missingRequiredFieldsProvider')]
public function it_rejects_requests_with_missing_required_fields(array $params): void
{
$request = $this->createRequest(array_merge([
'client_id' => 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
'client_secret' => 'secret',
], $params));
$this->mockValidClient();
$this->expectException(OAuthServerException::class);
$this->grant->respondToAccessTokenRequest(
$request,
Mockery::mock(ResponseTypeInterface::class),
new DateInterval('PT1H'),
);
}
public static function missingRequiredFieldsProvider(): array
{
return [
'missing username' => [['password' => 'pass']],
'missing password' => [['username' => 'user@example.com']],
'empty username' => [['username' => '', 'password' => 'pass']],
'empty password' => [['username' => 'user@example.com', 'password' => '']],
];
}
#[Test]
public function it_rejects_invalid_client_id_format(): void
{
$request = $this->createRequest([
'client_id' => 'not-a-uuid',
'client_secret' => 'secret',
'username' => 'user@example.com',
'password' => 'password',
]);
$this->expectException(OAuthServerException::class);
$this->grant->respondToAccessTokenRequest(
$request,
Mockery::mock(ResponseTypeInterface::class),
new DateInterval('PT1H'),
);
}
// ------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------
private function createRequest(array $params): ServerRequestInterface
{
$request = Mockery::mock(ServerRequestInterface::class);
$request->shouldReceive('getParsedBody')->andReturn($params);
$request->shouldReceive('getServerParams')->andReturn([
'REMOTE_ADDR' => '127.0.0.1',
]);
$request->shouldReceive('getHeaderLine')
->with('User-Agent')
->andReturn('TestAgent/1.0');
return $request;
}
private function mockValidClient(): ClientEntityInterface
{
$client = Mockery::mock(ClientEntityInterface::class);
$client->shouldReceive('getIdentifier')->andReturn('f47ac10b-58cc-4372-a567-0e02b2c3d479');
$client->shouldReceive('isConfidential')->andReturn(true);
$client->shouldReceive('getRedirectUri')->andReturn(['https://example.com/callback']);
$this->clientRepository
->shouldReceive('getClientEntity')
->andReturn($client);
$this->clientRepository
->shouldReceive('validateClient')
->andReturn(true);
return $client;
}
private function mockValidUser(int $id): UserEntityInterface
{
$user = Mockery::mock(UserEntityInterface::class);
$user->shouldReceive('getIdentifier')->andReturn($id);
return $user;
}
private function mockScopeFinalization(ClientEntityInterface $client, UserEntityInterface $user): void
{
$this->scopeRepository
->shouldReceive('getScopeEntityByIdentifier')
->andReturn(Mockery::mock(ScopeEntityInterface::class));
$this->scopeRepository
->shouldReceive('finalizeScopes')
->andReturn([]);
}
private function mockTokenIssuance(ClientEntityInterface $client, UserEntityInterface $user): void
{
$accessToken = Mockery::mock(AccessTokenEntityInterface::class);
$accessToken->shouldReceive('getIdentifier')->andReturn('access-token-id');
$accessToken->shouldReceive('setPrivateKey');
$accessToken->shouldReceive('setExpiryDateTime');
$accessToken->shouldReceive('setUserIdentifier');
$accessToken->shouldReceive('setClient');
$accessToken->shouldReceive('addScope');
$this->accessTokenRepository
->shouldReceive('getNewToken')
->andReturn($accessToken);
$this->accessTokenRepository
->shouldReceive('persistNewAccessToken');
$refreshToken = Mockery::mock(RefreshTokenEntityInterface::class);
$refreshToken->shouldReceive('getIdentifier')->andReturn('refresh-token-id');
$refreshToken->shouldReceive('setIdentifier');
$refreshToken->shouldReceive('setExpiryDateTime');
$refreshToken->shouldReceive('setAccessToken');
$this->refreshTokenRepository
->shouldReceive('getNewRefreshToken')
->andReturn($refreshToken);
$this->refreshTokenRepository
->shouldReceive('persistNewRefreshToken');
}
}
<?php
namespace Modules\OAuth\Grants;
use Psr\Http\Message\ServerRequestInterface;
use Modules\OAuth\Dto\OAuthPasswordSignupDto;
use Infrastructure\Validation\Rules\PasswordRule;
use League\OAuth2\Server\Entities\UserEntityInterface;
use League\OAuth2\Server\Entities\ClientEntityInterface;
final class PasswordSignupGrant extends AbstractGrant
{
public function getIdentifier(): string
{
return 'password_signup';
}
protected function shouldValidateOtp(): bool
{
return false;
}
protected function getServerRequestValidationRules(ServerRequestInterface $request): array
{
return [
'username' => [
'required',
'string',
'max:255',
'email',
'unique:users,email',
],
'password' => [
'required',
'string',
'max:255',
],
'first_name' => [
'required',
'string',
'max:255',
],
'last_name' => [
'required',
'string',
'max:255',
],
'company_name' => [
'required',
'string',
'max:255',
],
'company_address' => [
'required',
'string',
'max:255',
],
'company_type' => [
'required',
'string',
'max:255',
],
];
}
protected function getServerRequestValidationMessages(ServerRequestInterface $request): array
{
return [
'username.unique' => trans('validation.unique', ['attribute' => 'email']),
];
}
protected function validateUser(ServerRequestInterface $request, ClientEntityInterface $client): UserEntityInterface
{
$data = $this->validateRequest($request);
return $this->service->passwordSignup(new OAuthPasswordSignupDto(
$data['username'],
$data['password'],
$data['first_name'],
$data['last_name'],
$data['company_type'],
$data['company_address'],
$data['company_name'],
));
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment