Device flow: one-time plaintext token pickup (plain_token stash), poll throttle 120/min for 3s CLI rhythm
This commit is contained in:
parent
e406b52a2d
commit
646cecc403
5 changed files with 26 additions and 10 deletions
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
Loading…
Reference in a new issue