Skip to content

feat: Add support for device code grant #229

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

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions config/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
->add('oauth2_authorize', '/authorize')
->controller(['league.oauth2_server.controller.authorization', 'indexAction'])

->add('oauth2_device_code', '/device-code')
->controller(['league.oauth2_server.controller.device_code', 'indexAction'])
->methods(['POST'])

->add('oauth2_token', '/token')
->controller(['league.oauth2_server.controller.token', 'indexAction'])
->methods(['POST'])
Expand Down
36 changes: 36 additions & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use League\Bundle\OAuth2ServerBundle\Command\ListClientsCommand;
use League\Bundle\OAuth2ServerBundle\Command\UpdateClientCommand;
use League\Bundle\OAuth2ServerBundle\Controller\AuthorizationController;
use League\Bundle\OAuth2ServerBundle\Controller\DeviceCodeController;
use League\Bundle\OAuth2ServerBundle\Controller\TokenController;
use League\Bundle\OAuth2ServerBundle\Converter\ScopeConverter;
use League\Bundle\OAuth2ServerBundle\Converter\ScopeConverterInterface;
Expand All @@ -25,12 +26,14 @@
use League\Bundle\OAuth2ServerBundle\Manager\AccessTokenManagerInterface;
use League\Bundle\OAuth2ServerBundle\Manager\AuthorizationCodeManagerInterface;
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
use League\Bundle\OAuth2ServerBundle\Manager\DeviceCodeManagerInterface;
use League\Bundle\OAuth2ServerBundle\Manager\InMemory\ScopeManager;
use League\Bundle\OAuth2ServerBundle\Manager\RefreshTokenManagerInterface;
use League\Bundle\OAuth2ServerBundle\Manager\ScopeManagerInterface;
use League\Bundle\OAuth2ServerBundle\OAuth2Events;
use League\Bundle\OAuth2ServerBundle\Repository\AuthCodeRepository;
use League\Bundle\OAuth2ServerBundle\Repository\ClientRepository;
use League\Bundle\OAuth2ServerBundle\Repository\DeviceCodeRepository;
use League\Bundle\OAuth2ServerBundle\Repository\RefreshTokenRepository;
use League\Bundle\OAuth2ServerBundle\Repository\ScopeRepository;
use League\Bundle\OAuth2ServerBundle\Repository\UserRepository;
Expand All @@ -42,12 +45,14 @@
use League\OAuth2\Server\EventEmitting\EventEmitter;
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;
use League\OAuth2\Server\Repositories\AccessTokenRepositoryInterface;
use League\OAuth2\Server\Repositories\AuthCodeRepositoryInterface;
use League\OAuth2\Server\Repositories\ClientRepositoryInterface;
use League\OAuth2\Server\Repositories\DeviceCodeRepositoryInterface;
use League\OAuth2\Server\Repositories\RefreshTokenRepositoryInterface;
use League\OAuth2\Server\Repositories\ScopeRepositoryInterface;
use League\OAuth2\Server\Repositories\UserRepositoryInterface;
Expand Down Expand Up @@ -79,6 +84,16 @@
->alias(RefreshTokenRepositoryInterface::class, 'league.oauth2_server.repository.refresh_token')
->alias(RefreshTokenRepository::class, 'league.oauth2_server.repository.refresh_token')

->set('league.oauth2_server.repository.device_code', DeviceCodeRepository::class)
->args([
service(DeviceCodeManagerInterface::class),
service(ClientManagerInterface::class),
service(ScopeConverterInterface::class),
service(ClientRepositoryInterface::class),
])
->alias(DeviceCodeRepositoryInterface::class, 'league.oauth2_server.repository.device_code')
->alias(DeviceCodeRepository::class, 'league.oauth2_server.repository.device_code')

->set('league.oauth2_server.repository.scope', ScopeRepository::class)
->args([
service(ScopeManagerInterface::class),
Expand Down Expand Up @@ -195,6 +210,16 @@
])
->alias(AuthCodeGrant::class, 'league.oauth2_server.grant.auth_code')

->set('league.oauth2_server.grant.device_code', DeviceCodeGrant::class)
->args([
service(DeviceCodeRepositoryInterface::class),
service(RefreshTokenRepositoryInterface::class),
null,
null,
null,
])
->alias(DeviceCodeGrant::class, 'league.oauth2_server.grant.device_code')

