Skip to content

Commit 9656b14

Browse files
[13.x] Device Authorization Grant RFC8628 (#1750)
* add device code grant * formatting * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * fix controllers * formatting * add tests * formatting * revert unrelated changes * revert irrelevant changes * add device option on client command * formatting * formatting * formatting * add more tests * formatting * remove result view * formatting * formatting * formatting * formatting * add more tests * formatting * force re-run tests * resolve stateful guard * add more tests * simplify * fix tests * formatting * add interval * fix tests * simplify * formatting * add more tests * revert changes on interval * revert changes on interval * formatting * add a test * formatting * Update ClientCommand.php * formatting --------- Co-authored-by: Taylor Otwell <[email protected]>
1 parent 089e2c3 commit 9656b14

29 files changed

+1135
-12
lines changed

database/factories/ClientFactory.php

+11
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,15 @@ public function asClientCredentials(): static
9191
'redirect_uris' => [],
9292
]);
9393
}
94+
95+
/**
96+
* Use as a Device Code client.
97+
*/
98+
public function asDeviceCodeClient(): static
99+
{
100+
return $this->state([
101+
'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token'],
102+
'redirect_uris' => [],
103+
]);
104+
}
94105
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::create('oauth_device_codes', function (Blueprint $table) {
15+
$table->char('id', 80)->primary();
16+
$table->foreignId('user_id')->nullable()->index();
17+
$table->foreignUuid('client_id')->index();
18+
$table->char('user_code', 8)->unique();
19+
$table->text('scopes');
20+
$table->boolean('revoked');
21+
$table->dateTime('user_approved_at')->nullable();
22+
$table->dateTime('last_polled_at')->nullable();
23+
$table->dateTime('expires_at')->nullable();
24+
});
25+
}
26+
27+
/**
28+
* Reverse the migrations.
29+
*/
30+
public function down(): void
31+
{
32+
Schema::dropIfExists('oauth_device_codes');
33+
}
34+
35+
/**
36+
* Get the migration connection name.
37+
*/
38+
public function getConnection(): ?string
39+
{
40+
return $this->connection ?? config('passport.connection');
41+
}
42+
};

routes/web.php

+27
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@
1515
'middleware' => 'web',
1616
]);
1717

18+
Route::get('/device', [
19+
'uses' => 'DeviceUserCodeController',
20+
'as' => 'device',
21+
'middleware' => 'web',
22+
]);
23+
24+
Route::post('/device/code', [
25+
'uses' => 'DeviceCodeController',
26+
'as' => 'device.code',
27+
'middleware' => 'throttle',
28+
]);
29+
1830
$guard = config('passport.guard', null);
1931

2032
Route::middleware(['web', $guard ? 'auth:'.$guard : 'auth'])->group(function () {
@@ -33,6 +45,21 @@
3345
'as' => 'authorizations.deny',
3446
]);
3547

