diff --git a/database/factories/ClientFactory.php b/database/factories/ClientFactory.php index 76aae764..b5a9229e 100644 --- a/database/factories/ClientFactory.php +++ b/database/factories/ClientFactory.php @@ -91,4 +91,15 @@ public function asClientCredentials(): static 'redirect_uris' => [], ]); } + + /** + * Use as a Device Code client. + */ + public function asDeviceCodeClient(): static + { + return $this->state([ + 'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token'], + 'redirect_uris' => [], + ]); + } } diff --git a/database/migrations/2024_06_01_000001_create_oauth_device_codes_table.php b/database/migrations/2024_06_01_000001_create_oauth_device_codes_table.php new file mode 100644 index 00000000..ea078319 --- /dev/null +++ b/database/migrations/2024_06_01_000001_create_oauth_device_codes_table.php @@ -0,0 +1,42 @@ +char('id', 80)->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->foreignUuid('client_id')->index(); + $table->char('user_code', 8)->unique(); + $table->text('scopes'); + $table->boolean('revoked'); + $table->dateTime('user_approved_at')->nullable(); + $table->dateTime('last_polled_at')->nullable(); + $table->dateTime('expires_at')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('oauth_device_codes'); + } + + /** + * Get the migration connection name. + */ + public function getConnection(): ?string + { + return $this->connection ?? config('passport.connection'); + } +}; diff --git a/routes/web.php b/routes/web.php index a9288779..533b2335 100644 --- a/routes/web.php +++ b/routes/web.php @@ -15,6 +15,18 @@ 'middleware' => 'web', ]); +Route::get('/device', [ + 'uses' => 'DeviceUserCodeController', + 'as' => 'device', + 'middleware' => 'web', +]); + +Route::post('/device/code', [ + 'uses' => 'DeviceCodeController', + 'as' => 'device.code', + 'middleware' => 'throttle', +]); + $guard = config('passport.guard', null); Route::middleware(['web', $guard ? 'auth:'.$guard : 'auth'])->group(function () { @@ -33,6 +45,21 @@ 'as' => 'authorizations.deny', ]); + Route::get('/device/authorize', [ + 'uses' => 'DeviceAuthorizationController', + 'as' => 'device.authorizations.authorize', + ]); + + Route::post('/device/authorize', [ + 'uses' => 'ApproveDeviceAuthorizationController', + 'as' => 'device.authorizations.approve', + ]); + + Route::delete('/device/authorize', [ + 'uses' => 'DenyDeviceAuthorizationController', + 'as' => 'device.authorizations.deny', + ]); + if (Passport::$registersJsonApiRoutes) { Route::get('/tokens', [ 'uses' => 'AuthorizedAccessTokenController@forUser', diff --git a/src/Bridge/Client.php b/src/Bridge/Client.php index 66218608..79a4f855 100644 --- a/src/Bridge/Client.php +++ b/src/Bridge/Client.php @@ -18,15 +18,18 @@ class Client implements ClientEntityInterface */ public function __construct( string $identifier, - string $name, - array $redirectUri, + ?string $name = null, + array $redirectUri = [], bool $isConfidential = false, public ?string $provider = null, public array $grantTypes = [], ) { $this->setIdentifier($identifier); - $this->name = $name; + if (! is_null($name)) { + $this->name = $name; + } + $this->isConfidential = $isConfidential; $this->redirectUri = $redirectUri; } diff --git a/src/Bridge/DeviceCode.php b/src/Bridge/DeviceCode.php new file mode 100644 index 00000000..5718db94 --- /dev/null +++ b/src/Bridge/DeviceCode.php @@ -0,0 +1,60 @@ +setIdentifier($identifier); + } + + if (! is_null($userIdentifier)) { + $this->setUserIdentifier($userIdentifier); + } + + if (! is_null($clientIdentifier)) { + $this->setClient(new Client($clientIdentifier)); + } + + foreach ($scopes as $scope) { + $this->addScope(new Scope($scope)); + } + + if ($userApproved) { + $this->setUserApproved($userApproved); + } + + if (! is_null($lastPolledAt)) { + $this->setLastPolledAt($lastPolledAt); + } + + if (! is_null($expiryDateTime)) { + $this->setExpiryDateTime($expiryDateTime); + } + } +} diff --git a/src/Bridge/DeviceCodeRepository.php b/src/Bridge/DeviceCodeRepository.php new file mode 100644 index 00000000..d750bc86 --- /dev/null +++ b/src/Bridge/DeviceCodeRepository.php @@ -0,0 +1,106 @@ +getUserIdentifier())) { + Passport::deviceCode()->newQuery()->whereKey($deviceCodeEntity->getIdentifier())->update([ + 'user_id' => $deviceCodeEntity->getUserIdentifier(), + 'user_approved_at' => $deviceCodeEntity->getUserApproved() ? Date::now() : null, + ]); + } elseif (! is_null($deviceCodeEntity->getLastPolledAt())) { + Passport::deviceCode()->newQuery()->whereKey($deviceCodeEntity->getIdentifier())->update([ + 'last_polled_at' => $deviceCodeEntity->getLastPolledAt(), + ]); + } else { + Passport::deviceCode()->forceFill([ + 'id' => $deviceCodeEntity->getIdentifier(), + 'user_id' => null, + 'client_id' => $deviceCodeEntity->getClient()->getIdentifier(), + 'user_code' => $deviceCodeEntity->getUserCode(), + 'scopes' => $deviceCodeEntity->getScopes(), + 'revoked' => false, + 'user_approved_at' => null, + 'last_polled_at' => null, + 'expires_at' => $deviceCodeEntity->getExpiryDateTime(), + ])->save(); + } + } + + /** + * {@inheritdoc} + */ + public function getDeviceCodeEntityByDeviceCode(string $deviceCode): ?DeviceCodeEntityInterface + { + $record = Passport::deviceCode()->newQuery()->whereKey($deviceCode)->where(['revoked' => false])->first(); + + return $record ? $this->fromDeviceCodeModel($record) : null; + } + + /* + * Get the device code entity by the given user code. + */ + public function getDeviceCodeEntityByUserCode(string $userCode): ?DeviceCodeEntityInterface + { + $record = Passport::deviceCode()->newQuery() + ->where('user_code', $userCode) + ->whereNull('user_id') + ->where('expires_at', '>', Date::now()) + ->where('revoked', false) + ->first(); + + return $record ? $this->fromDeviceCodeModel($record) : null; + } + + /** + * {@inheritdoc} + */ + public function revokeDeviceCode(string $codeId): void + { + Passport::deviceCode()->newQuery()->whereKey($codeId)->update(['revoked' => true]); + } + + /** + * {@inheritdoc} + */ + public function isDeviceCodeRevoked(string $codeId): bool + { + return Passport::deviceCode()->newQuery()->whereKey($codeId)->where('revoked', false)->doesntExist(); + } + + /** + * Create a new device code entity from the given device code model instance. + */ + protected function fromDeviceCodeModel(DeviceCodeModel $model): DeviceCodeEntityInterface + { + return new DeviceCode( + $model->getKey(), + $model->user_id, + $model->client_id, + $model->scopes, + ! is_null($model->user_approved_at), + $model->last_polled_at?->toDateTimeImmutable(), + $model->expires_at?->toDateTimeImmutable() + ); + } +} diff --git a/src/ClientRepository.php b/src/ClientRepository.php index 94b9052f..2d94cd64 100644 --- a/src/ClientRepository.php +++ b/src/ClientRepository.php @@ -152,6 +152,21 @@ public function createImplicitGrantClient(string $name, array $redirectUris): Cl return $this->create($name, ['implicit'], $redirectUris, null, false); } + /** + * Store a new device authorization grant client. + * + * @param \Laravel\Passport\HasApiTokens|null $user + */ + public function createDeviceAuthorizationGrantClient( + string $name, + bool $confidential = true, + ?Authenticatable $user = null + ): Client { + return $this->create( + $name, ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token'], [], null, $confidential, $user + ); + } + /** * Store a new authorization code grant client. * @@ -161,11 +176,16 @@ public function createAuthorizationCodeGrantClient( string $name, array $redirectUris, bool $confidential = true, - ?Authenticatable $user = null + ?Authenticatable $user = null, + bool $enableDeviceFlow = false ): Client { - return $this->create( - $name, ['authorization_code', 'refresh_token'], $redirectUris, null, $confidential, $user - ); + $grantTypes = ['authorization_code', 'refresh_token']; + + if ($enableDeviceFlow) { + $grantTypes[] = 'urn:ietf:params:oauth:grant-type:device_code'; + } + + return $this->create($name, $grantTypes, $redirectUris, null, $confidential, $user); } /** diff --git a/src/Console/ClientCommand.php b/src/Console/ClientCommand.php index 80be539d..3a06ee5a 100644 --- a/src/Console/ClientCommand.php +++ b/src/Console/ClientCommand.php @@ -20,6 +20,7 @@ class ClientCommand extends Command {--password : Create a password grant client} {--client : Create a client credentials grant client} {--implicit : Create an implicit grant client} + {--device : Create a device authorization grant client} {--name= : The name of the client} {--provider= : The name of the user provider} {--redirect_uri= : The URI to redirect to after authorization } @@ -49,6 +50,7 @@ public function handle(ClientRepository $clients): void $this->option('password') => $this->createPasswordClient($clients), $this->option('client') => $this->createClientCredentialsClient($clients), $this->option('implicit') => $this->createImplicitClient($clients), + $this->option('device') => $this->createDeviceCodeClient($clients), default => $this->createAuthCodeClient($clients) }; @@ -119,6 +121,18 @@ protected function createImplicitClient(ClientRepository $clients): Client return $clients->createImplicitGrantClient($this->option('name'), explode(',', $redirect)); } + /** + * Create a device code client. + */ + protected function createDeviceCodeClient(ClientRepository $clients): Client + { + $confidential = $this->hasOption('public') + ? ! $this->option('public') + : $this->confirm('Would you like to make this client confidential?', true); + + return $clients->createDeviceAuthorizationGrantClient($this->option('name'), $confidential); + } + /** * Create an authorization code client. */ @@ -133,8 +147,10 @@ protected function createAuthCodeClient(ClientRepository $clients): Client ? ! $this->option('public') : $this->confirm('Would you like to make this client confidential?', true); + $enableDeviceFlow = $this->confirm('Would you like to enable the device authorization flow for this client?'); + return $clients->createAuthorizationCodeGrantClient( - $this->option('name'), explode(',', $redirect), $confidential, + $this->option('name'), explode(',', $redirect), $confidential, null, $enableDeviceFlow ); } } diff --git a/src/Console/PurgeCommand.php b/src/Console/PurgeCommand.php index 0012c5ba..656cecef 100644 --- a/src/Console/PurgeCommand.php +++ b/src/Console/PurgeCommand.php @@ -46,6 +46,7 @@ public function handle(): void Passport::token()->newQuery()->where($constraint)->delete(); Passport::authCode()->newQuery()->where($constraint)->delete(); Passport::refreshToken()->newQuery()->where($constraint)->delete(); + Passport::deviceCode()->newQuery()->where($constraint)->delete(); $this->components->info(sprintf('Purged %s.', implode(' and ', array_filter([ $revoked ? 'revoked items' : null, diff --git a/src/Contracts/ApprovedDeviceAuthorizationResponse.php b/src/Contracts/ApprovedDeviceAuthorizationResponse.php new file mode 100644 index 00000000..07f5da34 --- /dev/null +++ b/src/Contracts/ApprovedDeviceAuthorizationResponse.php @@ -0,0 +1,10 @@ + $parameters + */ + public function withParameters(array $parameters = []): static; +} diff --git a/src/Contracts/DeviceUserCodeViewResponse.php b/src/Contracts/DeviceUserCodeViewResponse.php new file mode 100644 index 00000000..97435d36 --- /dev/null +++ b/src/Contracts/DeviceUserCodeViewResponse.php @@ -0,0 +1,15 @@ + $parameters + */ + public function withParameters(array $parameters = []): static; +} diff --git a/src/DeviceCode.php b/src/DeviceCode.php new file mode 100644 index 00000000..b81abf51 --- /dev/null +++ b/src/DeviceCode.php @@ -0,0 +1,64 @@ +|bool + */ + protected $guarded = false; + + /** + * The attributes that should be cast to native types. + * + * @var array + */ + protected $casts = [ + 'scopes' => 'array', + 'revoked' => 'bool', + 'user_approved_at' => 'datetime', + 'last_polled_at' => 'datetime', + 'expires_at' => 'datetime', + ]; + + /** + * Get the current connection name for the model. + */ + public function getConnectionName(): ?string + { + return $this->connection ?? config('passport.connection'); + } +} diff --git a/src/Http/Controllers/ApproveDeviceAuthorizationController.php b/src/Http/Controllers/ApproveDeviceAuthorizationController.php new file mode 100644 index 00000000..5eeac5c7 --- /dev/null +++ b/src/Http/Controllers/ApproveDeviceAuthorizationController.php @@ -0,0 +1,38 @@ +getDeviceCodeFromSession($request); + + $this->withErrorHandling(fn () => $this->server->completeDeviceAuthorizationRequest( + $deviceCode->getIdentifier(), + $deviceCode->getUserIdentifier(), + true + )); + + return $response; + } +} diff --git a/src/Http/Controllers/DenyDeviceAuthorizationController.php b/src/Http/Controllers/DenyDeviceAuthorizationController.php new file mode 100644 index 00000000..c1df2b10 --- /dev/null +++ b/src/Http/Controllers/DenyDeviceAuthorizationController.php @@ -0,0 +1,38 @@ +getDeviceCodeFromSession($request); + + $this->withErrorHandling(fn () => $this->server->completeDeviceAuthorizationRequest( + $deviceCode->getIdentifier(), + $deviceCode->getUserIdentifier(), + false + )); + + return $response; + } +} diff --git a/src/Http/Controllers/DeviceAuthorizationController.php b/src/Http/Controllers/DeviceAuthorizationController.php new file mode 100644 index 00000000..37a13e22 --- /dev/null +++ b/src/Http/Controllers/DeviceAuthorizationController.php @@ -0,0 +1,82 @@ +query('user_code')) { + return to_route('passport.device'); + } + + $deviceCode = $this->deviceCodes->getDeviceCodeEntityByUserCode( + str_replace('-', '', $userCode) + ); + + if (! $deviceCode) { + return to_route('passport.device') + ->withInput(['user_code' => $userCode]) + ->withErrors([ + 'user_code' => 'Incorrect code.', + ]); + } + + $user = $this->guard->user(); + $deviceCode->setUserIdentifier($user->getAuthIdentifier()); + + $scopes = $this->parseScopes($deviceCode); + $client = $this->clients->find($deviceCode->getClient()->getIdentifier()); + + $request->session()->put('authToken', $authToken = Str::random()); + $request->session()->put('deviceCode', $deviceCode); + + return $viewResponse->withParameters([ + 'client' => $client, + 'user' => $user, + 'scopes' => $scopes, + 'request' => $request, + 'authToken' => $authToken, + ]); + } + + /** + * Transform the device code entity's scopes into Scope instances. + * + * @return \Laravel\Passport\Scope[] + */ + protected function parseScopes(DeviceCodeEntityInterface $deviceCode): array + { + return Passport::scopesFor( + collect($deviceCode->getScopes())->map( + fn (ScopeEntityInterface $scope): string => $scope->getIdentifier() + )->unique()->all() + ); + } +} diff --git a/src/Http/Controllers/DeviceCodeController.php b/src/Http/Controllers/DeviceCodeController.php new file mode 100644 index 00000000..f3e35004 --- /dev/null +++ b/src/Http/Controllers/DeviceCodeController.php @@ -0,0 +1,31 @@ +withErrorHandling(fn () => $this->convertResponse( + $this->server->respondToDeviceAuthorizationRequest($psrRequest, $psrResponse) + )); + } +} diff --git a/src/Http/Controllers/DeviceUserCodeController.php b/src/Http/Controllers/DeviceUserCodeController.php new file mode 100644 index 00000000..c88186b5 --- /dev/null +++ b/src/Http/Controllers/DeviceUserCodeController.php @@ -0,0 +1,28 @@ +query('user_code')) { + return to_route('passport.device.authorizations.authorize', [ + 'user_code' => $userCode, + ]); + } + + return $viewResponse->withParameters([ + 'request' => $request, + ]); + } +} diff --git a/src/Http/Controllers/RetrievesDeviceCodeFromSession.php b/src/Http/Controllers/RetrievesDeviceCodeFromSession.php new file mode 100644 index 00000000..eb9e55b0 --- /dev/null +++ b/src/Http/Controllers/RetrievesDeviceCodeFromSession.php @@ -0,0 +1,30 @@ +isNotFilled('auth_token') || + $request->session()->pull('authToken') !== $request->get('auth_token')) { + $request->session()->forget(['authToken', 'deviceCode']); + + throw InvalidAuthTokenException::different(); + } + + return $request->session()->pull('deviceCode') + ?? throw new Exception('Device code was not present in the session.'); + } +} diff --git a/src/Http/Responses/ApprovedDeviceAuthorizationResponse.php b/src/Http/Responses/ApprovedDeviceAuthorizationResponse.php new file mode 100644 index 00000000..11c78b36 --- /dev/null +++ b/src/Http/Responses/ApprovedDeviceAuthorizationResponse.php @@ -0,0 +1,20 @@ +with('status', 'authorization-approved'); + } +} diff --git a/src/Http/Responses/DeniedDeviceAuthorizationResponse.php b/src/Http/Responses/DeniedDeviceAuthorizationResponse.php new file mode 100644 index 00000000..5ab6cd6b --- /dev/null +++ b/src/Http/Responses/DeniedDeviceAuthorizationResponse.php @@ -0,0 +1,20 @@ +with('status', 'authorization-denied'); + } +} diff --git a/src/Http/Responses/SimpleViewResponse.php b/src/Http/Responses/SimpleViewResponse.php index a71727f3..518dfa8c 100644 --- a/src/Http/Responses/SimpleViewResponse.php +++ b/src/Http/Responses/SimpleViewResponse.php @@ -5,8 +5,13 @@ use Closure; use Illuminate\Contracts\Support\Responsable; use Laravel\Passport\Contracts\AuthorizationViewResponse; +use Laravel\Passport\Contracts\DeviceAuthorizationViewResponse; +use Laravel\Passport\Contracts\DeviceUserCodeViewResponse; -class SimpleViewResponse implements AuthorizationViewResponse +class SimpleViewResponse implements + AuthorizationViewResponse, + DeviceAuthorizationViewResponse, + DeviceUserCodeViewResponse { /** * An array of arguments that may be passed to the view response and used in the view. diff --git a/src/Passport.php b/src/Passport.php index e7e4b28f..351f8eb4 100644 --- a/src/Passport.php +++ b/src/Passport.php @@ -9,6 +9,8 @@ use Illuminate\Contracts\Encryption\Encrypter; use Illuminate\Support\Collection; use Laravel\Passport\Contracts\AuthorizationViewResponse; +use Laravel\Passport\Contracts\DeviceAuthorizationViewResponse; +use Laravel\Passport\Contracts\DeviceUserCodeViewResponse; use Laravel\Passport\Http\Responses\SimpleViewResponse; use League\OAuth2\Server\ResourceServer; use League\OAuth2\Server\ResponseTypes\ResponseTypeInterface; @@ -95,6 +97,13 @@ class Passport */ public static string $authCodeModel = AuthCode::class; + /** + * The device code model class name. + * + * @var class-string<\Laravel\Passport\DeviceCode> + */ + public static string $deviceCodeModel = DeviceCode::class; + /** * The client model class name. * @@ -439,6 +448,34 @@ public static function authCode(): AuthCode return new static::$authCodeModel; } + /** + * Set the device code model class name. + * + * @param class-string<\Laravel\Passport\DeviceCode> $deviceCodeModel + */ + public static function useDeviceCodeModel(string $deviceCodeModel): void + { + static::$deviceCodeModel = $deviceCodeModel; + } + + /** + * Get the device code model class name. + * + * @return class-string<\Laravel\Passport\DeviceCode> + */ + public static function deviceCodeModel(): string + { + return static::$deviceCodeModel; + } + + /** + * Get a new device code model instance. + */ + public static function deviceCode(): DeviceCode + { + return new static::$deviceCodeModel; + } + /** * Set the client model class name. * @@ -557,6 +594,8 @@ public static function viewNamespace(string $namespace): void public static function viewPrefix(string $prefix): void { static::authorizationView($prefix.'authorize'); + static::deviceAuthorizationView($prefix.'device.authorize'); + static::deviceUserCodeView($prefix.'device.user-code'); } /** @@ -569,6 +608,26 @@ public static function authorizationView(Closure|string $view): void app()->singleton(AuthorizationViewResponse::class, fn () => new SimpleViewResponse($view)); } + /** + * Specify which view should be used as the device authorization view. + * + * @param (\Closure(array): (\Symfony\Component\HttpFoundation\Response))|string $view + */ + public static function deviceAuthorizationView(Closure|string $view): void + { + app()->singleton(DeviceAuthorizationViewResponse::class, fn () => new SimpleViewResponse($view)); + } + + /** + * Specify which view should be used as the device user code view. + * + * @param (\Closure(array): (\Symfony\Component\HttpFoundation\Response))|string $view + */ + public static function deviceUserCodeView(Closure|string $view): void + { + app()->singleton(DeviceUserCodeViewResponse::class, fn () => new SimpleViewResponse($view)); + } + /** * Configure Passport to not register its routes. */ diff --git a/src/PassportServiceProvider.php b/src/PassportServiceProvider.php index 5fc6e636..8327d8a7 100644 --- a/src/PassportServiceProvider.php +++ b/src/PassportServiceProvider.php @@ -11,10 +11,16 @@ use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Route; use Illuminate\Support\ServiceProvider; +use Laravel\Passport\Bridge\DeviceCodeRepository; use Laravel\Passport\Bridge\PersonalAccessGrant; use Laravel\Passport\Bridge\RefreshTokenRepository; +use Laravel\Passport\Contracts\ApprovedDeviceAuthorizationResponse as ApprovedDeviceAuthorizationResponseContract; +use Laravel\Passport\Contracts\DeniedDeviceAuthorizationResponse as DeniedDeviceAuthorizationResponseContract; use Laravel\Passport\Guards\TokenGuard; use Laravel\Passport\Http\Controllers\AuthorizationController; +use Laravel\Passport\Http\Controllers\DeviceAuthorizationController; +use Laravel\Passport\Http\Responses\ApprovedDeviceAuthorizationResponse; +use Laravel\Passport\Http\Responses\DeniedDeviceAuthorizationResponse; use Lcobucci\JWT\Encoding\JoseEncoder; use Lcobucci\JWT\Parser as ParserContract; use Lcobucci\JWT\Token\Parser; @@ -22,6 +28,7 @@ use League\OAuth2\Server\CryptKey; use League\OAuth2\Server\Grant\AuthCodeGrant; use League\OAuth2\Server\Grant\ClientCredentialsGrant; +use League\OAuth2\Server\Grant\DeviceCodeGrant; use League\OAuth2\Server\Grant\ImplicitGrant; use League\OAuth2\Server\Grant\PasswordGrant; use League\OAuth2\Server\Grant\RefreshTokenGrant; @@ -100,18 +107,29 @@ public function register(): void { $this->mergeConfigFrom(__DIR__.'/../config/passport.php', 'passport'); - $this->app->when(AuthorizationController::class) - ->needs(StatefulGuard::class) - ->give(fn () => Auth::guard(config('passport.guard', null))); + $this->app->when([ + AuthorizationController::class, + DeviceAuthorizationController::class, + ])->needs(StatefulGuard::class)->give(fn () => Auth::guard(config('passport.guard', null))); $this->app->singleton(ClientRepository::class); + $this->registerResponseBindings(); $this->registerAuthorizationServer(); $this->registerJWTParser(); $this->registerResourceServer(); $this->registerGuard(); } + /** + * Register the response bindings. + */ + protected function registerResponseBindings(): void + { + $this->app->singleton(ApprovedDeviceAuthorizationResponseContract::class, ApprovedDeviceAuthorizationResponse::class); + $this->app->singleton(DeniedDeviceAuthorizationResponseContract::class, DeniedDeviceAuthorizationResponse::class); + } + /** * Register the authorization server. */ @@ -149,6 +167,10 @@ protected function registerAuthorizationServer(): void $this->makeImplicitGrant(), Passport::tokensExpireIn() ); } + + $server->enableGrantType( + $this->makeDeviceCodeGrant(), Passport::tokensExpireIn() + ); }); }); } @@ -208,6 +230,24 @@ protected function makeImplicitGrant(): ImplicitGrant return new ImplicitGrant(Passport::tokensExpireIn()); } + /** + * Create and configure an instance of the Device Code grant. + */ + protected function makeDeviceCodeGrant(): DeviceCodeGrant + { + return tap(new DeviceCodeGrant( + $this->app->make(DeviceCodeRepository::class), + $this->app->make(RefreshTokenRepository::class), + new DateInterval('PT10M'), + route('passport.device'), + 5 + ), function (DeviceCodeGrant $grant) { + $grant->setRefreshTokenTTL(Passport::refreshTokensExpireIn()); + $grant->setIncludeVerificationUriComplete(true); + $grant->setIntervalVisibility(true); + }); + } + /** * Make the authorization service instance. */ diff --git a/tests/Feature/Console/PurgeCommand.php b/tests/Feature/Console/PurgeCommand.php index 33adda95..ff5cdbd0 100644 --- a/tests/Feature/Console/PurgeCommand.php +++ b/tests/Feature/Console/PurgeCommand.php @@ -24,6 +24,7 @@ public function test_it_can_purge_tokens() 'delete from "oauth_access_tokens" where ("revoked" = 1 or "expires_at" < \'2000-01-01 00:00:00\')', 'delete from "oauth_auth_codes" where ("revoked" = 1 or "expires_at" < \'2000-01-01 00:00:00\')', 'delete from "oauth_refresh_tokens" where ("revoked" = 1 or "expires_at" < \'2000-01-01 00:00:00\')', + 'delete from "oauth_device_codes" where ("revoked" = 1 or "expires_at" < \'2000-01-01 00:00:00\')', ], array_column($query, 'query')); } @@ -38,6 +39,7 @@ public function test_it_can_purge_revoked_tokens() 'delete from "oauth_access_tokens" where ("revoked" = 1)', 'delete from "oauth_auth_codes" where ("revoked" = 1)', 'delete from "oauth_refresh_tokens" where ("revoked" = 1)', + 'delete from "oauth_device_codes" where ("revoked" = 1)', ], array_column($query, 'query')); } @@ -54,6 +56,7 @@ public function test_it_can_purge_expired_tokens() 'delete from "oauth_access_tokens" where ("expires_at" < \'2000-01-01 00:00:00\')', 'delete from "oauth_auth_codes" where ("expires_at" < \'2000-01-01 00:00:00\')', 'delete from "oauth_refresh_tokens" where ("expires_at" < \'2000-01-01 00:00:00\')', + 'delete from "oauth_device_codes" where ("expires_at" < \'2000-01-01 00:00:00\')', ], array_column($query, 'query')); } @@ -70,6 +73,7 @@ public function test_it_can_purge_revoked_and_expired_tokens() 'delete from "oauth_access_tokens" where ("revoked" = 1 or "expires_at" < \'2000-01-01 00:00:00\')', 'delete from "oauth_auth_codes" where ("revoked" = 1 or "expires_at" < \'2000-01-01 00:00:00\')', 'delete from "oauth_refresh_tokens" where ("revoked" = 1 or "expires_at" < \'2000-01-01 00:00:00\')', + 'delete from "oauth_device_codes" where ("revoked" = 1 or "expires_at" < \'2000-01-01 00:00:00\')', ], array_column($query, 'query')); } @@ -86,6 +90,7 @@ public function test_it_can_purge_tokens_by_hours() 'delete from "oauth_access_tokens" where ("revoked" = 1 or "expires_at" < \'2000-01-01 00:00:00\')', 'delete from "oauth_auth_codes" where ("revoked" = 1 or "expires_at" < \'2000-01-01 00:00:00\')', 'delete from "oauth_refresh_tokens" where ("revoked" = 1 or "expires_at" < \'2000-01-01 00:00:00\')', + 'delete from "oauth_device_codes" where ("revoked" = 1 or "expires_at" < \'2000-01-01 00:00:00\')', ], array_column($query, 'query')); } } diff --git a/tests/Feature/DeviceAuthorizationGrantTest.php b/tests/Feature/DeviceAuthorizationGrantTest.php new file mode 100644 index 00000000..e73b57ff --- /dev/null +++ b/tests/Feature/DeviceAuthorizationGrantTest.php @@ -0,0 +1,271 @@ + 'Create', + 'read' => 'Read', + 'update' => 'Update', + 'delete' => 'Delete', + ]); + + Passport::deviceAuthorizationView(fn ($params) => $params); + Passport::deviceUserCodeView(fn ($params) => $params); + } + + public function testIssueDeviceCode() + { + $client = ClientFactory::new()->asDeviceCodeClient()->create(); + + $json = $this->post('/oauth/device/code', [ + 'client_id' => $client->getKey(), + 'scope' => 'create read', + ])->assertOk()->json(); + + $this->assertArrayHasKey('device_code', $json); + $this->assertArrayHasKey('user_code', $json); + $this->assertSame(5, $json['interval']); + $this->assertSame(600, $json['expires_in']); + $this->assertSame('http://localhost/oauth/device', $json['verification_uri']); + $this->assertSame('http://localhost/oauth/device?user_code='.$json['user_code'], $json['verification_uri_complete']); + } + + public function testRequestAccessTokenAuthorizationPending() + { + $client = ClientFactory::new()->asDeviceCodeClient()->create(); + + $json = $this->post('/oauth/device/code', [ + 'client_id' => $client->getKey(), + 'scope' => 'create read', + ])->assertOk()->json(); + + $this->assertSame(5, $json['interval']); + $deviceCode = $json['device_code']; + + $json = $this->post('/oauth/token', [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:device_code', + 'client_id' => $client->getKey(), + 'client_secret' => $client->plainSecret, + 'device_code' => $deviceCode, + ])->assertBadRequest()->json(); + + $this->assertSame('authorization_pending', $json['error']); + $this->assertArrayHasKey('error_description', $json); + + $json = $this->post('/oauth/token', [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:device_code', + 'client_id' => $client->getKey(), + 'client_secret' => $client->plainSecret, + 'device_code' => $deviceCode, + ])->assertBadRequest()->json(); + + $this->assertSame('slow_down', $json['error']); + $this->assertArrayHasKey('error_description', $json); + } + + public function testAuthorizationWithoutUserCodeRedirects() + { + $this->actingAs(UserFactory::new()->create()) + ->get('/oauth/device/authorize') + ->assertRedirect('/oauth/device') + ->assertRedirectToRoute('passport.device'); + } + + public function testVerificationUrl() + { + $client = ClientFactory::new()->asDeviceCodeClient()->create(); + + [ + 'verification_uri' => $verificationUri, + 'verification_uri_complete' => $verificationUriComplete, + 'user_code' => $userCode, + ] = $this->post('/oauth/device/code', [ + 'client_id' => $client->getKey(), + 'scope' => 'create read', + ])->assertOk()->json(); + + $json = $this->get($verificationUri)->assertOk()->json(); + $this->assertEqualsCanonicalizing(['request'], array_keys($json)); + + $user = UserFactory::new()->create(); + $this->actingAs($user, 'web'); + + $this->get($verificationUriComplete) + ->assertRedirect('/oauth/device/authorize?user_code='.$userCode) + ->assertRedirectToRoute('passport.device.authorizations.authorize', ['user_code' => $userCode]); + + $this->get('/oauth/device/authorize?user_code='.Str::substrReplace($userCode, '-', 4, 0)) + ->assertOk() + ->assertSessionHas('deviceCode') + ->assertSessionHas('authToken') + ->assertSessionHasNoErrors(); + } + + public function testAuthorizationWithInvalidUserCode() + { + $this->actingAs(UserFactory::new()->create(), 'web'); + + $this->get('/oauth/device/authorize?user_code=12345678') + ->assertRedirectToRoute('passport.device') + ->assertSessionHasInput('user_code', '12345678') + ->assertSessionHasErrors(['user_code' => 'Incorrect code.']); + + $this->get('/oauth/device/authorize?user_code=ABCD-EFGH') + ->assertRedirectToRoute('passport.device') + ->assertSessionHasInput('user_code', 'ABCD-EFGH') + ->assertSessionHasErrors(['user_code' => 'Incorrect code.']); + } + + public function testIssueAccessToken() + { + $client = ClientFactory::new()->asDeviceCodeClient()->create(); + + [ + 'device_code' => $deviceCode, + 'user_code' => $userCode, + ] = $this->post('/oauth/device/code', [ + 'client_id' => $client->getKey(), + 'scope' => 'create read', + ])->assertOk()->json(); + + $user = UserFactory::new()->create(); + $this->actingAs($user, 'web'); + + $json = $this->get('/oauth/device/authorize?user_code='.$userCode) + ->assertOk() + ->assertSessionHas('deviceCode') + ->assertSessionHas('authToken') + ->json(); + $this->assertEqualsCanonicalizing(['client', 'user', 'scopes', 'request', 'authToken'], array_keys($json)); + $this->assertSame(collect(Passport::scopesFor(['create', 'read']))->toArray(), $json['scopes']); + + $this->post('/oauth/device/authorize', ['auth_token' => $json['authToken']]) + ->assertRedirectToRoute('passport.device') + ->assertSessionHas('status', 'authorization-approved') + ->assertSessionMissing(['deviceCode', 'authToken']); + + $this->get('/oauth/device/authorize?user_code='.$userCode) + ->assertRedirect() + ->assertSessionHasInput('user_code', $userCode) + ->assertSessionHasErrors(['user_code' => 'Incorrect code.']); + + $json = $this->post('/oauth/token', [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:device_code', + 'client_id' => $client->getKey(), + 'client_secret' => $client->plainSecret, + 'device_code' => $deviceCode, + ])->assertOk()->json(); + + $this->assertArrayHasKey('access_token', $json); + $this->assertArrayHasKey('refresh_token', $json); + $this->assertSame('Bearer', $json['token_type']); + $this->assertSame(31536000, $json['expires_in']); + + Route::get('/foo', fn (Request $request) => $request->user()->token()->toJson()) + ->middleware('auth:api'); + + $json = $this->withToken($json['access_token'], $json['token_type'])->get('/foo')->json(); + + $this->assertSame($client->getKey(), $json['oauth_client_id']); + $this->assertEquals($user->getAuthIdentifier(), $json['oauth_user_id']); + $this->assertSame(['create', 'read'], $json['oauth_scopes']); + } + + public function testDenyAuthorization() + { + $client = ClientFactory::new()->asDeviceCodeClient()->create(); + + [ + 'device_code' => $deviceCode, + 'user_code' => $userCode, + ] = $this->post('/oauth/device/code', [ + 'client_id' => $client->getKey(), + 'scope' => 'create read', + ])->assertOk()->json(); + + $user = UserFactory::new()->create(); + $this->actingAs($user, 'web'); + + $authToken = $this->get('/oauth/device/authorize?user_code='.$userCode)->assertOk()->json('authToken'); + + $this->delete('/oauth/device/authorize', ['auth_token' => $authToken]) + ->assertRedirectToRoute('passport.device') + ->assertSessionHas('status', 'authorization-denied') + ->assertSessionMissing(['deviceCode', 'authToken']); + + $this->get('/oauth/device/authorize?user_code='.$userCode) + ->assertRedirect() + ->assertSessionHasInput('user_code', $userCode) + ->assertSessionHasErrors(['user_code' => 'Incorrect code.']); + + $json = $this->post('/oauth/token', [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:device_code', + 'client_id' => $client->getKey(), + 'client_secret' => $client->plainSecret, + 'device_code' => $deviceCode, + ])->assertUnauthorized()->json(); + + $this->assertArrayHasKey('error', $json); + $this->assertArrayHasKey('error_description', $json); + $this->assertSame('access_denied', $json['error']); + } + + public function testPublicClient() + { + $client = ClientFactory::new()->asDeviceCodeClient()->asPublic()->create(); + + [ + 'device_code' => $deviceCode, + 'user_code' => $userCode, + ] = $this->post('/oauth/device/code', [ + 'client_id' => $client->getKey(), + 'scope' => 'create read', + ])->assertOk()->json(); + + $user = UserFactory::new()->create(); + $this->actingAs($user, 'web'); + + $authToken = $this->get('/oauth/device/authorize?user_code='.$userCode)->assertOk()->json('authToken'); + + $this->post('/oauth/device/authorize', ['auth_token' => $authToken])->assertRedirect(); + + $json = $this->post('/oauth/token', [ + 'grant_type' => 'urn:ietf:params:oauth:grant-type:device_code', + 'client_id' => $client->getKey(), + 'device_code' => $deviceCode, + ])->assertOk()->json(); + + $this->assertArrayHasKey('access_token', $json); + $this->assertArrayHasKey('refresh_token', $json); + $this->assertSame('Bearer', $json['token_type']); + $this->assertSame(31536000, $json['expires_in']); + } + + public function testUnauthorizedClient() + { + $client = ClientFactory::new()->create(); + + $json = $this->post('/oauth/device/code', [ + 'client_id' => $client->getKey(), + ])->assertBadRequest()->json(); + + $this->assertSame('unauthorized_client', $json['error']); + $this->assertArrayHasKey('error_description', $json); + } +} diff --git a/tests/Feature/RevokedTest.php b/tests/Feature/RevokedTest.php index 9a61f3dc..62e2b198 100644 --- a/tests/Feature/RevokedTest.php +++ b/tests/Feature/RevokedTest.php @@ -5,6 +5,8 @@ use Laravel\Passport\Bridge\AccessTokenRepository as BridgeAccessTokenRepository; use Laravel\Passport\Bridge\AuthCode; use Laravel\Passport\Bridge\AuthCodeRepository as BridgeAuthCodeRepository; +use Laravel\Passport\Bridge\DeviceCode; +use Laravel\Passport\Bridge\DeviceCodeRepository as BridgeDeviceCodeRepository; use Laravel\Passport\Bridge\RefreshToken; use Laravel\Passport\Bridge\RefreshTokenRepository as BridgeRefreshTokenRepository; use Laravel\Passport\Tests\Feature\PassportTestCase; @@ -90,6 +92,31 @@ public function test_it_can_determine_if_a_refresh_token_is_not_revoked() $this->assertFalse($repository->isRefreshTokenRevoked('tokenId')); } + public function test_it_can_determine_if_a_device_code_is_revoked() + { + $repository = $this->deviceCodeRepository(); + $this->persistNewDeviceCode($repository, 'deviceCodeId'); + + $repository->revokeDeviceCode('deviceCodeId'); + + $this->assertTrue($repository->isDeviceCodeRevoked('deviceCodeId')); + } + + public function test_a_device_code_is_also_revoked_if_it_cannot_be_found() + { + $repository = $this->deviceCodeRepository(); + + $this->assertTrue($repository->isDeviceCodeRevoked('notExistingDeviceCodeId')); + } + + public function test_it_can_determine_if_a_device_code_is_not_revoked() + { + $repository = $this->deviceCodeRepository(); + $this->persistNewDeviceCode($repository, 'deviceCodeId'); + + $this->assertFalse($repository->isDeviceCodeRevoked('deviceCodeId')); + } + private function accessTokenRepository(): BridgeAccessTokenRepository { $events = m::mock('Illuminate\Contracts\Events\Dispatcher'); @@ -144,4 +171,24 @@ private function persistNewRefreshToken(BridgeRefreshTokenRepository $repository $repository->persistNewRefreshToken($refreshToken); } + + private function deviceCodeRepository(): BridgeDeviceCodeRepository + { + return new BridgeDeviceCodeRepository; + } + + private function persistNewDeviceCode(BridgeDeviceCodeRepository $repository, string $id): void + { + $deviceCode = m::mock(DeviceCode::class); + $deviceCode->shouldReceive('getIdentifier')->andReturn($id); + $deviceCode->shouldReceive('getUserIdentifier')->andReturn(null); + $deviceCode->shouldReceive('getClient->getIdentifier')->andReturn('clientId'); + $deviceCode->shouldReceive('getUserCode')->andReturn('userCode'); + $deviceCode->shouldReceive('getScopes')->andReturn([]); + $deviceCode->shouldReceive('getExpiryDateTime')->andReturn(CarbonImmutable::now()); + $deviceCode->shouldReceive('getLastPolledAt')->andReturn(null); + $deviceCode->shouldReceive('getUserApproved')->andReturn(false); + + $repository->persistDeviceCode($deviceCode); + } } diff --git a/tests/Unit/PassportTest.php b/tests/Unit/PassportTest.php index 0056692a..4ac96415 100644 --- a/tests/Unit/PassportTest.php +++ b/tests/Unit/PassportTest.php @@ -4,6 +4,7 @@ use Laravel\Passport\AuthCode; use Laravel\Passport\Client; +use Laravel\Passport\DeviceCode; use Laravel\Passport\Passport; use Laravel\Passport\RefreshToken; use Laravel\Passport\Token; @@ -65,6 +66,14 @@ public function test_refresh_token_model_can_be_changed() Passport::useRefreshTokenModel(RefreshToken::class); } + + public function test_device_code_instance_can_be_created() + { + $deviceCode = Passport::deviceCode(); + + $this->assertInstanceOf(DeviceCode::class, $deviceCode); + $this->assertInstanceOf(Passport::deviceCodeModel(), $deviceCode); + } } class RefreshTokenStub extends RefreshToken