->set('league.oauth2_server.grant.implicit', ImplicitGrant::class)
->args([
null,
Expand All @@ -216,6 +241,16 @@
->tag('controller.service_arguments')
->alias(AuthorizationController::class, 'league.oauth2_server.controller.authorization')

->set('league.oauth2_server.controller.device_code', DeviceCodeController::class)
->args([
service(AuthorizationServer::class),
service('league.oauth2_server.factory.psr_http'),
service('league.oauth2_server.factory.http_foundation'),
service('league.oauth2_server.factory.psr17'),
])
->tag('controller.service_arguments')
->alias(DeviceCodeController::class, 'league.oauth2_server.controller.device_code')

// Token controller
->set('league.oauth2_server.controller.token', TokenController::class)
->args([
Expand Down Expand Up @@ -263,6 +298,7 @@
service(AccessTokenManagerInterface::class),
service(RefreshTokenManagerInterface::class),
service(AuthorizationCodeManagerInterface::class),
service(DeviceCodeManagerInterface::class),
])
->tag('console.command', ['command' => 'league:oauth2-server:clear-expired-tokens'])
->alias(ClearExpiredTokensCommand::class, 'league.oauth2_server.command.clear_expired_tokens')
Expand Down
9 changes: 9 additions & 0 deletions config/storage/doctrine.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
use League\Bundle\OAuth2ServerBundle\Manager\AccessTokenManagerInterface;
use League\Bundle\OAuth2ServerBundle\Manager\AuthorizationCodeManagerInterface;
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
use League\Bundle\OAuth2ServerBundle\Manager\DeviceCodeManagerInterface;
use League\Bundle\OAuth2ServerBundle\Manager\Doctrine\AccessTokenManager;
use League\Bundle\OAuth2ServerBundle\Manager\Doctrine\AuthorizationCodeManager;
use League\Bundle\OAuth2ServerBundle\Manager\Doctrine\ClientManager;
use League\Bundle\OAuth2ServerBundle\Manager\Doctrine\DeviceCodeManager;
use League\Bundle\OAuth2ServerBundle\Manager\Doctrine\RefreshTokenManager;
use League\Bundle\OAuth2ServerBundle\Manager\RefreshTokenManagerInterface;
use League\Bundle\OAuth2ServerBundle\Persistence\Mapping\Driver;
Expand Down Expand Up @@ -53,6 +55,13 @@
->alias(RefreshTokenManagerInterface::class, 'league.oauth2_server.manager.doctrine.refresh_token')
->alias(RefreshTokenManager::class, 'league.oauth2_server.manager.doctrine.refresh_token')

->set('league.oauth2_server.manager.doctrine.device_code', DeviceCodeManager::class)
->args([
null,
])
->alias(DeviceCodeManagerInterface::class, 'league.oauth2_server.manager.doctrine.device_code')
->alias(DeviceCodeManager::class, 'league.oauth2_server.manager.doctrine.device_code')

->set('league.oauth2_server.manager.doctrine.authorization_code', AuthorizationCodeManager::class)
->args([
null,
Expand Down
9 changes: 9 additions & 0 deletions config/storage/in_memory.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
use League\Bundle\OAuth2ServerBundle\Manager\AccessTokenManagerInterface;
use League\Bundle\OAuth2ServerBundle\Manager\AuthorizationCodeManagerInterface;
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
use League\Bundle\OAuth2ServerBundle\Manager\DeviceCodeManagerInterface;
use League\Bundle\OAuth2ServerBundle\Manager\InMemory\AccessTokenManager;
use League\Bundle\OAuth2ServerBundle\Manager\InMemory\AuthorizationCodeManager;
use League\Bundle\OAuth2ServerBundle\Manager\InMemory\ClientManager;
use League\Bundle\OAuth2ServerBundle\Manager\InMemory\DeviceCodeManager;
use League\Bundle\OAuth2ServerBundle\Manager\InMemory\RefreshTokenManager;
use League\Bundle\OAuth2ServerBundle\Manager\RefreshTokenManagerInterface;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
Expand Down Expand Up @@ -39,6 +41,13 @@
->alias(RefreshTokenManagerInterface::class, 'league.oauth2_server.manager.in_memory.refresh_token')
->alias(RefreshTokenManager::class, 'league.oauth2_server.manager.in_memory.refresh_token')

->set('league.oauth2_server.manager.in_memory.device_code', DeviceCodeManager::class)
->args([
null,
])
->alias(DeviceCodeManagerInterface::class, 'league.oauth2_server.manager.in_memory.device_code')
->alias(DeviceCodeManager::class, 'league.oauth2_server.manager.in_memory.device_code')

->set('league.oauth2_server.manager.in_memory.authorization_code', AuthorizationCodeManager::class)
->args([
null,
Expand Down
5 changes: 4 additions & 1 deletion docs/basic-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,14 +97,17 @@ security:
api_token:
pattern: ^/token$
security: false
api_device_code:
pattern: ^/device-code$
security: false
api:
pattern: ^/api
security: true
stateless: true
oauth2: true
```

* The `api_token` firewall will ensure that anyone can access the `/api/token` endpoint in order to be able to retrieve their access tokens.
* 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.
* The `api` firewall will protect all routes prefixed with `/api` and clients will require a valid access token in order to access them.

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.
Expand Down
66 changes: 66 additions & 0 deletions docs/device-code-grant.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Device grant handling

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.

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.

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.

## Requirements

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.

## Example

### Controller

This is a sample Symfony 7 controller to handle the user code input

```php
<?php

namespace App\Controller;

use League\Bundle\OAuth2ServerBundle\Repository\DeviceCodeRepository;
use League\OAuth2\Server\Exception\OAuthServerException;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

public function __construct(
private readonly DeviceCodeRepository $deviceCodeRepository
) {
}

#[Route(path: '/verify-device', name: 'app_verify_device', methods: ['GET', 'POST'])]
public function verifyDevice(
Request $request
): Response {
$form = $this->createFormBuilder()
->add('userCode', TextType::class, [
'required' => true,
])
->getForm()
->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
try {
$this->deviceCodeRepository->approveDeviceCode($form->get('userCode')->getData(), $this->getUser()->getId());
// Device code approved, show success message to user
} catch (OAuthServerException $e) {
// Handle exception (invalid code or missing user ID)
}
}

// Render the form to the user
}
```

### Configuration

```yaml
league_oauth2_server:
authorization_server:
device_code_verification_uri: 'https://your-domain.com/verify-device'
```
11 changes: 10 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ For implementation into Symfony projects, please see [bundle documentation](basi

## Features

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

Expand Down Expand Up @@ -78,6 +78,15 @@ For implementation into Symfony projects, please see [bundle documentation](basi
# Whether to revoke refresh tokens after they were used for all grant types (default to true)
revoke_refresh_tokens: true

# Whether to enable the device code grant
enable_device_code_grant: true

# The full URI the user will need to visit to enter the user code
device_code_verification_uri: ''

# How soon (in seconds) can the device code be used to poll for the access token without being throttled
device_code_polling_interval: 5

resource_server: # Required

# Full path to the public key file
Expand Down
32 changes: 31 additions & 1 deletion src/Command/ClearExpiredTokensCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use League\Bundle\OAuth2ServerBundle\Manager\AccessTokenManagerInterface;
use League\Bundle\OAuth2ServerBundle\Manager\AuthorizationCodeManagerInterface;
use League\Bundle\OAuth2ServerBundle\Manager\DeviceCodeManagerInterface;
use League\Bundle\OAuth2ServerBundle\Manager\RefreshTokenManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
Expand All @@ -32,16 +33,23 @@ final class ClearExpiredTokensCommand extends Command
*/
private $authorizationCodeManager;

/**
* @var DeviceCodeManagerInterface
*/
private $deviceCodeManager;

public function __construct(
AccessTokenManagerInterface $accessTokenManager,
RefreshTokenManagerInterface $refreshTokenManager,
AuthorizationCodeManagerInterface $authorizationCodeManager,
DeviceCodeManagerInterface $deviceCodeManager,
) {
parent::__construct();

$this->accessTokenManager = $accessTokenManager;
$this->refreshTokenManager = $refreshTokenManager;
$this->authorizationCodeManager = $authorizationCodeManager;
$this->deviceCodeManager = $deviceCodeManager;
}

protected function configure(): void
Expand All @@ -66,6 +74,12 @@ protected function configure(): void
InputOption::VALUE_NONE,
'Clear expired auth codes.'
)
->addOption(
'device-codes',
'dc',
InputOption::VALUE_NONE,
'Clear expired device codes.'
)
;
}

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

if (!$clearExpiredAccessTokens && !$clearExpiredRefreshTokens && !$clearExpiredAuthCodes) {
if (!$clearExpiredAccessTokens && !$clearExpiredRefreshTokens && !$clearExpiredAuthCodes && !$clearExpiredDeviceCodes) {
$this->clearExpiredAccessTokens($io);
$this->clearExpiredRefreshTokens($io);
$this->clearExpiredAuthCodes($io);
$this->clearExpiredDeviceCodes($io);

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

if ($clearExpiredDeviceCodes) {
$this->clearExpiredDeviceCodes($io);
}

return 0;
}

Expand Down Expand Up @@ -129,4 +149,14 @@ private function clearExpiredAuthCodes(SymfonyStyle $io): void
1 === $numOfClearedAuthCodes ? '' : 's'
));
}

private function clearExpiredDeviceCodes(SymfonyStyle $io): void
{
$numberOfClearedDeviceCodes = $this->deviceCodeManager->clearExpired();
$io->success(\sprintf(
'Cleared %d expired device code%s.',
$numberOfClearedDeviceCodes,
1 === $numberOfClearedDeviceCodes ? '' : 's'
));
}
}
Loading