states(); $states = $this->pruneExpiredStates($states); $payload['created_at'] = (int) ($payload['created_at'] ?? time()); $states[$state] = $payload; // Cap size to prevent session bloat from many unfinished authorization flows. if (count($states) > $this->maxEntries) { uasort($states, static function (array $a, array $b): int { return ((int) ($a['created_at'] ?? 0)) <=> ((int) ($b['created_at'] ?? 0)); }); while (count($states) > $this->maxEntries) { array_shift($states); } } $this->sessionStore->set(self::SESSION_KEY, $states); } /** * @return array{ok:bool,error?:string,entry?:array} */ public function consume(string $state): array { $state = trim($state); if ($state === '') { return ['ok' => false, 'error' => 'state_invalid']; } $states = $this->states(); $entry = is_array($states[$state] ?? null) ? $states[$state] : null; if ($entry === null) { return ['ok' => false, 'error' => 'state_invalid']; } // Remove the state before checking TTL — prevents replay even if the check below rejects it. unset($states[$state]); $this->sessionStore->set(self::SESSION_KEY, $states); $createdAt = (int) ($entry['created_at'] ?? 0); if ($createdAt <= 0 || (time() - $createdAt) > $this->ttlSeconds) { return ['ok' => false, 'error' => 'state_expired']; } return ['ok' => true, 'entry' => $entry]; } public function prune(): void { $this->sessionStore->set(self::SESSION_KEY, $this->pruneExpiredStates($this->states())); } private function states(): array { $states = $this->sessionStore->get(self::SESSION_KEY, []); return is_array($states) ? $states : []; } private function pruneExpiredStates(array $states): array { $now = time(); foreach ($states as $key => $payload) { if (!is_array($payload)) { unset($states[$key]); continue; } $createdAt = (int) ($payload['created_at'] ?? 0); if ($createdAt <= 0 || ($now - $createdAt) > $this->ttlSeconds) { unset($states[$key]); } } return $states; } }