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']);
|
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([
|
return response()->json([
|
||||||
'status' => 'granted',
|
'status' => 'granted',
|
||||||
'token' => $token->token,
|
'token' => $plainText,
|
||||||
'abilities' => $token->abilities,
|
'abilities' => $token->abilities,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
class DeviceCode extends Model
|
class DeviceCode extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = [
|
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',
|
'expires_at', 'consumed_at',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,10 @@ public function up(): void
|
||||||
$table->string('poll_token_hash', 128);
|
$table->string('poll_token_hash', 128);
|
||||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||||
$table->foreignId('personal_access_token_id')->nullable();
|
$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('expires_at')->index();
|
||||||
$table->timestamp('consumed_at')->nullable();
|
$table->timestamp('consumed_at')->nullable();
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,13 @@ public function grant(): void
|
||||||
|
|
||||||
// Mint a CLI token with abilities for the catalog (live now) and
|
// Mint a CLI token with abilities for the catalog (live now) and
|
||||||
// submissions (endpoint to come; ability is forward-compatible).
|
// 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([
|
$this->device->forceFill([
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'personal_access_token_id' => $token->accessToken->id,
|
'personal_access_token_id' => $plainText->accessToken->id,
|
||||||
|
'plain_token' => $plainText->plainTextToken,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
$this->granted = true;
|
$this->granted = true;
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,9 @@
|
||||||
Route::get('/gpus', [GpuCatalogController::class, 'index'])->middleware('ability:gpus:read');
|
Route::get('/gpus', [GpuCatalogController::class, 'index'])->middleware('ability:gpus:read');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::middleware('throttle:10,1')->group(function () {
|
Route::middleware('throttle:10,1')->post('/cli/device', [CliDeviceController::class, 'request']);
|
||||||
// Device authorization front door — public but rate-limited.
|
|
||||||
Route::post('/cli/device', [CliDeviceController::class, 'request']);
|
// The CLI polls every 3 seconds while waiting for browser confirmation
|
||||||
Route::post('/cli/poll', [CliDeviceController::class, 'poll']);
|
// (~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