Device flow: one-time plaintext token pickup (plain_token stash), poll throttle 120/min for 3s CLI rhythm

This commit is contained in:
PeterChrz 2026-09-19 21:06:20 -04:00
parent e406b52a2d
commit 646cecc403
Signed by untrusted user who does not match committer: pch
GPG key ID: 8F0826ECF7302C63
5 changed files with 26 additions and 10 deletions

View file

@ -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,
]);
}

View file

@ -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',
];

View file

@ -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();

View file

@ -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;

View file

@ -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']);