diff --git a/app/Http/Controllers/Api/CliDeviceController.php b/app/Http/Controllers/Api/CliDeviceController.php index 526341a..65376d1 100644 --- a/app/Http/Controllers/Api/CliDeviceController.php +++ b/app/Http/Controllers/Api/CliDeviceController.php @@ -72,11 +72,20 @@ public function poll(Request $request): JsonResponse return response()->json(['status' => 'expired']); } - $device->forceFill(['consumed_at' => now()])->save(); + if ($device->plain_token === null) { + // Should be unreachable (granted implies stashed), but a bare + // "granted" without a token is not a pickup the CLI can use. + return response()->json(['status' => 'expired']); + } + + // One-time pickup: hand the plaintext over, then erase it. The + // row keeps the hashed PAT link for revocation, never the secret. + $plainText = $device->plain_token; + $device->forceFill(['consumed_at' => now(), 'plain_token' => null])->save(); return response()->json([ 'status' => 'granted', - 'token' => $token->token, + 'token' => $plainText, 'abilities' => $token->abilities, ]); } diff --git a/app/Models/DeviceCode.php b/app/Models/DeviceCode.php index 395fb14..b9fad1b 100644 --- a/app/Models/DeviceCode.php +++ b/app/Models/DeviceCode.php @@ -10,7 +10,7 @@ class DeviceCode extends Model { protected $fillable = [ - 'code', 'poll_token_hash', 'user_id', 'personal_access_token_id', + 'code', 'poll_token_hash', 'user_id', 'personal_access_token_id', 'plain_token', 'expires_at', 'consumed_at', ]; diff --git a/database/migrations/2026_09_19_000001_create_device_codes.php b/database/migrations/2026_09_19_000001_create_device_codes.php index 9d6ae96..a569c67 100644 --- a/database/migrations/2026_09_19_000001_create_device_codes.php +++ b/database/migrations/2026_09_19_000001_create_device_codes.php @@ -20,6 +20,10 @@ public function up(): void $table->string('poll_token_hash', 128); $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); $table->foreignId('personal_access_token_id')->nullable(); + // Sanctum hashes tokens; the plaintext exists only at mint time. + // It is stashed here for exactly one pickup by the CLI's poll, + // then nulled and never persisted anywhere else. + $table->string('plain_token')->nullable(); $table->timestamp('expires_at')->index(); $table->timestamp('consumed_at')->nullable(); $table->timestamps(); diff --git a/resources/views/livewire/cli/authorize.blade.php b/resources/views/livewire/cli/authorize.blade.php index a7bce87..ec38278 100644 --- a/resources/views/livewire/cli/authorize.blade.php +++ b/resources/views/livewire/cli/authorize.blade.php @@ -39,11 +39,13 @@ public function grant(): void // Mint a CLI token with abilities for the catalog (live now) and // submissions (endpoint to come; ability is forward-compatible). - $token = $user->createToken('festbench-cli', ['gpus:read', 'results:submit'], now()->addYear()); + // The plaintext exists only here — stashed for one pickup by poll. + $plainText = $user->createToken('festbench-cli', ['gpus:read', 'results:submit'], now()->addYear()); $this->device->forceFill([ 'user_id' => $user->id, - 'personal_access_token_id' => $token->accessToken->id, + 'personal_access_token_id' => $plainText->accessToken->id, + 'plain_token' => $plainText->plainTextToken, ])->save(); $this->granted = true; diff --git a/routes/api.php b/routes/api.php index 591cd1d..985b53b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -11,8 +11,9 @@ Route::get('/gpus', [GpuCatalogController::class, 'index'])->middleware('ability:gpus:read'); }); -Route::middleware('throttle:10,1')->group(function () { - // Device authorization front door — public but rate-limited. - Route::post('/cli/device', [CliDeviceController::class, 'request']); - Route::post('/cli/poll', [CliDeviceController::class, 'poll']); -}); +Route::middleware('throttle:10,1')->post('/cli/device', [CliDeviceController::class, 'request']); + +// The CLI polls every 3 seconds while waiting for browser confirmation +// (~200 polls over the full 10-minute window) — the limiter must allow +// that rhythm or the login appears to time out while granted. +Route::middleware('throttle:120,1')->post('/cli/poll', [CliDeviceController::class, 'poll']);