Skip to content

Commit 61c0a9d

Browse files
committed
feat: Add support for device code grant
1 parent cee1e71 commit 61c0a9d

37 files changed

+1566
-11
lines changed

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

@@ -75,6 +75,15 @@ For implementation into Symfony projects, please see [bundle documentation](basi
7575
# Whether to enable access token saving to persistence layer (default to true)
7676
persist_access_token: true
7777
78+
# Whether to enable the device code grant
79+
enable_device_code_grant: true
80+
81+
# The full URI the user will need to visit to enter the user code
82+
device_code_verification_uri: ''
83+
84+
# How soon (in seconds) can the device code be used to poll for the access token without being throttled
85+
device_code_polling_interval: 5
86+
7887
resource_server: # Required
7988
8089
# 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
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace League\Bundle\OAuth2ServerBundle\Controller;
6+
7+
use League\Bundle\OAuth2ServerBundle\Converter\UserConverterInterface;
8+
use League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEventFactory;
9+
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
10+
use League\Bundle\OAuth2ServerBundle\Model\AbstractClient;
11+
use League\Bundle\OAuth2ServerBundle\OAuth2Events;
12+
use League\OAuth2\Server\AuthorizationServer;
13+
use League\OAuth2\Server\Exception\OAuthServerException;
14+
use Psr\Http\Message\ResponseFactoryInterface;
15+
use Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface;
16+
use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface;
17+
use Symfony\Component\HttpFoundation\Request;
18+
use Symfony\Component\HttpFoundation\Response;
19+
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
20+
21+
final class DeviceCodeController
22+
{
23+
/**
24+
* @var AuthorizationServer
25+
*/
26+
private $server;
27+
28+
/**
29+
* @var EventDispatcherInterface
30+
*/
31+
private $eventDispatcher;
32+
33+
/**
34+
* @var AuthorizationRequestResolveEventFactory
35+
*/
36+
private $eventFactory;
37+
38+
/**
39+
* @var UserConverterInterface
40+
*/
41+
private $userConverter;
42+
43+
/**
44+
* @var ClientManagerInterface
45+
*/
46+
private $clientManager;
47+
48+
/**
49+
* @var HttpMessageFactoryInterface
50+
*/
51+
private $httpMessageFactory;
52+
53+
/**
54+
* @var HttpFoundationFactoryInterface
55+
*/
56+
private $httpFoundationFactory;
57+
58+
/**
59+
* @var ResponseFactoryInterface
60+
*/
61+
private $responseFactory;
62+
63+
public function __construct(
64+
AuthorizationServer $server,
65+
EventDispatcherInterface $eventDispatcher,
66+
AuthorizationRequestResolveEventFactory $eventFactory,
67+
UserConverterInterface $userConverter,
68+
ClientManagerInterface $clientManager,
69+
HttpMessageFactoryInterface $httpMessageFactory,
70+
HttpFoundationFactoryInterface $httpFoundationFactory,
71+
ResponseFactoryInterface $responseFactory,
72+
) {
73+
$this->server = $server;
74+
$this->eventDispatcher = $eventDispatcher;
75+
$this->eventFactory = $eventFactory;
76+
$this->userConverter = $userConverter;
77+
$this->clientManager = $clientManager;
78+
$this->httpMessageFactory = $httpMessageFactory;
79+
$this->httpFoundationFactory = $httpFoundationFactory;
80+
$this->responseFactory = $responseFactory;
81+
}
82+
83+
public function indexAction(Request $request): Response
84+
{
85+
$serverRequest = $this->httpMessageFactory->createRequest($request);
86+
$serverResponse = $this->responseFactory->createResponse();
87+
88+
try {
89+
$response = $this->server->respondToDeviceAuthorizationRequest($serverRequest, $serverResponse);
90+
} catch (OAuthServerException $e) {
91+
$response = $e->generateHttpResponse($serverResponse);
92+
}
93+
94+
return $this->httpFoundationFactory->createResponse($response);
95+
}
96+
}

src/DependencyInjection/Configuration.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ private function createAuthorizationServerNode(): NodeDefinition
7979
->cannotBeEmpty()
8080
->defaultValue('PT10M')
8181
->end()
82+
->scalarNode('device_code_ttl')
83+
->info("How long the issued device code should be valid for.\nThe value should be a valid interval: http://php.net/manual/en/dateinterval.construct.php#refsect1-dateinterval.construct-parameters")
84+
->cannotBeEmpty()
85+
->defaultValue('PT10M')
86+
->end()
8287
->booleanNode('enable_client_credentials_grant')
8388
->info('Whether to enable the client credentials grant')
8489
->defaultTrue()
@@ -111,6 +116,19 @@ private function createAuthorizationServerNode(): NodeDefinition
111116
->info('Define a custom ResponseType')
112117
->defaultValue(null)
113118
->end()
119+
->booleanNode('enable_device_code_grant')
120+
->info('Whether to enable the device code grant')
121+
->defaultTrue()
122+
->end()
123+
->scalarNode('device_code_verification_uri')
124+
->info('The full URI the user will need to visit to enter the user code')
125+
->defaultValue('')
126+
->end()
127+
->scalarNode('device_code_polling_interval')
128+
->info('How soon (in seconds) can the device code be used to poll for the access token without being throttled')
129+
->defaultValue(5)
130+
->end()
131+
114132
->end()
115133
;
116134

@@ -223,6 +241,11 @@ private function createPersistenceNode(): NodeDefinition
223241
->cannotBeEmpty()
224242
->isRequired()
225243
->end()
244+
->scalarNode('device_code_manager')
245+
->info('Service id of the custom device code manager')
246+
->cannotBeEmpty()
247+
->isRequired()
248+
->end()
226249
->scalarNode('credentials_revoker')
227250
->info('Service id of the custom credentials revoker')
228251
->cannotBeEmpty()

0 commit comments

Comments
 (0)