Skip to content

Commit aff8bfb

Browse files
committed
feat: Add support for device code grant
1 parent 6f34b4e commit aff8bfb

37 files changed

+1565
-11
lines changed

config/routes.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
->add('oauth2_authorize', '/authorize')
1010
->controller(['league.oauth2_server.controller.authorization', 'indexAction'])
1111

12+
->add('oauth2_device_code', '/device-code')
13+
->controller(['league.oauth2_server.controller.device_code', 'indexAction'])
14+
->methods(['POST'])
15+
1216
->add('oauth2_token', '/token')
1317
->controller(['league.oauth2_server.controller.token', 'indexAction'])
1418
->methods(['POST'])

config/services.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
declare(strict_types=1);
44

5+
use League\Bundle\OAuth2ServerBundle\Controller\DeviceCodeController;
6+
use League\Bundle\OAuth2ServerBundle\Manager\DeviceCodeManagerInterface;
7+
use League\Bundle\OAuth2ServerBundle\Repository\DeviceCodeRepository;
8+
use League\OAuth2\Server\Grant\DeviceCodeGrant;
9+
use League\OAuth2\Server\Repositories\DeviceCodeRepositoryInterface;
510
use function Symfony\Component\DependencyInjection\Loader\Configurator\abstract_arg;
611
use function Symfony\Component\DependencyInjection\Loader\Configurator\param;
712
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
@@ -79,6 +84,16 @@
7984
->alias(RefreshTokenRepositoryInterface::class, 'league.oauth2_server.repository.refresh_token')
8085
->alias(RefreshTokenRepository::class, 'league.oauth2_server.repository.refresh_token')
8186

87+
->set('league.oauth2_server.repository.device_code', DeviceCodeRepository::class)
88+
->args([
89+
service(DeviceCodeManagerInterface::class),
90+
service(ClientManagerInterface::class),
91+
service(ScopeConverterInterface::class),
92+
service(ClientRepositoryInterface::class),
93+
])
94+
->alias(DeviceCodeRepositoryInterface::class, 'league.oauth2_server.repository.device_code')
95+
->alias(DeviceCodeRepository::class, 'league.oauth2_server.repository.device_code')
96+
8297
->set('league.oauth2_server.repository.scope', ScopeRepository::class)
8398
->args([
8499
service(ScopeManagerInterface::class),
@@ -195,6 +210,16 @@
195210
])
196211
->alias(AuthCodeGrant::class, 'league.oauth2_server.grant.auth_code')
197212

