Repository: laravel/passport (language: php)

Issue:
Support for OAuth Device Flow
Hi there.

I am working on a project in which I want to pair a piece of hardware to the user account.
I am a fan of how other project (youtube, apple tv, etc) do it - with OAuth Device Flow).
https://tools.ietf.org/html/draft-ietf-oauth-device-flow-13

Any chance Passport supports (or can support) this flow?
When testing the apis with [this instrucction](https://alexbilbie.com/2016/04/oauth-2-device-flow-grant/), I get 
```json
{
    "error": "unsupported_grant_type",
    "message": "The authorization grant type is not supported by the authorization server.",
    "hint": "Check that all required parameters have been provided"
}
```


Discussion / hints:
It's not supported at present. There is an old request to add this to OAuth2-Server though. I will pick this up in a week's time and add it in. I've tagged it for the version 8 branch but hopefully it can be added to version 7 if there is no BC breaks.

I plan on adding a lot of features during the Christmas break time permitting.
awesome! thx
I'll mark this as an enhancement we might consider once OAuth2-Server has added support :)
i get the same error. How to fix it
@Sephster : any news on this?
I started on this over Christmas and it is about 50% done. Need to pick it up again soon. No definite date on when this will be released though as my time has been stretched more than I thought.

The main issue I'm contending with at the moment is how to implement the view. I am leaning towards not implementing one and letting the implementers decide how this is created. This will require good guidance in the docs and also updates to the examples so it is a bigger job than I initially anticipated.
no problem. was just curious.
Will you update / link this issue when you have updates? or should I subscribe to another issue?
This was the original PR in the league's repo https://github.com/thephpleague/oauth2-server/issues/626. I will likely update this one as well though
> This was the original PR in the league's repo [thephpleague/oauth2-server#626](https://github.com/thephpleague/oauth2-server/issues/626). I will likely update this one as well though

Has there been any progress on that front?
@Sephster   
I think you have an off-by-one error in the first and last digit of the issue number. ;)

