pdo->prepare( 'INSERT INTO ' . self::TABLE . ' (purpose, token_hash, context, expires_at, is_valid, created_at) VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP)' ); $stmt->execute([$purpose, $hash, $contextJson, $expiresAt]); return $plain; } /** * Whether a token is currently redeemable (unused, unexpired). */ public function isValid(string $purpose, string $token): bool { $row = $this->findValid($purpose, $token); return $row !== null; } /** * Redeem a token, marking it used. Returns the stored context (or an * empty array when none was set). Returns null when the token is unknown, * expired, or already used. * * @return mixed|null */ public function redeem(string $purpose, string $token): mixed { $row = $this->findValid($purpose, $token); if ($row === null) { return null; } $stmt = $this->pdo->prepare( 'UPDATE ' . self::TABLE . ' SET used_at = CURRENT_TIMESTAMP, is_valid = 0 WHERE id = ?' ); $stmt->execute([(int) $row['id']]); if ($row['context'] === null || $row['context'] === '') { return []; // consumed successfully, no payload attached } return json_decode($row['context'], true); } /** * Look up a token by purpose + value WITHOUT applying validity checks. * * Used by callers that need to attribute a redemption attempt (audit * logging, resolving a bound resource) even when the token is expired * or already used. * * @return array{id:int, context:mixed, is_valid:int, used_at:?string, expires_at:string}|null */ public function lookup(string $purpose, string $token): ?array { $stmt = $this->pdo->prepare( 'SELECT id, context, is_valid, used_at, expires_at FROM ' . self::TABLE . ' WHERE purpose = ? AND token_hash = ? LIMIT 1' ); $stmt->execute([$purpose, self::hash($token)]); $row = $stmt->fetch(); if ($row === false) { return null; } $row['id'] = (int) $row['id']; $row['is_valid'] = (int) $row['is_valid']; $row['context'] = ($row['context'] === null || $row['context'] === '') ? null : json_decode($row['context'], true); return $row; } /** * Look up a valid (unused, unexpired) token row by purpose + hash. */ private function findValid(string $purpose, string $token): ?array { $stmt = $this->pdo->prepare( 'SELECT id, token_hash, context, expires_at FROM ' . self::TABLE . ' WHERE purpose = ? AND token_hash = ? AND is_valid = 1 AND used_at IS NULL AND expires_at > CURRENT_TIMESTAMP LIMIT 1' ); $stmt->execute([$purpose, self::hash($token)]); $row = $stmt->fetch(); return $row !== false ? $row : null; } /** * Constant-time-comparable token hash (SHA-256 hex). */ private static function hash(string $token): string { return hash('sha256', $token); } }