48+
Route::get('/device/authorize', [
49+
'uses' => 'DeviceAuthorizationController',
50+
'as' => 'device.authorizations.authorize',
51+
]);
52+
53+
Route::post('/device/authorize', [
54+
'uses' => 'ApproveDeviceAuthorizationController',
55+
'as' => 'device.authorizations.approve',
56+
]);
57+
58+
Route::delete('/device/authorize', [
59+
'uses' => 'DenyDeviceAuthorizationController',
60+
'as' => 'device.authorizations.deny',
61+
]);
62+
3663
if (Passport::$registersJsonApiRoutes) {
3764
Route::get('/tokens', [
3865
'uses' => 'AuthorizedAccessTokenController@forUser',

src/Bridge/Client.php

+6-3
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,18 @@ class Client implements ClientEntityInterface
1818
*/
1919
public function __construct(
2020
string $identifier,
21-
string $name,
22-
array $redirectUri,
21+
?string $name = null,
22+
array $redirectUri = [],
2323
bool $isConfidential = false,
2424
public ?string $provider = null,
2525
public array $grantTypes = [],
2626
) {
2727
$this->setIdentifier($identifier);
2828

29-
$this->name = $name;
29+
if (! is_null($name)) {
30+
$this->name = $name;
31+
}
32+
3033
$this->isConfidential = $isConfidential;
3134
$this->redirectUri = $redirectUri;
3235
}

src/Bridge/DeviceCode.php

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
3+
namespace Laravel\Passport\Bridge;
4+
5+
use DateTimeImmutable;
6+
use League\OAuth2\Server\Entities\DeviceCodeEntityInterface;
7+
use League\OAuth2\Server\Entities\Traits\DeviceCodeTrait;
8+
use League\OAuth2\Server\Entities\Traits\EntityTrait;
9+
use League\OAuth2\Server\Entities\Traits\TokenEntityTrait;
10+
11+
class DeviceCode implements DeviceCodeEntityInterface
12+
{
13+
use EntityTrait, DeviceCodeTrait, TokenEntityTrait;
14+
15+
/**
16+
* Create a new device code instance.
17+
*
18+
* @param non-empty-string|null $identifier
19+
* @param non-empty-string|null $userIdentifier
20+
* @param non-empty-string|null $clientIdentifier
21+
* @param string[] $scopes
22+
*/
23+
public function __construct(
24+
?string $identifier = null,
25+
?string $userIdentifier = null,
26+
?string $clientIdentifier = null,
27+
array $scopes = [],
28+
bool $userApproved = false,
29+
?DateTimeImmutable $lastPolledAt = null,
30+
?DateTimeImmutable $expiryDateTime = null
31+
) {
32+
if (! is_null($identifier)) {
33+
$this->setIdentifier($identifier);
34+
}
35+
36+
if (! is_null($userIdentifier)) {
37+
$this->setUserIdentifier($userIdentifier);
38+
}
39+
40+
if (! is_null($clientIdentifier)) {
41+
$this->setClient(new Client($clientIdentifier));
42+
}
43+
44+
foreach ($scopes as $scope) {
45+
$this->addScope(new Scope($scope));
46+
}
47+
48+
if ($userApproved) {
49+
$this->setUserApproved($userApproved);
50+
}
51+
52+
if (! is_null($lastPolledAt)) {
53+
$this->setLastPolledAt($lastPolledAt);
54+
}
55+
56+
if (! is_null($expiryDateTime)) {
57+
$this->setExpiryDateTime($expiryDateTime);
58+
}
59+
}
60+
}

src/Bridge/DeviceCodeRepository.php

+106
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php
2+
3+
namespace Laravel\Passport\Bridge;
4+
5+
use Illuminate\Support\Facades\Date;
6+
use Laravel\Passport\DeviceCode as DeviceCodeModel;
7+
use Laravel\Passport\Passport;
8+
use League\OAuth2\Server\Entities\DeviceCodeEntityInterface;
9+
use League\OAuth2\Server\Repositories\DeviceCodeRepositoryInterface;
10+
11+
class DeviceCodeRepository implements DeviceCodeRepositoryInterface
12+
{
13+
/**
14+
* {@inheritdoc}
15+
*/
16+
public function getNewDeviceCode(): DeviceCodeEntityInterface
17+
{
18+
return new DeviceCode;
19+
}
20+
21+
/**
22+
* {@inheritdoc}
23+
*/
24+
public function persistDeviceCode(DeviceCodeEntityInterface $deviceCodeEntity): void
25+
{
26+
if (! is_null($deviceCodeEntity->getUserIdentifier())) {
27+
Passport::deviceCode()->newQuery()->whereKey($deviceCodeEntity->getIdentifier())->update([
28+
'user_id' => $deviceCodeEntity->getUserIdentifier(),
29+
'user_approved_at' => $deviceCodeEntity->getUserApproved() ? Date::now() : null,
30+
]);
31+
} elseif (! is_null($deviceCodeEntity->getLastPolledAt())) {
32+
Passport::deviceCode()->newQuery()->whereKey($deviceCodeEntity->getIdentifier())->update([
33+
'last_polled_at' => $deviceCodeEntity->getLastPolledAt(),
34+
]);
35+
} else {
36+
Passport::deviceCode()->forceFill([
37+
'id' => $deviceCodeEntity->getIdentifier(),
38+
'user_id' => null,
39+
'client_id' => $deviceCodeEntity->getClient()->getIdentifier(),
40+
'user_code' => $deviceCodeEntity->getUserCode(),
41+
'scopes' => $deviceCodeEntity->getScopes(),
42+
'revoked' => false,
43+
'user_approved_at' => null,
44+
'last_polled_at' => null,
45+
'expires_at' => $deviceCodeEntity->getExpiryDateTime(),
46+
])->save();
47+
}
48+
}
49+
50+
/**
51+
* {@inheritdoc}
52+
*/
53+
public function getDeviceCodeEntityByDeviceCode(string $deviceCode): ?DeviceCodeEntityInterface
54+
{
55+
$record = Passport::deviceCode()->newQuery()->whereKey($deviceCode)->where(['revoked' => false])->first();
56+
57+
return $record ? $this->fromDeviceCodeModel($record) : null;
58+
}
59+
60+
/*
61+
* Get the device code entity by the given user code.
62+
*/
63+
public function getDeviceCodeEntityByUserCode(string $userCode): ?DeviceCodeEntityInterface
64+
{
65+
$record = Passport::deviceCode()->newQuery()
66+
->where('user_code', $userCode)
67+
->whereNull('user_id')
68+
->where('expires_at', '>', Date::now())
69+
->where('revoked', false)
70+
->first();
71+
72+
return $record ? $this->fromDeviceCodeModel($record) : null;
73+
}
74+
75+
/**
76+
* {@inheritdoc}
77+
*/
78+
public function revokeDeviceCode(string $codeId): void
79+
{
80+
Passport::deviceCode()->newQuery()->whereKey($codeId)->update(['revoked' => true]);
81+
}
82+
83+
/**
84+
* {@inheritdoc}
85+
*/
86+
public function isDeviceCodeRevoked(string $codeId): bool
87+
{
88+
return Passport::deviceCode()->newQuery()->whereKey($codeId)->where('revoked', false)->doesntExist();
89+
}
90+
91+
/**
92+
* Create a new device code entity from the given device code model instance.
93+
*/
94+
protected function fromDeviceCodeModel(DeviceCodeModel $model): DeviceCodeEntityInterface
95+
{
96+
return new DeviceCode(
97+
$model->getKey(),
98+
$model->user_id,
99+
$model->client_id,
100+
$model->scopes,
101+
! is_null($model->user_approved_at),
102+
$model->last_polled_at?->toDateTimeImmutable(),
103+
$model->expires_at?->toDateTimeImmutable()
104+
);
105+
}
106+
}

src/ClientRepository.php

+24-4
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,21 @@ public function createImplicitGrantClient(string $name, array $redirectUris): Cl
152152
return $this->create($name, ['implicit'], $redirectUris, null, false);
153153
}
154154

155+
/**
156+
* Store a new device authorization grant client.
157+
*
158+
* @param \Laravel\Passport\HasApiTokens|null $user
159+
*/
160+
public function createDeviceAuthorizationGrantClient(
161+
string $name,
162+
bool $confidential = true,
163+
?Authenticatable $user = null
164+
): Client {
165+
return $this->create(
166+
$name, ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token'], [], null, $confidential, $user
167+
);
168+
}
169+
155170
/**
156171
* Store a new authorization code grant client.
157172
*
@@ -161,11 +176,16 @@ public function createAuthorizationCodeGrantClient(
161176
string $name,
162177
array $redirectUris,
163178
bool $confidential = true,
164-
?Authenticatable $user = null
179+
?Authenticatable $user = null,
180+
bool $enableDeviceFlow = false
165181
): Client {
166-
return $this->create(
167-
$name, ['authorization_code', 'refresh_token'], $redirectUris, null, $confidential, $user
168-
);
182+
$grantTypes = ['authorization_code', 'refresh_token'];
183+
184+
if ($enableDeviceFlow) {
185+
$grantTypes[] = 'urn:ietf:params:oauth:grant-type:device_code';
186+
}
187+
188+
return $this->create($name, $grantTypes, $redirectUris, null, $confidential, $user);
169189
}
170190

171191
/**

src/Console/ClientCommand.php

+17-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class ClientCommand extends Command
2020
{--password : Create a password grant client}
2121
{--client : Create a client credentials grant client}
2222
{--implicit : Create an implicit grant client}
23+
{--device : Create a device authorization grant client}
2324
{--name= : The name of the client}
2425
{--provider= : The name of the user provider}
2526
{--redirect_uri= : The URI to redirect to after authorization }
@@ -49,6 +50,7 @@ public function handle(ClientRepository $clients): void
4950
$this->option('password') => $this->createPasswordClient($clients),
5051
$this->option('client') => $this->createClientCredentialsClient($clients),
5152
$this->option('implicit') => $this->createImplicitClient($clients),
53+
$this->option('device') => $this->createDeviceCodeClient($clients),
5254
default => $this->createAuthCodeClient($clients)
5355
};
5456

@@ -119,6 +121,18 @@ protected function createImplicitClient(ClientRepository $clients): Client
119121
return $clients->createImplicitGrantClient($this->option('name'), explode(',', $redirect));
120122
}
121123

124+
/**
125+
* Create a device code client.
126+
*/
127+
protected function createDeviceCodeClient(ClientRepository $clients): Client
128+
{
129+
$confidential = $this->hasOption('public')
130+
? ! $this->option('public')
131+
: $this->confirm('Would you like to make this client confidential?', true);
132+
133+
return $clients->createDeviceAuthorizationGrantClient($this->option('name'), $confidential);
134+
}
135+
122136
/**
123137
* Create an authorization code client.
124138
*/
@@ -133,8 +147,10 @@ protected function createAuthCodeClient(ClientRepository $clients): Client
133147
? ! $this->option('public')
134148
: $this->confirm('Would you like to make this client confidential?', true);
135149

150+
$enableDeviceFlow = $this->confirm('Would you like to enable the device authorization flow for this client?');
151+
136152
return $clients->createAuthorizationCodeGrantClient(
137-
$this->option('name'), explode(',', $redirect), $confidential,
153+
$this->option('name'), explode(',', $redirect), $confidential, null, $enableDeviceFlow
138154
);
139155
}
140156
}

src/Console/PurgeCommand.php

+1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public function handle(): void
4646
Passport::token()->newQuery()->where($constraint)->delete();
4747
Passport::authCode()->newQuery()->where($constraint)->delete();
4848
Passport::refreshToken()->newQuery()->where($constraint)->delete();
49+
Passport::deviceCode()->newQuery()->where($constraint)->delete();
4950

5051
$this->components->info(sprintf('Purged %s.', implode(' and ', array_filter([
5152
$revoked ? 'revoked items' : null,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
namespace Laravel\Passport\Contracts;
4+
5+
use Illuminate\Contracts\Support\Responsable;
6+
7+
interface ApprovedDeviceAuthorizationResponse extends Responsable
8+
{
9+
//
10+
}

0 commit comments

Comments
 (0)