This issue is referring to the Out-of-bounds device flow / device grant used on stuff like TVs, STBs, Home Stereos and Car Infotainment devices etc that don't have a browser or a comfortable user input.
While that is implemented on many devices, it is not yet an internet standard, but still in the draft phase. (See: https://tools.ietf.org/html/draft-ietf-oauth-device-flow-15 for the current draft specification as of July 2019)

The issue you mentioned on the oauth2-server is about the dynamic registration stuff and has a separate issue on Passport in #804
Hi @Sephster I have a working example with the [laravel/passport](https://github.com/laravel/passport) that implements the device flow using this fork [device-flow-grant](https://github.com/yovchev/oauth2-server/commits/yovchev/device-flow) that expands on the work that you already have done with @lucadegasperi  you can see it here [yovchev/passport](https://github.com/yovchev/passport/tree/yovchev/device-flow) 

I have the full device flow cycle using post requests (all requests and responses including errors are as per [rfc8628 spec](https://tools.ietf.org/html/rfc8628#page-5))

![image](https://user-images.githubusercontent.com/1344188/74292510-1d166f80-4d30-11ea-8463-8cac85018098.png)

And also I have implemented a widget for laravel passport for visual device code activation

![image](https://user-images.githubusercontent.com/1344188/74292625-6961af80-4d30-11ea-9e33-dc142c684611.png)
![image](https://user-images.githubusercontent.com/1344188/74293293-4a641d00-4d32-11ea-86e6-79a60cb3552b.png)

I'm going to be setting up a pull request for laravel passport next few days so please let me know if I can do anything to speed up the pull request on [thephpleague/oauth2-server](https://github.com/thephpleague/oauth2-server/pull/1074) 

@driesvints can you suggest where to do the pull request on laravel against master?

Also will be great if some of you guys want to help push this out as I don't have much time on my hands and there is some cleaning and test's to be written. 


Hey @yovchev. First of all: thanks for all your work on this 👍 

I think it's best that we wait until oauth2-server has merged that open pr first and tagged a new release.

The pull request for passport will depend on wether your PR introduces any breaking changes. If there are it's probably best sent to master. If not it can be sent to the current stable release branch.

I don't have much time myself at this point to help out I'm afraid, sorry. 
@yovchev - Have you submitted a PR to oauth2-server? I'm interested in seeing this making it into production
Did this go anywhere? I’ll be embarking on a Roku app soon and will need to use this to authenticate users against my Laravel-based app.
The issue on OAuth Server 2 is still open so I don't think so, no. https://github.com/thephpleague/oauth2-server/issues/626
Yeah the development is done but needs some testing. I am working on this but progress is slow
Thanks for your work @Sephster 👍 
Gonna close this for now until it's landed in OAuth2 Server. Thanks.
@driesvints can this be reopened now that OAuth Server 9.x has been released with the device grant included?

https://github.com/thephpleague/oauth2-server/releases/tag/9.0.0
Not re-opened but as said in https://github.com/laravel/passport/pull/1734 we're now accepting PR's to 13.x for the device flow. I believe @hafezdivandari will work on this.

Fix patch:
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 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    /**
+     * Run the migrations.
+     */
+    public function up(): void
+    {
+        Schema::create('oauth_device_codes', function (Blueprint $table) {
+            $table->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 @@
+<?php
+
+namespace Laravel\Passport\Bridge;
+
+use DateTimeImmutable;
+use League\OAuth2\Server\Entities\DeviceCodeEntityInterface;
+use League\OAuth2\Server\Entities\Traits\DeviceCodeTrait;
+use League\OAuth2\Server\Entities\Traits\EntityTrait;
+use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
+
+class DeviceCode implements DeviceCodeEntityInterface
+{
+    use EntityTrait, DeviceCodeTrait, TokenEntityTrait;
+
+    /**
+     * Create a new device code instance.
+     *
+     * @param  non-empty-string|null  $identifier
+     * @param  non-empty-string|null  $userIdentifier
+     * @param  non-empty-string|null  $clientIdentifier
+     * @param  string[]  $scopes
+     */
+    public function __construct(
+        ?string $identifier = null,
+        ?string $userIdentifier = null,
+        ?string $clientIdentifier = null,
+        array $scopes = [],
+        bool $userApproved = false,
+        ?DateTimeImmutable $lastPolledAt = null,
+        ?DateTimeImmutable $expiryDateTime = null
+    ) {
+        if (! is_null($identifier)) {
+            $this->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 @@
+<?php
+
+namespace Laravel\Passport\Bridge;
+
+use Illuminate\Support\Facades\Date;
+use Laravel\Passport\DeviceCode as DeviceCodeModel;
+use Laravel\Passport\Passport;
+use League\OAuth2\Server\Entities\DeviceCodeEntityInterface;
+use League\OAuth2\Server\Repositories\DeviceCodeRepositoryInterface;
+
+class DeviceCodeRepository implements DeviceCodeRepositoryInterface
+{
+    /**
+     * {@inheritdoc}
+     */
+    public function getNewDeviceCode(): DeviceCodeEntityInterface
+    {
+        return new DeviceCode;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function persistDeviceCode(DeviceCodeEntityInterface $deviceCodeEntity): void
+    {
+        if (! is_null($deviceCodeEntity->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 @@
+<?php
+
+namespace Laravel\Passport\Contracts;
+
+use Illuminate\Contracts\Support\Responsable;
+
+interface ApprovedDeviceAuthorizationResponse extends Responsable
+{
+    //
+}
diff --git a/src/Contracts/DeniedDeviceAuthorizationResponse.php b/src/Contracts/DeniedDeviceAuthorizationResponse.php
new file mode 100644
index 00000000..90c7fa51
--- /dev/null
+++ b/src/Contracts/DeniedDeviceAuthorizationResponse.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace Laravel\Passport\Contracts;
+
+use Illuminate\Contracts\Support\Responsable;
+
+interface DeniedDeviceAuthorizationResponse extends Responsable
+{
+    //
+}
diff --git a/src/Contracts/DeviceAuthorizationViewResponse.php b/src/Contracts/DeviceAuthorizationViewResponse.php
new file mode 100644
index 00000000..b48517c7
--- /dev/null
+++ b/src/Contracts/DeviceAuthorizationViewResponse.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Laravel\Passport\Contracts;
+
+use Illuminate\Contracts\Support\Responsable;
+
+interface DeviceAuthorizationViewResponse extends Responsable
+{
+    /**
+     * Specify the parameters that should be passed to the view.
+     *
+     * @param  array<string, mixed>  $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 @@
+<?php
+
+namespace Laravel\Passport\Contracts;
+
+use Illuminate\Contracts\Support\Responsable;
+
+interface DeviceUserCodeViewResponse extends Responsable
+{
+    /**
+     * Specify the parameters that should be passed to the view.
+     *
+     * @param  array<string, mixed>  $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 @@
+<?php
+
+namespace Laravel\Passport;
+
+use Illuminate\Database\Eloquent\Model;
+
+class DeviceCode extends Model
+{
+    /**
+     * The database table used by the model.
+     *
+     * @var string
+     */
+    protected $table = 'oauth_device_codes';
+
+    /**
+     * The "type" of the primary key ID.
+     *
+     * @var string
+     */
+    protected $keyType = 'string';
+
+    /**
+     * Indicates if the IDs are auto-incrementing.
+     *
+     * @var bool
+     */
+    public $incrementing = false;
+
+    /**
+     * Indicates if the model should be timestamped.
+     *
+     * @var bool
+     */
+    public $timestamps = false;
+
+    /**
+     * The guarded attributes on the model.
+     *
+     * @var array<string>|bool
+     */
+    protected $guarded = false;
+
+    /**
+     * The attributes that should be cast to native types.
+     *
+     * @var array<string, string>
+     */
+    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 @@
+<?php
+
+namespace Laravel\Passport\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Laravel\Passport\Contracts\ApprovedDeviceAuthorizationResponse;
+use League\OAuth2\Server\AuthorizationServer;
+
+class ApproveDeviceAuthorizationController
+{
+    use HandlesOAuthErrors, RetrievesDeviceCodeFromSession;
+
+    /**
+     * Create a new controller instance.
+     */
+    public function __construct(
+        protected AuthorizationServer $server
+    ) {
+    }
+
+    /**
+     * Approve the device authorization request.
+     */
+    public function __invoke(
+        Request $request,
+        ApprovedDeviceAuthorizationResponse $response
+    ): ApprovedDeviceAuthorizationResponse {
+        $deviceCode = $this->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 @@
+<?php
+
+namespace Laravel\Passport\Http\Controllers;
+
+use Illuminate\Http\Request;
+use Laravel\Passport\Contracts\DeniedDeviceAuthorizationResponse;
+use League\OAuth2\Server\AuthorizationServer;
+
+class DenyDeviceAuthorizationController
+{
+    use HandlesOAuthErrors, RetrievesDeviceCodeFromSession;
+
+    /**
+     * Create a new controller instance.
+     */
+    public function __construct(
+        protected AuthorizationServer $server
+    ) {
+    }
+
+    /**
+     * Deny the device authorization request.
+     */
+    public function __invoke(
+        Request $request,
+        DeniedDeviceAuthorizationResponse $response
+    ): DeniedDeviceAuthorizationResponse {
+        $deviceCode = $this->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 @@
+<?php
+
+namespace Laravel\Passport\Http\Controllers;
+
+use Illuminate\Contracts\Auth\StatefulGuard;
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Str;
+use Laravel\Passport\Bridge\DeviceCodeRepository;
+use Laravel\Passport\ClientRepository;
+use Laravel\Passport\Contracts\DeviceAuthorizationViewResponse;
+use Laravel\Passport\Passport;
+use League\OAuth2\Server\Entities\DeviceCodeEntityInterface;
+use League\OAuth2\Server\Entities\ScopeEntityInterface;
+
+class DeviceAuthorizationController
+{
+    /**
+     * Create a new controller instance.
+     */
+    public function __construct(
+        protected StatefulGuard $guard,
+        protected DeviceCodeRepository $deviceCodes,
+        protected ClientRepository $clients
+    ) {
+    }
+
+    /**
+     * Authorize a device to access the user's account.
+     */
+    public function __invoke(
+        Request $request,
+        DeviceAuthorizationViewResponse $viewResponse
+    ): RedirectResponse|DeviceAuthorizationViewResponse {
+        if (! $userCode = $request->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 @@
+<?php
+
+namespace Laravel\Passport\Http\Controllers;
+
+use League\OAuth2\Server\AuthorizationServer;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Symfony\Component\HttpFoundation\Response;
+
+class DeviceCodeController
+{
+    use ConvertsPsrResponses, HandlesOAuthErrors;
+
+    /**
+     * Create a new controller instance.
+     */
+    public function __construct(
+        protected AuthorizationServer $server
+    ) {
+    }
+
+    /**
+     * Issue a device code for the client.
+     */
+    public function __invoke(ServerRequestInterface $psrRequest, ResponseInterface $psrResponse): Response
+    {
+        return $this->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 @@
+<?php
+
+namespace Laravel\Passport\Http\Controllers;
+
+use Illuminate\Http\RedirectResponse;
+use Illuminate\Http\Request;
+use Laravel\Passport\Contracts\DeviceUserCodeViewResponse;
+
+class DeviceUserCodeController
+{
+    /**
+     * Show the form for entering the user code.
+     */
+    public function __invoke(
+        Request $request,
+        DeviceUserCodeViewResponse $viewResponse
+    ): RedirectResponse|DeviceUserCodeViewResponse {
+        if ($userCode = $request->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 @@
+<?php
+
+namespace Laravel\Passport\Http\Controllers;
+
+use Exception;
+use Illuminate\Http\Request;
+use Laravel\Passport\Exceptions\InvalidAuthTokenException;
+use League\OAuth2\Server\Entities\DeviceCodeEntityInterface;
+
+trait RetrievesDeviceCodeFromSession
+{
+    /**
+     * Get the device code from the session.
+     *
+     * @throws \Laravel\Passport\Exceptions\InvalidAuthTokenException
+     * @throws \Exception
+     */
+    protected function getDeviceCodeFromSession(Request $request): DeviceCodeEntityInterface
+    {
+        if ($request->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 @@
+<?php
+
+namespace Laravel\Passport\Http\Responses;
+
+use Laravel\Passport\Contracts\ApprovedDeviceAuthorizationResponse as ApprovedDeviceAuthorizationResponseContract;
+
+class ApprovedDeviceAuthorizationResponse implements ApprovedDeviceAuthorizationResponseContract
+{
+    /**
+     * Create an HTTP response that represents the object.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Symfony\Component\HttpFoundation\Response
+     */
+    public function toResponse($request)
+    {
+        return to_route('passport.device')
+            ->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 @@
+<?php
+
+namespace Laravel\Passport\Http\Responses;
+
+use Laravel\Passport\Contracts\DeniedDeviceAuthorizationResponse as DeniedDeviceAuthorizationResponseContract;
+
+class DeniedDeviceAuthorizationResponse implements DeniedDeviceAuthorizationResponseContract
+{
+    /**
+     * Create an HTTP response that represents the object.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return \Symfony\Component\HttpFoundation\Response
+     */
+    public function toResponse($request)
+    {
+        return to_route('passport.device')
+            ->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<string, mixed>): (\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<string, mixed>): (\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.
      */


Test patch:
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 @@
+<?php
+
+namespace Laravel\Passport\Tests\Feature;
+
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Route;
+use Illuminate\Support\Str;
+use Laravel\Passport\Database\Factories\ClientFactory;
+use Laravel\Passport\Passport;
+use Orchestra\Testbench\Concerns\WithLaravelMigrations;
+use Workbench\Database\Factories\UserFactory;
+
+class DeviceAuthorizationGrantTest extends PassportTestCase
+{
+    use WithLaravelMigrations;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        Passport::tokensCan([
+            'create' => '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