213+
->set('league.oauth2_server.grant.device_code', DeviceCodeGrant::class)
214+
->args([
215+
service(DeviceCodeRepositoryInterface::class),
216+
service(RefreshTokenRepositoryInterface::class),
217+
null,
218+
null,
219+
null
220+
])
221+
->alias(DeviceCodeGrant::class, 'league.oauth2_server.grant.device_code')
222+
198223
->set('league.oauth2_server.grant.implicit', ImplicitGrant::class)
199224
->args([
200225
null,
@@ -216,6 +241,20 @@
216241
->tag('controller.service_arguments')
217242
->alias(AuthorizationController::class, 'league.oauth2_server.controller.authorization')
218243

244+
->set('league.oauth2_server.controller.device_code', DeviceCodeController::class)
245+
->args([
246+
service(AuthorizationServer::class),
247+
service(EventDispatcherInterface::class),
248+
service(AuthorizationRequestResolveEventFactory::class),
249+
service(UserConverterInterface::class),
250+
service(ClientManagerInterface::class),
251+
service('league.oauth2_server.factory.psr_http'),
252+
service('league.oauth2_server.factory.http_foundation'),
253+
service('league.oauth2_server.factory.psr17'),
254+
])
255+
->tag('controller.service_arguments')
256+
->alias(DeviceCodeController::class, 'league.oauth2_server.controller.device_code')
257+
219258
// Token controller
220259
->set('league.oauth2_server.controller.token', TokenController::class)
221260
->args([
@@ -263,6 +302,7 @@
263302
service(AccessTokenManagerInterface::class),
264303
service(RefreshTokenManagerInterface::class),
265304
service(AuthorizationCodeManagerInterface::class),
305+
service(DeviceCodeManagerInterface::class),
266306
])
267307
->tag('console.command', ['command' => 'league:oauth2-server:clear-expired-tokens'])
268308
->alias(ClearExpiredTokensCommand::class, 'league.oauth2_server.command.clear_expired_tokens')

config/storage/doctrine.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
declare(strict_types=1);
44

5+
use League\Bundle\OAuth2ServerBundle\Manager\DeviceCodeManagerInterface;
6+
use League\Bundle\OAuth2ServerBundle\Manager\Doctrine\DeviceCodeManager;
57
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
68

79
use League\Bundle\OAuth2ServerBundle\Manager\AccessTokenManagerInterface;
@@ -53,6 +55,13 @@
5355
->alias(RefreshTokenManagerInterface::class, 'league.oauth2_server.manager.doctrine.refresh_token')
5456
->alias(RefreshTokenManager::class, 'league.oauth2_server.manager.doctrine.refresh_token')
5557

58+
->set('league.oauth2_server.manager.doctrine.device_code', DeviceCodeManager::class)
59+
->args([
60+
null,
61+
])
62+
->alias(DeviceCodeManagerInterface::class, 'league.oauth2_server.manager.doctrine.device_code')
63+
->alias(DeviceCodeManager::class, 'league.oauth2_server.manager.doctrine.device_code')
64+
5665
->set('league.oauth2_server.manager.doctrine.authorization_code', AuthorizationCodeManager::class)
5766
->args([
5867
null,

config/storage/in_memory.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
declare(strict_types=1);
44

5+
use League\Bundle\OAuth2ServerBundle\Manager\DeviceCodeManagerInterface;
6+
use League\Bundle\OAuth2ServerBundle\Manager\InMemory\DeviceCodeManager;
57
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
68

79
use League\Bundle\OAuth2ServerBundle\Manager\AccessTokenManagerInterface;
@@ -39,6 +41,13 @@
3941
->alias(RefreshTokenManagerInterface::class, 'league.oauth2_server.manager.in_memory.refresh_token')
4042
->alias(RefreshTokenManager::class, 'league.oauth2_server.manager.in_memory.refresh_token')
4143

44+
->set('league.oauth2_server.manager.in_memory.device_code', DeviceCodeManager::class)
45+
->args([
46+
null,
47+
])
48+
->alias(DeviceCodeManagerInterface::class, 'league.oauth2_server.manager.in_memory.device_code')
49+
->alias(DeviceCodeManager::class, 'league.oauth2_server.manager.in_memory.device_code')
50+
4251
->set('league.oauth2_server.manager.in_memory.authorization_code', AuthorizationCodeManager::class)
4352
->args([
4453
null,

docs/basic-setup.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,14 +97,17 @@ security:
9797
api_token:
9898
pattern: ^/token$
9999
security: false
100+
api_device_code:
101+
pattern: ^/device-code$
102+
security: false
100103
api:
101104
pattern: ^/api
102105
security: true
103106
stateless: true
104107
oauth2: true
105108
```
106109
107-
* The `api_token` firewall will ensure that anyone can access the `/api/token` endpoint in order to be able to retrieve their access tokens.
110+
* The `api_token` and `api_device_code` firewall will ensure that anyone can access the `/token` and `/device-code` endpoint respectively in order to be able to retrieve their access tokens or device codes.
108111
* The `api` firewall will protect all routes prefixed with `/api` and clients will require a valid access token in order to access them.
109112
110113
Basically, any firewall which sets the `oauth2` parameter to `true` will make any routes that match the selected pattern go through our OAuth 2.0 security layer.

docs/device-code-grant.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Password grant handling
2+
3+
The device code grant type is designed for devices without a browser or with limited input capabilities. In this flow, the user authenticates on another device—like a smartphone or computer—and receives a code to enter on the original device.
4+
5+
Initially, the device sends a request to /device-code with its client ID and scope. The server then returns a device code, a user code, and a verification URL. The user takes the code to a secondary device, opens the verification URL in a browser, and enters the user code.
6+
7+
Meanwhile, the original device continuously polls the /token endpoint with the device code. Once the user approves the request on the secondary device, the token endpoint returns the access token to the polling device.
8+
9+
## Requirements
10+
11+
You need to implement the verification URL yourself and handle the user code input : this bundle does not provide a route or UI for this.
12+
13+
## Example
14+
15+
### Controller
16+
17+
This is a sample Symfony 7 controller to handle the user code input
18+
19+
```php
20+
<?php
21+
22+
namespace App\Controller;
23+
24+
use League\Bundle\OAuth2ServerBundle\Repository\DeviceCodeRepository;
25+
use League\OAuth2\Server\Exception\OAuthServerException;
26+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
27+
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
28+
use Symfony\Component\Form\Extension\Core\Type\TextType;
29+
use Symfony\Component\HttpFoundation\Request;
30+
use Symfony\Component\HttpFoundation\Response;
31+
use Symfony\Component\Routing\Attribute\Route;
32+
33+
class DeviceCodeController extends AbstractController
34+
{
35+
36+
public function __construct(
37+
private readonly DeviceCodeRepository $deviceCodeRepository
38+
) {
39+
}
40+
41+
#[Route(path: '/verify-device', name: 'app_verify_device', methods: ['GET', 'POST'])]
42+
public function verifyDevice(
43+
Request $request
44+
): Response {
45+
$form = $this->createFormBuilder()
46+
->add('userCode', TextType::class, [
47+
'required' => true,
48+
])
49+
->getForm()
50+
->handleRequest($request);
51+
52+
if ($form->isSubmitted() && $form->isValid()) {
53+
try {
54+
$this->deviceCodeRepository->approveDeviceCode($form->get('userCode')->getData(), $this->getUser()->getId());
55+
// Device code approved, show success message to user
56+
} catch (OAuthServerException $e) {
57+
// Handle exception (invalid code or missing user ID)
58+
}
59+
}
60+
61+
return $this->render(
62+
'verify_device.html.twig',
63+
['form' => $form]
64+
);
65+
}
66+
67+
}
68+
```
69+
70+
### Configuration
71+
72+
```yaml
73+
league_oauth2_server:
74+
authorization_server:
75+
device_code_verification_uri: 'https://your-domain.com/verify-device'
76+
```

docs/index.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ For implementation into Symfony projects, please see [bundle documentation](basi
66

77
## Features
88

9-
* API endpoint for client authorization and token issuing
9+
* API endpoint for client authorization, device code and token issuing
1010
* Configurable client and token persistance (includes [Doctrine](https://www.doctrine-project.org/) support)
1111
* Integration with Symfony's [Security](https://symfony.com/doc/current/security.html) layer
1212

@@ -78,6 +78,15 @@ For implementation into Symfony projects, please see [bundle documentation](basi
7878
# Whether to revoke refresh tokens after they were used for all grant types (default to true)
7979
revoke_refresh_tokens: true
8080
81+
# Whether to enable the device code grant
82+
enable_device_code_grant: true
83+
84+
# The full URI the user will need to visit to enter the user code
85+
device_code_verification_uri: ''
86+
87+
# How soon (in seconds) can the device code be used to poll for the access token without being throttled
88+
device_code_polling_interval: 5
89+
8190
resource_server: # Required
8291
8392
# Full path to the public key file

src/Command/ClearExpiredTokensCommand.php

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use League\Bundle\OAuth2ServerBundle\Manager\AccessTokenManagerInterface;
88
use League\Bundle\OAuth2ServerBundle\Manager\AuthorizationCodeManagerInterface;
9+
use League\Bundle\OAuth2ServerBundle\Manager\DeviceCodeManagerInterface;
910
use League\Bundle\OAuth2ServerBundle\Manager\RefreshTokenManagerInterface;
1011
use Symfony\Component\Console\Attribute\AsCommand;
1112
use Symfony\Component\Console\Command\Command;
@@ -32,16 +33,23 @@ final class ClearExpiredTokensCommand extends Command
3233
*/
3334
private $authorizationCodeManager;
3435

36+
/**
37+
* @var DeviceCodeManagerInterface
38+
*/
39+
private $deviceCodeManager;
40+
3541
public function __construct(
3642
AccessTokenManagerInterface $accessTokenManager,
3743
RefreshTokenManagerInterface $refreshTokenManager,
3844
AuthorizationCodeManagerInterface $authorizationCodeManager,
45+
DeviceCodeManagerInterface $deviceCodeManager,
3946
) {
4047
parent::__construct();
4148

4249
$this->accessTokenManager = $accessTokenManager;
4350
$this->refreshTokenManager = $refreshTokenManager;
4451
$this->authorizationCodeManager = $authorizationCodeManager;
52+
$this->deviceCodeManager = $deviceCodeManager;
4553
}
4654

4755
protected function configure(): void
@@ -66,6 +74,12 @@ protected function configure(): void
6674
InputOption::VALUE_NONE,
6775
'Clear expired auth codes.'
6876
)
77+
->addOption(
78+
'device-codes',
79+
'dc',
80+
InputOption::VALUE_NONE,
81+
'Clear expired device codes.'
82+
)
6983
;
7084
}
7185

@@ -76,11 +90,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int
7690
$clearExpiredAccessTokens = $input->getOption('access-tokens');
7791
$clearExpiredRefreshTokens = $input->getOption('refresh-tokens');
7892
$clearExpiredAuthCodes = $input->getOption('auth-codes');
93+
$clearExpiredDeviceCodes = $input->getOption('device-codes');
7994

80-
if (!$clearExpiredAccessTokens && !$clearExpiredRefreshTokens && !$clearExpiredAuthCodes) {
95+
if (!$clearExpiredAccessTokens && !$clearExpiredRefreshTokens && !$clearExpiredAuthCodes && !$clearExpiredDeviceCodes) {
8196
$this->clearExpiredAccessTokens($io);
8297
$this->clearExpiredRefreshTokens($io);
8398
$this->clearExpiredAuthCodes($io);
99+
$this->clearExpiredDeviceCodes($io);
84100

85101
return 0;
86102
}
@@ -97,6 +113,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int
97113
$this->clearExpiredAuthCodes($io);
98114
}
99115

116+
if ($clearExpiredDeviceCodes) {
117+
$this->clearExpiredDeviceCodes($io);
118+
}
119+
100120
return 0;
101121
}
102122

@@ -129,4 +149,14 @@ private function clearExpiredAuthCodes(SymfonyStyle $io): void
129149
1 === $numOfClearedAuthCodes ? '' : 's'
130150
));
131151
}
152+
153+
private function clearExpiredDeviceCodes(SymfonyStyle $io): void
154+
{
155+
$numberOfClearedDeviceCodes = $this->deviceCodeManager->clearExpired();
156+
$io->success(\sprintf(
157+
'Cleared %d expired device code%s.',
158+
$numberOfClearedDeviceCodes,
159+
1 === $numberOfClearedDeviceCodes ? '' : 's'
160+
));
161+
}
132162
}

0 commit comments

Comments
 (0)