operator > viewer); the mutating actions guard the * `manage-users`/`operate`/etc. abilities themselves. ConfirmToken remains an orthogonal * integrity/confirmation control — it proves a human passed through the modal and seals the * routing/audit descriptor, it does NOT decide authorization. Server-scoped actions * additionally seal the server id — the handler must match it against the server it acts on. */ class ConfirmToken { /** The only confirm events the modal may dispatch / audit. */ public const ACTIONS = [ 'serviceConfirmed', 'userRemoved', 'userLoggedOut', 'userRoleChanged', 'domainChanged', 'tlsModeChanged', 'fileConfirmed', 'keyRemoved', 'credentialDeleted', 'firewallRuleDelete', 'webauthnKeyRemoved', 'sessionsLogoutOthers', 'sessionsLogoutAll', 'twoFactorDisabled', 'banCleared', 'bansClearedAll', 'wgPeerRemoved', 'wgGateToggle', 'wgSshGate', 'wgSetPort', 'wgSetSubnet', 'releaseStaged', 'releasePublic', 'releaseYank', 'groupConfirmed', 'alertRuleDeleted', 'commandRun', 'runbookDeleted', 'patchApply', 'certEndpointDeleted', 'healthCheckDeleted', ]; /** How long an issued confirm stays valid (seconds) — generous for a human click. */ private const TTL = 600; private const CACHE_PREFIX = 'confirm-token:'; private const STATE_PENDING = 'pending'; private const STATE_CONFIRMED = 'confirmed'; /** * Mint a `pending` token for one confirm action. Called server-side by the opener. * * @param array $params * * @throws InvalidArgumentException when $event is not allowlisted */ public static function issue( string $event, array $params = [], string $auditAction = '', ?string $auditTarget = null, ?int $serverId = null, ): string { if (! in_array($event, self::ACTIONS, true)) { throw new InvalidArgumentException("Unknown confirm action: {$event}"); } $nonce = (string) Str::uuid(); Cache::put(self::CACHE_PREFIX.$nonce, self::STATE_PENDING, self::TTL); return Crypt::encryptString(json_encode([ 'nonce' => $nonce, 'event' => $event, 'params' => $params, 'auditAction' => $auditAction, 'auditTarget' => $auditTarget, 'serverId' => $serverId, 'uid' => Auth::id(), ], JSON_THROW_ON_ERROR)); } /** * The modal's confirm step: atomically flip the nonce `pending` → `confirmed`. * Proves a human passed through the modal before any apply handler may run. * * @return array * * @throws InvalidConfirmToken */ public static function confirm(string $token): array { $payload = self::decode($token); self::transition($payload['nonce'], function (string $key) { if (Cache::get($key) !== self::STATE_PENDING) { return false; } Cache::put($key, self::STATE_CONFIRMED, self::TTL); return true; }); return $payload; } /** * The apply handler's gate: a `confirmed` nonce is burned (single-use) and the * sealed payload returned. $event is the handler's own action; it must match. * * @return array * * @throws InvalidConfirmToken */ public static function consume(string $token, string $event): array { $payload = self::decode($token); if ($payload['event'] !== $event) { throw new InvalidConfirmToken('confirm token action mismatch'); } self::transition($payload['nonce'], function (string $key) { if (Cache::get($key) !== self::STATE_CONFIRMED) { return false; } Cache::forget($key); return true; }); return $payload; } /** * Signature-only decode for DISPLAY (e.g. the audit-target line). Never throws and * does not touch the lifecycle state — display must not depend on it. * * @return array|null */ public static function peek(string $token): ?array { try { return self::decode($token); } catch (InvalidConfirmToken) { return null; } } /** * Run a state transition under a per-nonce lock so two concurrent requests cannot * both pass the check (Cache::pull is get-then-forget, not atomic). The closure * returns true on a successful transition; anything else (or a lost lock race) is * an invalid token. * * @param Closure(string): bool $mutate * * @throws InvalidConfirmToken */ private static function transition(string $nonce, Closure $mutate): void { $key = self::CACHE_PREFIX.$nonce; $ok = Cache::lock($key.':lock', 10)->get(fn () => $mutate($key)); if ($ok !== true) { throw new InvalidConfirmToken('confirm token expired, already used, or not confirmed'); } } /** * Decrypt + integrity/allowlist/uid check. No lifecycle-state check here. * * @return array * * @throws InvalidConfirmToken */ private static function decode(string $token): array { try { $payload = json_decode(Crypt::decryptString($token), true, 512, JSON_THROW_ON_ERROR); } catch (DecryptException|JsonException) { throw new InvalidConfirmToken('malformed confirm token'); } if (! is_array($payload) || ! is_string($payload['nonce'] ?? null) || ! in_array($payload['event'] ?? null, self::ACTIONS, true) || ($payload['uid'] ?? null) !== Auth::id()) { throw new InvalidConfirmToken('invalid confirm token'); } return $payload; } }