Skip to content
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: 3 additions & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,8 @@ Sources: [API routes](routes/api.php), [API controllers](app/Http/Controllers/Ap

An app can use the same `/api/v1` for you with signed requests. It keeps an SSH key on the device and signs every request with it; there are no passwords or API tokens. To sign in, the app opens `/mobile/authorize` on the web. It sends the device's name with the key, and can also send its own name as `app_name`, like "/dev/null for Mac", so you can tell apps apart: the approval page and your devices show both. An app can also send a stable `app_id`, its platform and bundle identifier like `macos:sh.dev-null.mac`, which stays the same when its name changes. It isn't shown, but it is in your data export. A guest goes straight to GitHub and returns to that page; someone already signed in checks the key's fingerprint and approves it. The key then becomes one of your devices, and it does not need to be on your GitHub account. A key that is only on GitHub, such as a GitHub Actions key, cannot use the signed API.

A terminal or an agent, which has no browser to be sent back to, signs in with a short code instead: it asks the API for one and shows it, you open `/activate` on the web, sign in with GitHub if needed, type the code, check the device's name and the key's fingerprint, and approve or deny it. The terminal asks the API every few seconds whether you decided, and signs in on its own once you approve. A code lasts **15 minutes**, and you can look up or decide **20 codes a minute**.

A signed request sees what the web shows you, such as your own pending commits and your organizations'. It can also, with the web's validation, moderation, and rate limits:

- read your account, logs, replies, notifications (including grouped activity), and starred commits;
Expand All @@ -586,7 +588,7 @@ A signed request sees what the web shows you, such as your own pending commits a

Signed request bodies are JSON, or multipart when they carry images or a voice note, so app commits can have images with descriptions or a poll but no link-card choice. Before your first app submission, accept the [commit rules](#moderation) through the web composer. Signed requests are limited to **120 requests per minute per user**. Settings → Devices lists your approved devices, with the app's name when it gave one and when each was last used, and revokes them.

Sources: [signed requests](app/Services/SignedRequests.php), [sign-in page](app/Http/Controllers/MobileAuthorizationController.php), [devices settings](resources/js/pages/device/index.tsx), [API routes](routes/api.php).
Sources: [signed requests](app/Services/SignedRequests.php), [sign-in page](app/Http/Controllers/MobileAuthorizationController.php), [terminal sign-in page](app/Http/Controllers/DeviceActivationController.php), [terminal sign-ins](app/Http/Controllers/Api/DeviceAuthorizationController.php), [devices settings](resources/js/pages/device/index.tsx), [API routes](routes/api.php).

### Mobile apps

Expand Down
2 changes: 1 addition & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Developers who left X or Bluesky, or are thinking about it, and want a feed with

## Operating Context

- Surfaces: the web app (also installable as a PWA), the mobile app and the Mac app (the whole product as a native workspace, downloadable from /apps) through a signed JSON API, the terminal script and GitHub Action, a public read-only API, Atom feeds, and an activity graph SVG for GitHub READMEs.
- Surfaces: the web app (also installable as a PWA), the mobile app, the Mac app (the whole product as a native workspace, downloadable from /apps) and terminals through a signed JSON API, the terminal script and GitHub Action, a public read-only API, Atom feeds, and an activity graph SVG for GitHub READMEs.
- Guests can read the Latest and Trending logs, profiles, approved commits and their threads, and search developers. Who follows whom and commit search (which pays for AI) need sign-in, so bots can't walk the graph or run up the bill, like everything that writes or is personal (Following log, notifications, Saved, Remotes, Settings).
- Site admins (`ADMINS`) moderate (`/moderation`) and run the site (`/operations`).
- Exact behavior, limits, and edge cases for every feature live in [KNOWLEDGE_BASE.md](KNOWLEDGE_BASE.md) and in the code; this file only records what must stay true.
Expand Down
54 changes: 54 additions & 0 deletions app/Actions/CreateDeviceActivation.php
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));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

return true;
}
}
43 changes: 43 additions & 0 deletions app/Actions/CreateDeviceAuthorization.php
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];
}
}
103 changes: 103 additions & 0 deletions app/Dto/DeviceAuthorization.php
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,
];
}
}
5 changes: 4 additions & 1 deletion app/Enums/ApiErrorCode.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ enum ApiErrorCode: string
case InvalidSignature = 'invalid_signature';
case DeviceNotApproved = 'device_not_approved';
case InvalidGrant = 'invalid_grant';
case AuthorizationPending = 'authorization_pending';
case SlowDown = 'slow_down';
case AccessDenied = 'access_denied';
case UserBlocked = 'user_blocked';
case UserUnavailable = 'user_unavailable';
case OwnCommit = 'own_commit';
Expand All @@ -27,7 +30,7 @@ enum ApiErrorCode: string
public function status(): int
{
return match ($this) {
self::InvalidGrant => 400,
self::InvalidGrant, self::AuthorizationPending, self::SlowDown, self::AccessDenied => 400,
self::InvalidSignature, self::DeviceNotApproved => 401,
self::UserBlocked, self::UserUnavailable, self::OwnCommit, self::TimedOut, self::Banned, self::CommitRulesRequired => 403,
self::CommitUnavailable => 404,
Expand Down
15 changes: 15 additions & 0 deletions app/Enums/DeviceAuthorizationStatus.php
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';
}
25 changes: 25 additions & 0 deletions app/Exceptions/ApiException.php
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,31 @@ public static function banned(): self
return new self(ApiErrorCode::Banned, __('Your account is banned from /dev/null. All you can do is delete it.'));
}

/**
* The answers a terminal polling for its sign-in gets until the user decides, as RFC 8628 names them.
*/
public static function authorizationPending(): self
{
return new self(ApiErrorCode::AuthorizationPending, __('Waiting for the device to be approved on the web.'));
}

/**
* Says the interval to wait between tries from now on, so a terminal doesn't have to guess it.
*/
public static function slowDown(int $interval): self
{
return new self(
ApiErrorCode::SlowDown,
__('Asking too often. Wait :seconds seconds between tries from now on.', ['seconds' => $interval]),
data: ['interval' => $interval],
);
}

public static function accessDenied(): self
{
return new self(ApiErrorCode::AccessDenied, __('The device was denied on the web. Start the sign-in again if that was a mistake.'));
}

public function render(): JsonResponse
{
return response()->json([
Expand Down
32 changes: 32 additions & 0 deletions app/Http/Controllers/Api/DeviceAuthorizationController.php
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);
}
}
7 changes: 4 additions & 3 deletions app/Http/Controllers/Api/DeviceController.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,13 @@ public function index(#[CurrentUser] User $user): AnonymousResourceCollection
}

/**
* Approves the key that signed the request as a device, for the user who approved the app on the web, and answers
* as GET /api/v1/me does. A code redeemed by another request in the meantime gets the same answer as a wrong one.
* Approves the key that signed the request as a device, for the user who approved the app on the web, or finds
* the device the user approved on the web for a terminal's sign-in, and answers as GET /api/v1/me does. A code
* redeemed by another request in the meantime gets the same answer as a wrong one.
*/
public function store(CreateApiDeviceRequest $request, CreateDevice $action, UpdateUserTermsAcceptance $acceptTerms, NotificationGroupsQuery $notifications): CurrentUserResource|JsonResponse
{
$device = $action->handle($request->authorizationCode());
$device = $request->device() ?? $action->handle($request->authorizationCode());

if (! $device instanceof Device) {
return ApiException::invalidGrant()->render();
Expand Down
Loading