Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[13.x] Device Authorization Grant RFC8628 #1750

Draft
wants to merge 54 commits into
base: 13.x
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
78a7136
add device code grant
hafezdivandari May 30, 2024
fc9f827
formatting
hafezdivandari May 30, 2024
35f0c6a
Merge branch '13.x' into 13.x-device-code-grant
hafezdivandari Jul 5, 2024
abba12d
wip
hafezdivandari Jul 5, 2024
6d3908f
wip
hafezdivandari Jul 5, 2024
1bf6ded
wip
hafezdivandari Jul 5, 2024
e3f55ba
wip
hafezdivandari Jul 5, 2024
6311bf8
wip
hafezdivandari Jul 5, 2024
0fb9b53
wip
hafezdivandari Jul 6, 2024
2ce2365
wip
hafezdivandari Jul 6, 2024
77ef335
wip
hafezdivandari Jul 6, 2024
4f5c8c1
wip
hafezdivandari Jul 6, 2024
63255d1
wip
hafezdivandari Jul 6, 2024
4201306
wip
hafezdivandari Jul 6, 2024
d9dd43a
Merge branch '13.x' into 13.x-device-code-grant
hafezdivandari Jul 29, 2024
4341491
fix controllers
hafezdivandari Aug 16, 2024
59c7555
Merge branch '13.x' into 13.x-device-code-grant
hafezdivandari Aug 16, 2024
43f273c
formatting
hafezdivandari Aug 16, 2024
da26e7d
add tests
hafezdivandari Aug 16, 2024
90726be
formatting
hafezdivandari Aug 16, 2024
2e516a9
revert unrelated changes
hafezdivandari Aug 16, 2024
24a6464
revert irrelevant changes
hafezdivandari Aug 16, 2024
a36fd87
add device option on client command
hafezdivandari Aug 24, 2024
6b1f909
Merge branch '13.x' into 13.x-device-code-grant
hafezdivandari Aug 24, 2024
90e566a
formatting
hafezdivandari Aug 25, 2024
e5f0c53
Merge branch '13.x' into 13.x-device-code-grant
hafezdivandari Sep 6, 2024
c4ec6f3
Merge branch '13.x' into 13.x-device-code-grant
hafezdivandari Sep 18, 2024
ae8b47e
formatting
hafezdivandari Sep 18, 2024
3bb3572
formatting
hafezdivandari Sep 18, 2024
1117607
Merge branch '13.x' into 13.x-device-code-grant
hafezdivandari Sep 19, 2024
662c50c
add more tests
hafezdivandari Sep 19, 2024
0fd4368
formatting
hafezdivandari Sep 20, 2024
3a5ac72
remove result view
hafezdivandari Sep 20, 2024
688830c
Merge branch '13.x' into 13.x-device-code-grant
hafezdivandari Sep 22, 2024
7458bfd
Merge branch '13.x' into 13.x-device-code-grant
hafezdivandari Sep 30, 2024
c4368a3
formatting
hafezdivandari Sep 30, 2024
6231c9e
formatting
hafezdivandari Sep 30, 2024
9081d78
Merge branch '13.x' into 13.x-device-code-grant
hafezdivandari Oct 6, 2024
332d16e
formatting
hafezdivandari Oct 6, 2024
0a2a47f
formatting
hafezdivandari Oct 6, 2024
5b2f16e
add more tests
hafezdivandari Oct 7, 2024
ca4848b
formatting
hafezdivandari Oct 7, 2024
fb7f932
force re-run tests
hafezdivandari Oct 7, 2024
503884c
Merge branch '13.x' into 13.x-device-code-grant
hafezdivandari Oct 9, 2024
a27abc7
resolve stateful guard
hafezdivandari Oct 13, 2024
133b4b7
add more tests
hafezdivandari Oct 15, 2024
eca145a
simplify
hafezdivandari Oct 17, 2024
b5c0165
fix tests
hafezdivandari Oct 17, 2024
fc88d8c
formatting
hafezdivandari Oct 17, 2024
0205c0f
add interval
hafezdivandari Oct 28, 2024
55d4401
fix tests
hafezdivandari Oct 28, 2024
c09bbb5
simplify
hafezdivandari Oct 29, 2024
fe9f89a
formatting
hafezdivandari Oct 29, 2024
f002f55
add more tests
hafezdivandari Oct 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions database/factories/ClientFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [],
]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?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->unsignedTinyInteger('interval');
$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');
}
};
27 changes: 27 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,18 @@
'middleware' => 'web',
]);

Route::post('/device/code', [
'uses' => 'DeviceCodeController',
'as' => 'device.code',
'middleware' => 'throttle',
]);

Route::get('/device', [
'uses' => 'DeviceUserCodeController',
'as' => 'device',
'middleware' => 'web',
]);

$guard = config('passport.guard', null);

Route::middleware(['web', $guard ? 'auth:'.$guard : 'auth'])->group(function () {
Expand All @@ -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',
Expand Down
9 changes: 6 additions & 3 deletions src/Bridge/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,17 @@ 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
) {
$this->setIdentifier($identifier);

$this->name = $name;
if (! is_null($name)) {
$this->name = $name;
}

$this->isConfidential = $isConfidential;
$this->redirectUri = $redirectUri;
}
Expand Down
66 changes: 66 additions & 0 deletions src/Bridge/DeviceCode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?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
* @param positive-int|null $interval
*/
public function __construct(
?string $identifier = null,
?string $userIdentifier = null,
?string $clientIdentifier = null,
array $scopes = [],
?int $interval = null,
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 (! is_null($interval)) {
$this->setInterval($interval);
}

if ($userApproved) {
$this->setUserApproved($userApproved);
}

if (! is_null($lastPolledAt)) {
$this->setLastPolledAt($lastPolledAt);
}

if (! is_null($expiryDateTime)) {
$this->setExpiryDateTime($expiryDateTime);
}
}
}
109 changes: 109 additions & 0 deletions src/Bridge/DeviceCodeRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?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([
'interval' => $deviceCodeEntity->getInterval(),
'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(),
'interval' => $deviceCodeEntity->getInterval(),
'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,
$model->interval,
! is_null($model->user_approved_at),
$model->last_polled_at?->toDateTimeImmutable(),
$model->expires_at?->toDateTimeImmutable()
);
}
}
26 changes: 22 additions & 4 deletions src/ClientRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,19 @@ public function createImplicitGrantClient(string $name, array $redirectUris): Cl
return $this->create($name, ['implicit'], $redirectUris, null, false);
}

/**
* Store a new device authorization grant client.
*/
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.
*
Expand All @@ -161,11 +174,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);
}

/**
Expand Down
18 changes: 17 additions & 1 deletion src/Console/ClientCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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)
};

Expand Down Expand Up @@ -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.
*/
Expand All @@ -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 device authorization flow for this client?');

return $clients->createAuthorizationCodeGrantClient(
$this->option('name'), explode(',', $redirect), $confidential,
$this->option('name'), explode(',', $redirect), $confidential, null, $enableDeviceFlow
);
}
}
1 change: 1 addition & 0 deletions src/Console/PurgeCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading