Repository navigation
feat: sign in to the API from a terminal with a device code #585
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
jubianchi
wants to merge
10
commits into
dev-null-sh:production
Choose a base branch
from
jubianchi:feat/device-authorization-grant
base: production
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+2,103
−81
Open
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
485d28c
refactor: extract the sign-in page layout
jubianchi ee2dc75
feat: approve devices on the web with a user code
jubianchi 3bf7f95
feat: hand out device authorizations to terminals
jubianchi fbc19d3
feat: redeem device authorizations for approved devices
jubianchi ab3c800
docs: describe signing in from a terminal
jubianchi b84365e
feat: tell a terminal told to slow down which interval to use
jubianchi 9260af2
fix: decide a terminal's sign-in only once, even from two requests at…
jubianchi d5a01d1
fix: count a terminal's sign-in attempt and check its limit in one step
jubianchi a98edd2
fix: give a terminal's sign-in back when its device could not be saved
jubianchi c6950f1
fix: give starting a terminal sign-in a rate limit bucket of its own
jubianchi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Actions; | ||
|
|
||
| use App\Dto\DeviceAuthorization; | ||
| use App\Enums\DeviceAuthorizationStatus; | ||
| use App\Models\Device; | ||
| use App\Models\User; | ||
| use Illuminate\Support\Facades\Cache; | ||
| use Throwable; | ||
|
|
||
| final readonly class CreateDeviceActivation | ||
| { | ||
| /** | ||
| * Decides a sign-in started from a terminal. Approving makes its key a device of the user right away, as redeeming | ||
| * a code does for the mobile app, since the key proved itself when it started the sign-in and the user approved | ||
| * that very fingerprint. Either way, the decision is kept for the terminal to find until the sign-in would have | ||
| * expired, and the user code is taken back, so a code is decided once. | ||
| * | ||
| * Of two requests deciding the same sign-in at once, only the first decides it: the other changes nothing and gets | ||
| * false, so a denied key is never approved behind the terminal's back. An approval whose device couldn't be saved | ||
| * gives the sign-in back, so the user can try again. | ||
| */ | ||
| public function handle(User $user, DeviceAuthorization $authorization, DeviceAuthorizationStatus $status): bool | ||
| { | ||
| $claim = "{$authorization->key}:decided"; | ||
|
|
||
| if (! Cache::add($claim, true, $authorization->expiresAt)) { | ||
| return false; | ||
| } | ||
|
|
||
| if ($status === DeviceAuthorizationStatus::Approved) { | ||
| try { | ||
| Device::query()->updateOrCreate(['fingerprint' => $authorization->fingerprint], [ | ||
| 'user_id' => $user->id, | ||
| 'name' => $authorization->deviceName, | ||
| 'approved_at' => now(), | ||
| 'last_used_at' => null, | ||
| ]); | ||
| } catch (Throwable $throwable) { | ||
| Cache::forget($claim); | ||
|
|
||
| throw $throwable; | ||
| } | ||
| } | ||
|
|
||
| Cache::put($authorization->key, $authorization->decided($status, $user)->toArray(), $authorization->expiresAt); | ||
| Cache::forget(DeviceAuthorization::userCodeKeyFor($authorization->userCode)); | ||
|
|
||
| return true; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Actions; | ||
|
|
||
| use App\Dto\DeviceAuthorization; | ||
| use App\Services\UserCodes; | ||
| use Carbon\CarbonImmutable; | ||
| use Illuminate\Support\Facades\Cache; | ||
|
|
||
| final readonly class CreateDeviceAuthorization | ||
| { | ||
| public function __construct(private UserCodes $userCodes) | ||
| { | ||
| // | ||
| } | ||
|
|
||
| /** | ||
| * Starts a sign-in from a terminal for a key: a long device code for the terminal to poll with, and a short user | ||
| * code for the user to type on the web, where approving it makes the key a device. Both only live in the cache, | ||
| * under their hashes, until the sign-in expires a few minutes later. A user code that is already out, however | ||
| * unlikely, is drawn again rather than pointed at two sign-ins. | ||
| * | ||
| * @return array{device_code: string, authorization: DeviceAuthorization} | ||
| */ | ||
| public function handle(string $fingerprint, string $deviceName): array | ||
| { | ||
| $deviceCode = bin2hex(random_bytes(32)); | ||
| $key = DeviceAuthorization::keyFor($deviceCode); | ||
| $expiresAt = CarbonImmutable::now()->addMinutes(DeviceAuthorization::EXPIRES_AFTER_MINUTES)->startOfSecond(); | ||
|
|
||
| do { | ||
| $userCode = $this->userCodes->generate(); | ||
| } while (! Cache::add(DeviceAuthorization::userCodeKeyFor($userCode), $key, $expiresAt)); | ||
|
|
||
| $authorization = new DeviceAuthorization($key, $fingerprint, $deviceName, $userCode, $expiresAt); | ||
|
|
||
| Cache::put($key, $authorization->toArray(), $expiresAt); | ||
|
|
||
| return ['device_code' => $deviceCode, 'authorization' => $authorization]; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Dto; | ||
|
|
||
| use App\Enums\DeviceAuthorizationStatus; | ||
| use App\Models\User; | ||
| use Carbon\CarbonImmutable; | ||
|
|
||
| /** | ||
| * A sign-in started from a terminal, the device authorization grant of RFC 8628: the key it was started for, the name | ||
| * the terminal gave itself, the short code the user types on the web, and what the user decided, if they did yet. It | ||
| * only lives in the cache until it expires, under the hash of the device code the terminal polls with, with the hash | ||
| * of the user code pointing at it while nothing is decided. | ||
| */ | ||
| final readonly class DeviceAuthorization | ||
| { | ||
| public const int EXPIRES_AFTER_MINUTES = 15; | ||
|
|
||
| public const int INTERVAL_SECONDS = 5; | ||
|
|
||
| /** What RFC 8628 adds to the interval of a terminal told to slow down. */ | ||
| public const int SLOW_DOWN_SECONDS = 5; | ||
|
|
||
| public function __construct( | ||
| public string $key, | ||
| public string $fingerprint, | ||
| public string $deviceName, | ||
| public string $userCode, | ||
| public CarbonImmutable $expiresAt, | ||
| public DeviceAuthorizationStatus $status = DeviceAuthorizationStatus::Pending, | ||
| public ?string $userId = null, | ||
| ) { | ||
| // | ||
| } | ||
|
|
||
| /** | ||
| * The cache key of a device code. Device codes are long and random, so a fast hash is enough. | ||
| */ | ||
| public static function keyFor(string $deviceCode): string | ||
| { | ||
| return 'device-authorization:'.hash('sha256', $deviceCode); | ||
| } | ||
|
|
||
| /** | ||
| * The cache key under which a user code points at its authorization, never the code itself. | ||
| */ | ||
| public static function userCodeKeyFor(string $userCode): string | ||
| { | ||
| return 'device-authorization-user-code:'.hash('sha256', $userCode); | ||
| } | ||
|
|
||
| /** | ||
| * @param array<mixed> $attributes | ||
| */ | ||
| public static function fromArray(string $key, array $attributes): self | ||
| { | ||
| $attributes = fluent($attributes); | ||
| $userId = $attributes->get('user_id'); | ||
|
|
||
| return new self( | ||
| $key, | ||
| $attributes->string('fingerprint')->value(), | ||
| $attributes->string('device_name')->value(), | ||
| $attributes->string('user_code')->value(), | ||
| CarbonImmutable::createFromTimestamp($attributes->integer('expires_at')), | ||
| DeviceAuthorizationStatus::from($attributes->string('status')->value()), | ||
| is_string($userId) ? $userId : null, | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * The same sign-in once the user decided on it: approved for them, or denied, in which case it names nobody. | ||
| */ | ||
| public function decided(DeviceAuthorizationStatus $status, User $user): self | ||
| { | ||
| return new self( | ||
| $this->key, | ||
| $this->fingerprint, | ||
| $this->deviceName, | ||
| $this->userCode, | ||
| $this->expiresAt, | ||
| $status, | ||
| $status === DeviceAuthorizationStatus::Approved ? $user->id : null, | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * @return array{fingerprint: string, device_name: string, user_code: string, expires_at: int, status: string, user_id: string|null} | ||
| */ | ||
| public function toArray(): array | ||
| { | ||
| return [ | ||
| 'fingerprint' => $this->fingerprint, | ||
| 'device_name' => $this->deviceName, | ||
| 'user_code' => $this->userCode, | ||
| 'expires_at' => $this->expiresAt->getTimestamp(), | ||
| 'status' => $this->status->value, | ||
| 'user_id' => $this->userId, | ||
| ]; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Enums; | ||
|
|
||
| /** | ||
| * Where a sign-in from a terminal stands: waiting for the user to decide on the web, or decided either way. | ||
| */ | ||
| enum DeviceAuthorizationStatus: string | ||
| { | ||
| case Pending = 'pending'; | ||
| case Approved = 'approved'; | ||
| case Denied = 'denied'; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
32 changes: 32 additions & 0 deletions
32
app/Http/Controllers/Api/DeviceAuthorizationController.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Http\Controllers\Api; | ||
|
|
||
| use App\Actions\CreateDeviceAuthorization; | ||
| use App\Dto\DeviceAuthorization; | ||
| use App\Http\Requests\CreateApiDeviceAuthorizationRequest; | ||
| use Illuminate\Http\JsonResponse; | ||
|
|
||
| final readonly class DeviceAuthorizationController | ||
| { | ||
| /** | ||
| * Starts a sign-in from a terminal, the device authorization grant of RFC 8628. The terminal shows the user the | ||
| * code and where to type it, then polls POST /api/v1/devices with the device code, no more often than the interval | ||
| * says, until the user decides on the web. | ||
| */ | ||
| public function store(CreateApiDeviceAuthorizationRequest $request, CreateDeviceAuthorization $action): JsonResponse | ||
| { | ||
| ['device_code' => $deviceCode, 'authorization' => $authorization] = $action->handle($request->fingerprint(), $request->string('device_name')->value()); | ||
|
|
||
| return response()->json([ | ||
| 'device_code' => $deviceCode, | ||
| 'user_code' => $authorization->userCode, | ||
| 'verification_uri' => route('device-activation.show'), | ||
| 'verification_uri_complete' => route('device-activation.show', ['user_code' => $authorization->userCode]), | ||
| 'expires_in' => DeviceAuthorization::EXPIRES_AFTER_MINUTES * 60, | ||
| 'interval' => DeviceAuthorization::INTERVAL_SECONDS, | ||
| ], 201); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.